Files
FabledSteward/tests/core/test_backup_contents.py
T
bvandeusenandClaude Opus 5.5 b875160c40
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m14s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped
feat(backup): steward backup and steward restore; CI restores a dump and boots on it (#5672, #5674)
The backup file (#5672 "Backup file: pg_dump of config + audit + alert
history, the secret key and a manifest"): steward-<UTC>.tar holding a
pg_dump --format=custom with metrics', check results' and Docker/Traefik/
UniFi rows left out, the secret key, and a manifest. Every table is named
KEPT or EXCLUDED in steward/backup/contents.py, and a guard test fails on a
new one. The image gains postgresql-client-17 (approved, System 180).

The restore (#5674 "Restore: `steward restore` and a CI test that a dump
brings Steward back"): pg_restore in its three sections. Between data and
constraints, a kept row pointing at a left-out row gets what its foreign
key's ON DELETE says (alert_events.ansible_run_id is cleared). It refuses
a non-empty database unless --wipe, and puts the backup's key in
/data/secret.key. Migrations run on Steward's next start.

`steward` stays the server command: it is now a click group that serves
when given no subcommand.

The publish smoke step backs up the booted image's database, restores it
into a second one, checks a marker setting came back and plugin_metrics
is empty, and starts Steward on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:14:04 -04:00

113 lines
4.5 KiB
Python

"""What a backup carries (steward/backup/contents.py) and the file it makes
(steward/backup/dump.py): every table is a decision, and the tar reads back."""
import tarfile
from datetime import datetime, timezone
import pytest
from sqlalchemy import Column, ForeignKey, Integer, MetaData, String, Table
from steward.backup import contents, dump
from steward.backup.restore import all_metadata, dangling_fixes
def undecided(tables: set[str]) -> set[str]:
return tables - contents.KEPT - contents.EXCLUDED
def test_every_table_is_kept_or_left_out():
tables = set(all_metadata().tables)
assert "prometheus_targets" in tables and "plugin_metrics" in tables # plugins were loaded
assert undecided(tables) == set(), (
"Name these in steward/backup/contents.py as KEPT or EXCLUDED")
def test_the_guard_can_fail():
assert undecided({"app_settings", "plugin_metrics", "a_new_table"}) == {"a_new_table"}
def test_no_table_is_both_and_none_is_named_for_nothing():
assert contents.KEPT & contents.EXCLUDED == set()
stale = (contents.KEPT | contents.EXCLUDED) - set(all_metadata().tables)
assert stale == set(), "contents.py names tables that no longer exist"
def test_an_alert_event_keeps_its_row_when_its_ansible_run_is_left_out():
fixes = dangling_fixes(all_metadata())
assert ('UPDATE "alert_events" SET "ansible_run_id" = NULL WHERE "ansible_run_id" IS NOT NULL '
'AND NOT EXISTS (SELECT 1 FROM "ansible_runs" p WHERE p."id" = "alert_events"."ansible_run_id")'
) in fixes
def test_a_cascading_reference_removes_the_row():
md = MetaData()
Table("plugin_metrics", md, Column("id", Integer, primary_key=True))
Table("notes", md, Column("id", Integer, primary_key=True),
Column("metric_id", Integer, ForeignKey("plugin_metrics.id", ondelete="CASCADE")),
Column("owner", String, ForeignKey("users.name")))
Table("users", md, Column("name", String, primary_key=True))
assert dangling_fixes(md) == [
'DELETE FROM "notes" WHERE "metric_id" IS NOT NULL AND NOT EXISTS '
'(SELECT 1 FROM "plugin_metrics" p WHERE p."id" = "notes"."metric_id")'
]
def test_dump_leaves_out_each_excluded_tables_rows(tmp_path):
args = dump.dump_command(tmp_path / "x.dump")
assert "--format=custom" in args
assert "--exclude-table-data=plugin_metrics" in args
assert "--exclude-table-data=audit_events" not in args
def test_password_goes_in_the_environment_not_the_command_line():
env = dump.pg_env("postgresql+asyncpg://steward:s3cret@db:5433/steward?ssl=true")
assert env["PGHOST"] == "db" and env["PGPORT"] == "5433"
assert env["PGUSER"] == "steward" and env["PGPASSWORD"] == "s3cret"
assert env["PGDATABASE"] == "steward" and env["PGSSLMODE"] == "require"
def test_file_name_is_utc():
at = datetime(2026, 10, 12, 21, 5, 9, tzinfo=timezone.utc)
assert dump.file_name(at) == "steward-20261012T210509Z.tar"
def test_a_packed_backup_reads_back(tmp_path):
at = datetime(2026, 10, 12, 21, 5, 9, tzinfo=timezone.utc)
src = tmp_path / "in.dump"
src.write_bytes(b"PGDMP" + b"x" * 3000)
info = dump.manifest(at, ["0004_app_settings", "prometheus_001_targets"])
out = tmp_path / dump.file_name(at)
dump.pack(out, src, "the-key", info)
assert out.stat().st_mode & 0o777 == 0o600
assert not out.with_name(out.name + ".part").exists()
into = tmp_path / "out"
into.mkdir()
got, dumped, key = dump.read_backup(out, into)
assert key == "the-key"
assert dumped.read_bytes() == src.read_bytes()
assert got["format"] == dump.FORMAT and got["contains_secret_key"] is True
assert got["created_at"] == "2026-10-12T21:05:09+00:00"
assert "plugin_metrics" in got["rows_left_out"]
def test_read_backup_refuses_what_isnt_one(tmp_path):
plain = tmp_path / "plain.tar"
with tarfile.open(plain, "w") as tar:
tar.add(__file__, arcname="notes.txt")
with pytest.raises(dump.BackupError, match="lacks manifest.json, secret.key, steward.dump"):
dump.read_backup(plain, tmp_path)
(tmp_path / "junk.tar").write_bytes(b"not a tar")
with pytest.raises(dump.BackupError, match="isn't a Steward backup"):
dump.read_backup(tmp_path / "junk.tar", tmp_path)
def test_read_backup_refuses_another_format(tmp_path):
src = tmp_path / "in.dump"
src.write_bytes(b"PGDMP")
info = dump.manifest(datetime.now(timezone.utc), []) | {"format": 99}
out = tmp_path / "b.tar"
dump.pack(out, src, "k", info)
with pytest.raises(dump.BackupError, match="format 99"):
dump.read_backup(out, tmp_path)