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
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
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
|
## 0.33.0 — 2026-06-04
|
||||||
|
|
||||||
**Minor (breaking) — §22 M3 backend Plan A: project registry mirror +
|
**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",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.33.0",
|
"version": "0.34.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user