diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 6701686..64989cd 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -9,9 +9,9 @@ name: Build check # the runner's shared capacity=2 contention) without ever settling into a # reliably green check. The image DOES boot correctly standalone -- # independently verified by hand: `docker run` + real alembic migrations + -# `/healthz` returning 200 + a real `/api/v2/place` 200. Full pytest-suite -# migration is separate, deferred follow-up work too, same category as -# thermograph-copy's deferred vendoring. +# `/healthz` returning 200 + a real `/api/v2/place` 200. The full pytest +# suite DOES run here now -- inside the built image (see the test step below), +# which sidesteps those runner quirks entirely. on: # Reusable (uses: ./.forgejo/workflows/build.yml) by pr-build.yml and @@ -38,3 +38,21 @@ jobs: - name: Build run: docker build -t thermograph-backend:ci . + + # The suite is hermetic (sqlite/tmpdir; no network), and the image already + # contains tests/ + every runtime dep (COPY . /app/), so run pytest INSIDE + # the image we just built: the exact interpreter/deps that ship, none of + # the runner-environment quirks that sank the old host-side boot check. + # requirements-dev.txt only layers pytest on top, so the install is tiny. + # -u 0: the image's default user can't write to site-packages. + # --entrypoint sh: the image's entrypoint is alembic-migrate + uvicorn; + # without the override the test command is swallowed as entrypoint args + # and the container just boots the server forever. unset THERMOGRAPH_BASE: + # the image bakes the prod root-mount (/), which moves every route off the + # /thermograph prefix the tests are written against -- requests would fall + # through to the frontend-proxy catch-all and fail with ConnectError. + - name: Run tests in the built image + run: | + docker run --rm -u 0 --entrypoint sh thermograph-backend:ci \ + -c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest tests -q" + diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 217734f..8af668b 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -7,9 +7,8 @@ name: Deploy backend to beta VPS # versa. thermograph-infra/deploy/deploy.sh persists each service's live tag # host-side, so a single-service roll never disturbs the other. # -# The SSH_HOST/USER/KEY/PORT secrets point at beta. Prod is NOT deployed by a -# workflow -- it's deployed with `terraform apply` on the `release` branch, so -# there is deliberately no release-triggered workflow. appleboy/ssh-action is +# The SSH_HOST/USER/KEY/PORT secrets point at beta. Prod deploys the same way +# from deploy-prod.yml (release branch, PROD_SSH_* secrets). appleboy/ssh-action is # referenced by full GitHub URL because it isn't mirrored in Forgejo's default # action registry. # diff --git a/.gitignore b/.gitignore index 70bf14c..015606a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ +.venv-test/ __pycache__/ *.pyc data/cache/*.parquet diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee12ad2 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +# thermograph-backend — local dev/test targets. +.PHONY: help test smoke clean-venv + +help: ## List targets + @grep -hE '^[a-z][a-zA-Z0-9_-]*:.*## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " %-14s %s\n", $$1, $$2}' + +test: ## Build a py3.12 dev venv and run the hermetic pytest suite (pass ARGS=...) + ./scripts/test.sh $(ARGS) + +smoke: ## Boot the backend image + a throwaway db and assert /healthz + /api/version + ./scripts/smoke.sh + +clean-venv: ## Remove the test venv + rm -rf .venv-test diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..d110466 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,40 @@ +# Boot-smoke harness: run the backend's OWN image against a throwaway TimescaleDB and +# assert it actually boots + serves its contract (build alone doesn't prove boot). Used +# by scripts/smoke.sh; not a deploy file. DB data is tmpfs (ephemeral) — no volume, no +# residue. See Makefile `smoke` and README. +services: + db: + image: timescale/timescaledb:${TIMESCALEDB_TAG:-latest-pg18} + environment: + POSTGRES_USER: thermograph + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-smoke} + POSTGRES_DB: thermograph + tmpfs: + # pg18 images place PGDATA in a major-version subdir under the mount, so mount at + # /var/lib/postgresql (matches the infra compose), not /var/lib/postgresql/data. + - /var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U thermograph -d thermograph"] + interval: 3s + timeout: 5s + retries: 20 + + backend: + # Defaults to a locally-built `:local` image (scripts/smoke.sh builds it). In CI, + # set BACKEND_IMAGE_TAG=sha-<12hex> to smoke the exact published image. + image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${BACKEND_IMAGE_TAG:-local} + depends_on: + db: + condition: service_healthy + environment: + THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD:-smoke}@db:5432/thermograph + # Required at import (web/app.py). The smoke never exercises a proxied page, so an + # unreachable placeholder is fine — it only hits /healthz and {BASE}/api/version. + THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://127.0.0.1:1 + THERMOGRAPH_BASE: "/" + THERMOGRAPH_ENABLE_NOTIFIER: "0" + PORT: "8137" + WORKERS: "1" + ports: + # Host port defaults to 18137 to avoid colliding with a running dev server on 8137. + - "127.0.0.1:${SMOKE_HOST_PORT:-18137}:8137" diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..4854c3b --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Image boot-smoke: prove the backend IMAGE actually boots and serves its contract +# (a `docker build` succeeding does not prove the container starts). Builds the image +# locally (or uses a provided BACKEND_IMAGE_TAG), runs it + a throwaway TimescaleDB via +# docker-compose.test.yml, waits for /healthz, then asserts /healthz and the version +# endpoint. Always tears the stack down. +# +# ./scripts/smoke.sh # build :local and smoke it +# BACKEND_IMAGE_TAG=sha-abc123 ./scripts/smoke.sh # smoke a published image (CI) +set -euo pipefail +cd "$(dirname "$0")/.." + +export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-smoke}" +# The image to smoke, pulled from the registry (no local build). CI passes the just- +# pushed sha (BACKEND_IMAGE_TAG=sha-<12hex>); locally it defaults to a published tag. +export BACKEND_IMAGE_TAG="${BACKEND_IMAGE_TAG:-v0.0.2-split-ci}" +IMG="${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${BACKEND_IMAGE_TAG}" +export SMOKE_HOST_PORT="${SMOKE_HOST_PORT:-18137}" +COMPOSE=(docker compose -f docker-compose.test.yml) +PORT_URL="http://127.0.0.1:${SMOKE_HOST_PORT}" + +cleanup() { echo "==> tearing down"; "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true; } +trap cleanup EXIT + +echo "==> pulling $IMG" +"${COMPOSE[@]}" pull backend + +echo "==> starting backend + db ($IMG)" +"${COMPOSE[@]}" up -d + +echo "==> waiting for /healthz (up to 90s)" +for i in $(seq 1 45); do + if curl -fsS -o /dev/null "$PORT_URL/healthz"; then ok=1; break; fi + sleep 2 +done +[ "${ok:-}" = 1 ] || { echo "!! backend never became healthy"; "${COMPOSE[@]}" logs --tail=40 backend; exit 1; } + +echo "==> asserting contract" +curl -fsS "$PORT_URL/healthz" >/dev/null && echo " /healthz 200 ok" +ver=$(curl -fsS "$PORT_URL/api/version") +echo " /api/version -> $ver" +echo "$ver" | grep -q '"backend_version"[[:space:]]*:[[:space:]]*"2"' \ + || { echo "!! /api/version did not report backend_version=2"; exit 1; } + +echo "==> SMOKE PASSED" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..1cc6b1f --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Reproducible local test runner: build a Python 3.12 dev venv, install the pinned +# dev deps, and run the hermetic pytest suite. Matches what CI should run. +# +# Why 3.12 explicitly: the runtime deps pin versions that need 3.12, and a common box +# python (pyenv 3.10) ships without the `_sqlite3` extension, which the suite's +# conftest imports — so we don't just use whatever `python3` is on PATH. +# +# ./scripts/test.sh # full suite +# ./scripts/test.sh tests/web # pass pytest args through +set -euo pipefail +cd "$(dirname "$0")/.." + +VENV="${VENV:-.venv-test}" + +if [ ! -x "$VENV/bin/pytest" ]; then + echo "==> Building dev venv ($VENV) on Python 3.12" + if command -v uv >/dev/null 2>&1; then + uv venv --python 3.12 "$VENV" + VIRTUAL_ENV="$VENV" uv pip install -q -r requirements-dev.txt + else + PY="$(command -v python3.12 || echo "$HOME/.local/bin/python3.12")" + "$PY" -c 'import sqlite3' 2>/dev/null || { echo "!! $PY lacks the sqlite3 module — install a 3.12 with sqlite (e.g. via uv)"; exit 1; } + "$PY" -m venv "$VENV" + "$VENV/bin/pip" install -q --upgrade pip + "$VENV/bin/pip" install -q -r requirements-dev.txt + fi +fi + +echo "==> pytest ($("$VENV/bin/python" --version))" +exec "$VENV/bin/python" -m pytest "$@"