# 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: [<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`

```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` |
| `<your-domain>` placeholder in Ingress | `kubectl apply --dry-run=client` validates everything except host value | Noted in quickstart; hostname must be substituted before applying |
