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>
209 lines
9.5 KiB
Python
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)
|