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>
239 lines
8.8 KiB
Markdown
239 lines
8.8 KiB
Markdown
# 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 |
|