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.
This commit is contained in:
emi 2026-07-21 15:57:17 -07:00
parent 452788fb18
commit 8e9dfa1539
6 changed files with 109 additions and 48 deletions

View file

@ -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"

14
.gitignore vendored Normal file
View file

@ -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

42
Dockerfile Normal file
View file

@ -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"]

View file

@ -7,18 +7,7 @@
# ready to accept the migration. # ready to accept the migration.
set -euo pipefail set -euo pipefail
# THERMOGRAPH_SERVICE_ROLE (repo-split Stage 4): which app this container cd /app
# 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
# --- Swarm secrets shim ------------------------------------------------------ # --- Swarm secrets shim ------------------------------------------------------
# The app reads config from os.environ everywhere (users.py falls back to a random # 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 # 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 # the migration and exit — a one-shot task, so it runs once against the DB rather
# it runs once against the DB rather than racing per web replica. # than racing per web replica.
if [ "${1:-}" = "migrate" ] || [ "${THERMOGRAPH_MIGRATE_ONLY:-0}" = "1" ]; then if [ "${1:-}" = "migrate" ] || [ "${THERMOGRAPH_MIGRATE_ONLY:-0}" = "1" ]; then
run_migrations run_migrations
exit 0 exit 0
@ -88,5 +77,5 @@ if [ "${RUN_MIGRATIONS:-1}" != "0" ]; then
run_migrations run_migrations
fi 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}" exec uvicorn app:app --host 0.0.0.0 --port "${PORT:-8137}" --workers "${WORKERS:-4}"

View file

@ -11,30 +11,11 @@ keep doing so; these are only the defaults.
""" """
import os import os
_HERE = os.path.dirname(os.path.abspath(__file__)) REPO_DIR = 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")
DATA_DIR = os.path.join(REPO_DIR, "data") DATA_DIR = os.path.join(REPO_DIR, "data")
LOGS_DIR = os.path.join(REPO_DIR, "logs") LOGS_DIR = os.path.join(REPO_DIR, "logs")
# Bundled reference data shipped alongside the code (generated by gen_cities / # Bundled reference data shipped alongside the code (generated by gen_cities /
# gen_flavor), not runtime state — hence under backend/, not data/. # gen_flavor), not runtime state — hence at the repo root, not data/.
CITIES_JSON = os.path.join(BACKEND_DIR, "cities.json") CITIES_JSON = os.path.join(REPO_DIR, "cities.json")
CITIES_FLAVOR_JSON = os.path.join(BACKEND_DIR, "cities_flavor.json") CITIES_FLAVOR_JSON = os.path.join(REPO_DIR, "cities_flavor.json")

View file

@ -12,19 +12,11 @@ aiosqlite==0.22.1
asyncpg==0.30.0 asyncpg==0.30.0
psycopg[binary]==3.2.3 psycopg[binary]==3.2.3
alembic==1.14.0 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. # py-vapid, cryptography, and http-ece.
pywebpush==2.0.0 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 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 # Recurring worker-tier jobs — city warming, IndexNow pings (see
# notifications/scheduler.py). In-process; no broker/queue needed at this scale. # notifications/scheduler.py). In-process; no broker/queue needed at this scale.
apscheduler==3.11.3 apscheduler==3.11.3