From 0252e405279043dcde71b030ea3a50e196ed56f3 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Mon, 1 Jun 2026 06:51:22 -0700 Subject: [PATCH] =?UTF-8?q?v0.34.0:=20containerize=20for=20per-PR=20previe?= =?UTF-8?q?w=20environments=20(flotilla=20SPEC=20=C2=A715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 15 +++++++ CHANGELOG.md | 25 ++++++++++++ Dockerfile | 62 +++++++++++++++++++++++++++++ VERSION | 2 +- deploy/preview/entrypoint.sh | 55 +++++++++++++++++++++++++ deploy/preview/nginx.conf.template | 64 ++++++++++++++++++++++++++++++ deploy/preview/preview.env.example | 49 +++++++++++++++++++++++ deploy/preview/seed.sql | 20 ++++++++++ frontend/package.json | 2 +- 9 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy/preview/entrypoint.sh create mode 100644 deploy/preview/nginx.conf.template create mode 100644 deploy/preview/preview.env.example create mode 100644 deploy/preview/seed.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8946a45 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Keep the preview build context lean + reproducible. +.git +.gitea +**/__pycache__/ +**/*.pyc +backend/.venv/ +backend/data/ +frontend/node_modules/ +frontend/dist/ +e2e/ +mockups/ +docs/ +*.md +!VERSION +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 596917d..ec6edab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,31 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.34.0 — 2026-06-04 + +**Minor — containerize rfc-app for per-PR preview environments (flotilla +SPEC §15). Additive: production is unaffected — it still deploys via the +pin-based on-VM gesture. No upgrade steps for existing deployments.** + +Adds a `Dockerfile` (+ `deploy/preview/`) so the operator's flotilla can build +a PR's tree and run it as an ephemeral, scale-to-zero Cloud Run preview with a +seeded **synthetic** database and **zero real secrets** (test-secret env only): + +- `Dockerfile` — multi-stage: Vite SPA build → Python runtime serving the SPA + via nginx on `$PORT` and reverse-proxying `/api/`,`/auth/` to a single-process + uvicorn on `127.0.0.1:8000` (mirrors the prod nginx + systemd split, minus + TLS, minus prod secrets). Single process, single SQLite file (§4.2). +- `deploy/preview/entrypoint.sh` — renders nginx against Cloud Run's `$PORT`, + boots uvicorn (which runs migrations), and applies the synthetic seed on a + fresh DB. +- `deploy/preview/seed.sql` — version-controlled synthetic fixture (no PII). +- `deploy/preview/preview.env.example` — the test-secret env shape (Cloudflare + always-pass Turnstile keys, a Mailpit SMTP sink, analytics no-op'd) the + operator loads into flotilla's preview overlay layer. + +The framework still knows nothing about flotilla — the image is a generic +container of rfc-app; flotilla is one possible orchestrator of it. + ## 0.33.0 — 2026-06-04 **Minor (breaking) — §22 M3 backend Plan A: project registry mirror + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae6b176 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# rfc-app container image — the Cloud Run keystone for per-PR preview +# environments (flotilla SPEC §15). NOT used by production: prod still deploys +# via the pin-based on-VM gesture (flotilla §8). This image exists so flotilla +# `preview up --pr=N` can build a PR's tree and run it as an ephemeral, +# scale-to-zero Cloud Run service with a SEEDED SYNTHETIC database and ZERO real +# secrets (test-secret env only — flotilla §15 / §3 invariant 1). +# +# Single container, single port: nginx serves the built SPA on $PORT (Cloud Run +# injects it) and reverse-proxies /api/ + /auth/ to a single-process uvicorn on +# 127.0.0.1:8000 — mirroring the prod nginx + systemd split +# (deploy/nginx/ohm.wiggleverse.org.conf, deploy/systemd/rfc-app.service), so a +# preview behaves like prod minus the secrets. Single uvicorn process + single +# SQLite file, per §4.2 (never scale workers). +# +# Build context is the repo root: docker build -t . + +# ---- stage 1: build the Vite SPA ------------------------------------------- +FROM node:20-slim AS web +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +# VITE_* values are baked into the bundle at build time (intentionally public — +# §8 phase-5 note). VITE_APP_NAME is REQUIRED by vite.config.js (the framework +# ships no default — each deployment names itself); previews self-name. Turnstile +# uses Cloudflare's always-pass test SITE key so the widget renders + auto-passes. +ARG VITE_APP_NAME="RFC App (preview)" +ARG VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA +ARG VITE_AMPLITUDE_API_KEY= +RUN VITE_APP_NAME="$VITE_APP_NAME" \ + VITE_TURNSTILE_SITE_KEY="$VITE_TURNSTILE_SITE_KEY" \ + VITE_AMPLITUDE_API_KEY="$VITE_AMPLITUDE_API_KEY" \ + npm run build + +# ---- stage 2: runtime (backend + nginx) ------------------------------------ +FROM python:3.12-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends nginx gettext-base sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/rfc-app + +# Backend deps first for layer caching. +COPY backend/requirements.txt backend/requirements.txt +RUN pip install --no-cache-dir -r backend/requirements.txt + +COPY backend/ backend/ +COPY deploy/preview/ deploy/preview/ +# health.py reads VERSION at parents[2] (== /opt/rfc-app/VERSION). +COPY VERSION ./ +COPY --from=web /app/frontend/dist/ frontend/dist/ + +RUN chmod +x deploy/preview/entrypoint.sh + +# Cloud Run injects $PORT (default 8080); the entrypoint renders nginx against +# it. The synthetic preview DB lives on the container's ephemeral filesystem and +# dies with the instance (zero create/seed/drop lifecycle — §15). +ENV PORT=8080 \ + DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db +EXPOSE 8080 + +ENTRYPOINT ["deploy/preview/entrypoint.sh"] diff --git a/VERSION b/VERSION index be386c9..85e60ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.0 +0.34.0 diff --git a/deploy/preview/entrypoint.sh b/deploy/preview/entrypoint.sh new file mode 100644 index 0000000..82dd132 --- /dev/null +++ b/deploy/preview/entrypoint.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Preview container entrypoint (flotilla SPEC §15). +# +# 1. Render nginx against Cloud Run's $PORT. +# 2. Start the single-process uvicorn (which runs DB migrations on startup via +# the app lifespan — §4.2: one process, one SQLite file, never scale workers). +# 3. On a brand-new DB, apply the SYNTHETIC seed fixture so the preview shows +# realistic content with no real user PII (§15). +# 4. Hand the foreground to nginx. +# +# This is preview-only plumbing — production runs uvicorn under systemd and nginx +# under the OS, never this script. +set -euo pipefail + +: "${PORT:=8080}" +: "${DATABASE_PATH:=/opt/rfc-app/backend/data/rfc-app.db}" +export PORT DATABASE_PATH + +mkdir -p "$(dirname "$DATABASE_PATH")" + +# Render the nginx config with the injected port. +envsubst '${PORT}' \ + < /opt/rfc-app/deploy/preview/nginx.conf.template \ + > /etc/nginx/nginx.conf + +fresh=0 +[ -f "$DATABASE_PATH" ] || fresh=1 + +# Start uvicorn (backgrounded); the app's lifespan runs migrations on boot. +cd /opt/rfc-app/backend +uvicorn app.main:app --host 127.0.0.1 --port 8000 & +UVICORN_PID=$! + +# Seed synthetic data once the schema exists (only on a fresh DB). +if [ "$fresh" = "1" ]; then + for _ in $(seq 1 30); do + if [ -f "$DATABASE_PATH" ] \ + && sqlite3 "$DATABASE_PATH" "SELECT 1 FROM schema_migrations LIMIT 1;" >/dev/null 2>&1; then + break + fi + sleep 1 + done + if sqlite3 "$DATABASE_PATH" < /opt/rfc-app/deploy/preview/seed.sql; then + echo "[preview-entrypoint] applied synthetic seed" >&2 + else + # Best-effort: a seed that drifts from the schema must not block the + # preview from booting (it still serves /api/health + an empty app). + echo "[preview-entrypoint] WARNING: synthetic seed failed (schema drift?) — continuing" >&2 + fi +fi + +# nginx in the foreground becomes the container's main process. (uvicorn is a +# reparented child; for an ephemeral preview the hard stop on instance teardown +# is fine — a process supervisor is a follow-up if graceful drain matters.) +exec nginx -g 'daemon off;' diff --git a/deploy/preview/nginx.conf.template b/deploy/preview/nginx.conf.template new file mode 100644 index 0000000..1b110dd --- /dev/null +++ b/deploy/preview/nginx.conf.template @@ -0,0 +1,64 @@ +# nginx config TEMPLATE for the preview container (flotilla SPEC §15). +# `${PORT}` is substituted by deploy/preview/entrypoint.sh (envsubst) with the +# port Cloud Run injects. Mirrors the prod vhost +# (deploy/nginx/ohm.wiggleverse.org.conf) minus TLS (Cloud Run terminates TLS) +# and minus the prod security headers tuned for the public host. + +worker_processes 1; +pid /run/nginx.pid; +error_log /dev/stderr warn; + +events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + sendfile on; + server_tokens off; + + server { + listen ${PORT}; + listen [::]:${PORT}; + server_name _; + + root /opt/rfc-app/frontend/dist; + index index.html; + + # API + auth proxy to the single-process uvicorn. SSE chat streams need + # buffering off so chunks reach the browser immediately. + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + } + + location /auth/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA fallback so React Router can take over. + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + client_max_body_size 4M; + } +} diff --git a/deploy/preview/preview.env.example b/deploy/preview/preview.env.example new file mode 100644 index 0000000..27cd50b --- /dev/null +++ b/deploy/preview/preview.env.example @@ -0,0 +1,49 @@ +# Preview environment shape (flotilla SPEC §15) — TEST values only. +# +# These are the env vars a per-PR preview boots with. The operator loads them +# into flotilla's PREVIEW overlay layer (NOT the base overlay, NOT secrets): +# +# while IFS='=' read -r k v; do +# [ -n "$k" ] && case "$k" in \#*) ;; *) \ +# ohm-rfc-app-flotilla overlay set ohm-rfc-app "$k=$v" --preview ;; esac +# done < deploy/preview/preview.env.example +# +# CRITICAL (§15 / §3 invariant 1): a preview resolves ZERO real secret bytes. +# Every value here is synthetic or a documented public test value. None is a +# real credential. Do NOT bind real `secret_refs` for previews. + +# --- Turnstile: Cloudflare's documented ALWAYS-PASS test keys (public) ------- +# Site key is also baked into the image at build time (Dockerfile ARG); the +# secret key here makes server-side verification always succeed. +CLOUDFLARE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA +VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA + +# --- Email: a catch-all SMTP sink (run Mailpit/Inbucket as a sidecar/service)- +# No mail leaves the preview; everything lands in the sink's web UI. +SMTP_HOST=mailpit +SMTP_PORT=1025 +SMTP_STARTTLS=0 +SMTP_PASSWORD=preview-sink-no-auth +EMAIL_FROM=preview@preview.invalid +EMAIL_FROM_NAME=RFC App (preview) + +# --- Analytics: no-op'd ------------------------------------------------------- +VITE_AMPLITUDE_API_KEY= + +# --- App identity / required env (synthetic) --------------------------------- +# These satisfy backend/app/config.py's required vars with non-secret test +# values. SECRET_KEY is a throwaway — sessions in an ephemeral preview need a +# key, not a SECRET one. GITEA_* point at whatever read surface the preview +# uses; for a content-light framework-PR preview the seeded synthetic DB +# carries the visible state and Gitea need not be reachable. +SECRET_KEY=preview-throwaway-not-a-secret-0000000000 +GITEA_URL=https://git.wiggleverse.org +GITEA_BOT_USER=preview-bot +GITEA_BOT_TOKEN=preview-not-a-real-token +GITEA_ORG=preview +GITEA_WEBHOOK_SECRET=preview-webhook-not-a-secret +OAUTH_CLIENT_ID=preview-oauth-client +OAUTH_CLIENT_SECRET=preview-oauth-not-a-secret +# APP_URL: set to the Cloud Run service URL once `preview up` reports it, if the +# app's OAuth redirect / absolute-URL building needs it for your review flow. +APP_URL=http://localhost:8080 diff --git a/deploy/preview/seed.sql b/deploy/preview/seed.sql new file mode 100644 index 0000000..49d3086 --- /dev/null +++ b/deploy/preview/seed.sql @@ -0,0 +1,20 @@ +-- Synthetic seed for per-PR preview environments (flotilla SPEC §15). +-- +-- SYNTHETIC DATA ONLY. This file is version-controlled and reviewable, carries +-- NO real user PII, and is applied by deploy/preview/entrypoint.sh onto a +-- brand-new preview DB AFTER the app's own migrations have created the schema. +-- It must never be applied to a production database. +-- +-- Applied best-effort: if a future migration changes a table shape this seed +-- references, the entrypoint logs a warning and the preview still boots (it +-- just shows less content). Keep the inserts conservative and schema-stable; +-- grow richer fixtures (RFCs, branches, threads, PRs) here as the preview's +-- review value warrants — they are reproducible because they live in git. + +-- A small synthetic user set covering each role (§6.1 owner/admin/contributor). +-- Negative gitea_ids keep these clear of any real Gitea account id space. +INSERT OR IGNORE INTO users (gitea_id, gitea_login, email, display_name, role) VALUES + (-1, 'preview-owner', 'owner@preview.invalid', 'Preview Owner', 'owner'), + (-2, 'preview-admin', 'admin@preview.invalid', 'Preview Admin', 'admin'), + (-3, 'preview-alice', 'alice@preview.invalid', 'Alice Preview', 'contributor'), + (-4, 'preview-bob', 'bob@preview.invalid', 'Bob Preview', 'contributor'); diff --git a/frontend/package.json b/frontend/package.json index 473d2c1..bfdf271 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.33.0", + "version": "0.34.0", "type": "module", "scripts": { "dev": "vite",