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>
This commit is contained in:
238
specs/013-k8s-manifests/plan.md
Normal file
238
specs/013-k8s-manifests/plan.md
Normal file
@@ -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: [<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 |
|
||||
Reference in New Issue
Block a user