Files
reactbin/specs/013-k8s-manifests/tasks.md
agatha bf27c97deb Feat: Add Kubernetes manifests for k3s production deployment
Adds complete k8s/ manifest tree: Namespace, VaultAuth + VaultStaticSecret
CRDs (VSO secret sync from Vault KV v2), API and UI Deployments and Services,
nginx Ingress with cert-manager TLS, MinIO StatefulSet with PVC and init Job,
and Alembic init container on the API Deployment for automatic schema
migrations. Includes .yamllint.yml config and validate-k8s Makefile target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:19:09 +00:00

16 KiB
Raw Permalink Blame History

Tasks: Kubernetes Production Manifests

Input: Design documents from specs/013-k8s-manifests/ Prerequisites: plan.md , spec.md , research.md , contracts/operator-deploy.md , quickstart.md

Tests: K8s manifests have no unit test framework. Validation is via yamllint (format) and kubectl apply --dry-run=client (schema). Each phase ends with a validation step. The TDD analogue is: write the validate-k8s Makefile target (Phase 1) before any manifest exists, so it immediately fails — then manifests are written to make it pass.

Organization: Phase 1 creates the directory structure and validation target. Phase 2 creates the namespace and Vault CRDs (foundational — required by all user story deployments). Phases 36 implement user stories. Phase 7 polishes.

Format: [ID] [P?] [Story] Description

  • [P]: Can run in parallel with other [P] tasks in the same phase
  • [Story]: Which user story this task belongs to
  • Exact file paths included in every task description

Phase 1: Setup

Goal: Create the k8s/ directory structure and the validation Makefile target before any manifests exist.

  • T001 Create the k8s/ directory tree: mkdir -p k8s/api k8s/ui k8s/minio k8s/vault from the repository root; confirm the four subdirectories exist

  • T002 Add a validate-k8s target to Makefile immediately after the existing verify-ui-prod target: the target MUST run yamllint -d relaxed k8s/ then kubectl apply --dry-run=client -f k8s/; add validate-k8s to the .PHONY line; note in a comment that kubectl apply --dry-run=client requires a kubeconfig with cluster access — offline validation uses yamllint only


Phase 2: Foundational (Namespace + Vault CRDs)

Goal: Namespace and Vault secret-sync resources that every other manifest depends on.

