Files
FabledSteward/steward/mcp/tools/backups.py
T
bvandeusenandClaude Opus 5.5 8feec4dc77
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m25s
CI / integration (push) Successful in 1m31s
CI / publish (push) Successful in 1m13s
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>
2026-10-15 13:09:12 -04:00

209 lines
9.5 KiB
Python

"""Steward's own backups (#5836): what was made, where it went, how its check
came out, and making or checking one, as Settings → Backups does.
Admin only, like the page: the bucket and its backups hold every secret
Steward keeps. Restoring stays in the browser, behind its typed confirmation.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from steward.backup import job
from steward.backup.dump import BackupError
from steward.backup.s3 import S3Error, list_objects
from steward.core.settings import get_all_settings
from steward.mcp._context import current_caller, mcp_app
from steward.models.users import UserRole
from ._audit import audit_mcp, mcp_username
from ._shape import age_s, bounded, clamp_limit, db_session, iso, now_utc, redact_url
ROWS_SHOWN = 20 # rows named per table in a check; the rest are counted
def next_backup_at(settings: dict, now: datetime) -> datetime | None:
"""When the schedule makes the next one: the next day's hour once today's
is done, an hour after a failed try, else the next 15-minute tick. None
while scheduled backups are off."""
from steward.core.formatting import zone
if not settings.get("backup.enabled"):
return None
hour, tz = int(settings.get("backup.schedule_hour", 3)), zone(settings.get("backup.schedule_tz"))
slot = job.last_slot(now, hour, tz)
last_success = job._moment(settings.get("backup.last_success_at"))
if last_success is not None and last_success >= slot:
return job.last_slot(slot + timedelta(hours=25), hour, tz) # tomorrow's, across a DST change
last_attempt = job._moment(settings.get("backup.last_attempt_at"))
if last_attempt is not None and now - last_attempt < job.RETRY_AFTER:
return last_attempt + job.RETRY_AFTER
return now + timedelta(seconds=job.TICK_SECONDS)
def _history_row(entry: dict, now: datetime) -> dict:
at = job._moment(entry.get("at"))
return {
"name": entry.get("name") or None, "ok": bool(entry.get("ok")),
"at": iso(at), "age_s": age_s(at, now), "by": entry.get("by") or "schedule",
"size_bytes": entry.get("size") or 0, "secs": entry.get("secs"),
"error": entry.get("error") or None, "check": entry.get("check"),
}
async def list_backups(limit: int = 10, bucket: bool = False) -> dict:
"""Steward's own backups: whether they're on, where they go, when the next
one is due, and the runs so far (newest first), each with the check made
on it just after it was sent.
`verdict` is the line Settings → Backups opens with. `running` is true
while one is being made. A run row: `name` (the file in the bucket),
`ok`, `at`, `by` (a person, or "schedule"), `size_bytes`, `secs`,
`error` and `check` (`ok` false means the backup's key didn't open all
its secrets; `changes` counts differences from what was in place, which
is not a fault).
A backup holds settings and keys, users and tokens, hosts, monitors,
alert rules, inventory and dashboards; no metrics, logs or history.
Steward never deletes backups (its key is append-only); how long they
are kept is set on the bucket's side.
bucket: true also lists the Steward backups in the bucket. An
append-only key may not list; then `stored_error` says so.
limit: runs to return, default 10, max 30 (all that are kept).
"""
now = now_utc()
async with db_session() as db:
settings = await get_all_settings(db)
running = job.is_running()
target = job.target_from(settings)
verdict = job.verdict(settings, running)
history = [_history_row(e, now) for e in settings.get("backup.history") or []]
nxt = next_backup_at(settings, now)
out = {
"enabled": bool(settings.get("backup.enabled")),
"running": running,
"verdict": {"tone": verdict["tone"], "text": verdict["text"],
"at": iso(verdict.get("moment")), "error": verdict.get("error")},
"bucket": ({"endpoint": redact_url(target.endpoint), "bucket": target.bucket,
"region": target.region} if target else None),
"schedule": {"hour": int(settings.get("backup.schedule_hour", 3)),
"time_zone": settings.get("backup.schedule_tz") or "UTC",
"next_at": iso(nxt)},
"last_success_at": iso(job._moment(settings.get("backup.last_success_at"))),
"last_success_age_s": age_s(job._moment(settings.get("backup.last_success_at")), now),
**bounded(history, clamp_limit(limit, 10, job.HISTORY_KEEP), key="runs"),
}
if bucket:
if target is None:
out["stored_error"] = "The bucket isn't set up: fill in the endpoint, bucket and keys."
else:
try:
found = await list_objects(target, job.PREFIX, limit=1000)
except S3Error as exc:
out["stored_error"] = str(exc) + (
" An append-only key can't list; the runs above are what was sent."
if "AccessDenied" in str(exc) else "")
else:
stored = sorted((o for o in found if job.is_backup_name(o.key)),
key=lambda o: o.key, reverse=True)
out["stored"] = [{"name": o.key, "size_bytes": o.size, "modified_at": o.last_modified}
for o in stored[:50]]
out["stored_total"] = len(stored)
return out
def _table(t) -> dict:
row = {"table": t.table, "same": t.same, "changes": t.changes}
if t.note:
row["note"] = t.note
if t.differ:
row["differ"] = [
{"label": d.label, "columns": d.columns, "secret": d.secret,
**({"backup_value": d.backup_value, "here_value": d.here_value}
if not d.secret and (d.backup_value is not None or d.here_value is not None) else {})}
for d in t.differ[:ROWS_SHOWN]]
for key, rows in (("only_in_backup", t.only_backup), ("only_here", t.only_here)):
if rows:
row[key] = rows[:ROWS_SHOWN]
cut = sum(max(0, len(x) - ROWS_SHOWN) for x in (t.differ, t.only_backup, t.only_here))
if cut:
row["not_named"] = cut
return row
def report_dict(report) -> dict:
"""A CheckReport as the agent reads it: the verdict first, then only the
tables that differ."""
return {
"name": report.name,
"made_at": iso(report.made_at),
"ok": report.ok,
"summary": report.summary(),
"schema": report.schema,
"backup_revisions": report.backup_heads,
"here_revisions": report.here_heads,
"key_same": report.key_same,
"sealed": report.sealed,
"unopened": report.unopened,
"rows": report.rows,
"changes": report.changes,
"areas": [
{"area": a.name, "same": a.same, "changes": a.changes,
"tables": [_table(t) for t in a.tables if t.changes or t.note]}
for a in report.areas],
}
async def check_backup(name: str) -> dict:
"""Fetch one backup from the bucket and compare it with what is in place,
as Settings → Backups → Check does. Nothing changes here.
name: the file, e.g. "steward-20261015T030000Z.tar" (list_backups).
`ok` is whether the backup's own key opens every secret in it, so a
restore would bring them back. `summary` says it in a line. `schema` is
"same", "older" (made by an earlier Steward; a restore upgrades it) or
"newer" (made by a later Steward; this one can't restore it). `key_same`
is whether the backup carries this Steward's secret key. `changes`
counts rows that differ, exist only in the backup, or only here; `areas`
names them, table by table, with a setting's two values when it isn't
secret. A difference is what a restore would put back, not a fault.
An append-only key may not read files back; then this fails, and the
check can be made on the page with a downloaded copy instead.
"""
try:
report = await job.check_stored(mcp_app(), (name or "").strip())
except (BackupError, S3Error) as exc:
said = str(exc)
if "AccessDenied" in said:
said += (" The bucket's key may not read backups back; download the file and "
"check it on Settings → Backups instead.")
raise ValueError(said) from None
return report_dict(report)
async def make_backup() -> dict:
"""Make a backup now and send it to the bucket, as Settings → Backups →
Back up now does. It runs in the background: list_backups shows
`running`, then the new run at the top with its check. Audited.
Refused while one is already being made, or when no bucket is set up.
"""
async with db_session() as db:
settings = await get_all_settings(db)
if job.target_from(settings) is None:
raise ValueError("The bucket isn't set up: fill in the endpoint, bucket and keys on "
"Settings → Backups. Nothing was started.")
caller = current_caller()
if not job.start_backup(mcp_app(), by=mcp_username(caller.username), user_id=caller.user_id):
raise ValueError("A backup is already being made; list_backups shows it. Nothing was started.")
await audit_mcp("backup.started", "backup")
return {"started": True,
"summary": "Making a backup now; list_backups shows it once it is sent and checked."}
def register(register_tool) -> None:
for fn in (list_backups, check_backup):
register_tool(fn, role=UserRole.admin, read_only=True)
register_tool(make_backup, role=UserRole.admin, read_only=False)