Files
FabledSteward/tests/core/test_backup_contents.py
T
bvandeusenandClaude Opus 5.5 e6da0ff299
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m10s
feat(backup): a backup holds settings and keys only, for recovery (#5822)
#5822 "Backup contents: settings and keys only". The operator's ruling
(System 180, 2026-10-15) overturns 2026-10-12's "Config + audit + alerts":
a backup is for system recovery and carries what would otherwise have to
be set up again, with no logs or history.

- contents.py: audit_events, alert_events, alert_states, metric_anomalies,
  metric_anomaly_spans and user_invites move to EXCLUDED.
- Alert state is no longer restored, and evaluation skips a rule with no
  state row. ensure_rule_states (alerts/service.py) gives every
  single-resource rule lacking one an inactive row; alert_reconcile runs
  it each minute.
- Docs, the Backups page and the CLI help say what a backup holds now.
  CI's image check also finds the audit log empty after a restore.
- tests/integration/test_backup_restore_fixes.py is removed: its case (an
  alert event naming a left-out Ansible run) no longer arises. The SET NULL
  fix-up is covered by a unit test on synthetic tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:39:55 -04:00

128 lines
5.2 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_a_set_null_reference_is_cleared_and_the_row_kept():
md = MetaData()
Table("ansible_runs", md, Column("id", Integer, primary_key=True))
Table("plans", md, Column("id", Integer, primary_key=True),
Column("run_id", Integer, ForeignKey("ansible_runs.id", ondelete="SET NULL")))
assert dangling_fixes(md) == [
'UPDATE "plans" SET "run_id" = NULL WHERE "run_id" IS NOT NULL AND NOT EXISTS '
'(SELECT 1 FROM "ansible_runs" p WHERE p."id" = "plans"."run_id")'
]
def test_logs_history_and_state_are_left_out():
"""The 2026-10-15 ruling: a backup is settings and keys, for recovery."""
for table in ("audit_events", "alert_events", "alert_states", "metric_anomalies",
"metric_anomaly_spans", "user_invites"):
assert table in contents.EXCLUDED, table
for table in ("app_settings", "users", "api_tokens", "hosts", "host_agent_registrations",
"monitors", "alert_rules"):
assert table in contents.KEPT, table
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" in args
assert "--exclude-table-data=app_settings" 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)