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>
8.8 KiB
Implementation Plan: Kubernetes Production Manifests
Branch: 013-k8s-manifests | Date: 2026-05-07 | Spec: 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)
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
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
# 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
apiVersion: v1
kind: Namespace
metadata:
name: reactbin
k8s/vault/vault-auth.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
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
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
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: [<your-domain>]
secretName: reactbin-tls
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}}
/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
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
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 |
<your-domain> placeholder in Ingress |
kubectl apply --dry-run=client validates everything except host value |
Noted in quickstart; hostname must be substituted before applying |