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
+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');