diff --git a/.github/workflows/k8s.yml b/.github/workflows/k8s.yml new file mode 100644 index 0000000..20bda2a --- /dev/null +++ b/.github/workflows/k8s.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 3e4fedb..472f6da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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]" diff --git a/Makefile b/Makefile index 8c164f0..d2044ff 100644 --- a/Makefile +++ b/Makefile @@ -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}' @@ -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) diff --git a/README.md b/README.md index ebe2cf5..093a909 100644 --- a/README.md +++ b/README.md @@ -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 | --- @@ -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 diff --git a/docs/step-19-k8s-deploy.md b/docs/step-19-k8s-deploy.md new file mode 100644 index 0000000..33e51cb --- /dev/null +++ b/docs/step-19-k8s-deploy.md @@ -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`. diff --git a/k8s/api.yaml b/k8s/api.yaml new file mode 100644 index 0000000..fd1c332 --- /dev/null +++ b/k8s/api.yaml @@ -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} diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..e983169 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: api-config +data: + ENV: k8s + LOG_LEVEL: INFO + ASSISTANT_MODE: rag diff --git a/k8s/jobs/migrate.yaml b/k8s/jobs/migrate.yaml new file mode 100644 index 0000000..4ca94a8 --- /dev/null +++ b/k8s/jobs/migrate.yaml @@ -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} diff --git a/k8s/jobs/reindex.yaml b/k8s/jobs/reindex.yaml new file mode 100644 index 0000000..ca41b05 --- /dev/null +++ b/k8s/jobs/reindex.yaml @@ -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} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..a95f7ea --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: catalog +resources: + - namespace.yaml + - configmap.yaml + - postgres.yaml + - api.yaml diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..f639fe2 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: catalog diff --git a/k8s/postgres.yaml b/k8s/postgres.yaml new file mode 100644 index 0000000..914adfa --- /dev/null +++ b/k8s/postgres.yaml @@ -0,0 +1,61 @@ +apiVersion: v1 +kind: Service +metadata: + name: db +spec: + selector: + app: db + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: db +spec: + serviceName: db + replicas: 1 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: postgres + image: pgvector/pgvector:pg16 + ports: + - containerPort: 5432 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: {name: catalog-db, key: POSTGRES_USER} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: {name: catalog-db, key: POSTGRES_PASSWORD} + - name: POSTGRES_DB + valueFrom: + secretKeyRef: {name: catalog-db, key: POSTGRES_DB} + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + readinessProbe: + exec: + command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] + periodSeconds: 5 + resources: + requests: {cpu: 100m, memory: 256Mi} + limits: {memory: 512Mi} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi diff --git a/k8s/secrets.example.yaml b/k8s/secrets.example.yaml new file mode 100644 index 0000000..27f46c9 --- /dev/null +++ b/k8s/secrets.example.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Secret +metadata: + name: catalog-db + namespace: catalog +stringData: + POSTGRES_USER: + POSTGRES_PASSWORD: + POSTGRES_DB: catalog + DATABASE_URL: postgresql+asyncpg://:@db:5432/catalog +--- +apiVersion: v1 +kind: Secret +metadata: + name: assistant-llm + namespace: catalog +stringData: + ANTHROPIC_API_KEY: