feat(mcp): Steward's own backups: the runs, the next one, checking and making one (#5836)
list_backups, check_backup and make_backup are Settings → Backups for an agent, admin only like the page. make_backup runs the page's job in the background and is audited as "<user> via MCP". Restoring stays in the browser. get_overview names a scheduled backup that failed, is two hours past its slot, or has no bucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""Integration (#5836): the MCP's backup tools against real Postgres.
|
||||
|
||||
The history Steward keeps in its settings, read back through list_backups;
|
||||
make_backup refused with no bucket, then started (the run itself stubbed, as
|
||||
in test_backup_job.py) and audited; the overview names a failing backup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
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://user:pw@archives:8081",
|
||||
"backup.s3_bucket": "steward", "backup.s3_access_key": "AK",
|
||||
"backup.s3_secret_key": "SK", "backup.schedule_hour": 3, "backup.schedule_tz": "UTC",
|
||||
"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 test_backups_read_made_and_reported_through_mcp(app, monkeypatch):
|
||||
from sqlalchemy import select
|
||||
from steward.backup import job
|
||||
from steward.core.api_tokens import Caller
|
||||
from steward.mcp import _context
|
||||
from steward.mcp.tools import backups, overview
|
||||
from steward.models.audit import AuditEvent
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
now = datetime.now(timezone.utc)
|
||||
good_at, failed_at = now - timedelta(days=1), now - timedelta(minutes=10)
|
||||
history = [
|
||||
{"at": failed_at.isoformat(), "ok": False, "by": "schedule", "name": "", "size": 0,
|
||||
"secs": 1.0, "error": "Upload of steward-x.tar refused: 503"},
|
||||
{"at": good_at.isoformat(), "ok": True, "by": f"bk-{tag}", "name": "steward-20261014T030000Z.tar",
|
||||
"size": 52000, "secs": 4.2, "error": "",
|
||||
"check": {"ok": True, "changes": 0, "rows": 310, "summary": "Matches what is in place."}},
|
||||
]
|
||||
made = []
|
||||
|
||||
async def _fake_run(app_, by="schedule", user_id=None):
|
||||
made.append((by, user_id))
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(job, "run_backup", _fake_run)
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
user = User(username=f"bk-{tag}", email=f"bk-{tag}@test", password_hash="x",
|
||||
role=UserRole.admin)
|
||||
s.add(user)
|
||||
await s.flush()
|
||||
user_id = user.id
|
||||
_context._app_ctx.set(app)
|
||||
_context._caller_ctx.set(Caller(user_id=user_id, username=f"bk-{tag}",
|
||||
role=UserRole.admin, token_id=9))
|
||||
|
||||
await _configure(app, **{"backup.s3_bucket": ""})
|
||||
with pytest.raises(ValueError, match="bucket isn't set up.*Nothing was started"):
|
||||
await backups.make_backup()
|
||||
|
||||
await _configure(app, **{"backup.history": history,
|
||||
"backup.last_success_at": good_at.isoformat(),
|
||||
"backup.last_attempt_at": failed_at.isoformat()})
|
||||
listed = await backups.list_backups()
|
||||
assert listed["enabled"] is True and listed["running"] is False
|
||||
assert listed["bucket"] == {"endpoint": "http://user:***@archives:8081", "bucket": "steward",
|
||||
"region": "us-east-1"}
|
||||
assert listed["verdict"]["tone"] == "bad" and "503" in listed["verdict"]["error"]
|
||||
assert listed["schedule"]["next_at"] is not None
|
||||
failed, good = listed["runs"]
|
||||
assert failed["ok"] is False and failed["by"] == "schedule" and failed["name"] is None
|
||||
assert good["name"] == "steward-20261014T030000Z.tar" and good["size_bytes"] == 52000
|
||||
assert good["check"]["rows"] == 310 and listed["last_success_age_s"] >= 86000
|
||||
assert (await backups.list_backups(limit=1))["truncated"] is True
|
||||
|
||||
started = await backups.make_backup()
|
||||
assert started["started"] is True
|
||||
await asyncio.sleep(0) # the background task runs the stub
|
||||
assert made == [(f"bk-{tag} via MCP", user_id)]
|
||||
|
||||
async with app.db_sessionmaker() as s:
|
||||
rows = list((await s.execute(select(AuditEvent).where(
|
||||
AuditEvent.user_id == user_id, AuditEvent.action == "backup.started"))).scalars())
|
||||
assert len(rows) == 1 and rows[0].username == f"bk-{tag} via MCP"
|
||||
|
||||
ov = await overview.get_overview()
|
||||
mine = [f for f in ov["findings"] if f["kind"] == "backup_failed"]
|
||||
assert len(mine) == 1 and "refused: 503" in mine[0]["summary"]
|
||||
|
||||
await _configure(app, **{"backup.enabled": False})
|
||||
|
||||
asyncio.run(_go())
|
||||
@@ -0,0 +1,109 @@
|
||||
"""The backup tools' pure parts (#5836): when the next backup is due, what the
|
||||
overview says about backups, how a check reads to an agent, and their roles.
|
||||
|
||||
Reading the history, starting one and the audit row run against Postgres in
|
||||
tests/integration/test_mcp_backups.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from steward.backup.check import AreaCheck, CheckReport, RowDiff, TableCheck
|
||||
from steward.mcp.tools import backups, overview
|
||||
from steward.models.users import UserRole
|
||||
|
||||
NOW = datetime(2026, 10, 15, 6, 0, tzinfo=timezone.utc) # three hours after the 03:00 slot
|
||||
SLOT = datetime(2026, 10, 15, 3, 0, tzinfo=timezone.utc)
|
||||
|
||||
BUCKET = {"backup.s3_endpoint": "http://archives:8081", "backup.s3_bucket": "steward",
|
||||
"backup.s3_access_key": "AK", "backup.s3_secret_key": "SK"}
|
||||
|
||||
|
||||
def _settings(**over) -> dict:
|
||||
return {"backup.enabled": True, "backup.schedule_hour": 3, "backup.schedule_tz": "UTC",
|
||||
**BUCKET, **over}
|
||||
|
||||
|
||||
def _entry(at: datetime, ok: bool, error: str = "") -> dict:
|
||||
return {"at": at.isoformat(), "ok": ok, "by": "schedule", "name": "steward-x.tar" if ok else "",
|
||||
"size": 1000 if ok else 0, "secs": 2.0, "error": error}
|
||||
|
||||
|
||||
def test_the_next_backup_is_tomorrows_slot_an_hour_after_a_failure_or_the_next_tick():
|
||||
assert backups.next_backup_at(_settings(**{"backup.enabled": False}), NOW) is None
|
||||
done = _settings(**{"backup.last_success_at": (SLOT + timedelta(minutes=5)).isoformat()})
|
||||
assert backups.next_backup_at(done, NOW) == SLOT + timedelta(days=1)
|
||||
tried = _settings(**{"backup.last_success_at": (SLOT - timedelta(days=1)).isoformat(),
|
||||
"backup.last_attempt_at": (NOW - timedelta(minutes=20)).isoformat()})
|
||||
assert backups.next_backup_at(tried, NOW) == NOW + timedelta(minutes=40)
|
||||
due = _settings(**{"backup.last_success_at": (SLOT - timedelta(days=1)).isoformat()})
|
||||
assert backups.next_backup_at(due, NOW) == NOW + timedelta(minutes=15)
|
||||
|
||||
|
||||
def test_the_overview_says_nothing_about_backups_that_are_off_running_or_fine():
|
||||
assert overview.backup_findings({"backup.enabled": False}, False, NOW) == []
|
||||
failed = _settings(**{"backup.history": [_entry(NOW, False, "refused")]})
|
||||
assert overview.backup_findings(failed, True, NOW) == []
|
||||
fine = _settings(**{"backup.history": [_entry(SLOT, True)],
|
||||
"backup.last_success_at": SLOT.isoformat()})
|
||||
assert overview.backup_findings(fine, False, NOW) == []
|
||||
assert overview.backup_findings(_settings(), False, NOW) == [] # never run yet: not overdue
|
||||
|
||||
|
||||
def test_backups_on_with_no_bucket_is_a_finding():
|
||||
settings = {"backup.enabled": True, "backup.schedule_hour": 3}
|
||||
[f] = overview.backup_findings(settings, False, NOW)
|
||||
assert f["kind"] == "backup_not_set_up" and f["next"] == "list_backups()"
|
||||
|
||||
|
||||
def test_failed_backups_are_counted_since_the_first_in_a_row():
|
||||
good = SLOT - timedelta(days=1)
|
||||
history = [_entry(NOW - timedelta(hours=1), False, "Upload of steward-x.tar refused: 500"),
|
||||
_entry(SLOT, False, "unreachable"), _entry(good, True)]
|
||||
[f] = overview.backup_findings(_settings(**{"backup.history": history,
|
||||
"backup.last_success_at": good.isoformat()}),
|
||||
False, NOW)
|
||||
assert f["kind"] == "backup_failed" and f["severity"] == "warning"
|
||||
assert "(2 tries in a row): Upload of steward-x.tar refused: 500" in f["summary"]
|
||||
assert f"the newest good one was sent {overview.iso(good)}" in f["summary"]
|
||||
assert f["since"] == "2026-10-15T03:00:00Z"
|
||||
|
||||
|
||||
def test_no_backup_two_hours_past_its_slot_is_overdue():
|
||||
yesterday = SLOT - timedelta(days=1)
|
||||
settings = _settings(**{"backup.history": [_entry(yesterday, True)],
|
||||
"backup.last_success_at": yesterday.isoformat()})
|
||||
[f] = overview.backup_findings(settings, False, NOW)
|
||||
assert f["kind"] == "backup_overdue" and f["since"] == "2026-10-15T03:00:00Z"
|
||||
assert overview.backup_findings(settings, False, SLOT + timedelta(minutes=90)) == []
|
||||
|
||||
|
||||
def test_a_check_names_only_the_tables_that_differ_and_keeps_secret_values_out():
|
||||
many = [f"host-{i}" for i in range(backups.ROWS_SHOWN + 5)]
|
||||
report = CheckReport(
|
||||
name="steward-20261015T030000Z.tar", manifest={"created_at": SLOT.isoformat()},
|
||||
schema="same", backup_heads=["a1"], here_heads=["a1"], key_same=True, sealed=4, unopened=0,
|
||||
areas=[AreaCheck("Settings", [
|
||||
TableCheck("app_settings", same=10, differ=[
|
||||
RowDiff("backup.schedule_hour", ["value_json"], False, "3", "4"),
|
||||
RowDiff("smtp.password", ["value_json"], True)]),
|
||||
TableCheck("users", same=2)]),
|
||||
AreaCheck("Hosts", [TableCheck("hosts", same=1, only_backup=many)])])
|
||||
out = backups.report_dict(report)
|
||||
assert out["ok"] is True and out["made_at"] == "2026-10-15T03:00:00Z"
|
||||
assert out["changes"] == 2 + len(many) and out["rows"] == 13 + out["changes"]
|
||||
settings_area, hosts_area = out["areas"]
|
||||
assert [t["table"] for t in settings_area["tables"]] == ["app_settings"]
|
||||
plain, secret = settings_area["tables"][0]["differ"]
|
||||
assert (plain["backup_value"], plain["here_value"]) == ("3", "4")
|
||||
assert secret == {"label": "smtp.password", "columns": ["value_json"], "secret": True}
|
||||
hosts = hosts_area["tables"][0]
|
||||
assert len(hosts["only_in_backup"]) == backups.ROWS_SHOWN and hosts["not_named"] == 5
|
||||
|
||||
|
||||
def test_backup_tools_are_admin_like_the_page(app):
|
||||
mcp = app.mcp_server
|
||||
for name, read_only in (("list_backups", True), ("check_backup", True), ("make_backup", False)):
|
||||
assert mcp.tool_roles[name] is UserRole.admin, name
|
||||
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name
|
||||
assert (mcp._tool_manager.get_tool(name).description or "").strip(), name
|
||||
Reference in New Issue
Block a user