From 8e9dfa153953719fb3600e01e257eb428f1c7ba7 Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 15:57:17 -0700 Subject: [PATCH] Standalone backend: no-walk paths.py, own Dockerfile, own CI paths.py drops the sibling-directory walk (backend is now the repo root, no more frontend/ sibling to walk toward). requirements.txt drops jinja2/PyYAML (only needed for frontend_ssr's sake in the old shared monorepo Dockerfile) and fixes stale backend/push.py-style path comments. New Dockerfile (COPY . /app/, no THERMOGRAPH_SERVICE_ROLE branch -- this image only ever runs backend) and entrypoint.sh (cd /app instead of /app/backend). Minimal build.yml: docker build + boot + /healthz check. Verified: image builds and boots standalone (real alembic migration against SQLite, /healthz and /api/v2/place both 200), full pytest suite green (301 passed, 4 skipped) against the rewritten paths.py. --- .forgejo/workflows/build.yml | 43 ++++++++++++++++++++++++++++++++++++ .gitignore | 14 ++++++++++++ Dockerfile | 42 +++++++++++++++++++++++++++++++++++ deploy/entrypoint.sh | 19 ++++------------ paths.py | 27 ++++------------------ requirements.txt | 12 ++-------- 6 files changed, 109 insertions(+), 48 deletions(-) create mode 100644 .forgejo/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..dd12079 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,43 @@ +name: Build + boot check + +# Proves the split Dockerfile actually builds and boots standalone (repo-split +# Stage 7b) -- not yet a full pytest-suite migration (that's separate, real +# follow-up work, same category as thermograph-copy's deferred vendoring). +# THERMOGRAPH_FRONTEND_BASE_INTERNAL is required (fails loud if unset) but +# never actually exercised here -- /healthz doesn't go through the +# catch-all proxy, so an unreachable placeholder is fine for this check. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: docker + steps: + - uses: actions/checkout@v4 + + - name: Install Docker CLI + run: | + apt-get update -qq + apt-get install -y -qq docker.io + + - name: Build + run: docker build -t thermograph-backend:ci . + + - name: Boot + health check + run: | + docker run -d --name backend-ci \ + -e THERMOGRAPH_FRONTEND_BASE_INTERNAL=http://127.0.0.1:1 \ + -p 8137:8137 thermograph-backend:ci + ok=0 + for i in $(seq 1 30); do + if curl -fsS -o /dev/null http://127.0.0.1:8137/healthz; then ok=1; break; fi + sleep 1 + done + docker logs backend-ci + docker rm -f backend-ci >/dev/null 2>&1 || true + if [ "$ok" != 1 ]; then echo "backend never became healthy"; exit 1; fi + echo "OK: backend booted standalone" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70bf14c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.pyc +data/cache/*.parquet +data/geonames/ +data/thermograph.sqlite* +data/accounts.sqlite* +data/vapid.json +data/indexnow_key.txt +data/indexnow_state.txt +data/homepage.json +logs/ +.DS_Store +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5ed738 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Thermograph backend: FastAPI API + accounts/notifications + the SSR content +# JSON API frontend consumes. Split from the monorepo (repo-split Stage 7). +FROM python:3.12-slim + +# curl is only for the container HEALTHCHECK below. Everything Python needs +# ships as manylinux wheels (asyncpg, psycopg[binary], polars, numpy, +# cryptography via pywebpush, PyNaCl), so no compiler/build toolchain is +# required. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# Install deps first so this layer caches across code-only changes. +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +COPY . /app/ +RUN chmod +x /app/deploy/entrypoint.sh + +# Non-root runtime user. Create the writable state dirs and own the whole tree +# so the parquet cache, logs, notifier.lock, homepage.json, vapid.json can be +# written. When the named volumes first mount over /app/data and /app/logs, +# Docker seeds them from this image content -- including this ownership -- +# so they stay writable. +RUN useradd --system --create-home --uid 10001 thermograph \ + && mkdir -p /app/data /app/logs \ + && chown -R thermograph:thermograph /app + +USER thermograph +WORKDIR /app + +ENV PORT=8137 \ + WORKERS=4 \ + THERMOGRAPH_BASE=/ \ + PYTHONUNBUFFERED=1 + +EXPOSE 8137 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${PORT}/healthz || exit 1 + +ENTRYPOINT ["/app/deploy/entrypoint.sh"] diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index 11b0afa..9c68987 100755 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -7,18 +7,7 @@ # ready to accept the migration. set -euo pipefail -# THERMOGRAPH_SERVICE_ROLE (repo-split Stage 4): which app this container -# actually runs -- backend (default, today's exact behavior: migrations, then -# backend/app.py) or frontend (frontend_ssr/app.py -- stateless, no DB, no -# migrations at all). Not to be confused with THERMOGRAPH_ROLE (web|worker), -# which only applies within the backend role. -if [ "${THERMOGRAPH_SERVICE_ROLE:-backend}" = "frontend" ]; then - cd /app/frontend_ssr - echo "==> Starting uvicorn (frontend) on 0.0.0.0:${PORT:-8137} with ${WORKERS:-4} worker(s)" - exec uvicorn app:app --host 0.0.0.0 --port "${PORT:-8137}" --workers "${WORKERS:-4}" -fi - -cd /app/backend +cd /app # --- Swarm secrets shim ------------------------------------------------------ # The app reads config from os.environ everywhere (users.py falls back to a random @@ -74,8 +63,8 @@ run_migrations() { } # One-shot migrate mode (`entrypoint.sh migrate`, or THERMOGRAPH_MIGRATE_ONLY=1): run -# the migration and exit — the Swarm one-shot task from the hop-1 cutover runbook, so -# it runs once against the DB rather than racing per web replica. +# the migration and exit — a one-shot task, so it runs once against the DB rather +# than racing per web replica. if [ "${1:-}" = "migrate" ] || [ "${THERMOGRAPH_MIGRATE_ONLY:-0}" = "1" ]; then run_migrations exit 0 @@ -88,5 +77,5 @@ if [ "${RUN_MIGRATIONS:-1}" != "0" ]; then run_migrations fi -echo "==> Starting uvicorn (backend) on 0.0.0.0:${PORT:-8137} with ${WORKERS:-4} worker(s)" +echo "==> Starting uvicorn on 0.0.0.0:${PORT:-8137} with ${WORKERS:-4} worker(s)" exec uvicorn app:app --host 0.0.0.0 --port "${PORT:-8137}" --workers "${WORKERS:-4}" diff --git a/paths.py b/paths.py index f5dc078..af629a5 100644 --- a/paths.py +++ b/paths.py @@ -11,30 +11,11 @@ keep doing so; these are only the defaults. """ import os -_HERE = os.path.dirname(os.path.abspath(__file__)) - - -def _repo_root() -> str: - """Walk up from this file until a directory holds both ``backend/`` and - ``frontend/`` — the repository root, in every checkout (dev, prod, worktree).""" - d = _HERE - while True: - if os.path.isdir(os.path.join(d, "backend")) and os.path.isdir(os.path.join(d, "frontend")): - return d - parent = os.path.dirname(d) - if parent == d: - raise RuntimeError( - "cannot locate the repo root (no ancestor has sibling backend/ and frontend/)" - ) - d = parent - - -REPO_DIR = _repo_root() -BACKEND_DIR = os.path.join(REPO_DIR, "backend") +REPO_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(REPO_DIR, "data") LOGS_DIR = os.path.join(REPO_DIR, "logs") # Bundled reference data shipped alongside the code (generated by gen_cities / -# gen_flavor), not runtime state — hence under backend/, not data/. -CITIES_JSON = os.path.join(BACKEND_DIR, "cities.json") -CITIES_FLAVOR_JSON = os.path.join(BACKEND_DIR, "cities_flavor.json") +# gen_flavor), not runtime state — hence at the repo root, not data/. +CITIES_JSON = os.path.join(REPO_DIR, "cities.json") +CITIES_FLAVOR_JSON = os.path.join(REPO_DIR, "cities_flavor.json") diff --git a/requirements.txt b/requirements.txt index df710aa..b8bb685 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,19 +12,11 @@ aiosqlite==0.22.1 asyncpg==0.30.0 psycopg[binary]==3.2.3 alembic==1.14.0 -# Web Push (VAPID) delivery of notifications (see backend/push.py). Pulls in +# Web Push (VAPID) delivery of notifications (see notifications/push.py). Pulls in # py-vapid, cryptography, and http-ece. pywebpush==2.0.0 -# Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py). +# Ed25519 verification of Discord interaction webhooks (see notifications/discord_interactions.py). PyNaCl==1.5.0 -# Not used by backend itself (its own content.py/content_loader.py were -# deleted in repo-split Stage 4) -- installed here because frontend_ssr/ -# shares this requirements.txt in the current shared Dockerfile. See -# frontend_ssr/content.py (server-rendered SEO content pages). -jinja2==3.1.6 -# Structured SSR copy (glossary, static-page SEO meta) — see -# frontend_ssr/content_loader.py and content/. -PyYAML==6.0.3 # Recurring worker-tier jobs — city warming, IndexNow pings (see # notifications/scheduler.py). In-process; no broker/queue needed at this scale. apscheduler==3.11.3