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>
16 KiB
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 3–6 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/vaultfrom the repository root; confirm the four subdirectories exist -
T002 Add a
validate-k8starget toMakefileimmediately after the existingverify-ui-prodtarget: the target MUST runyamllint -d relaxed k8s/thenkubectl apply --dry-run=client -f k8s/; addvalidate-k8sto the.PHONYline; note in a comment thatkubectl apply --dry-run=clientrequires a kubeconfig with cluster access — offline validation usesyamllintonly
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 singleNamespaceresource withname: reactbinand no additional labels -
T004 [P] Create
k8s/vault/vault-auth.yaml: aVaultAuthresource (apiVersion: secrets.hashicorp.com/v1beta1) withname: 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 thedefaultSA in thereactbinnamespace with read access to both secret paths -
T005 [P] Create
k8s/vault/api-secret.yaml: aVaultStaticSecretresource withname: 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 butname: 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}; containername: 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}; containername: 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; runkubectl 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 thatk8s/vault/api-secret.yamlandk8s/vault/minio-secret.yamlreference Vault paths underspec.pathand thatspec.destination.create: trueis 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 (theFROM python:3.12-slimstage), after the lineCOPY --chown=appuser:appgroup app/ ./app/, add two new lines:COPY --chown=appuser:appgroup alembic/ ./alembic/andCOPY --chown=appuser:appgroup alembic.ini .; the builder stage is unchanged; verify withdocker 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 aninitContainersblock to the pod spec (before thecontainersblock) 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(oryamllint -d relaxed k8s/) and confirm the updated deployment.yaml with the init container passes validation; rundocker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:testand confirm it succeeds; rundocker run --rm reactbin-api-prod:test ls /app/alembic.iniand 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; podsecurityContext: {runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000}; containername: 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; containername: 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(oryamllint -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.ymlat the repository root (if not already present) withextends: relaxedandrules: {line-length: {max: 120}}to keep line length reasonable for verbose K8s YAML -
T023 Run
make build-prodto confirmapi/Dockerfile.prodstill builds cleanly after the T014 addition; rundocker 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 T007–T011
- 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 T017–T019
- 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)
- Phase 1 (Setup) + Phase 2 (Foundational)
- Phase 3 (US1 — API, UI, Ingress)
- Phase 4 (US2 — verify no plaintext secrets)
- STOP and VALIDATE: apply to cluster, confirm
https://<domain>/and/api/v1/healthreturn 200 - Deploy MVP
Incremental Delivery
- Setup + Foundational → Apply → namespace and Vault sync ready
- Add US1 (API + UI + Ingress) → Deploy → application reachable at domain
- Add US3 (Alembic init container) → Deploy → migrations run automatically on rollout
- Add US4 (MinIO) → Deploy → persistent image storage in-cluster
- Polish → clean YAML, confirmed builds