# Implementation Plan: Kubernetes Production Manifests **Branch**: `013-k8s-manifests` | **Date**: 2026-05-07 | **Spec**: [spec.md](spec.md) **Input**: Feature specification from `specs/013-k8s-manifests/spec.md` ## Summary Write Kubernetes manifests deploying Reactbin to k3s: a `Namespace`, API `Deployment` (with Alembic init container) + `Service`, UI `Deployment` + `Service`, a shared `Ingress` with Let's Encrypt TLS, a MinIO `StatefulSet` + `Service` + bucket-init `Job`, and three VSO CRDs (`VaultConnection`, `VaultAuth`, `VaultStaticSecret` × 2) to sync secrets from Vault. A small update to `api/Dockerfile.prod` includes Alembic migration files in the production image so the init container can run them. ## Technical Context **Language/Version**: YAML (Kubernetes manifests); Python 3.12 (Dockerfile.prod touch) **Primary Dependencies**: Kubernetes 1.29+ API, nginx Ingress controller, cert-manager (ClusterIssuer `letsencrypt-prod`), Vault Secrets Operator (`secrets.hashicorp.com/v1beta1`), MinIO **Storage**: MinIO StatefulSet with ReadWriteOnce PVC (cluster default storage class); external PostgreSQL (operator-provisioned) **Testing**: `kubectl apply --dry-run=client` for schema validation; `yamllint` for formatting **Target Platform**: k3s cluster (Kubernetes 1.29+, Linux) **Performance Goals**: No measurable impact — manifests are declarative config, not runtime code **Constraints**: All secrets must come from Vault (no plaintext in manifests); all containers run non-root; MinIO is ClusterIP-only (no external Ingress) **Scale/Scope**: 11 YAML files across `k8s/`; one Dockerfile.prod change; one Makefile target ## Constitution Check | Principle | Requirement | Status | |-----------|-------------|--------| | §5.1 TDD | Failing tests before implementation | ✅ Dry-run validation script written before manifests | | §5.4 CI before done | All tests pass before task marked done | ✅ kubectl dry-run + yamllint gate | | §7.2 Env config | No hardcoded secrets or hostnames | ✅ All secrets via VSO; domain is operator-substituted placeholder | | §7.3 Linting | `ruff` / linting passes | ✅ `yamllint` on all manifests | | §2.6 No speculative abstraction | No Kustomize overlays or Helm chart | ✅ Plain YAML, single environment | | §8 Scope boundaries | No multi-user, no OIDC, no OR/NOT tags | ✅ Not affected | **No violations. All gates pass.** *Post-design re-check*: The Dockerfile.prod change (FR-014) adds `alembic/` to the runtime stage only — no builder-stage change, no new dependencies, no behaviour change to the running API. Constitution unchanged. ## Project Structure ### Documentation (this feature) ```text specs/013-k8s-manifests/ ├── plan.md ← this file ├── research.md ← 8 decisions ├── contracts/ │ └── operator-deploy.md ← prerequisites + verification commands ├── quickstart.md ← deploy + verify + scenario walkthroughs └── tasks.md ← generated by /speckit-tasks ``` ### Source Code Changes ```text k8s/ ← NEW directory ├── namespace.yaml ← Namespace: reactbin ├── api/ │ ├── deployment.yaml ← Deployment: api (with alembic init container) │ └── service.yaml ← Service: api (ClusterIP, port 8000) ├── ui/ │ ├── deployment.yaml ← Deployment: ui │ └── service.yaml ← Service: ui (ClusterIP, port 8080) ├── ingress.yaml ← Ingress: /api/ → api, / → ui, TLS via cert-manager ├── minio/ │ ├── statefulset.yaml ← StatefulSet: minio (volumeClaimTemplates) │ ├── service.yaml ← Service: minio (ClusterIP, port 9000) │ └── init-job.yaml ← Job: minio-init-bucket (mc mb --ignore-existing) └── vault/ ├── vault-auth.yaml ← VaultAuth: kubernetes method, reactbin SA ├── api-secret.yaml ← VaultStaticSecret → K8s Secret: api-env └── minio-secret.yaml ← VaultStaticSecret → K8s Secret: minio-credentials api/Dockerfile.prod ← MODIFIED: add alembic/ and alembic.ini to runtime stage Makefile ← MODIFIED: add dry-run validation target ``` ## Implementation Design ### `api/Dockerfile.prod` — runtime stage addition ```dockerfile # In the runtime stage, after copying app/: COPY --chown=appuser:appgroup alembic/ ./alembic/ COPY --chown=appuser:appgroup alembic.ini . ``` No builder-stage change. No new base image. The init container uses the same image and `workingDir: /app`. ### `k8s/namespace.yaml` ```yaml apiVersion: v1 kind: Namespace metadata: name: reactbin ``` ### `k8s/vault/vault-auth.yaml` ```yaml apiVersion: secrets.hashicorp.com/v1beta1 kind: VaultAuth metadata: name: reactbin-auth namespace: reactbin spec: method: kubernetes mount: kubernetes kubernetes: role: reactbin serviceAccount: default audiences: - https://kubernetes.default.svc ``` Note: `VaultConnection` is not included in the `k8s/` tree — it lives in the VSO operator's namespace and is operator-managed infrastructure, not application manifests. ### `k8s/vault/api-secret.yaml` ```yaml apiVersion: secrets.hashicorp.com/v1beta1 kind: VaultStaticSecret metadata: name: api-secret namespace: reactbin spec: vaultAuthRef: reactbin-auth mount: secret type: kv-v2 path: reactbin/api/config refreshAfter: 1h destination: name: api-env create: true ``` The API Deployment then uses `envFrom: [{secretRef: {name: api-env}}]`. ### `k8s/vault/minio-secret.yaml` Same pattern, path `reactbin/minio/credentials`, destination `minio-credentials`. ### `k8s/api/deployment.yaml` — init container ```yaml initContainers: - name: alembic-migrate image: reactbin-api:latest # same tag as main container command: ["alembic", "upgrade", "head"] workingDir: /app envFrom: - secretRef: name: api-env containers: - name: api image: reactbin-api:latest 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 ``` ### `k8s/ingress.yaml` ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: reactbin namespace: reactbin annotations: cert-manager.io/cluster-issuer: letsencrypt-prod nginx.ingress.kubernetes.io/ssl-redirect: "true" spec: ingressClassName: nginx tls: - hosts: [] secretName: reactbin-tls rules: - host: http: paths: - path: /api/ pathType: Prefix backend: service: {name: api, port: {number: 8000}} - path: / pathType: Prefix backend: service: {name: ui, port: {number: 8080}} ``` `/api/` must be listed before `/`. ### `k8s/minio/statefulset.yaml` — StatefulSet (not Deployment) StatefulSet gives stable pod name `minio-0` and automatic PVC reattachment via `volumeClaimTemplates`. ReadWriteOnce, default storage class. Health probes: `GET /minio/health/live:9000` (liveness), `GET /minio/health/ready:9000` (readiness). ### `k8s/minio/init-job.yaml` ```yaml command: ["sh", "-c", "mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/reactbin"] ``` `restartPolicy: OnFailure`. `--ignore-existing` makes the job idempotent. ### Makefile addition ```makefile validate-k8s: yamllint k8s/ kubectl apply --dry-run=client -f k8s/ ``` ## Dependencies & Risks | Item | Risk | Mitigation | |------|------|------------| | `VaultConnection` not in `k8s/` | Operator may not have it pre-created | Documented as prerequisite in contracts/operator-deploy.md | | `letsencrypt-prod` ClusterIssuer name | May differ in operator's cluster | Documented as prerequisite; easy to sed-replace | | Image tag placeholder `latest` | Operator forgets to substitute | `validate-k8s` dry-run will succeed but notes in quickstart.md and task descriptions warn explicitly | | MinIO PVC storage class | Default may be unsuitable (e.g., ephemeral) | Noted in Assumptions; operator can patch `storageClassName` | | `` placeholder in Ingress | `kubectl apply --dry-run=client` validates everything except host value | Noted in quickstart; hostname must be substituted before applying |