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:
parent
452788fb18
commit
8e9dfa1539
6 changed files with 109 additions and 48 deletions
43
.forgejo/workflows/build.yml
Normal file
43
.forgejo/workflows/build.yml
Normal 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
14
.gitignore
vendored
Normal 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
42
Dockerfile
Normal 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"]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
27
paths.py
27
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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue