Files
bvandeusenandClaude Opus 5.5 e8b75143f1
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m8s
feat(backup): check a backup against what is in place, changing nothing (#5823)
#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>
2026-10-15 10:48:48 -04:00

172 lines
7.4 KiB
Python

"""Integration: the backup job records each run against Postgres (#5673).
pg_dump isn't in this lane (the publish smoke step runs the real one), so the
dump and the upload are stubbed; what is real is everything Steward records:
history, the last-success time, metrics, audit, and the sealed secret key.
Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _configure(app, **over):
from steward.core.settings import set_setting
values = {"backup.enabled": True, "backup.s3_endpoint": "http://archives:8081",
"backup.s3_bucket": "steward", "backup.s3_access_key": "AK",
"backup.s3_secret_key": "SK", "backup.history": [],
"backup.last_success_at": "", "backup.last_attempt_at": "", **over}
async with app.db_sessionmaker() as db:
async with db.begin():
for k, v in values.items():
await set_setting(db, k, v)
def _stub(monkeypatch, *, fail_upload: str = ""):
from steward.backup import job
from steward.backup.dump import Made
from steward.backup.s3 import S3Error
sent = []
async def make_backup(database_url, secret_key, heads, workdir, now=None):
path = workdir / "steward-stub.tar"
path.write_bytes(b"x" * 4096)
return Made(path, path.name, 4096, {"migration_heads": heads})
async def put_file(target, key, path, **kw):
if fail_upload:
raise S3Error(fail_upload)
sent.append((target.bucket, key, path.read_bytes()))
monkeypatch.setattr(job, "make_backup", make_backup)
monkeypatch.setattr(job, "put_file", put_file)
return sent
async def _state(app):
from sqlalchemy import select
from steward.core.settings import get_all_settings
from steward.models.audit import AuditEvent
from steward.models.metrics import PluginMetricLatest
async with app.db_sessionmaker() as db:
settings = await get_all_settings(db)
metrics = dict((await db.execute(select(PluginMetricLatest.metric_name, PluginMetricLatest.value).where(
PluginMetricLatest.source_module == "steward",
PluginMetricLatest.resource_name == "backup"))).all())
audits = list((await db.execute(select(AuditEvent.action).where(
AuditEvent.action.like("backup.%")).order_by(AuditEvent.timestamp.desc(), AuditEvent.id.desc()).limit(1))).scalars())
return settings, metrics, audits
def test_a_sent_backup_is_recorded_and_a_failed_one_says_why(app, monkeypatch):
from steward.backup import job
async def _go():
await _configure(app)
sent = _stub(monkeypatch)
entry = await job.run_backup(app, by="admin-x")
ok_state = await _state(app)
_stub(monkeypatch, fail_upload="Upload of steward-stub.tar refused: HTTP 403 AccessDenied.")
failed = await job.run_backup(app)
bad_state = await _state(app)
return sent, entry, ok_state, failed, bad_state
sent, entry, (settings, metrics, audits), failed, (settings2, _, audits2) = asyncio.run(_go())
assert sent == [("steward", "steward-stub.tar", b"x" * 4096)]
assert entry["ok"] and entry["by"] == "admin-x" and entry["size"] == 4096
# The stub isn't a real backup, so its self-check (#5823) says so; it was sent anyway.
assert not entry["check"]["ok"] and "isn't a Steward backup" in entry["check"]["summary"]
assert settings["backup.history"][0]["name"] == "steward-stub.tar"
assert settings["backup.last_success_at"] == entry["at"]
assert metrics["backup_bytes"] == 4096 and metrics["backup_age_secs"] == 0
assert audits == ["backup.made"]
assert not failed["ok"] and "AccessDenied" in failed["error"]
assert [h["ok"] for h in settings2["backup.history"]] == [False, True]
assert settings2["backup.last_success_at"] == entry["at"] # the good one still stands
assert settings2["backup.last_attempt_at"] == failed["at"]
assert audits2 == ["backup.failed"]
def test_the_tick_records_the_age_and_backs_up_when_due(app, monkeypatch):
from steward.backup import job
async def _go():
long_ago = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
await _configure(app, **{"backup.last_success_at": long_ago, "backup.last_attempt_at": long_ago})
sent = _stub(monkeypatch)
await job.tick(app)
settings, metrics, _ = await _state(app)
await job.tick(app) # not due again until the next slot
return sent, settings
sent, settings = asyncio.run(_go())
assert len(sent) == 1
assert settings["backup.history"][0]["by"] == "schedule"
def test_the_tick_does_nothing_while_backups_are_off(app, monkeypatch):
from steward.backup import job
async def _go():
await _configure(app, **{"backup.enabled": False})
sent = _stub(monkeypatch)
await job.tick(app)
return sent
assert asyncio.run(_go()) == []
def test_the_secret_key_is_sealed_and_a_blank_box_keeps_it(app):
from sqlalchemy import select
from steward.models.settings import AppSetting
from steward.models.users import User, UserRole
async def _go():
await _configure(app)
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"bk-{uid[:8]}", email=f"bk-{uid[:8]}@test",
password_hash="x", role=UserRole.admin, is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="admin", username=f"bk-{uid[:8]}", _csrf="test-token",
login_at=time.time(), tz="UTC")
form = {"csrf_token": "test-token", "backup.enabled": "on", "backup.s3_endpoint": "http://archives:8081",
"backup.s3_bucket": "steward", "backup.s3_region": "", "backup.s3_access_key": "AK2",
"backup.s3_secret_key": "new-secret", "backup.schedule_hour": "4"}
r1 = await client.post("/settings/backups/", form=form)
async with app.db_sessionmaker() as db:
stored = (await db.execute(select(AppSetting.value_json).where(
AppSetting.key == "backup.s3_secret_key"))).scalar()
r2 = await client.post("/settings/backups/", form=form | {"backup.s3_secret_key": ""})
page = await client.get("/settings/backups/")
from steward.core.settings import get_all_settings
async with app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return r1.status_code, stored, r2.status_code, page.status_code, await page.get_data(as_text=True), settings
s1, stored, s2, s3, page, settings = asyncio.run(_go())
assert s1 == 302 and s2 == 302 and s3 == 200
assert "enc:v1:" in stored and "new-secret" not in stored
assert settings["backup.s3_secret_key"] == "new-secret"
assert settings["backup.s3_access_key"] == "AK2" and settings["backup.s3_region"] == "us-east-1"
assert settings["backup.schedule_hour"] == 4
assert "new-secret" not in page and "kept; type to change" in page