⚠️ CRITICAL: No user story manifest can be applied until this phase is complete — the namespace must exist before any namespaced resource, and the Vault CRDs must exist before the API or MinIO pods can start.

  • T003 Create k8s/namespace.yaml: a single Namespace resource with name: reactbin and no additional labels

  • T004 [P] Create k8s/vault/vault-auth.yaml: a VaultAuth resource (apiVersion: secrets.hashicorp.com/v1beta1) with name: reactbin-auth, namespace: reactbin, spec.method: kubernetes, spec.mount: kubernetes, spec.kubernetes.role: reactbin, spec.kubernetes.serviceAccount: default, spec.kubernetes.audiences: [https://kubernetes.default.svc]; add a comment noting the operator must create the Vault role and bind it to the default SA in the reactbin namespace with read access to both secret paths

  • T005 [P] Create k8s/vault/api-secret.yaml: a VaultStaticSecret resource with name: api-secret, namespace: reactbin, spec.vaultAuthRef: reactbin-auth, spec.mount: secret, spec.type: kv-v2, spec.path: reactbin/api/config, spec.refreshAfter: 1h, spec.destination.name: api-env, spec.destination.create: true; add a comment listing all required Vault keys: DATABASE_URL, JWT_SECRET_KEY, OWNER_USERNAME, OWNER_PASSWORD, S3_ENDPOINT_URL, S3_BUCKET_NAME, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, API_BASE_URL

  • T006 [P] Create k8s/vault/minio-secret.yaml: same structure as T005 but name: minio-secret, spec.path: reactbin/minio/credentials, spec.destination.name: minio-credentials; comment listing required Vault keys: MINIO_ROOT_USER, MINIO_ROOT_PASSWORD

Checkpoint: Foundational resources complete. User story implementation can now begin.


Phase 3: User Story 1 — Application Reachable in Production (Priority: P1) 🎯 MVP

Goal: API and UI are deployed and reachable at the production domain via HTTPS with TLS from cert-manager.

Independent Test: Apply all Phase 2 + Phase 3 manifests. Confirm kubectl get pods -n reactbin shows api and ui pods Running. Confirm curl https://<domain>/api/v1/health returns 200 and curl https://<domain>/ returns 200.

  • T007 [P] [US1] Create k8s/api/service.yaml: Service, name: api, namespace: reactbin, type: ClusterIP, selector: {app: api}, ports: [{port: 8000, targetPort: 8000, name: http}]

  • T008 [P] [US1] Create k8s/ui/service.yaml: Service, name: ui, namespace: reactbin, type: ClusterIP, selector: {app: ui}, ports: [{port: 8080, targetPort: 8080, name: http}]

  • T009 [P] [US1] Create k8s/ui/deployment.yaml: Deployment, name: ui, namespace: reactbin, 1 replica, selector.matchLabels: {app: ui}; container name: ui, image: reactbin-ui:latest (placeholder — operator substitutes real tag), ports: [{containerPort: 8080}]; livenessProbe: {httpGet: {path: /, port: 8080}, initialDelaySeconds: 10, periodSeconds: 30}; securityContext: {runAsNonRoot: true, runAsUser: 101} (UID 101 is the nginxinc/nginx-unprivileged user); add comment: # Replace 'latest' with the real image tag before applying

  • T010 [US1] Create k8s/api/deployment.yaml: Deployment, name: api, namespace: reactbin, 1 replica, selector.matchLabels: {app: api}; container name: api, image: reactbin-api:latest (placeholder), ports: [{containerPort: 8000}]; envFrom: [{secretRef: {name: api-env}}]; env: [{name: API_DOCS_ENABLED, value: "false"}]; livenessProbe: {httpGet: {path: /api/v1/health, port: 8000}, initialDelaySeconds: 10, periodSeconds: 30}; readinessProbe: {httpGet: {path: /api/v1/health, port: 8000}, initialDelaySeconds: 5, periodSeconds: 10}; securityContext: {runAsNonRoot: true, runAsUser: 1001}; add comment: # initContainers block added in US3 (T015); add comment: # Replace 'latest' with the real image tag before applying

  • T011 [US1] Create k8s/ingress.yaml: Ingress, name: reactbin, namespace: reactbin; annotations: {"cert-manager.io/cluster-issuer": "letsencrypt-prod", "nginx.ingress.kubernetes.io/ssl-redirect": "true"}; spec.ingressClassName: nginx; spec.tls: [{hosts: ["<your-domain>"], secretName: reactbin-tls}]; spec.rules: [{host: "<your-domain>", http: {paths: [{path: /api/, pathType: Prefix, backend: {service: {name: api, port: {number: 8000}}}}, {path: /, pathType: Prefix, backend: {service: {name: ui, port: {number: 8080}}}}]}}]; IMPORTANT — /api/ path entry MUST appear before / in the YAML (nginx evaluates in declaration order); add comment: # Replace <your-domain> with the real domain before applying

  • T012 [US1] Verify US1: run yamllint -d relaxed k8s/ from the repository root and confirm no errors; run kubectl apply --dry-run=client -f k8s/ (requires cluster kubeconfig) and confirm all resources in namespace.yaml, vault/, api/, ui/, and ingress.yaml are accepted; if no cluster is available, yamllint passing is sufficient for this checkpoint

Checkpoint: US1 complete. API and UI manifests are schema-valid and ready to apply.


Phase 4: User Story 2 — Secrets Sourced from Vault (Priority: P2)

Goal: Confirm that no plaintext secret values appear in any committed manifest file. The implementation (VaultAuth + VaultStaticSecret × 2) was completed in Phase 2.

Independent Test: git grep across k8s/ finds no plaintext credential values.

  • T013 [US2] Verify US2: run git grep -rn "password\|secret_key\|access_key\|DATABASE_URL" k8s/ and confirm that only key names (in comments) and Vault path references appear — no actual values; also confirm that k8s/vault/api-secret.yaml and k8s/vault/minio-secret.yaml reference Vault paths under spec.path and that spec.destination.create: true is set so VSO creates the K8s Secrets

Checkpoint: US2 complete. Zero plaintext secrets in manifests; all secrets flow through Vault.


Phase 5: User Story 3 — Schema Migrations Run Before API Starts (Priority: P3)

Goal: The API Deployment includes an Alembic init container. api/Dockerfile.prod is updated to include migration files.

Independent Test: docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test succeeds and docker run --rm reactbin-api-prod:test ls /app/alembic shows migration files. make validate-k8s confirms the init container spec is accepted by the Kubernetes schema.

  • T014 [US3] Update api/Dockerfile.prod: in the runtime stage (the FROM python:3.12-slim stage), after the line COPY --chown=appuser:appgroup app/ ./app/, add two new lines: COPY --chown=appuser:appgroup alembic/ ./alembic/ and COPY --chown=appuser:appgroup alembic.ini .; the builder stage is unchanged; verify with docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test && docker run --rm reactbin-api-prod:test ls /app/alembic /app/alembic.ini

  • T015 [US3] Update k8s/api/deployment.yaml: add an initContainers block to the pod spec (before the containers block) containing one init container: name: alembic-migrate, image: reactbin-api:latest (same placeholder tag as the main container), command: ["alembic", "upgrade", "head"], workingDir: /app, envFrom: [{secretRef: {name: api-env}}], securityContext: {runAsNonRoot: true, runAsUser: 1001}; remove the # initContainers block added in US3 (T015) comment added in T010

  • T016 [US3] Verify US3: run make validate-k8s (or yamllint -d relaxed k8s/) and confirm the updated deployment.yaml with the init container passes validation; run docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test and confirm it succeeds; run docker run --rm reactbin-api-prod:test ls /app/alembic.ini and confirm the file is present

Checkpoint: US3 complete. API Deployment includes Alembic init container; production image includes migration files.


Phase 6: User Story 4 — MinIO In-Cluster with Persistent Storage (Priority: P4)

Goal: MinIO runs as a StatefulSet with a PVC, is accessible only within the cluster, and has the required bucket created by a Job.

Independent Test: make validate-k8s confirms all MinIO manifests pass schema validation. On a live cluster: MinIO pod reaches Running state, bucket exists, no external Ingress for MinIO.

  • T017 [P] [US4] Create k8s/minio/service.yaml: Service, name: minio, namespace: reactbin, type: ClusterIP, selector: {app: minio}, ports: [{port: 9000, targetPort: 9000, name: s3}]; add comment: # No Ingress for MinIO — internal access only (FR-012)

  • T018 [US4] Create k8s/minio/statefulset.yaml: StatefulSet (NOT Deployment — StatefulSet ensures stable PVC binding on pod recreation), name: minio, namespace: reactbin, replicas: 1, selector.matchLabels: {app: minio}, serviceName: minio; pod securityContext: {runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000}; container name: minio, image: minio/minio:latest, args: ["server", "/data", "--console-address", ":9001"], ports: [{containerPort: 9000, name: s3}]; env: [{name: MINIO_ROOT_USER, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_USER}}}, {name: MINIO_ROOT_PASSWORD, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_PASSWORD}}}]; livenessProbe: {httpGet: {path: /minio/health/live, port: 9000}, initialDelaySeconds: 30, periodSeconds: 20}; readinessProbe: {httpGet: {path: /minio/health/ready, port: 9000}, initialDelaySeconds: 15, periodSeconds: 10}; volumeMounts: [{name: minio-data, mountPath: /data}]; volumeClaimTemplates: [{metadata: {name: minio-data}, spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 10Gi}}}}]; add comment: # storageClassName omitted — uses cluster default; override if needed

  • T019 [US4] Create k8s/minio/init-job.yaml: Job, name: minio-init-bucket, namespace: reactbin; spec.template.spec.restartPolicy: OnFailure; container name: mc, image: minio/mc:latest, command: ["sh", "-c"], args: ["mc alias set local http://minio.reactbin.svc.cluster.local:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/reactbin"]; env: [{name: MINIO_ROOT_USER, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_USER}}}, {name: MINIO_ROOT_PASSWORD, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_PASSWORD}}}]; securityContext: {runAsNonRoot: false} with comment # minio/mc runs as root by default; FR-013 exception for this one-off init Job; add comment: # --ignore-existing makes this Job idempotent — safe to re-apply

  • T020 [US4] Verify US4: run make validate-k8s (or yamllint -d relaxed k8s/) and confirm all three MinIO manifests (statefulset.yaml, service.yaml, init-job.yaml) pass validation; confirm no Ingress resource references MinIO

