v0.34.0: containerize for per-PR preview environments (flotilla SPEC §15)
This commit is contained in:
@@ -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/
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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;'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.33.0",
|
||||
"version": "0.34.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user