#5823 "Check a backup: compare what it would restore with what is in place, without restoring". - steward/backup/check.py: pg_restore writes the backup's rows as COPY text with no database involved. The rows in place go out through COPY in the same form. Tables are compared on their primary keys, over the columns both sides have, and grouped by area. - Sealed values are opened with each side's own key and compared as plaintext digests, so a secret sealed twice still matches. They are never shown. A backup whose key can't open its own secrets fails the check. - What Steward records as it runs (last-seen times, a schedule's last run, backup history, scraped UniFi fields) is left out of the comparison: contents.ACTIVITY, STATE_SETTINGS and HAND_SET_ONLY. - Settings → Backups: - a Check button on each sent backup, which fetches it from the bucket (new s3.get_file); - "Check a file" to upload one, with the size limit raised before the CSRF check reads the form; - a results page. - Each backup Steward sends checks itself once written, and its history row says how that went. - New CLI command `steward check-backup FILE`. CI's image smoke step checks a real backup against its source and expects a match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
15 KiB
YAML
298 lines
15 KiB
YAML
name: CI
|
|
|
|
# CI lanes per FabledRulebook forgejo.md "CI philosophy":
|
|
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
|
# - unit: pytest -m "not integration"; no service containers, no DB.
|
|
# - integration: postgres service container; boots the real app + runs all
|
|
# (core + bundled-plugin) migrations.
|
|
# Single repo: first-party plugins are bundled in-tree under plugins/, so the
|
|
# unit lane imports them directly — no cross-repo checkout.
|
|
|
|
on:
|
|
push:
|
|
branches: [dev, main]
|
|
# pull_request intentionally absent — push on [dev, main] already fires CI for
|
|
# every dev commit and dev→main PRs. Single-operator repo, no fork PRs.
|
|
|
|
# Serialize CI runs per ref so the publish lane never shares the runner's docker
|
|
# daemon with another run. Two publishes racing on one daemon evict each other's
|
|
# freshly-built image mid-push, breaking the post-build tag/push (issue #1093).
|
|
# cancel-in-progress:false is deliberate — rule 46 requires EVERY dev/main push to
|
|
# publish its own immutable :<sha> image, so a superseded run must still run to
|
|
# completion (never cancelled) to emit its SHA. (Forgejo honors `concurrency:` —
|
|
# CI-runner's build workflows use it.)
|
|
concurrency:
|
|
group: ci-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this
|
|
# runs with NO dependency install and surfaces lint bounces in seconds.
|
|
lint:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Ruff lint
|
|
run: ruff check steward/ plugins/ tests/
|
|
|
|
unit:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Install Steward + test deps
|
|
# uv: 5-10x faster wheel resolve than pip on cold caches. Falls back to
|
|
# pip on uv-missing runners. Steward declares its deps in pyproject; the
|
|
# [dev] extra adds pytest + pytest-asyncio.
|
|
run: |
|
|
if command -v uv >/dev/null 2>&1; then
|
|
uv pip install --system -e '.[dev]'
|
|
else
|
|
pip install -e '.[dev]'
|
|
fi
|
|
- name: Pytest (unit only — integration runs in its own lane)
|
|
run: pytest tests/ -v -m "not integration"
|
|
|
|
integration:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
env:
|
|
STEWARD_SECRET_KEY: ci_integration_placeholder
|
|
services:
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
env:
|
|
POSTGRES_USER: steward
|
|
POSTGRES_PASSWORD: steward
|
|
POSTGRES_DB: steward
|
|
# The data directory lives in RAM (#5746): postgres images declare
|
|
# VOLUME for it, so without a tmpfs each run left an anonymous volume
|
|
# that act_runner never removes, and 2,488 of them used up the runner
|
|
# host's inodes.
|
|
options: >-
|
|
--tmpfs /var/lib/postgresql/data
|
|
--health-cmd "pg_isready -U steward"
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 10
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Boot + migrate against Postgres
|
|
# act_runner (swarm-runner) puts service containers on the default bridge
|
|
# with no embedded DNS, so the hostname `postgres` won't resolve — we
|
|
# discover the service container's bridge IP via the docker socket. The
|
|
# job container and its Postgres share a task-number name prefix.
|
|
run: |
|
|
set -euxo pipefail
|
|
echo "=== container landscape (diagnostic for filter scoping) ==="
|
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
|
echo "=== end landscape ==="
|
|
# The runner's daemon is shared: another repo's integration job can be
|
|
# running beside this one, and `name=integration` matches its Postgres
|
|
# too (run 8358 took the wrong one and every test failed to connect).
|
|
# Scope to this job's own task: act_runner names every container of a
|
|
# task GITEA-ACTIONS-TASK-<n>-…, so read <n> from our own container
|
|
# (its id is in the /etc/hostname bind mount) and require one match.
|
|
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
|
SELF=${SELF:-$(hostname)}
|
|
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
|
|
test -n "$TASK"
|
|
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q)
|
|
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
|
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
|
test -n "$PG_IP"
|
|
export STEWARD_DATABASE_URL="postgresql+asyncpg://steward:steward@$PG_IP:5432/steward"
|
|
for i in $(seq 1 60); do
|
|
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
|
sleep 2
|
|
done
|
|
if command -v uv >/dev/null 2>&1; then
|
|
uv pip install --system -e '.[dev,ansible]'
|
|
else
|
|
pip install -e '.[dev,ansible]'
|
|
fi
|
|
pytest tests/integration -v -m integration --durations=10
|
|
|
|
# Image-publish lane — FabledRulebook rule 46: every push to dev/main publishes
|
|
# an immutable commit-SHA image (the rollback unit); dev moves :dev, main moves
|
|
# :latest. Gated on the three test lanes via `needs:` so a red commit never
|
|
# produces a :dev image. Mechanics mirror CI-runner's build-ci-python.yml.
|
|
publish:
|
|
needs: [lint, unit, integration]
|
|
runs-on: python-ci
|
|
container:
|
|
# ci-builder carries docker + buildx + git. The docker socket is
|
|
# auto-mounted by act_runner (valid_volumes) — do NOT add a
|
|
# container.options "-v /var/run/docker.sock:..." mount; that duplicate
|
|
# mount fails the job at "Set up job".
|
|
image: git.fabledsword.com/bvandeusen/ci-builder:latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Registry login
|
|
# The injected GITHUB_TOKEN lacks write:package scope, so docker push
|
|
# 401s with it. REGISTRY_TOKEN is a repo Actions secret holding a
|
|
# Forgejo token scoped read:package + write:package.
|
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.fabledsword.com -u bvandeusen --password-stdin
|
|
- name: Build (commit-SHA always; :dev on dev, :latest on main)
|
|
run: |
|
|
set -eux
|
|
IMAGE=git.fabledsword.com/bvandeusen/steward
|
|
# The moving tag for this ref: dev→:dev, main→:latest (rule 46). main IS
|
|
# the production line, so :latest tracks main's tip; there is no :main.
|
|
MOVING=""
|
|
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
|
MOVING="dev"
|
|
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
MOVING="latest"
|
|
fi
|
|
# Tag BOTH targets at build time (mirrors CI-runner's build workflows):
|
|
# one `docker build -t :<sha> -t :<moving>` points both tags at the image
|
|
# atomically, so we never run a post-push `docker tag` of an image a
|
|
# concurrent run could have evicted from the shared daemon (issue #1093).
|
|
if [ -n "$MOVING" ]; then
|
|
docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:$MOVING" .
|
|
else
|
|
docker build -t "$IMAGE:${{ github.sha }}" .
|
|
fi
|
|
|
|
# Rule 164's check, BETWEEN build and push (rule 177): an image that
|
|
# can't boot, migrate and serve with no internet never reaches a tag.
|
|
# An --internal network lets it reach its own Postgres and nothing else;
|
|
# --network none can't work for an app that needs a database to start.
|
|
# Embedded DNS works on a user-defined network, unlike the services:
|
|
# containers above (rule 79), so the app finds Postgres by name.
|
|
- name: The image boots with no internet
|
|
env:
|
|
SMOKE: steward-smoke-${{ github.run_id }}
|
|
run: |
|
|
set -eux
|
|
IMAGE=git.fabledsword.com/bvandeusen/steward:${{ github.sha }}
|
|
docker network create --internal "$SMOKE-net"
|
|
docker run -d --name "$SMOKE-pg" --network "$SMOKE-net" \
|
|
--tmpfs /var/lib/postgresql/data \
|
|
-e POSTGRES_USER=steward -e POSTGRES_PASSWORD=steward -e POSTGRES_DB=steward \
|
|
postgres:16-alpine
|
|
# Waits with deadlines (rule 156); pg_isready, not /dev/tcp, which is
|
|
# a bash builtin and never succeeds under sh.
|
|
ready=""
|
|
for _ in $(seq 1 60); do
|
|
if docker exec "$SMOKE-pg" pg_isready -U steward -d steward -q; then ready=1; break; fi
|
|
sleep 2
|
|
done
|
|
test -n "$ready" || { echo "FATAL: postgres not ready within 120s"; exit 1; }
|
|
|
|
docker run -d --name "$SMOKE-app" --network "$SMOKE-net" \
|
|
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/steward" \
|
|
-e STEWARD_SECRET_KEY=smoke-test-placeholder \
|
|
"$IMAGE"
|
|
# The image has no curl; its own Python fetches. Prints the body.
|
|
fetch() {
|
|
docker exec "$SMOKE-app" python -c 'import sys, urllib.request
|
|
r = urllib.request.urlopen("http://127.0.0.1:5000" + sys.argv[1], timeout=5)
|
|
sys.stdout.write(r.read().decode())' "$1"
|
|
}
|
|
# Migrations run before the server starts, so /health answering
|
|
# means the schema applied against a real database.
|
|
up=""
|
|
for _ in $(seq 1 60); do
|
|
if fetch /health >/dev/null 2>&1; then up=1; break; fi
|
|
sleep 2
|
|
done
|
|
if [ -z "$up" ]; then
|
|
echo "FATAL: Steward never answered /health with no internet access"
|
|
docker logs "$SMOKE-app" 2>&1 | tail -80
|
|
exit 1
|
|
fi
|
|
fetch /health | grep -q '"ok"'
|
|
|
|
# tini is PID 1, so zombies are reaped and SIGTERM reaches Steward.
|
|
test "$(docker exec "$SMOKE-app" cat /proc/1/comm)" = "tini"
|
|
|
|
# The zone database is in the image, so a reader's zone resolves
|
|
# instead of quietly falling back to UTC (milestone 490).
|
|
docker exec "$SMOKE-app" python -c 'from zoneinfo import ZoneInfo; ZoneInfo("America/New_York")'
|
|
|
|
# A fresh database lands on first-run setup: a real page, its assets
|
|
# served by Steward itself, and no third-party host in it (rule 155).
|
|
fetch / > /tmp/smoke-page.html
|
|
grep -qi "<!doctype html" /tmp/smoke-page.html
|
|
grep -q "/static/" /tmp/smoke-page.html
|
|
if grep -Eqi 'https?://(fonts\.|cdn\.|unpkg\.|cdnjs\.|ajax\.)' /tmp/smoke-page.html; then
|
|
echo "FATAL: the page references a third-party host"
|
|
grep -Eoi 'https?://[^"'"'"' ]+' /tmp/smoke-page.html
|
|
exit 1
|
|
fi
|
|
|
|
# A backup made by the image restores into an empty database, and
|
|
# Steward starts on it (milestone 507). It carries settings (the
|
|
# marker setting) and leaves metrics and the audit log out.
|
|
sql() { docker exec "$SMOKE-pg" psql -U steward -v ON_ERROR_STOP=1 -tA "$@"; }
|
|
sql -d steward -c "INSERT INTO app_settings (key, value_json, updated_at) VALUES ('smoke.backup', '\"kept\"', now())"
|
|
sql -d steward -c "INSERT INTO plugin_metrics (id, source_module, resource_name, metric_name, value, recorded_at) VALUES ('smoke-metric', 'smoke', 'smoke', 'smoke', 1, now())"
|
|
sql -d steward -c "INSERT INTO audit_events (id, timestamp, username, action) VALUES ('smoke-audit', now(), 'smoke', 'smoke.backup')"
|
|
docker exec -u app "$SMOKE-app" steward backup --to /tmp/backups
|
|
# Checked against the database it was made from, it matches (#5823):
|
|
# a real pg_restore read, and what a running Steward writes by itself
|
|
# (last-seen times, backup history) left out of the comparison.
|
|
docker exec -u app "$SMOKE-app" sh -c 'steward check-backup /tmp/backups/steward-*.tar' | tee /tmp/smoke-check.txt
|
|
grep -q "Matches what is in place" /tmp/smoke-check.txt
|
|
sql -d steward -c "CREATE DATABASE restored"
|
|
docker exec -u app \
|
|
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/restored" \
|
|
"$SMOKE-app" sh -c 'steward restore /tmp/backups/steward-*.tar'
|
|
test "$(sql -d restored -c "SELECT value_json FROM app_settings WHERE key = 'smoke.backup'")" = '"kept"'
|
|
test "$(sql -d restored -c "SELECT count(*) FROM plugin_metrics")" = "0"
|
|
test "$(sql -d restored -c "SELECT count(*) FROM audit_events")" = "0"
|
|
test "$(sql -d restored -c "SELECT count(*) FROM alembic_version")" -ge 1
|
|
docker run -d --name "$SMOKE-restored" --network "$SMOKE-net" \
|
|
-e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/restored" \
|
|
-e STEWARD_SECRET_KEY=smoke-test-placeholder \
|
|
"$IMAGE"
|
|
up=""
|
|
for _ in $(seq 1 60); do
|
|
if docker exec "$SMOKE-restored" python -c 'import urllib.request
|
|
urllib.request.urlopen("http://127.0.0.1:5000/health", timeout=5)' >/dev/null 2>&1; then up=1; break; fi
|
|
sleep 2
|
|
done
|
|
if [ -z "$up" ]; then
|
|
echo "FATAL: Steward never started on the restored database"
|
|
docker logs "$SMOKE-restored" 2>&1 | tail -80
|
|
exit 1
|
|
fi
|
|
|
|
# A stop is a clean shutdown: SIGTERM through tini to Hypercorn, which
|
|
# finishes within its graceful timeout and exits 0.
|
|
docker stop -t 30 "$SMOKE-app"
|
|
code="$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE-app")"
|
|
test "$code" = "0" || { echo "FATAL: Steward exited $code on SIGTERM"; exit 1; }
|
|
|
|
- name: Push
|
|
run: |
|
|
set -eux
|
|
IMAGE=git.fabledsword.com/bvandeusen/steward
|
|
docker push "$IMAGE:${{ github.sha }}"
|
|
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
|
docker push "$IMAGE:dev"
|
|
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
docker push "$IMAGE:latest"
|
|
fi
|
|
|
|
- name: Smoke logs
|
|
if: always()
|
|
run: docker logs "steward-smoke-${{ github.run_id }}-app" 2>&1 | tail -60 || true
|
|
|
|
# A failed smoke must not leave a Postgres and an app on the shared daemon.
|
|
- name: Smoke clean-up
|
|
if: always()
|
|
run: |
|
|
docker rm -fv "steward-smoke-${{ github.run_id }}-app" "steward-smoke-${{ github.run_id }}-restored" \
|
|
"steward-smoke-${{ github.run_id }}-pg" >/dev/null 2>&1 || true
|
|
docker network rm "steward-smoke-${{ github.run_id }}-net" >/dev/null 2>&1 || true
|
|
- name: Prune dangling layers
|
|
if: always()
|
|
run: docker image prune -f
|