Checkpoint: All four user stories complete.


Phase 7: Polish & Cross-Cutting Concerns

  • T021 [P] Run yamllint -d relaxed k8s/ from the repository root and fix any YAML formatting violations across all 12 manifest files; confirm output shows no errors

  • T022 [P] Add .yamllint.yml at the repository root (if not already present) with extends: relaxed and rules: {line-length: {max: 120}} to keep line length reasonable for verbose K8s YAML

  • T023 Run make build-prod to confirm api/Dockerfile.prod still builds cleanly after the T014 addition; run docker run --rm reactbin-api-prod:latest ls /app/alembic.ini /app/alembic/ and confirm both are present in the production image


Dependencies & Execution Order

  • T001 and T002 can run in parallel (directory creation vs Makefile edit)
  • T003, T004, T005, T006 can run in parallel after T001 (different files, same phase)
  • T007, T008, T009 can run in parallel after Phase 2 completes
  • T010 after T007 (deployment references service name, easier to write with service done) — but they're different files so technically parallel; keep sequential for clarity
  • T011 after T007 and T008 (Ingress references both service names)
  • T012 after T007T011
  • T013 after Phase 2 (Vault CRDs exist to inspect)
  • T014 and T015 can run in parallel (different files: Dockerfile.prod vs deployment.yaml)
  • T016 after T014 and T015
  • T017, T018, T019 can run in parallel after Phase 2 completes
  • T020 after T017T019
  • T021, T022, T023 can run in parallel

