Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/k8s.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: k8s

on:
pull_request:
paths:
- k8s/**
- Dockerfile
- Makefile
- alembic/**
- .github/workflows/k8s.yml
workflow_dispatch:

concurrency:
group: k8s-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
kind:
name: Deploy to kind and call /healthz
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build api image (with GHA layer cache)
uses: docker/build-push-action@v7
with:
context: .
load: true
tags: fastapi-api:latest
cache-from: type=gha,scope=fastapi-api
cache-to: type=gha,mode=max,scope=fastapi-api

- name: Create kind cluster
uses: helm/kind-action@v1.15.1
with:
cluster_name: catalog

- name: Deploy (migrate, reindex, api)
run: make k8s-deploy

- name: Smoke test through the Service
run: make k8s-smoke

- name: Diagnostics on failure
if: failure()
run: |
kubectl --context kind-catalog -n catalog get pods,jobs
kubectl --context kind-catalog -n catalog logs job/migrate --tail=50 || true
kubectl --context kind-catalog -n catalog logs job/reindex --tail=50 || true
kubectl --context kind-catalog -n catalog logs deployment/api --tail=100 || true
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ RUN pip install "fastembed>=0.9.1" \
COPY pyproject.toml ./
COPY src ./src
COPY main.py ./
COPY alembic.ini ./
COPY alembic ./alembic

RUN pip install -e ".[dev]"

Expand Down
37 changes: 36 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
.PHONY: help install run up down stop sh test unit-test eval integration-test e2e-test lint format build migrate migration migrate-test reindex
.PHONY: help install run up down stop sh test unit-test eval integration-test k8s-up k8s-deploy k8s-smoke k8s-down e2e-test lint format build migrate migration migrate-test reindex

DC = docker compose
SERVICE = api
KIND_CLUSTER = catalog
K8S_NS = catalog
KUBECTL = kubectl --context kind-$(KIND_CLUSTER)

help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
Expand Down Expand Up @@ -59,3 +62,35 @@ reindex: ## Index catalog products for the assistant (incremental)

migration: ## Create a new migration. Usage: make migration m="add products table"
$(DC) run --rm $(SERVICE) alembic revision --autogenerate -m "$(m)"

k8s-up: build k8s-deploy ## Build the image and deploy it to a local kind cluster

k8s-deploy: ## Deploy the existing fastapi-api:latest image to the kind cluster
kind get clusters | grep -qx $(KIND_CLUSTER) || kind create cluster --name $(KIND_CLUSTER)
kind load docker-image fastapi-api:latest --name $(KIND_CLUSTER)
$(KUBECTL) apply -f k8s/namespace.yaml
$(KUBECTL) -n $(K8S_NS) create secret generic catalog-db \
--from-literal=POSTGRES_USER=catalog --from-literal=POSTGRES_PASSWORD=catalog \
--from-literal=POSTGRES_DB=catalog \
--from-literal=DATABASE_URL=postgresql+asyncpg://catalog:catalog@db:5432/catalog \
--dry-run=client -o yaml | $(KUBECTL) apply -f -
(grep '^ANTHROPIC_API_KEY=' .env 2>/dev/null || true) | $(KUBECTL) -n $(K8S_NS) create secret generic assistant-llm \
--from-env-file=/dev/stdin --dry-run=client -o yaml | $(KUBECTL) apply -f -
$(KUBECTL) apply -k k8s
$(KUBECTL) -n $(K8S_NS) rollout status statefulset/db --timeout=180s
$(KUBECTL) -n $(K8S_NS) delete job migrate reindex --ignore-not-found
$(KUBECTL) apply -f k8s/jobs/migrate.yaml
$(KUBECTL) -n $(K8S_NS) wait --for=condition=complete job/migrate --timeout=180s
$(KUBECTL) apply -f k8s/jobs/reindex.yaml
$(KUBECTL) -n $(K8S_NS) wait --for=condition=complete job/reindex --timeout=180s
$(KUBECTL) -n $(K8S_NS) rollout restart deployment/api
$(KUBECTL) -n $(K8S_NS) rollout status deployment/api --timeout=300s

k8s-smoke: ## Call /healthz and GET /products through the api Service
$(KUBECTL) -n $(K8S_NS) port-forward svc/api 8080:8000 >/dev/null 2>&1 & pid=$$!; \
sleep 3; \
curl -fsS http://localhost:8080/healthz && echo && curl -fsS http://localhost:8080/products && echo; \
status=$$?; kill $$pid; exit $$status

k8s-down: ## Delete the local kind cluster
kind delete cluster --name $(KIND_CLUSTER)
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Delivered as small, shippable PRs. Full plan: [`docs/plan-assistant-genai.md`](d
| 1 | `LLMClient` port, Anthropic adapter, `POST /assistant/ask`, failure contract | ✅ Done |
| 2 | **RAG** with `pgvector` in the existing Postgres: local multilingual embeddings behind a port, incremental `make reindex`, top-k retrieval, `sources` in the answer, deterministic fallback when the LLM fails | ✅ Done |
| 3 | **Agent** with LangGraph: candidate selection by rubric and pure `select()` (3a), agent with catalog tools, step limit and summed token usage (3b), evals E1–E4 | ✅ Done |
| 4 | **Deploy**: Kubernetes manifests, local `kind` cluster, optional cloud target | ⏳ Planned |
| 4 | **Deploy**: Kubernetes manifests (api, Postgres + pgvector, migrate and reindex Jobs), local `kind` cluster, CI smoke test | ✅ Done |

---

Expand Down Expand Up @@ -291,6 +291,21 @@ API is served on `http://localhost:8000` with auto-generated docs:
- `/redoc` — [ReDoc](https://github.com/Rebilly/ReDoc) (alternative reference UI)
- `/openapi.json` — raw OpenAPI schema (importable into Postman, OpenAPI generators, etc.)

### Kubernetes (local, with kind)

**Prerequisites:** [`kind`](https://kind.sigs.k8s.io/) and `kubectl`.

```bash
make k8s-up # Build the image, create the kind cluster, deploy Postgres, run migrate → reindex, roll out the api
make k8s-smoke # GET /healthz and GET /products through the api Service
make k8s-down # Delete the cluster
```

- **Manifests:** they live in [`k8s/`](k8s/) as a kustomization, with the migrate and reindex Jobs in `k8s/jobs/`. `make k8s-deploy` runs them in order and is safe to re-run.
- **Secrets:** `make` creates them; `k8s/secrets.example.yaml` only documents the keys. `ANTHROPIC_API_KEY` is piped from `.env` through stdin, so it never appears in a command. Without it, `rag` mode still answers with the fallback.
- **Embeddings:** the model is baked into the image, so the pods never download it.
- **CI:** the `k8s` workflow runs the same deploy and smoke test on a kind cluster when the manifests, `Dockerfile`, `Makefile` or migrations change.

---

## Testing
Expand Down
82 changes: 82 additions & 0 deletions docs/step-19-k8s-deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Paso 19 — Despliegue en Kubernetes (PR 4)

## Qué

Hay que desplegar en un clúster `kind` local la misma imagen que se usa en desarrollo y en CI:

- la API;
- Postgres con pgvector;
- un `Job` de migración (`alembic upgrade head`);
- un `Job` de reindexado (`python -m contexts.assistant.ui.cli.reindex`).

Para manejarlo hay cuatro objetivos de `make`: `make k8s-up`, `make k8s-deploy`, `make k8s-smoke` y
`make k8s-down`. Un workflow de CI aparte repite el despliegue y la prueba de humo.

## Por qué

Para demostrar que la imagen funciona tal cual fuera de Docker Compose:

- los secretos llegan por `Secret`;
- las migraciones y el reindexado son pasos explícitos;
- el modelo de embeddings ya va dentro de la imagen, así que los pods no descargan nada.

## Decisiones

| Decisión | Por qué |
|---|---|
| `kustomize` (`kubectl apply -k k8s`), sin Helm | Pocos recursos y ningún valor que parametrizar todavía |
| Los `Job` van en `k8s/jobs/`, fuera de la kustomization, y `make` los lanza en orden: BD lista → migrar → reindexar → API | Si se aplicaran a la vez que la BD, fallarían y dependerían de los reintentos. Con el orden explícito, cada paso empieza cuando el anterior ha terminado. |
| `make k8s-deploy` borra y vuelve a crear los `Job` | Un `Job` no se puede modificar. Así el objetivo se puede repetir sin efectos. |
| `rollout restart` de la API al final | `kind load` reemplaza `fastapi-api:latest`, pero sin reinicio el pod seguiría con la imagen anterior |
| Los secretos los crea `make`, y `k8s/secrets.example.yaml` es solo una plantilla que no se aplica | En el repositorio no hay ningún valor real |
| `ANTHROPIC_API_KEY` se pasa por stdin, filtrando solo esa línea de `.env` | La clave no aparece en ningún comando ni en la salida. Sin clave, el `Secret` queda vacío y es `optional`, así que `rag` responde con el fallback. |
| La API se arranca sin `--reload` (se sobrescribe el `command`) | El `CMD` del Dockerfile es el de desarrollo |
| Sondas: `startupProbe` para el calentamiento del modelo; `liveness` y `readiness` en `/healthz` | `/healthz` no comprueba dependencias, y así un fallo de la BD no reinicia el pod |
| `imagePullPolicy: IfNotPresent` | Con la etiqueta `:latest`, el valor por defecto sería `Always`, y fallaría con una imagen cargada por `kind` |
| El Dockerfile copia `alembic.ini` y `alembic/` | Antes solo los montaba Compose, y el `Job` de migración no podía ejecutarse desde la imagen |
| El CI va en un workflow aparte (`k8s.yml`), filtrado por rutas y también lanzable a mano | Es el «job opcional» del plan: no hace más lentos los PR que no tocan el despliegue |
| Sin AWS | El usuario no tiene cuenta. El paso opcional del plan queda fuera. |

## Criterios de aceptación

| # | Criterio | Evidencia |
|---|---|---|
| K1 | `make k8s-deploy` crea el clúster, carga la imagen, crea los secretos, espera a la BD, migra, reindexa y despliega la API | Ver «Evidencia» |
| K2 | La migración se ejecuta desde la imagen, sin volúmenes | Log del `Job` `migrate`: revisiones 1 → 4 |
| K3 | `make k8s-smoke` pasa a través del `Service` | `{"status":"ok"}` y `[]` |
| K4 | Repetir `make k8s-deploy` no tiene efectos y conserva los datos | La segunda ejecución sale con 0; la BD no se reinicia (generación 1, 0 reinicios) y `kubectl diff` sale vacío |
| K5 | El reindexado calcula los embeddings dentro del pod, sin red | Un producto creado antes del segundo despliegue → `indexed=1` |
| K6 | La clave no aparece en la salida | 0 apariciones de `sk-ant` en la salida del despliegue; el `Secret` solo contiene `ANTHROPIC_API_KEY` |
| K7 | El workflow `k8s` pasa en el PR | [Run del PR #13](https://github.com/jcmoro/python-fastapi/actions/runs/40611790225): «Deploy to kind and call /healthz» en verde (3 min 16 s) |

## Evidencia (local, kind v0.33.0)

Primer `make k8s-deploy`: sale con 0.

```
job.batch/migrate condition met
job.batch/reindex condition met
deployment "api" successfully rolled out
```

```
pod/api-… 1/1 Running
pod/db-0 1/1 Running
job.batch/migrate Complete 1/1 5s
job.batch/reindex Complete 1/1 5s
```

Log del `Job` `migrate`: `init → products → product_documents → catalog columns`.
`reindex`: `indexed=0 unchanged=0 deleted=0`.

`make k8s-smoke`:

```
{"status":"ok"}
[]
```

Después de crear el producto `K8S-1` con `POST /products`, un segundo `make k8s-deploy` sale con 0 y
el `reindex` da `indexed=1 unchanged=0 deleted=0`. El `StatefulSet` `db` sigue en la generación 1,
`db-0` tiene 0 reinicios y `kubectl diff -k k8s` sale vacío. El `configured` de esa segunda
ejecución solo era la anotación de `kubectl apply`.
55 changes: 55 additions & 0 deletions k8s/api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- port: 8000
targetPort: 8000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: fastapi-api:latest
imagePullPolicy: IfNotPresent
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: api-config
- secretRef:
name: assistant-llm
optional: true
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: {name: catalog-db, key: DATABASE_URL}
startupProbe:
httpGet: {path: /healthz, port: 8000}
periodSeconds: 2
failureThreshold: 60
livenessProbe:
httpGet: {path: /healthz, port: 8000}
periodSeconds: 10
readinessProbe:
httpGet: {path: /healthz, port: 8000}
periodSeconds: 5
resources:
requests: {cpu: 100m, memory: 512Mi}
limits: {memory: 1Gi}
8 changes: 8 additions & 0 deletions k8s/configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: api-config
data:
ENV: k8s
LOG_LEVEL: INFO
ASSISTANT_MODE: rag
25 changes: 25 additions & 0 deletions k8s/jobs/migrate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
apiVersion: batch/v1
kind: Job
metadata:
name: migrate
namespace: catalog
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: fastapi-api:latest
imagePullPolicy: IfNotPresent
command: ["alembic", "upgrade", "head"]
envFrom:
- configMapRef:
name: api-config
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: {name: catalog-db, key: DATABASE_URL}
resources:
requests: {cpu: 100m, memory: 512Mi}
limits: {memory: 1Gi}
25 changes: 25 additions & 0 deletions k8s/jobs/reindex.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
apiVersion: batch/v1
kind: Job
metadata:
name: reindex
namespace: catalog
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: reindex
image: fastapi-api:latest
imagePullPolicy: IfNotPresent
command: ["python", "-m", "contexts.assistant.ui.cli.reindex"]
envFrom:
- configMapRef:
name: api-config
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: {name: catalog-db, key: DATABASE_URL}
resources:
requests: {cpu: 100m, memory: 512Mi}
limits: {memory: 1Gi}
8 changes: 8 additions & 0 deletions k8s/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: catalog
resources:
- namespace.yaml
- configmap.yaml
- postgres.yaml
- api.yaml
4 changes: 4 additions & 0 deletions k8s/namespace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: catalog
Loading
Loading