v0.34.0: containerize for per-PR preview environments (flotilla SPEC §15)

This commit is contained in:
Ben Stull
2026-06-01 06:51:22 -07:00
parent 97ba3ae9b5
commit 0252e40527
9 changed files with 292 additions and 2 deletions
+15
View File
@@ -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/
+25
View File
@@ -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 +
+62
View File
@@ -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 <image> .
# ---- 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"]
+1 -1
View File
@@ -1 +1 @@
0.33.0
0.34.0
+55
View File
@@ -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;'
+64
View File
@@ -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;
}
}
+49
View File
@@ -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
+20
View File
@@ -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');
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.33.0",
"version": "0.34.0",
"type": "module",
"scripts": {
"dev": "vite",