Execution Order Summary

Step 1: T001 ∥ T002                          (setup)
Step 2: T003 ∥ T004 ∥ T005 ∥ T006           (foundational: namespace + Vault CRDs)
Step 3: T007 ∥ T008 ∥ T009                  (US1: services + UI deployment)
Step 4: T010                                  (US1: API deployment)
Step 5: T011                                  (US1: Ingress)
Step 6: T012                                  (US1: validate)
Step 7: T013                                  (US2: verify no plaintext secrets)
Step 8: T014 ∥ T015                          (US3: Dockerfile.prod + init container)
Step 9: T016                                  (US3: verify)
Step 10: T017 ∥ T018 ∥ T019                 (US4: MinIO manifests)
Step 11: T020                                 (US4: validate MinIO)
Step 12: T021 ∥ T022 ∥ T023                 (polish)

Implementation Strategy

MVP (US1 + US2 — application is reachable with Vault-backed secrets)

  1. Phase 1 (Setup) + Phase 2 (Foundational)
  2. Phase 3 (US1 — API, UI, Ingress)
  3. Phase 4 (US2 — verify no plaintext secrets)
  4. STOP and VALIDATE: apply to cluster, confirm https://<domain>/ and /api/v1/health return 200
  5. Deploy MVP

Incremental Delivery

  1. Setup + Foundational → Apply → namespace and Vault sync ready
  2. Add US1 (API + UI + Ingress) → Deploy → application reachable at domain
  3. Add US3 (Alembic init container) → Deploy → migrations run automatically on rollout
  4. Add US4 (MinIO) → Deploy → persistent image storage in-cluster
  5. Polish → clean YAML, confirmed builds