Files
reactbin/specs/013-k8s-manifests/plan.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

8.8 KiB
Raw Blame History

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