diff --git a/.specify/feature.json b/.specify/feature.json index c0a7dcc..a8b5fad 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/012-api-docs-gate" + "feature_directory": "specs/013-k8s-manifests" } diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..524ca96 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,4 @@ +extends: relaxed +rules: + line-length: + max: 120 diff --git a/CLAUDE.md b/CLAUDE.md index 47f8eb0..396aeef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan at -`specs/012-api-docs-gate/plan.md`. +`specs/013-k8s-manifests/plan.md`. diff --git a/Makefile b/Makefile index fe0136d..14d08fa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod +.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod validate-k8s test-unit: cd api && python -m pytest tests/unit/ -v @@ -18,3 +18,8 @@ build-ui-prod: verify-ui-prod: bash ui/tests/build/verify_production_image.sh + +# Offline: yamllint only. Online (requires kubeconfig): kubectl apply --dry-run=client -f k8s/ +validate-k8s: + yamllint -d relaxed k8s/ + kubectl apply --dry-run=client -f k8s/ diff --git a/api/Dockerfile.prod b/api/Dockerfile.prod index 63d2c28..b2f08a6 100644 --- a/api/Dockerfile.prod +++ b/api/Dockerfile.prod @@ -35,6 +35,8 @@ RUN groupadd --system --gid 1001 appgroup \ COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv COPY --chown=appuser:appgroup app/ ./app/ +COPY --chown=appuser:appgroup alembic/ ./alembic/ +COPY --chown=appuser:appgroup alembic.ini . USER appuser diff --git a/k8s/api/deployment.yaml b/k8s/api/deployment.yaml new file mode 100644 index 0000000..3c25187 --- /dev/null +++ b/k8s/api/deployment.yaml @@ -0,0 +1,53 @@ +# Replace 'latest' with the real image tag before applying +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: reactbin +spec: + replicas: 1 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + initContainers: + - name: migrate + image: reactbin-api:latest + command: ["alembic", "upgrade", "head"] + workingDir: /app + envFrom: + - secretRef: + name: api-env + securityContext: + runAsNonRoot: true + runAsUser: 1001 + 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 diff --git a/k8s/api/service.yaml b/k8s/api/service.yaml new file mode 100644 index 0000000..f1fc91d --- /dev/null +++ b/k8s/api/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: api + namespace: reactbin +spec: + type: ClusterIP + selector: + app: api + ports: + - name: http + port: 8000 + targetPort: 8000 diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..977dc25 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,34 @@ +# Replace with the real domain before applying +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: + # /api/ must appear before / — nginx evaluates paths in declaration order + - path: /api/ + pathType: Prefix + backend: + service: + name: api + port: + number: 8000 + - path: / + pathType: Prefix + backend: + service: + name: ui + port: + number: 8080 diff --git a/k8s/minio/init-job.yaml b/k8s/minio/init-job.yaml new file mode 100644 index 0000000..bba61c8 --- /dev/null +++ b/k8s/minio/init-job.yaml @@ -0,0 +1,24 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: minio-init + namespace: reactbin +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: mc + image: minio/mc:latest + # mc runs as root by default; FR-013 exception documented in spec + securityContext: + runAsNonRoot: false + command: + - sh + - -c + - | + mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" + mc mb --ignore-existing local/reactbin + envFrom: + - secretRef: + name: minio-credentials diff --git a/k8s/minio/service.yaml b/k8s/minio/service.yaml new file mode 100644 index 0000000..8a6fe1b --- /dev/null +++ b/k8s/minio/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: minio + namespace: reactbin +spec: + type: ClusterIP + selector: + app: minio + ports: + - name: api + port: 9000 + targetPort: 9000 + - name: console + port: 9001 + targetPort: 9001 diff --git a/k8s/minio/statefulset.yaml b/k8s/minio/statefulset.yaml new file mode 100644 index 0000000..90dbed4 --- /dev/null +++ b/k8s/minio/statefulset.yaml @@ -0,0 +1,58 @@ +# Replace 'latest' with the real image tag before applying +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: minio + namespace: reactbin +spec: + serviceName: minio + replicas: 1 + selector: + matchLabels: + app: minio + template: + metadata: + labels: + app: minio + spec: + containers: + - name: minio + image: minio/minio:latest + args: + - server + - /data + - --console-address + - ":9001" + ports: + - containerPort: 9000 + - containerPort: 9001 + envFrom: + - secretRef: + name: minio-credentials + livenessProbe: + httpGet: + path: /minio/health/live + port: 9000 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /minio/health/ready + port: 9000 + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: data + mountPath: /data + securityContext: + runAsNonRoot: true + runAsUser: 1000 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..927d688 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: reactbin diff --git a/k8s/ui/deployment.yaml b/k8s/ui/deployment.yaml new file mode 100644 index 0000000..f216ac2 --- /dev/null +++ b/k8s/ui/deployment.yaml @@ -0,0 +1,30 @@ +# Replace 'latest' with the real image tag before applying +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ui + namespace: reactbin +spec: + replicas: 1 + selector: + matchLabels: + app: ui + template: + metadata: + labels: + app: ui + spec: + containers: + - name: ui + image: reactbin-ui:latest + ports: + - containerPort: 8080 + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 30 + securityContext: + runAsNonRoot: true + runAsUser: 101 # nginxinc/nginx-unprivileged default UID diff --git a/k8s/ui/service.yaml b/k8s/ui/service.yaml new file mode 100644 index 0000000..00cd340 --- /dev/null +++ b/k8s/ui/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: ui + namespace: reactbin +spec: + type: ClusterIP + selector: + app: ui + ports: + - name: http + port: 8080 + targetPort: 8080 diff --git a/k8s/vault/api-secret.yaml b/k8s/vault/api-secret.yaml new file mode 100644 index 0000000..f7b7ad7 --- /dev/null +++ b/k8s/vault/api-secret.yaml @@ -0,0 +1,18 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: api-secret + namespace: reactbin +spec: + vaultAuthRef: reactbin-auth + mount: secret + type: kv-v2 + # Required Vault keys at this path: + # 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 + path: reactbin/api/config + refreshAfter: 1h + destination: + name: api-env + create: true diff --git a/k8s/vault/minio-secret.yaml b/k8s/vault/minio-secret.yaml new file mode 100644 index 0000000..f187925 --- /dev/null +++ b/k8s/vault/minio-secret.yaml @@ -0,0 +1,16 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultStaticSecret +metadata: + name: minio-secret + namespace: reactbin +spec: + vaultAuthRef: reactbin-auth + mount: secret + type: kv-v2 + # Required Vault keys at this path: + # MINIO_ROOT_USER, MINIO_ROOT_PASSWORD + path: reactbin/minio/credentials + refreshAfter: 1h + destination: + name: minio-credentials + create: true diff --git a/k8s/vault/vault-auth.yaml b/k8s/vault/vault-auth.yaml new file mode 100644 index 0000000..30ac590 --- /dev/null +++ b/k8s/vault/vault-auth.yaml @@ -0,0 +1,16 @@ +apiVersion: secrets.hashicorp.com/v1beta1 +kind: VaultAuth +metadata: + name: reactbin-auth + namespace: reactbin +spec: + method: kubernetes + mount: kubernetes + kubernetes: + # The operator must create this role in Vault and bind it to the + # default service account in the reactbin namespace with read access + # to both reactbin/api/config and reactbin/minio/credentials. + role: reactbin + serviceAccount: default + audiences: + - https://kubernetes.default.svc diff --git a/specs/013-k8s-manifests/checklists/requirements.md b/specs/013-k8s-manifests/checklists/requirements.md new file mode 100644 index 0000000..18949d9 --- /dev/null +++ b/specs/013-k8s-manifests/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Kubernetes Production Manifests + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-07 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- FR-014 (migration files in production image) is a prerequisite code change to `Dockerfile.prod`, not a manifest. Included in scope as it is required for the init container to function. +- Image tag placeholder strategy is documented in Assumptions; the specifics of tag substitution (kustomize, sed, etc.) are left to planning. diff --git a/specs/013-k8s-manifests/contracts/operator-deploy.md b/specs/013-k8s-manifests/contracts/operator-deploy.md new file mode 100644 index 0000000..85b4bf2 --- /dev/null +++ b/specs/013-k8s-manifests/contracts/operator-deploy.md @@ -0,0 +1,59 @@ +# Contract: Operator Deployment Interface + +The manifests in `k8s/` define the operator's deployment interface — the inputs required before applying and the observable outputs after applying. + +## Pre-deployment Prerequisites (Operator-supplied) + +| Prerequisite | Details | +|---|---| +| Vault KV v2 secret at `reactbin/api/config` | Must contain 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` | +| Vault KV v2 secret at `reactbin/minio/credentials` | Must contain keys: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` | +| Vault Kubernetes auth role | A role in the Vault Kubernetes auth mount bound to the `default` service account in the `reactbin` namespace with read access to both paths above | +| `VaultConnection` resource | Named `default` in the operator's VSO namespace pointing to the Vault server address | +| External PostgreSQL database | A dedicated database and user created; `DATABASE_URL` in Vault reflects the credentials | +| DNS | The production domain resolves to the cluster ingress IP | +| `ClusterIssuer` | A cert-manager `ClusterIssuer` named `letsencrypt-prod` exists in the cluster | +| Image tags | The operator substitutes the `latest` placeholder in `k8s/api/deployment.yaml` and `k8s/ui/deployment.yaml` with the real image tag before applying | + +## Apply Command + +```bash +# Substitute image tags +sed -i 's|reactbin-api:latest|reactbin-api:|g' k8s/api/deployment.yaml +sed -i 's|reactbin-ui:latest|reactbin-ui:|g' k8s/ui/deployment.yaml + +# Apply all manifests +kubectl apply -f k8s/ +``` + +Applying is idempotent — safe to re-run on every deployment. + +## Observable Outputs (Post-apply) + +| Resource | Expected State | +|---|---| +| `Namespace/reactbin` | Active | +| `Deployment/api` in `reactbin` | 1/1 Ready (init container completes first) | +| `Deployment/ui` in `reactbin` | 1/1 Ready | +| `StatefulSet/minio` in `reactbin` | 1/1 Ready | +| `Job/minio-init-bucket` in `reactbin` | Completed | +| `Secret/api-env` in `reactbin` | Created by VSO, populated with all API env keys | +| `Secret/minio-credentials` in `reactbin` | Created by VSO, populated with MinIO root keys | +| `Certificate/reactbin-tls` in `reactbin` | Issued (may take up to 2 minutes on first apply) | +| `Ingress/reactbin` in `reactbin` | Address populated with cluster ingress IP | + +## Verification Commands + +```bash +# All pods running +kubectl get pods -n reactbin + +# API health +curl -sf https:///api/v1/health + +# UI reachable +curl -sf https:/// + +# Docs correctly gated (should return 404) +curl -o /dev/null -w "%{http_code}" https:///docs +``` diff --git a/specs/013-k8s-manifests/plan.md b/specs/013-k8s-manifests/plan.md new file mode 100644 index 0000000..6f954e2 --- /dev/null +++ b/specs/013-k8s-manifests/plan.md @@ -0,0 +1,238 @@ +# 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 | diff --git a/specs/013-k8s-manifests/quickstart.md b/specs/013-k8s-manifests/quickstart.md new file mode 100644 index 0000000..f45ed8c --- /dev/null +++ b/specs/013-k8s-manifests/quickstart.md @@ -0,0 +1,92 @@ +# Quickstart: Kubernetes Production Deployment + +## Before You Apply + +1. Store API secrets in Vault at `reactbin/api/config` (KV v2): + ``` + DATABASE_URL = postgresql+asyncpg://reactbin:@:5432/reactbin + JWT_SECRET_KEY = + OWNER_USERNAME = + OWNER_PASSWORD = + S3_ENDPOINT_URL = http://minio.reactbin.svc.cluster.local:9000 + S3_BUCKET_NAME = reactbin + S3_ACCESS_KEY_ID = + S3_SECRET_ACCESS_KEY = + API_BASE_URL = https:// + API_DOCS_ENABLED = false + ``` + +2. Store MinIO credentials in Vault at `reactbin/minio/credentials` (KV v2): + ``` + MINIO_ROOT_USER = + MINIO_ROOT_PASSWORD = + ``` + +3. Create a Vault Kubernetes auth role bound to the `default` service account in the `reactbin` namespace with read access to both paths above. + +4. Confirm DNS resolves to the cluster ingress IP and the `letsencrypt-prod` ClusterIssuer exists. + +## Deploy + +```bash +# Substitute the real image tags +sed -i 's|reactbin-api:latest|reactbin-api:v1.0.0|g' k8s/api/deployment.yaml +sed -i 's|reactbin-ui:latest|reactbin-ui:v1.0.0|g' k8s/ui/deployment.yaml + +# Apply everything +kubectl apply -f k8s/ +``` + +## Verify + +```bash +# Watch pods come up (init container runs first on the API pod) +kubectl get pods -n reactbin -w + +# API health +curl -sf https:///api/v1/health && echo "API OK" + +# UI reachable +curl -sf -o /dev/null -w "%{http_code}\n" https:/// + +# Docs correctly gated +curl -o /dev/null -w "%{http_code}\n" https:///docs # → 404 +curl -o /dev/null -w "%{http_code}\n" https:///redoc # → 404 + +# Check migration init container ran +kubectl logs -n reactbin -l app=api -c alembic-migrate +``` + +## Scenario: Migration fails on deploy + +```bash +# Pod will be stuck in Init state +kubectl get pods -n reactbin +# NAME READY STATUS RESTARTS +# api-xxx-yyy 0/1 Init:CrashLoopBackOff 2 + +# See why +kubectl logs -n reactbin -c alembic-migrate + +# Fix the issue (e.g. correct DATABASE_URL in Vault, wait for VSO to resync) +# Then delete the pod to force a fresh rollout +kubectl rollout restart deployment/api -n reactbin +``` + +## Scenario: Update to a new image version + +```bash +kubectl set image deployment/api api=reactbin-api:v1.1.0 -n reactbin +kubectl set image deployment/ui ui=reactbin-ui:v1.1.0 -n reactbin +# Kubernetes rolls out new pods; init container runs migrations before traffic switches +``` + +## Scenario: Restore after MinIO pod restart + +MinIO uses a PersistentVolumeClaim. Pod restarts do not affect stored data. Verify: + +```bash +kubectl delete pod -n reactbin minio-0 +kubectl get pods -n reactbin -w # minio-0 restarts, PVC reattaches +# Previously uploaded images should still be accessible via the API +``` diff --git a/specs/013-k8s-manifests/research.md b/specs/013-k8s-manifests/research.md new file mode 100644 index 0000000..b57a7a9 --- /dev/null +++ b/specs/013-k8s-manifests/research.md @@ -0,0 +1,63 @@ +# Research: Kubernetes Production Manifests + +## Decision 1: VSO CRD chain (VaultConnection → VaultAuth → VaultStaticSecret) + +**Decision**: Use three CRDs — `VaultConnection`, `VaultAuth`, and `VaultStaticSecret` — all under `apiVersion: secrets.hashicorp.com/v1beta1`. +**Rationale**: This is the required VSO resource chain. `VaultConnection` points to the Vault server address. `VaultAuth` declares the Kubernetes auth method (role, service account, mount path). `VaultStaticSecret` references a `VaultAuth` via `vaultAuthRef` and declares the Vault KV path and the destination K8s Secret name. VSO syncs all Vault keys to the K8s Secret 1:1 by default — no explicit key mapping needed. +**Alternatives considered**: `VaultAuthGlobal` for cross-namespace sharing — not needed; all resources are in the same `reactbin` namespace. + +Key fields: +- `VaultStaticSecret.spec.type`: `kv-v2` (standard for modern Vault) +- `VaultStaticSecret.spec.refreshAfter`: `1h` (Go duration string) +- `VaultStaticSecret.spec.destination.create: true` — VSO creates the K8s Secret if absent +- `VaultAuth.spec.kubernetes.role` — a Vault role the operator must pre-create and bind to the `reactbin` namespace service account + +## Decision 2: MinIO as StatefulSet (not Deployment) + +**Decision**: Run MinIO as a `StatefulSet` with `volumeClaimTemplates`. +**Rationale**: StatefulSet gives the pod a stable name (`minio-0`) and automatically reattaches its PVC on pod recreation. A Deployment would require a manually-created PVC and is prone to PVC binding issues on reschedule. The marginal complexity of a StatefulSet over a Deployment is acceptable. `ReadWriteOnce` PVC is correct for single-replica MinIO. +**Alternatives considered**: Deployment with explicit PVC — works but PVC lifecycle is decoupled from the pod, creating operational risk. + +MinIO health probes: +- Liveness: `GET /minio/health/live:9000` +- Readiness: `GET /minio/health/ready:9000` + +MinIO env vars: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` (injected from a K8s Secret synced by VSO). + +## Decision 3: Bucket initialisation via Kubernetes Job with `minio/mc` + +**Decision**: A one-off `Job` using `minio/mc:latest` runs `mc mb --ignore-existing` to create the bucket idempotently. +**Rationale**: This is the standard in-cluster pattern. `--ignore-existing` makes the job safe to re-apply (exits 0 if bucket already exists). `restartPolicy: OnFailure` retries transient failures (e.g. MinIO not yet ready). +**Alternatives considered**: Init container on the API pod — tightly couples bucket creation to API startup; a Job is cleaner and independently rerunnable. + +## Decision 4: Ingress — single resource, `/api/` path before `/` + +**Decision**: One `Ingress` resource with `ingressClassName: nginx`, two path entries in a single rule: `/api/` (Prefix) → API Service, `/` (Prefix) → UI Service; `/api/` must be listed first. +**Rationale**: nginx ingress evaluates paths in declaration order; the more specific `/api/` prefix must appear before `/` or all traffic is routed to the UI. No path rewriting annotation is needed — the API already handles full `/api/v1/...` paths. +**TLS**: cert-manager annotation `cert-manager.io/cluster-issuer: letsencrypt-prod` triggers automatic certificate provisioning into a K8s Secret named in `spec.tls[].secretName`. HTTP→HTTPS redirect is on by default when TLS is configured (`nginx.ingress.kubernetes.io/ssl-redirect: "true"` is explicit but redundant). +**Alternatives considered**: Two separate Ingress resources (one per service) — works but harder to reason about routing order; single Ingress is canonical. + +## Decision 5: Alembic init container — same image, workdir `/app` + +**Decision**: The API Deployment includes an init container with the same image as the main container, `command: ["alembic", "upgrade", "head"]`, and `workingDir: /app`. It shares the API's env secret via `envFrom` so it can read `DATABASE_URL`. +**Rationale**: Alembic needs `DATABASE_URL` to connect and `alembic.ini` + `alembic/` to find migrations. Both are available in the production image once `Dockerfile.prod` is updated. Using the same image guarantees the migration files match the running version. +**Dockerfile.prod update required**: Add `COPY --chown=appuser:appgroup alembic/ ./alembic/` and `COPY --chown=appuser:appgroup alembic.ini .` in the runtime stage (not the builder stage — no compilation needed). +**Alternatives considered**: Separate migration image — adds a second image to build and push on every release; unnecessary when the source image already has everything. + +## Decision 6: Image tag strategy — placeholder `latest`, substituted at deploy time + +**Decision**: Manifests reference image tags using `latest` as a documented placeholder. The operator substitutes the real tag with `kubectl set image` or a `sed` one-liner before applying. +**Rationale**: Kustomize's `images` transformer is the clean alternative, but introduces a tooling dependency. For a personal single-operator deployment, `sed` or `kubectl set image` after `kubectl apply` is simpler and requires no additional setup. The placeholder is documented in the operator guide (quickstart.md). +**Alternatives considered**: Kustomize overlays — appropriate for multi-environment setups; over-engineered for one environment. + +## Decision 7: Two VaultStaticSecrets (API env and MinIO credentials) + +**Decision**: Separate VaultStaticSecret resources for API env vars and MinIO root credentials, syncing into `api-env` and `minio-credentials` K8s Secrets respectively. +**Rationale**: The API's env secret contains database, JWT, and S3 access credentials. MinIO's root credentials are a different concern with a different rotation lifecycle. Keeping them separate makes Vault policies simpler (least privilege) and avoids giving the API pod access to MinIO's root password. +**Vault paths assumed**: `reactbin/api/config` (KV v2) for API env; `reactbin/minio/credentials` (KV v2) for MinIO root credentials. + +## Decision 8: Namespace manifest included in `k8s/` + +**Decision**: `k8s/namespace.yaml` creates the `reactbin` namespace as part of the manifest set. +**Rationale**: Makes the full deployment self-contained — operator runs `kubectl apply -f k8s/` without a prerequisite namespace creation step. +**Note**: If the namespace already exists, `kubectl apply` is idempotent. diff --git a/specs/013-k8s-manifests/spec.md b/specs/013-k8s-manifests/spec.md new file mode 100644 index 0000000..03fecbe --- /dev/null +++ b/specs/013-k8s-manifests/spec.md @@ -0,0 +1,124 @@ +# Feature Specification: Kubernetes Production Manifests + +**Feature Branch**: `013-k8s-manifests` +**Created**: 2026-05-07 +**Status**: Draft +**Input**: User description: "Kubernetes manifests for production deployment to k3s: Deployment, Service, and Ingress for the API and UI; VaultStaticSecret CRDs to sync secrets from HashiCorp Vault; Alembic init container on the API Deployment for schema migrations. The cluster uses an nginx ingress controller with Let's Encrypt TLS, a shared external Postgres instance, MinIO running in-cluster, and VSO (Vault Secrets Operator) for secret management." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Application Reachable in Production (Priority: P1) + +As an operator, I can apply the manifests to my k3s cluster and have both the API and UI reachable at the production domain over HTTPS, with all health checks passing. + +**Why this priority**: This is the core deployment goal. Nothing else matters if the application is not reachable. + +**Independent Test**: Apply the API and UI manifests with a manually-created K8s Secret (bypassing Vault). Confirm the UI loads at the domain root and the API health endpoint returns 200 at `/api/v1/health`. Confirm HTTPS is enforced and HTTP redirects to HTTPS. + +**Acceptance Scenarios**: + +1. **Given** the manifests are applied to the cluster, **When** a browser navigates to `https:///`, **Then** the UI loads successfully with a valid TLS certificate. +2. **Given** the manifests are applied, **When** a request is made to `https:///api/v1/health`, **Then** a 200 response is returned. +3. **Given** the API docs flag is disabled, **When** a request is made to `https:///docs`, **Then** a 404 is returned. +4. **Given** the API pod is restarted, **When** it comes back up, **Then** it passes readiness checks before receiving traffic. +5. **Given** a request for an unknown path, **When** it is made to the UI, **Then** the SPA serves the index page (client-side routing is preserved). + +--- + +### User Story 2 — Secrets Sourced from Vault (Priority: P2) + +As an operator, no secrets are stored in version-controlled manifest files. All sensitive values are declared in Vault and synced automatically into the cluster as Kubernetes Secrets by the Vault Secrets Operator. + +**Why this priority**: Security prerequisite for production. Hardcoded secrets in manifests are a material risk. + +**Independent Test**: Run `git grep` for known secret patterns across `k8s/` and confirm zero matches. Confirm VaultStaticSecret CRDs reference a Vault path and that the synced K8s Secret is created and the API pod's environment is populated from it. + +**Acceptance Scenarios**: + +1. **Given** Vault contains the required secret values at the declared path, **When** VSO is running, **Then** a K8s Secret is created in the cluster namespace with the declared keys. +2. **Given** the K8s Secret exists, **When** the API pod starts, **Then** its environment variables are populated from that secret. +3. **Given** a `git grep` for plaintext credentials across `k8s/`, **When** run against the committed manifests, **Then** no plaintext secrets are found. + +--- + +### User Story 3 — Schema Migrations Run Before API Starts (Priority: P3) + +As an operator, every time the API is deployed, database migrations run automatically in an init container before the main application container starts. A failed migration prevents the pod from starting, protecting against schema drift. + +**Why this priority**: Prevents the API from serving requests against a stale or incompatible schema. Safe deployment ordering is essential for production. + +**Independent Test**: Deploy with the init container pointing at a valid database. Confirm migrations run and the API starts. Simulate a failing migration by pointing the init container at an unreachable database and confirm the pod stays in init state and does not serve traffic. + +**Acceptance Scenarios**: + +1. **Given** the API Deployment is applied, **When** the pod starts, **Then** the init container completes `alembic upgrade head` before the main container starts. +2. **Given** the schema is already current, **When** the pod starts, **Then** the migration init container exits successfully with no changes applied. +3. **Given** the migration fails, **When** the pod starts, **Then** the init container exits non-zero, the main container does not start, and the pod enters a visible error state. + +--- + +### User Story 4 — MinIO Runs In-Cluster with Persistent Storage (Priority: P4) + +As an operator, MinIO runs inside the cluster with a PersistentVolumeClaim for durable storage, is not externally reachable, and has the required bucket initialised on first deployment. + +**Why this priority**: Required for image storage, but decoupled from the other manifests — the S3 endpoint is just a config value the API reads. + +**Independent Test**: Confirm the MinIO pod is running and has no external Ingress. Confirm the required bucket exists. Restart the MinIO pod and confirm previously stored objects are still accessible. + +**Acceptance Scenarios**: + +1. **Given** the MinIO manifests are applied, **When** the MinIO pod starts, **Then** the required bucket is created and the API can store and retrieve images. +2. **Given** the MinIO pod is restarted, **When** it comes back up, **Then** all previously stored objects remain accessible (PVC-backed storage persists). +3. **Given** no Ingress is defined for MinIO, **When** a connection is attempted from outside the cluster, **Then** MinIO is not reachable. + +--- + +### Edge Cases + +- What if Vault is unavailable when VSO tries to sync? VSO retries on a configurable interval; the pod will not start until the K8s Secret exists. +- What if the database is unreachable during migration? The init container exits non-zero; the pod does not start and Kubernetes retries with backoff. +- What if the MinIO PVC runs out of space? MinIO will fail writes; the API will return upload errors. Capacity monitoring is out of scope for this feature. +- What if migrations and the main container use different image tags? They use the same tag in the same Deployment spec, so they are always in sync. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: All manifests MUST target a single configurable namespace (default: `reactbin`). +- **FR-002**: The API MUST be deployed as a Deployment with liveness and readiness probes on `/api/v1/health`. +- **FR-003**: The API Deployment MUST include an init container using the same image that runs database schema migrations before the main container starts. +- **FR-004**: The API Deployment MUST set `API_DOCS_ENABLED=false`. +- **FR-005**: The UI MUST be deployed as a Deployment with a liveness probe confirming the nginx process is serving. +- **FR-006**: A single Ingress MUST route `https:///api/` to the API Service and all other paths to the UI Service, with TLS termination via a cert-manager Let's Encrypt certificate. +- **FR-007**: HTTP requests MUST be redirected to HTTPS via the Ingress. +- **FR-008**: All API secrets MUST be declared in a VaultStaticSecret CRD and synced into a K8s Secret; no secret value MUST appear as plaintext in any manifest file. +- **FR-009**: The API Deployment MUST source all environment variables from the synced K8s Secret via `envFrom`. +- **FR-010**: MinIO MUST be deployed as a StatefulSet with a PersistentVolumeClaim using the cluster's default storage class. +- **FR-011**: A Kubernetes Job MUST create the required S3 bucket in MinIO on first deployment and MUST be idempotent on re-apply. +- **FR-012**: MinIO MUST have no Ingress; it MUST only be accessible within the cluster via ClusterIP. +- **FR-013**: All containers MUST run as non-root users. +- **FR-014**: The API production image MUST include migration files so the init container can run migrations without a separate image. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: The application is accessible at the production domain within 120 seconds of `kubectl apply`. +- **SC-002**: Schema migrations complete and the API begins serving traffic without manual operator intervention on every deployment. +- **SC-003**: A `git grep` across `k8s/` finds zero plaintext secret values in committed files. +- **SC-004**: A simulated migration failure holds the pod in init state and the application never serves traffic. +- **SC-005**: Restarting the MinIO pod does not result in data loss — previously uploaded images remain accessible. + +## Assumptions + +- The k3s cluster is running with the nginx ingress controller installed. +- cert-manager is installed and a `ClusterIssuer` named `letsencrypt-prod` is already configured. +- The Vault Secrets Operator is installed in the cluster. +- A HashiCorp Vault instance is accessible from the cluster and the required secret values are stored at the declared Vault path before deployment. +- A shared external PostgreSQL instance is available; the operator creates a dedicated database and user before deploying. +- DNS for the production domain is already pointing at the cluster ingress IP. +- Manifests are stored in a `k8s/` directory at the repository root. +- The cluster's default storage class supports ReadWriteOnce (sufficient for single-replica MinIO). +- All Deployments run a single replica (personal tool, no HA requirement). +- Image tags are managed externally; manifests use a placeholder tag that the operator substitutes at deploy time. +- The `API_DOCS_ENABLED` flag exists on the API (implemented in feature 012). diff --git a/specs/013-k8s-manifests/tasks.md b/specs/013-k8s-manifests/tasks.md new file mode 100644 index 0000000..dc5027a --- /dev/null +++ b/specs/013-k8s-manifests/tasks.md @@ -0,0 +1,174 @@ +# 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. + +- [X] 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 + +- [X] 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. + +- [X] T003 Create `k8s/namespace.yaml`: a single `Namespace` resource with `name: reactbin` and no additional labels + +- [X] 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 + +- [X] 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` + +- [X] 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:///api/v1/health` returns 200 and `curl https:///` returns 200. + +- [X] 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}]` + +- [X] 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}]` + +- [X] 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` + +- [X] 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` + +- [X] 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: [""], secretName: reactbin-tls}]`; `spec.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}}}}]}}]`; IMPORTANT — `/api/` path entry MUST appear before `/` in the YAML (nginx evaluates in declaration order); add comment: `# Replace with the real domain before applying` + +- [X] 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. + +- [X] 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. + +- [X] 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` + +- [X] 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 + +- [X] 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. + +- [X] 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)` + +- [X] 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` + +- [X] 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` + +- [X] 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 + +- [X] 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 + +- [X] 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 + +- [X] 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 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) + +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:///` 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