test: reproducible local runner + image boot-smoke (#2)

This commit is contained in:
emi 2026-07-23 00:40:13 +00:00
parent 97d0e53d77
commit c2ce93fad6
5 changed files with 131 additions and 0 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
.venv/ .venv/
.venv-test/
__pycache__/ __pycache__/
*.pyc *.pyc
data/cache/*.parquet data/cache/*.parquet

14
Makefile Normal file
View file

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

40
docker-compose.test.yml Normal file
View file

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

45
scripts/smoke.sh Executable file
View file

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

31
scripts/test.sh Executable file
View file

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