Compare commits
12 Commits
ce279e6121
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 728efeaa48 | |||
| c858e47daa | |||
| 9db20fdf90 | |||
| 9b66fe1918 | |||
| e9a2e9f014 | |||
| 7b3d4a9257 | |||
| 7c57629941 | |||
| 4fe8b19d19 | |||
| e34c9f7b7f | |||
| 551ddbec3b | |||
| 666c32cd69 | |||
| bf27c97deb |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/012-api-docs-gate"
|
||||
"feature_directory": "specs/013-k8s-manifests"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: 1.2.0 → 1.3.0
|
||||
Ratified: 2026-05-01 | Last amended: 2026-05-06
|
||||
Version change: 1.3.0 → 1.4.0
|
||||
Ratified: 2026-05-01 | Last amended: 2026-05-08
|
||||
|
||||
Principles introduced (first population from docs/CONSTITUTION.md):
|
||||
- §2 Architecture Principles (6 sub-principles)
|
||||
@@ -171,11 +171,14 @@ OR/NOT logic is explicitly out of scope until the constitution is revised.
|
||||
|
||||
## 5. Testing Discipline
|
||||
|
||||
### 5.1 TDD is non-negotiable
|
||||
### 5.1 Tests are required alongside every implementation task
|
||||
|
||||
No production code MAY be written before a failing test exists for it. This
|
||||
applies to both API and UI. Tasks MUST include a "write failing test" step
|
||||
before any implementation step.
|
||||
Every implementation task MUST be accompanied by tests covering its behaviour.
|
||||
The ideal is red-green-refactor: write a failing test, then make it pass. In
|
||||
practice, tests written in the same task as the implementation are acceptable;
|
||||
what is non-negotiable is that no implementation task is marked done without
|
||||
corresponding test coverage. Tasks MUST NOT be split such that implementation
|
||||
is complete but tests are deferred to a later task.
|
||||
|
||||
### 5.2 Test pyramid
|
||||
|
||||
@@ -194,10 +197,15 @@ Unit and integration tests are required. E2E tests are best-effort in v1.
|
||||
API tests in `api/tests/`, UI tests colocated with their components. No
|
||||
separate top-level `tests/` directory that mirrors the source tree.
|
||||
|
||||
### 5.4 CI must pass before any task is considered done
|
||||
### 5.4 The test suite must pass before any task is considered done
|
||||
|
||||
"Done" means: all tests pass, linter passes, type checker passes. A task MUST
|
||||
NOT be marked complete while CI is failing.
|
||||
NOT be marked complete while any of these are failing.
|
||||
|
||||
The acceptance gate is `make test-unit && make test-integration` plus `ruff
|
||||
check` / `ruff format --check` for the API. A formal CI pipeline is planned
|
||||
but not yet in place; until one exists, passing the above commands locally is
|
||||
the required gate. When CI is introduced it MUST enforce the same checks.
|
||||
|
||||
---
|
||||
|
||||
@@ -214,6 +222,9 @@ NOT be marked complete while CI is failing.
|
||||
| UI framework | Angular (latest stable) | Job-relevant, learning goal |
|
||||
| UI language | TypeScript strict mode | No `any`, no implicit types |
|
||||
| Containerisation | Docker + Docker Compose | Local dev must start with one command |
|
||||
| Production runtime | k3s (Kubernetes) | Manifests in `k8s/`; see deployment docs |
|
||||
| Ingress | nginx ingress controller + cert-manager | TLS via Let's Encrypt (`letsencrypt-prod` ClusterIssuer) |
|
||||
| Secret management | HashiCorp Vault + VSO (Vault Secrets Operator) | Secrets never committed; VSO syncs Vault KV v2 → K8s Secrets |
|
||||
|
||||
---
|
||||
|
||||
@@ -251,6 +262,15 @@ revised:
|
||||
- Mobile-native app
|
||||
- OIDC auth (planned Phase 3)
|
||||
|
||||
**Known gaps carried forward from v1** — these are not out of scope; they are
|
||||
acknowledged deficiencies that MUST be resolved before the affected area is
|
||||
expanded:
|
||||
|
||||
- **Password hashing**: The owner password is currently stored and compared in
|
||||
plaintext. Hashing (bcrypt or Argon2) MUST be implemented before any
|
||||
additional authentication work (e.g. OIDC, additional accounts) is started.
|
||||
Specs that touch credential storage MUST address this first.
|
||||
|
||||
---
|
||||
|
||||
## 9. Governance
|
||||
@@ -289,7 +309,8 @@ Phase 1 design is complete.
|
||||
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
|
||||
| 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) |
|
||||
| 1.3.0 | 2026-05-06 | §2.5: Remove planned PostgreSQL → SQLite refactor note; prohibit alternative database engines in integration tests. §5.2: Explicitly require PostgreSQL for integration tests; prohibit SQLite — a production HAVING/GROUP BY bug was masked by SQLite's permissive dialect. |
|
||||
| 1.4.0 | 2026-05-08 | §5.1: Soften strict TDD wording to reflect actual practice — tests alongside implementation are acceptable; deferring tests to a later task is not. §5.4: Replace "CI must pass" with local test suite gate; note CI is planned but not yet in place. §6: Add production runtime rows (k3s, nginx ingress + cert-manager, Vault + VSO). §8: Add "known gaps" subsection; document plaintext password storage as a deficiency that must be resolved before further auth work. |
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.3.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-06
|
||||
**Version**: 1.4.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-08
|
||||
|
||||
4
.yamllint.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
extends: relaxed
|
||||
rules:
|
||||
line-length:
|
||||
max: 120
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan at
|
||||
`specs/012-api-docs-gate/plan.md`.
|
||||
`specs/013-k8s-manifests/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
7
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod
|
||||
.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod validate-k8s
|
||||
|
||||
test-unit:
|
||||
cd api && python -m pytest tests/unit/ -v
|
||||
@@ -18,3 +18,8 @@ build-ui-prod:
|
||||
|
||||
verify-ui-prod:
|
||||
bash ui/tests/build/verify_production_image.sh
|
||||
|
||||
# Offline: yamllint only. Online (requires kubeconfig): kubectl apply --dry-run=client -f k8s/
|
||||
validate-k8s:
|
||||
yamllint -d relaxed k8s/
|
||||
kubectl apply --dry-run=client -f k8s/
|
||||
|
||||
138
README.md
@@ -2,3 +2,141 @@
|
||||
_Organize your reaction images._
|
||||
|
||||

|
||||
|
||||
A self-hosted reaction image board. Single owner account, tag-based browsing, S3-compatible image storage.
|
||||
|
||||
---
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env — defaults work out of the box for local dev
|
||||
docker compose up
|
||||
```
|
||||
|
||||
- UI: http://localhost:4200
|
||||
- API: http://localhost:8000
|
||||
- MinIO console: http://localhost:9001 (minioadmin / minioadmin)
|
||||
|
||||
The API serves on port 8000 directly in dev. In production the nginx ingress routes `/api/` there.
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
make test-unit # pytest unit tests (no Docker)
|
||||
make test-integration # builds api-test image, runs full suite against Postgres + MinIO
|
||||
```
|
||||
|
||||
### Production image builds
|
||||
|
||||
```bash
|
||||
make build-prod # builds reactbin-api-prod:latest from api/Dockerfile.prod
|
||||
make verify-prod # smoke-tests the production image
|
||||
make build-ui-prod # builds reactbin-ui-prod:latest from ui/Dockerfile.prod
|
||||
make verify-ui-prod # smoke-tests the production UI image
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production deployment (k3s)
|
||||
|
||||
### Cluster prerequisites
|
||||
|
||||
- nginx ingress controller
|
||||
- cert-manager with a `letsencrypt-prod` ClusterIssuer
|
||||
- Vault Secrets Operator (VSO) installed and connected to Vault
|
||||
- Vault KV v2 secrets populated (see below)
|
||||
|
||||
### Vault secrets
|
||||
|
||||
Two KV v2 paths. VSO syncs these into Kubernetes Secrets automatically.
|
||||
|
||||
**`reactbin/api/config`** → K8s Secret `api-env`
|
||||
|
||||
| Key | Notes |
|
||||
|-----|-------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://user:pass@host:5432/db` |
|
||||
| `JWT_SECRET_KEY` | Long random string — `openssl rand -base64 48` |
|
||||
| `OWNER_USERNAME` | Login username |
|
||||
| `OWNER_PASSWORD` | Login password |
|
||||
| `S3_ENDPOINT_URL` | `http://minio.reactbin.svc.cluster.local:9000` |
|
||||
| `S3_BUCKET_NAME` | `reactbin` |
|
||||
| `S3_ACCESS_KEY_ID` | Same value as `MINIO_ROOT_USER` |
|
||||
| `S3_SECRET_ACCESS_KEY` | Same value as `MINIO_ROOT_PASSWORD` |
|
||||
| `API_BASE_URL` | `https://<your-domain>` |
|
||||
| `LOGIN_TRUSTED_PROXY_IPS` | Pod CIDR of nginx ingress pods, e.g. `10.42.0.0/16` — needed for per-client login rate limiting behind the ingress |
|
||||
|
||||
**`reactbin/minio/credentials`** → K8s Secret `minio-credentials`
|
||||
|
||||
| Key | Notes |
|
||||
|-----|-------|
|
||||
| `MINIO_ROOT_USER` | MinIO admin username |
|
||||
| `MINIO_ROOT_PASSWORD` | `openssl rand -base64 32` |
|
||||
|
||||
### Apply order
|
||||
|
||||
```bash
|
||||
# 1. Namespace first
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
|
||||
# 2. Vault CRDs — wait for VSO to create api-env and minio-credentials Secrets
|
||||
kubectl apply -f k8s/vault/
|
||||
kubectl get secret -n reactbin api-env minio-credentials # wait until both appear
|
||||
|
||||
# 3. API, UI, Ingress — replace 'latest' tags and <your-domain> first
|
||||
kubectl apply -f k8s/api/ -f k8s/ui/ -f k8s/ingress.yaml
|
||||
kubectl rollout status deployment/api -n reactbin # Alembic init container runs here
|
||||
|
||||
# 4. MinIO — wait for StatefulSet ready before running the bucket init Job
|
||||
kubectl apply -f k8s/minio/service.yaml -f k8s/minio/statefulset.yaml
|
||||
kubectl rollout status statefulset/minio -n reactbin
|
||||
kubectl apply -f k8s/minio/init-job.yaml
|
||||
```
|
||||
|
||||
Before applying: substitute real image tags in the Deployment manifests and replace `<your-domain>` in `k8s/ingress.yaml`.
|
||||
|
||||
### Updating a secret
|
||||
|
||||
1. Update the value in Vault
|
||||
2. Force VSO to sync immediately (otherwise waits up to 1 hour):
|
||||
```bash
|
||||
kubectl annotate vaultstaticsecret api-secret -n reactbin \
|
||||
secrets.hashicorp.com/force-sync=$(date +%s) --overwrite
|
||||
```
|
||||
3. Restart the deployment to pick up the new Secret:
|
||||
```bash
|
||||
kubectl rollout restart deployment/api -n reactbin
|
||||
```
|
||||
|
||||
### Validating manifests
|
||||
|
||||
```bash
|
||||
make validate-k8s # yamllint + kubectl apply --dry-run=client (requires kubeconfig)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment variables reference
|
||||
|
||||
All variables are read at startup from environment / `.env`.
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|----------|---------|-------|
|
||||
| `DATABASE_URL` | — | Async DSN: `postgresql+asyncpg://...` |
|
||||
| `JWT_SECRET_KEY` | — | Required; use a long random string in production |
|
||||
| `JWT_EXPIRY_SECONDS` | `86400` | Token lifetime (24 h) |
|
||||
| `OWNER_USERNAME` | — | Single owner account username |
|
||||
| `OWNER_PASSWORD` | — | Single owner account password |
|
||||
| `S3_ENDPOINT_URL` | — | MinIO or any S3-compatible endpoint |
|
||||
| `S3_BUCKET_NAME` | `reactbin` | |
|
||||
| `S3_ACCESS_KEY_ID` | — | |
|
||||
| `S3_SECRET_ACCESS_KEY` | — | |
|
||||
| `S3_REGION` | `us-east-1` | |
|
||||
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MiB |
|
||||
| `API_BASE_URL` | — | Used for generating public URLs |
|
||||
| `API_DOCS_ENABLED` | `true` | Set to `false` in production |
|
||||
| `LOGIN_MAX_FAILURES` | `5` | Failed attempts before cooldown |
|
||||
| `LOGIN_WINDOW_SECONDS` | `300` | Sliding window for failure count |
|
||||
| `LOGIN_COOLDOWN_SECONDS` | `900` | Lock duration after threshold hit |
|
||||
| `LOGIN_TRUSTED_PROXY_IPS` | `""` | Comma-separated CIDRs of trusted upstream proxies |
|
||||
|
||||
@@ -35,6 +35,8 @@ RUN groupadd --system --gid 1001 appgroup \
|
||||
|
||||
COPY --from=builder --chown=appuser:appgroup /app/.venv /app/.venv
|
||||
COPY --chown=appuser:appgroup app/ ./app/
|
||||
COPY --chown=appuser:appgroup alembic/ ./alembic/
|
||||
COPY --chown=appuser:appgroup alembic.ini .
|
||||
|
||||
USER appuser
|
||||
|
||||
|
||||
52
k8s/api/deployment.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: api
|
||||
namespace: reactbin
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: api
|
||||
spec:
|
||||
initContainers:
|
||||
- name: migrate
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.0.1
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
workingDir: /app
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: api-env
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
containers:
|
||||
- name: api
|
||||
image: git.juggalol.com/juggalol/reactbin-api:v1.0.1
|
||||
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
|
||||
13
k8s/api/service.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: api
|
||||
namespace: reactbin
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: api
|
||||
ports:
|
||||
- name: http
|
||||
port: 8000
|
||||
targetPort: 8000
|
||||
34
k8s/ingress.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: reactbin
|
||||
namespace: reactbin
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
kubernetes.io/tls-acme: "true"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "52m"
|
||||
spec:
|
||||
ingressClassName: nginx-public
|
||||
tls:
|
||||
- hosts:
|
||||
- reactbin.juggalol.com
|
||||
secretName: reactbin-tls
|
||||
rules:
|
||||
- host: reactbin.juggalol.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api/
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ui
|
||||
port:
|
||||
number: 8080
|
||||
24
k8s/minio/init-job.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: minio-init
|
||||
namespace: reactbin
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: mc
|
||||
image: minio/mc:latest
|
||||
# mc runs as root by default; FR-013 exception documented in spec
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
|
||||
mc mb --ignore-existing local/reactbin
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: minio-credentials
|
||||
16
k8s/minio/service.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: reactbin
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: minio
|
||||
ports:
|
||||
- name: api
|
||||
port: 9000
|
||||
targetPort: 9000
|
||||
- name: console
|
||||
port: 9001
|
||||
targetPort: 9001
|
||||
59
k8s/minio/statefulset.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: reactbin
|
||||
spec:
|
||||
serviceName: minio
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: minio
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: minio
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: minio
|
||||
image: minio/minio:latest
|
||||
args:
|
||||
- server
|
||||
- /data
|
||||
- --console-address
|
||||
- ":9001"
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
- containerPort: 9001
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: minio-credentials
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /minio/health/live
|
||||
port: 9000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /minio/health/ready
|
||||
port: 9000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
4
k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: reactbin
|
||||
29
k8s/ui/deployment.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ui
|
||||
namespace: reactbin
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ui
|
||||
spec:
|
||||
containers:
|
||||
- name: ui
|
||||
image: git.juggalol.com/juggalol/reactbin-ui:v1.0.1
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101 # nginxinc/nginx-unprivileged default UID
|
||||
13
k8s/ui/service.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ui
|
||||
namespace: reactbin
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: ui
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
18
k8s/vault/api-secret.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: api-secret
|
||||
namespace: reactbin
|
||||
spec:
|
||||
vaultAuthRef: reactbin-vault-auth
|
||||
mount: kv
|
||||
type: kv-v2
|
||||
# Required Vault keys at this path:
|
||||
# DATABASE_URL, JWT_SECRET_KEY, OWNER_USERNAME, OWNER_PASSWORD,
|
||||
# S3_ENDPOINT_URL, S3_BUCKET_NAME, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY,
|
||||
# API_BASE_URL
|
||||
path: reactbin/api/config
|
||||
refreshAfter: 1h
|
||||
destination:
|
||||
name: api-env
|
||||
create: true
|
||||
16
k8s/vault/minio-secret.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: minio-secret
|
||||
namespace: reactbin
|
||||
spec:
|
||||
vaultAuthRef: reactbin-vault-auth
|
||||
mount: kv
|
||||
type: kv-v2
|
||||
# Required Vault keys at this path:
|
||||
# MINIO_ROOT_USER, MINIO_ROOT_PASSWORD
|
||||
path: reactbin/minio/credentials
|
||||
refreshAfter: 1h
|
||||
destination:
|
||||
name: minio-credentials
|
||||
create: true
|
||||
22
k8s/vault/vault-auth.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: vso-reactbin
|
||||
namespace: reactbin
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultAuth
|
||||
metadata:
|
||||
name: reactbin-vault-auth
|
||||
namespace: reactbin
|
||||
spec:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
kubernetes:
|
||||
# The operator must create this role in Vault and bind it to the
|
||||
# default service account in the reactbin namespace with read access
|
||||
# to both reactbin/api/config and reactbin/minio/credentials.
|
||||
role: vso-reactbin
|
||||
serviceAccount: vso-reactbin
|
||||
audiences:
|
||||
- vault
|
||||
35
specs/013-k8s-manifests/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Kubernetes Production Manifests
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- FR-014 (migration files in production image) is a prerequisite code change to `Dockerfile.prod`, not a manifest. Included in scope as it is required for the init container to function.
|
||||
- Image tag placeholder strategy is documented in Assumptions; the specifics of tag substitution (kustomize, sed, etc.) are left to planning.
|
||||
59
specs/013-k8s-manifests/contracts/operator-deploy.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Contract: Operator Deployment Interface
|
||||
|
||||
The manifests in `k8s/` define the operator's deployment interface — the inputs required before applying and the observable outputs after applying.
|
||||
|
||||
## Pre-deployment Prerequisites (Operator-supplied)
|
||||
|
||||
| Prerequisite | Details |
|
||||
|---|---|
|
||||
| Vault KV v2 secret at `reactbin/api/config` | Must contain keys: `DATABASE_URL`, `JWT_SECRET_KEY`, `OWNER_USERNAME`, `OWNER_PASSWORD`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `API_BASE_URL` |
|
||||
| Vault KV v2 secret at `reactbin/minio/credentials` | Must contain keys: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` |
|
||||
| Vault Kubernetes auth role | A role in the Vault Kubernetes auth mount bound to the `default` service account in the `reactbin` namespace with read access to both paths above |
|
||||
| `VaultConnection` resource | Named `default` in the operator's VSO namespace pointing to the Vault server address |
|
||||
| External PostgreSQL database | A dedicated database and user created; `DATABASE_URL` in Vault reflects the credentials |
|
||||
| DNS | The production domain resolves to the cluster ingress IP |
|
||||
| `ClusterIssuer` | A cert-manager `ClusterIssuer` named `letsencrypt-prod` exists in the cluster |
|
||||
| Image tags | The operator substitutes the `latest` placeholder in `k8s/api/deployment.yaml` and `k8s/ui/deployment.yaml` with the real image tag before applying |
|
||||
|
||||
## Apply Command
|
||||
|
||||
```bash
|
||||
# Substitute image tags
|
||||
sed -i 's|reactbin-api:latest|reactbin-api:<tag>|g' k8s/api/deployment.yaml
|
||||
sed -i 's|reactbin-ui:latest|reactbin-ui:<tag>|g' k8s/ui/deployment.yaml
|
||||
|
||||
# Apply all manifests
|
||||
kubectl apply -f k8s/
|
||||
```
|
||||
|
||||
Applying is idempotent — safe to re-run on every deployment.
|
||||
|
||||
## Observable Outputs (Post-apply)
|
||||
|
||||
| Resource | Expected State |
|
||||
|---|---|
|
||||
| `Namespace/reactbin` | Active |
|
||||
| `Deployment/api` in `reactbin` | 1/1 Ready (init container completes first) |
|
||||
| `Deployment/ui` in `reactbin` | 1/1 Ready |
|
||||
| `StatefulSet/minio` in `reactbin` | 1/1 Ready |
|
||||
| `Job/minio-init-bucket` in `reactbin` | Completed |
|
||||
| `Secret/api-env` in `reactbin` | Created by VSO, populated with all API env keys |
|
||||
| `Secret/minio-credentials` in `reactbin` | Created by VSO, populated with MinIO root keys |
|
||||
| `Certificate/reactbin-tls` in `reactbin` | Issued (may take up to 2 minutes on first apply) |
|
||||
| `Ingress/reactbin` in `reactbin` | Address populated with cluster ingress IP |
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# All pods running
|
||||
kubectl get pods -n reactbin
|
||||
|
||||
# API health
|
||||
curl -sf https://<domain>/api/v1/health
|
||||
|
||||
# UI reachable
|
||||
curl -sf https://<domain>/
|
||||
|
||||
# Docs correctly gated (should return 404)
|
||||
curl -o /dev/null -w "%{http_code}" https://<domain>/docs
|
||||
```
|
||||
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 |
|
||||
92
specs/013-k8s-manifests/quickstart.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Quickstart: Kubernetes Production Deployment
|
||||
|
||||
## Before You Apply
|
||||
|
||||
1. Store API secrets in Vault at `reactbin/api/config` (KV v2):
|
||||
```
|
||||
DATABASE_URL = postgresql+asyncpg://reactbin:<pw>@<host>:5432/reactbin
|
||||
JWT_SECRET_KEY = <long-random-string>
|
||||
OWNER_USERNAME = <your-username>
|
||||
OWNER_PASSWORD = <your-password>
|
||||
S3_ENDPOINT_URL = http://minio.reactbin.svc.cluster.local:9000
|
||||
S3_BUCKET_NAME = reactbin
|
||||
S3_ACCESS_KEY_ID = <same as MINIO_ROOT_USER>
|
||||
S3_SECRET_ACCESS_KEY = <same as MINIO_ROOT_PASSWORD>
|
||||
API_BASE_URL = https://<your-domain>
|
||||
API_DOCS_ENABLED = false
|
||||
```
|
||||
|
||||
2. Store MinIO credentials in Vault at `reactbin/minio/credentials` (KV v2):
|
||||
```
|
||||
MINIO_ROOT_USER = <choose a strong username>
|
||||
MINIO_ROOT_PASSWORD = <choose a strong password>
|
||||
```
|
||||
|
||||
3. Create a Vault Kubernetes auth role bound to the `default` service account in the `reactbin` namespace with read access to both paths above.
|
||||
|
||||
4. Confirm DNS resolves to the cluster ingress IP and the `letsencrypt-prod` ClusterIssuer exists.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
# Substitute the real image tags
|
||||
sed -i 's|reactbin-api:latest|reactbin-api:v1.0.0|g' k8s/api/deployment.yaml
|
||||
sed -i 's|reactbin-ui:latest|reactbin-ui:v1.0.0|g' k8s/ui/deployment.yaml
|
||||
|
||||
# Apply everything
|
||||
kubectl apply -f k8s/
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# Watch pods come up (init container runs first on the API pod)
|
||||
kubectl get pods -n reactbin -w
|
||||
|
||||
# API health
|
||||
curl -sf https://<your-domain>/api/v1/health && echo "API OK"
|
||||
|
||||
# UI reachable
|
||||
curl -sf -o /dev/null -w "%{http_code}\n" https://<your-domain>/
|
||||
|
||||
# Docs correctly gated
|
||||
curl -o /dev/null -w "%{http_code}\n" https://<your-domain>/docs # → 404
|
||||
curl -o /dev/null -w "%{http_code}\n" https://<your-domain>/redoc # → 404
|
||||
|
||||
# Check migration init container ran
|
||||
kubectl logs -n reactbin -l app=api -c alembic-migrate
|
||||
```
|
||||
|
||||
## Scenario: Migration fails on deploy
|
||||
|
||||
```bash
|
||||
# Pod will be stuck in Init state
|
||||
kubectl get pods -n reactbin
|
||||
# NAME READY STATUS RESTARTS
|
||||
# api-xxx-yyy 0/1 Init:CrashLoopBackOff 2
|
||||
|
||||
# See why
|
||||
kubectl logs -n reactbin <pod-name> -c alembic-migrate
|
||||
|
||||
# Fix the issue (e.g. correct DATABASE_URL in Vault, wait for VSO to resync)
|
||||
# Then delete the pod to force a fresh rollout
|
||||
kubectl rollout restart deployment/api -n reactbin
|
||||
```
|
||||
|
||||
## Scenario: Update to a new image version
|
||||
|
||||
```bash
|
||||
kubectl set image deployment/api api=reactbin-api:v1.1.0 -n reactbin
|
||||
kubectl set image deployment/ui ui=reactbin-ui:v1.1.0 -n reactbin
|
||||
# Kubernetes rolls out new pods; init container runs migrations before traffic switches
|
||||
```
|
||||
|
||||
## Scenario: Restore after MinIO pod restart
|
||||
|
||||
MinIO uses a PersistentVolumeClaim. Pod restarts do not affect stored data. Verify:
|
||||
|
||||
```bash
|
||||
kubectl delete pod -n reactbin minio-0
|
||||
kubectl get pods -n reactbin -w # minio-0 restarts, PVC reattaches
|
||||
# Previously uploaded images should still be accessible via the API
|
||||
```
|
||||
63
specs/013-k8s-manifests/research.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Research: Kubernetes Production Manifests
|
||||
|
||||
## Decision 1: VSO CRD chain (VaultConnection → VaultAuth → VaultStaticSecret)
|
||||
|
||||
**Decision**: Use three CRDs — `VaultConnection`, `VaultAuth`, and `VaultStaticSecret` — all under `apiVersion: secrets.hashicorp.com/v1beta1`.
|
||||
**Rationale**: This is the required VSO resource chain. `VaultConnection` points to the Vault server address. `VaultAuth` declares the Kubernetes auth method (role, service account, mount path). `VaultStaticSecret` references a `VaultAuth` via `vaultAuthRef` and declares the Vault KV path and the destination K8s Secret name. VSO syncs all Vault keys to the K8s Secret 1:1 by default — no explicit key mapping needed.
|
||||
**Alternatives considered**: `VaultAuthGlobal` for cross-namespace sharing — not needed; all resources are in the same `reactbin` namespace.
|
||||
|
||||
Key fields:
|
||||
- `VaultStaticSecret.spec.type`: `kv-v2` (standard for modern Vault)
|
||||
- `VaultStaticSecret.spec.refreshAfter`: `1h` (Go duration string)
|
||||
- `VaultStaticSecret.spec.destination.create: true` — VSO creates the K8s Secret if absent
|
||||
- `VaultAuth.spec.kubernetes.role` — a Vault role the operator must pre-create and bind to the `reactbin` namespace service account
|
||||
|
||||
## Decision 2: MinIO as StatefulSet (not Deployment)
|
||||
|
||||
**Decision**: Run MinIO as a `StatefulSet` with `volumeClaimTemplates`.
|
||||
**Rationale**: StatefulSet gives the pod a stable name (`minio-0`) and automatically reattaches its PVC on pod recreation. A Deployment would require a manually-created PVC and is prone to PVC binding issues on reschedule. The marginal complexity of a StatefulSet over a Deployment is acceptable. `ReadWriteOnce` PVC is correct for single-replica MinIO.
|
||||
**Alternatives considered**: Deployment with explicit PVC — works but PVC lifecycle is decoupled from the pod, creating operational risk.
|
||||
|
||||
MinIO health probes:
|
||||
- Liveness: `GET /minio/health/live:9000`
|
||||
- Readiness: `GET /minio/health/ready:9000`
|
||||
|
||||
MinIO env vars: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` (injected from a K8s Secret synced by VSO).
|
||||
|
||||
## Decision 3: Bucket initialisation via Kubernetes Job with `minio/mc`
|
||||
|
||||
**Decision**: A one-off `Job` using `minio/mc:latest` runs `mc mb --ignore-existing` to create the bucket idempotently.
|
||||
**Rationale**: This is the standard in-cluster pattern. `--ignore-existing` makes the job safe to re-apply (exits 0 if bucket already exists). `restartPolicy: OnFailure` retries transient failures (e.g. MinIO not yet ready).
|
||||
**Alternatives considered**: Init container on the API pod — tightly couples bucket creation to API startup; a Job is cleaner and independently rerunnable.
|
||||
|
||||
## Decision 4: Ingress — single resource, `/api/` path before `/`
|
||||
|
||||
**Decision**: One `Ingress` resource with `ingressClassName: nginx`, two path entries in a single rule: `/api/` (Prefix) → API Service, `/` (Prefix) → UI Service; `/api/` must be listed first.
|
||||
**Rationale**: nginx ingress evaluates paths in declaration order; the more specific `/api/` prefix must appear before `/` or all traffic is routed to the UI. No path rewriting annotation is needed — the API already handles full `/api/v1/...` paths.
|
||||
**TLS**: cert-manager annotation `cert-manager.io/cluster-issuer: letsencrypt-prod` triggers automatic certificate provisioning into a K8s Secret named in `spec.tls[].secretName`. HTTP→HTTPS redirect is on by default when TLS is configured (`nginx.ingress.kubernetes.io/ssl-redirect: "true"` is explicit but redundant).
|
||||
**Alternatives considered**: Two separate Ingress resources (one per service) — works but harder to reason about routing order; single Ingress is canonical.
|
||||
|
||||
## Decision 5: Alembic init container — same image, workdir `/app`
|
||||
|
||||
**Decision**: The API Deployment includes an init container with the same image as the main container, `command: ["alembic", "upgrade", "head"]`, and `workingDir: /app`. It shares the API's env secret via `envFrom` so it can read `DATABASE_URL`.
|
||||
**Rationale**: Alembic needs `DATABASE_URL` to connect and `alembic.ini` + `alembic/` to find migrations. Both are available in the production image once `Dockerfile.prod` is updated. Using the same image guarantees the migration files match the running version.
|
||||
**Dockerfile.prod update required**: Add `COPY --chown=appuser:appgroup alembic/ ./alembic/` and `COPY --chown=appuser:appgroup alembic.ini .` in the runtime stage (not the builder stage — no compilation needed).
|
||||
**Alternatives considered**: Separate migration image — adds a second image to build and push on every release; unnecessary when the source image already has everything.
|
||||
|
||||
## Decision 6: Image tag strategy — placeholder `latest`, substituted at deploy time
|
||||
|
||||
**Decision**: Manifests reference image tags using `latest` as a documented placeholder. The operator substitutes the real tag with `kubectl set image` or a `sed` one-liner before applying.
|
||||
**Rationale**: Kustomize's `images` transformer is the clean alternative, but introduces a tooling dependency. For a personal single-operator deployment, `sed` or `kubectl set image` after `kubectl apply` is simpler and requires no additional setup. The placeholder is documented in the operator guide (quickstart.md).
|
||||
**Alternatives considered**: Kustomize overlays — appropriate for multi-environment setups; over-engineered for one environment.
|
||||
|
||||
## Decision 7: Two VaultStaticSecrets (API env and MinIO credentials)
|
||||
|
||||
**Decision**: Separate VaultStaticSecret resources for API env vars and MinIO root credentials, syncing into `api-env` and `minio-credentials` K8s Secrets respectively.
|
||||
**Rationale**: The API's env secret contains database, JWT, and S3 access credentials. MinIO's root credentials are a different concern with a different rotation lifecycle. Keeping them separate makes Vault policies simpler (least privilege) and avoids giving the API pod access to MinIO's root password.
|
||||
**Vault paths assumed**: `reactbin/api/config` (KV v2) for API env; `reactbin/minio/credentials` (KV v2) for MinIO root credentials.
|
||||
|
||||
## Decision 8: Namespace manifest included in `k8s/`
|
||||
|
||||
**Decision**: `k8s/namespace.yaml` creates the `reactbin` namespace as part of the manifest set.
|
||||
**Rationale**: Makes the full deployment self-contained — operator runs `kubectl apply -f k8s/` without a prerequisite namespace creation step.
|
||||
**Note**: If the namespace already exists, `kubectl apply` is idempotent.
|
||||
124
specs/013-k8s-manifests/spec.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Feature Specification: Kubernetes Production Manifests
|
||||
|
||||
**Feature Branch**: `013-k8s-manifests`
|
||||
**Created**: 2026-05-07
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Kubernetes manifests for production deployment to k3s: Deployment, Service, and Ingress for the API and UI; VaultStaticSecret CRDs to sync secrets from HashiCorp Vault; Alembic init container on the API Deployment for schema migrations. The cluster uses an nginx ingress controller with Let's Encrypt TLS, a shared external Postgres instance, MinIO running in-cluster, and VSO (Vault Secrets Operator) for secret management."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 — Application Reachable in Production (Priority: P1)
|
||||
|
||||
As an operator, I can apply the manifests to my k3s cluster and have both the API and UI reachable at the production domain over HTTPS, with all health checks passing.
|
||||
|
||||
**Why this priority**: This is the core deployment goal. Nothing else matters if the application is not reachable.
|
||||
|
||||
**Independent Test**: Apply the API and UI manifests with a manually-created K8s Secret (bypassing Vault). Confirm the UI loads at the domain root and the API health endpoint returns 200 at `/api/v1/health`. Confirm HTTPS is enforced and HTTP redirects to HTTPS.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the manifests are applied to the cluster, **When** a browser navigates to `https://<domain>/`, **Then** the UI loads successfully with a valid TLS certificate.
|
||||
2. **Given** the manifests are applied, **When** a request is made to `https://<domain>/api/v1/health`, **Then** a 200 response is returned.
|
||||
3. **Given** the API docs flag is disabled, **When** a request is made to `https://<domain>/docs`, **Then** a 404 is returned.
|
||||
4. **Given** the API pod is restarted, **When** it comes back up, **Then** it passes readiness checks before receiving traffic.
|
||||
5. **Given** a request for an unknown path, **When** it is made to the UI, **Then** the SPA serves the index page (client-side routing is preserved).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Secrets Sourced from Vault (Priority: P2)
|
||||
|
||||
As an operator, no secrets are stored in version-controlled manifest files. All sensitive values are declared in Vault and synced automatically into the cluster as Kubernetes Secrets by the Vault Secrets Operator.
|
||||
|
||||
**Why this priority**: Security prerequisite for production. Hardcoded secrets in manifests are a material risk.
|
||||
|
||||
**Independent Test**: Run `git grep` for known secret patterns across `k8s/` and confirm zero matches. Confirm VaultStaticSecret CRDs reference a Vault path and that the synced K8s Secret is created and the API pod's environment is populated from it.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** Vault contains the required secret values at the declared path, **When** VSO is running, **Then** a K8s Secret is created in the cluster namespace with the declared keys.
|
||||
2. **Given** the K8s Secret exists, **When** the API pod starts, **Then** its environment variables are populated from that secret.
|
||||
3. **Given** a `git grep` for plaintext credentials across `k8s/`, **When** run against the committed manifests, **Then** no plaintext secrets are found.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Schema Migrations Run Before API Starts (Priority: P3)
|
||||
|
||||
As an operator, every time the API is deployed, database migrations run automatically in an init container before the main application container starts. A failed migration prevents the pod from starting, protecting against schema drift.
|
||||
|
||||
**Why this priority**: Prevents the API from serving requests against a stale or incompatible schema. Safe deployment ordering is essential for production.
|
||||
|
||||
**Independent Test**: Deploy with the init container pointing at a valid database. Confirm migrations run and the API starts. Simulate a failing migration by pointing the init container at an unreachable database and confirm the pod stays in init state and does not serve traffic.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API Deployment is applied, **When** the pod starts, **Then** the init container completes `alembic upgrade head` before the main container starts.
|
||||
2. **Given** the schema is already current, **When** the pod starts, **Then** the migration init container exits successfully with no changes applied.
|
||||
3. **Given** the migration fails, **When** the pod starts, **Then** the init container exits non-zero, the main container does not start, and the pod enters a visible error state.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — MinIO Runs In-Cluster with Persistent Storage (Priority: P4)
|
||||
|
||||
As an operator, MinIO runs inside the cluster with a PersistentVolumeClaim for durable storage, is not externally reachable, and has the required bucket initialised on first deployment.
|
||||
|
||||
**Why this priority**: Required for image storage, but decoupled from the other manifests — the S3 endpoint is just a config value the API reads.
|
||||
|
||||
**Independent Test**: Confirm the MinIO pod is running and has no external Ingress. Confirm the required bucket exists. Restart the MinIO pod and confirm previously stored objects are still accessible.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the MinIO manifests are applied, **When** the MinIO pod starts, **Then** the required bucket is created and the API can store and retrieve images.
|
||||
2. **Given** the MinIO pod is restarted, **When** it comes back up, **Then** all previously stored objects remain accessible (PVC-backed storage persists).
|
||||
3. **Given** no Ingress is defined for MinIO, **When** a connection is attempted from outside the cluster, **Then** MinIO is not reachable.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What if Vault is unavailable when VSO tries to sync? VSO retries on a configurable interval; the pod will not start until the K8s Secret exists.
|
||||
- What if the database is unreachable during migration? The init container exits non-zero; the pod does not start and Kubernetes retries with backoff.
|
||||
- What if the MinIO PVC runs out of space? MinIO will fail writes; the API will return upload errors. Capacity monitoring is out of scope for this feature.
|
||||
- What if migrations and the main container use different image tags? They use the same tag in the same Deployment spec, so they are always in sync.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: All manifests MUST target a single configurable namespace (default: `reactbin`).
|
||||
- **FR-002**: The API MUST be deployed as a Deployment with liveness and readiness probes on `/api/v1/health`.
|
||||
- **FR-003**: The API Deployment MUST include an init container using the same image that runs database schema migrations before the main container starts.
|
||||
- **FR-004**: The API Deployment MUST set `API_DOCS_ENABLED=false`.
|
||||
- **FR-005**: The UI MUST be deployed as a Deployment with a liveness probe confirming the nginx process is serving.
|
||||
- **FR-006**: A single Ingress MUST route `https://<domain>/api/` to the API Service and all other paths to the UI Service, with TLS termination via a cert-manager Let's Encrypt certificate.
|
||||
- **FR-007**: HTTP requests MUST be redirected to HTTPS via the Ingress.
|
||||
- **FR-008**: All API secrets MUST be declared in a VaultStaticSecret CRD and synced into a K8s Secret; no secret value MUST appear as plaintext in any manifest file.
|
||||
- **FR-009**: The API Deployment MUST source all environment variables from the synced K8s Secret via `envFrom`.
|
||||
- **FR-010**: MinIO MUST be deployed as a StatefulSet with a PersistentVolumeClaim using the cluster's default storage class.
|
||||
- **FR-011**: A Kubernetes Job MUST create the required S3 bucket in MinIO on first deployment and MUST be idempotent on re-apply.
|
||||
- **FR-012**: MinIO MUST have no Ingress; it MUST only be accessible within the cluster via ClusterIP.
|
||||
- **FR-013**: All containers MUST run as non-root users.
|
||||
- **FR-014**: The API production image MUST include migration files so the init container can run migrations without a separate image.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: The application is accessible at the production domain within 120 seconds of `kubectl apply`.
|
||||
- **SC-002**: Schema migrations complete and the API begins serving traffic without manual operator intervention on every deployment.
|
||||
- **SC-003**: A `git grep` across `k8s/` finds zero plaintext secret values in committed files.
|
||||
- **SC-004**: A simulated migration failure holds the pod in init state and the application never serves traffic.
|
||||
- **SC-005**: Restarting the MinIO pod does not result in data loss — previously uploaded images remain accessible.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The k3s cluster is running with the nginx ingress controller installed.
|
||||
- cert-manager is installed and a `ClusterIssuer` named `letsencrypt-prod` is already configured.
|
||||
- The Vault Secrets Operator is installed in the cluster.
|
||||
- A HashiCorp Vault instance is accessible from the cluster and the required secret values are stored at the declared Vault path before deployment.
|
||||
- A shared external PostgreSQL instance is available; the operator creates a dedicated database and user before deploying.
|
||||
- DNS for the production domain is already pointing at the cluster ingress IP.
|
||||
- Manifests are stored in a `k8s/` directory at the repository root.
|
||||
- The cluster's default storage class supports ReadWriteOnce (sufficient for single-replica MinIO).
|
||||
- All Deployments run a single replica (personal tool, no HA requirement).
|
||||
- Image tags are managed externally; manifests use a placeholder tag that the operator substitutes at deploy time.
|
||||
- The `API_DOCS_ENABLED` flag exists on the API (implemented in feature 012).
|
||||
174
specs/013-k8s-manifests/tasks.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Tasks: Kubernetes Production Manifests
|
||||
|
||||
**Input**: Design documents from `specs/013-k8s-manifests/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/operator-deploy.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: K8s manifests have no unit test framework. Validation is via `yamllint` (format) and `kubectl apply --dry-run=client` (schema). Each phase ends with a validation step. The TDD analogue is: write the validate-k8s Makefile target (Phase 1) before any manifest exists, so it immediately fails — then manifests are written to make it pass.
|
||||
|
||||
**Organization**: Phase 1 creates the directory structure and validation target. Phase 2 creates the namespace and Vault CRDs (foundational — required by all user story deployments). Phases 3–6 implement user stories. Phase 7 polishes.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel with other [P] tasks in the same phase
|
||||
- **[Story]**: Which user story this task belongs to
|
||||
- Exact file paths included in every task description
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
**Goal**: Create the `k8s/` directory structure and the validation Makefile target before any manifests exist.
|
||||
|
||||
- [X] T001 Create the `k8s/` directory tree: `mkdir -p k8s/api k8s/ui k8s/minio k8s/vault` from the repository root; confirm the four subdirectories exist
|
||||
|
||||
- [X] T002 Add a `validate-k8s` target to `Makefile` immediately after the existing `verify-ui-prod` target: the target MUST run `yamllint -d relaxed k8s/` then `kubectl apply --dry-run=client -f k8s/`; add `validate-k8s` to the `.PHONY` line; note in a comment that `kubectl apply --dry-run=client` requires a kubeconfig with cluster access — offline validation uses `yamllint` only
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Namespace + Vault CRDs)
|
||||
|
||||
**Goal**: Namespace and Vault secret-sync resources that every other manifest depends on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story manifest can be applied until this phase is complete — the namespace must exist before any namespaced resource, and the Vault CRDs must exist before the API or MinIO pods can start.
|
||||
|
||||
- [X] T003 Create `k8s/namespace.yaml`: a single `Namespace` resource with `name: reactbin` and no additional labels
|
||||
|
||||
- [X] T004 [P] Create `k8s/vault/vault-auth.yaml`: a `VaultAuth` resource (`apiVersion: secrets.hashicorp.com/v1beta1`) with `name: reactbin-auth`, `namespace: reactbin`, `spec.method: kubernetes`, `spec.mount: kubernetes`, `spec.kubernetes.role: reactbin`, `spec.kubernetes.serviceAccount: default`, `spec.kubernetes.audiences: [https://kubernetes.default.svc]`; add a comment noting the operator must create the Vault role and bind it to the `default` SA in the `reactbin` namespace with read access to both secret paths
|
||||
|
||||
- [X] T005 [P] Create `k8s/vault/api-secret.yaml`: a `VaultStaticSecret` resource with `name: api-secret`, `namespace: reactbin`, `spec.vaultAuthRef: reactbin-auth`, `spec.mount: secret`, `spec.type: kv-v2`, `spec.path: reactbin/api/config`, `spec.refreshAfter: 1h`, `spec.destination.name: api-env`, `spec.destination.create: true`; add a comment listing all required Vault keys: `DATABASE_URL`, `JWT_SECRET_KEY`, `OWNER_USERNAME`, `OWNER_PASSWORD`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `API_BASE_URL`
|
||||
|
||||
- [X] T006 [P] Create `k8s/vault/minio-secret.yaml`: same structure as T005 but `name: minio-secret`, `spec.path: reactbin/minio/credentials`, `spec.destination.name: minio-credentials`; comment listing required Vault keys: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`
|
||||
|
||||
**Checkpoint**: Foundational resources complete. User story implementation can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Application Reachable in Production (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: API and UI are deployed and reachable at the production domain via HTTPS with TLS from cert-manager.
|
||||
|
||||
**Independent Test**: Apply all Phase 2 + Phase 3 manifests. Confirm `kubectl get pods -n reactbin` shows api and ui pods Running. Confirm `curl https://<domain>/api/v1/health` returns 200 and `curl https://<domain>/` returns 200.
|
||||
|
||||
- [X] T007 [P] [US1] Create `k8s/api/service.yaml`: `Service`, `name: api`, `namespace: reactbin`, `type: ClusterIP`, `selector: {app: api}`, `ports: [{port: 8000, targetPort: 8000, name: http}]`
|
||||
|
||||
- [X] T008 [P] [US1] Create `k8s/ui/service.yaml`: `Service`, `name: ui`, `namespace: reactbin`, `type: ClusterIP`, `selector: {app: ui}`, `ports: [{port: 8080, targetPort: 8080, name: http}]`
|
||||
|
||||
- [X] T009 [P] [US1] Create `k8s/ui/deployment.yaml`: `Deployment`, `name: ui`, `namespace: reactbin`, 1 replica, `selector.matchLabels: {app: ui}`; container `name: ui`, `image: reactbin-ui:latest` (placeholder — operator substitutes real tag), `ports: [{containerPort: 8080}]`; `livenessProbe: {httpGet: {path: /, port: 8080}, initialDelaySeconds: 10, periodSeconds: 30}`; `securityContext: {runAsNonRoot: true, runAsUser: 101}` (UID 101 is the nginxinc/nginx-unprivileged user); add comment: `# Replace 'latest' with the real image tag before applying`
|
||||
|
||||
- [X] T010 [US1] Create `k8s/api/deployment.yaml`: `Deployment`, `name: api`, `namespace: reactbin`, 1 replica, `selector.matchLabels: {app: api}`; container `name: api`, `image: reactbin-api:latest` (placeholder), `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}`; add comment: `# initContainers block added in US3 (T015)`; add comment: `# Replace 'latest' with the real image tag before applying`
|
||||
|
||||
- [X] T011 [US1] Create `k8s/ingress.yaml`: `Ingress`, `name: reactbin`, `namespace: reactbin`; `annotations: {"cert-manager.io/cluster-issuer": "letsencrypt-prod", "nginx.ingress.kubernetes.io/ssl-redirect": "true"}`; `spec.ingressClassName: nginx`; `spec.tls: [{hosts: ["<your-domain>"], secretName: reactbin-tls}]`; `spec.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}}}}]}}]`; IMPORTANT — `/api/` path entry MUST appear before `/` in the YAML (nginx evaluates in declaration order); add comment: `# Replace <your-domain> with the real domain before applying`
|
||||
|
||||
- [X] T012 [US1] Verify US1: run `yamllint -d relaxed k8s/` from the repository root and confirm no errors; run `kubectl apply --dry-run=client -f k8s/` (requires cluster kubeconfig) and confirm all resources in namespace.yaml, vault/, api/, ui/, and ingress.yaml are accepted; if no cluster is available, yamllint passing is sufficient for this checkpoint
|
||||
|
||||
**Checkpoint**: US1 complete. API and UI manifests are schema-valid and ready to apply.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Secrets Sourced from Vault (Priority: P2)
|
||||
|
||||
**Goal**: Confirm that no plaintext secret values appear in any committed manifest file. The implementation (VaultAuth + VaultStaticSecret × 2) was completed in Phase 2.
|
||||
|
||||
**Independent Test**: `git grep` across `k8s/` finds no plaintext credential values.
|
||||
|
||||
- [X] T013 [US2] Verify US2: run `git grep -rn "password\|secret_key\|access_key\|DATABASE_URL" k8s/` and confirm that only key names (in comments) and Vault path references appear — no actual values; also confirm that `k8s/vault/api-secret.yaml` and `k8s/vault/minio-secret.yaml` reference Vault paths under `spec.path` and that `spec.destination.create: true` is set so VSO creates the K8s Secrets
|
||||
|
||||
**Checkpoint**: US2 complete. Zero plaintext secrets in manifests; all secrets flow through Vault.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Schema Migrations Run Before API Starts (Priority: P3)
|
||||
|
||||
**Goal**: The API Deployment includes an Alembic init container. `api/Dockerfile.prod` is updated to include migration files.
|
||||
|
||||
**Independent Test**: `docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test` succeeds and `docker run --rm reactbin-api-prod:test ls /app/alembic` shows migration files. `make validate-k8s` confirms the init container spec is accepted by the Kubernetes schema.
|
||||
|
||||
- [X] T014 [US3] Update `api/Dockerfile.prod`: in the **runtime stage** (the `FROM python:3.12-slim` stage), after the line `COPY --chown=appuser:appgroup app/ ./app/`, add two new lines: `COPY --chown=appuser:appgroup alembic/ ./alembic/` and `COPY --chown=appuser:appgroup alembic.ini .`; the builder stage is unchanged; verify with `docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test && docker run --rm reactbin-api-prod:test ls /app/alembic /app/alembic.ini`
|
||||
|
||||
- [X] T015 [US3] Update `k8s/api/deployment.yaml`: add an `initContainers` block to the pod spec (before the `containers` block) containing one init container: `name: alembic-migrate`, `image: reactbin-api:latest` (same placeholder tag as the main container), `command: ["alembic", "upgrade", "head"]`, `workingDir: /app`, `envFrom: [{secretRef: {name: api-env}}]`, `securityContext: {runAsNonRoot: true, runAsUser: 1001}`; remove the `# initContainers block added in US3 (T015)` comment added in T010
|
||||
|
||||
- [X] T016 [US3] Verify US3: run `make validate-k8s` (or `yamllint -d relaxed k8s/`) and confirm the updated deployment.yaml with the init container passes validation; run `docker build -f api/Dockerfile.prod api/ -t reactbin-api-prod:test` and confirm it succeeds; run `docker run --rm reactbin-api-prod:test ls /app/alembic.ini` and confirm the file is present
|
||||
|
||||
**Checkpoint**: US3 complete. API Deployment includes Alembic init container; production image includes migration files.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — MinIO In-Cluster with Persistent Storage (Priority: P4)
|
||||
|
||||
**Goal**: MinIO runs as a StatefulSet with a PVC, is accessible only within the cluster, and has the required bucket created by a Job.
|
||||
|
||||
**Independent Test**: `make validate-k8s` confirms all MinIO manifests pass schema validation. On a live cluster: MinIO pod reaches Running state, bucket exists, no external Ingress for MinIO.
|
||||
|
||||
- [X] T017 [P] [US4] Create `k8s/minio/service.yaml`: `Service`, `name: minio`, `namespace: reactbin`, `type: ClusterIP`, `selector: {app: minio}`, `ports: [{port: 9000, targetPort: 9000, name: s3}]`; add comment: `# No Ingress for MinIO — internal access only (FR-012)`
|
||||
|
||||
- [X] T018 [US4] Create `k8s/minio/statefulset.yaml`: `StatefulSet` (NOT Deployment — StatefulSet ensures stable PVC binding on pod recreation), `name: minio`, `namespace: reactbin`, `replicas: 1`, `selector.matchLabels: {app: minio}`, `serviceName: minio`; pod `securityContext: {runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000}`; container `name: minio`, `image: minio/minio:latest`, `args: ["server", "/data", "--console-address", ":9001"]`, `ports: [{containerPort: 9000, name: s3}]`; `env: [{name: MINIO_ROOT_USER, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_USER}}}, {name: MINIO_ROOT_PASSWORD, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_PASSWORD}}}]`; `livenessProbe: {httpGet: {path: /minio/health/live, port: 9000}, initialDelaySeconds: 30, periodSeconds: 20}`; `readinessProbe: {httpGet: {path: /minio/health/ready, port: 9000}, initialDelaySeconds: 15, periodSeconds: 10}`; `volumeMounts: [{name: minio-data, mountPath: /data}]`; `volumeClaimTemplates: [{metadata: {name: minio-data}, spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 10Gi}}}}]`; add comment: `# storageClassName omitted — uses cluster default; override if needed`
|
||||
|
||||
- [X] T019 [US4] Create `k8s/minio/init-job.yaml`: `Job`, `name: minio-init-bucket`, `namespace: reactbin`; `spec.template.spec.restartPolicy: OnFailure`; container `name: mc`, `image: minio/mc:latest`, `command: ["sh", "-c"]`, `args: ["mc alias set local http://minio.reactbin.svc.cluster.local:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/reactbin"]`; `env: [{name: MINIO_ROOT_USER, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_USER}}}, {name: MINIO_ROOT_PASSWORD, valueFrom: {secretKeyRef: {name: minio-credentials, key: MINIO_ROOT_PASSWORD}}}]`; `securityContext: {runAsNonRoot: false}` with comment `# minio/mc runs as root by default; FR-013 exception for this one-off init Job`; add comment: `# --ignore-existing makes this Job idempotent — safe to re-apply`
|
||||
|
||||
- [X] T020 [US4] Verify US4: run `make validate-k8s` (or `yamllint -d relaxed k8s/`) and confirm all three MinIO manifests (statefulset.yaml, service.yaml, init-job.yaml) pass validation; confirm no Ingress resource references MinIO
|
||||
|
||||
**Checkpoint**: All four user stories complete.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T021 [P] Run `yamllint -d relaxed k8s/` from the repository root and fix any YAML formatting violations across all 12 manifest files; confirm output shows no errors
|
||||
|
||||
- [X] T022 [P] Add `.yamllint.yml` at the repository root (if not already present) with `extends: relaxed` and `rules: {line-length: {max: 120}}` to keep line length reasonable for verbose K8s YAML
|
||||
|
||||
- [X] T023 Run `make build-prod` to confirm `api/Dockerfile.prod` still builds cleanly after the T014 addition; run `docker run --rm reactbin-api-prod:latest ls /app/alembic.ini /app/alembic/` and confirm both are present in the production image
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- T001 and T002 can run in parallel (directory creation vs Makefile edit)
|
||||
- T003, T004, T005, T006 can run in parallel after T001 (different files, same phase)
|
||||
- T007, T008, T009 can run in parallel after Phase 2 completes
|
||||
- T010 after T007 (deployment references service name, easier to write with service done) — but they're different files so technically parallel; keep sequential for clarity
|
||||
- T011 after T007 and T008 (Ingress references both service names)
|
||||
- T012 after T007–T011
|
||||
- T013 after Phase 2 (Vault CRDs exist to inspect)
|
||||
- T014 and T015 can run in parallel (different files: Dockerfile.prod vs deployment.yaml)
|
||||
- T016 after T014 and T015
|
||||
- T017, T018, T019 can run in parallel after Phase 2 completes
|
||||
- T020 after T017–T019
|
||||
- T021, T022, T023 can run in parallel
|
||||
|
||||
### Execution Order Summary
|
||||
|
||||
```
|
||||
Step 1: T001 ∥ T002 (setup)
|
||||
Step 2: T003 ∥ T004 ∥ T005 ∥ T006 (foundational: namespace + Vault CRDs)
|
||||
Step 3: T007 ∥ T008 ∥ T009 (US1: services + UI deployment)
|
||||
Step 4: T010 (US1: API deployment)
|
||||
Step 5: T011 (US1: Ingress)
|
||||
Step 6: T012 (US1: validate)
|
||||
Step 7: T013 (US2: verify no plaintext secrets)
|
||||
Step 8: T014 ∥ T015 (US3: Dockerfile.prod + init container)
|
||||
Step 9: T016 (US3: verify)
|
||||
Step 10: T017 ∥ T018 ∥ T019 (US4: MinIO manifests)
|
||||
Step 11: T020 (US4: validate MinIO)
|
||||
Step 12: T021 ∥ T022 ∥ T023 (polish)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 + US2 — application is reachable with Vault-backed secrets)
|
||||
|
||||
1. Phase 1 (Setup) + Phase 2 (Foundational)
|
||||
2. Phase 3 (US1 — API, UI, Ingress)
|
||||
3. Phase 4 (US2 — verify no plaintext secrets)
|
||||
4. **STOP and VALIDATE**: apply to cluster, confirm `https://<domain>/` and `/api/v1/health` return 200
|
||||
5. Deploy MVP
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Apply → namespace and Vault sync ready
|
||||
2. Add US1 (API + UI + Ingress) → Deploy → application reachable at domain
|
||||
3. Add US3 (Alembic init container) → Deploy → migrations run automatically on rollout
|
||||
4. Add US4 (MinIO) → Deploy → persistent image storage in-cluster
|
||||
5. Polish → clean YAML, confirmed builds
|
||||
BIN
ui/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
ui/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
ui/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
ui/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
ui/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
ui/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
ui/public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -5,6 +5,11 @@
|
||||
<title>Reactbin</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||
<link rel="manifest" href="site.webmanifest">
|
||||
<link rel="icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||