feat(backup): daily backups to an S3 bucket, Settings → Backups, and the guide (#5673, #5675)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m15s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped

#5673 "S3 client and the backup job: settings, schedule, history, metrics":
- steward/backup/s3.py: SigV4 over httpx, path-style; put and list only
  (an append-only key is enough; retention is the bucket's). Signing is held
  to AWS's published examples.
- steward/backup/job.py: a 15-minute tick. The daily backup goes at the hour
  chosen on the reader's clock; a missed day is made up, and a failure is
  retried an hour later. History of the last 30 runs, audit
  (backup.made / backup.failed), and backup_age_secs / backup_bytes under
  "Steward itself", resource backup, for alert rules.
- Settings → Backups: verdict first, then the bucket (secret sealed; a
  blank box keeps it), the schedule, Test the bucket, Back up now, and the
  recent runs.

#5675 "Docs: backing up to Archives, and restoring": docs/core/backups.md.

Also: the audit page now words the Prometheus target actions from #5668,
which showed as codes, and a guard fails on any logged action without
wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 07:23:58 -04:00
co-authored by Claude Opus 5.5
parent 357bb6480d
commit 49799bd0b3
16 changed files with 1254 additions and 5 deletions
+86
View File
@@ -687,6 +687,92 @@ async def send_report_now():
return await _reports_page(settings, {"ok": ok, "message": message})
# ── Backups ───────────────────────────────────────────────────────────────────
async def _backups_page(flash=None):
"""Settings → Backups (#5673): how the newest backup went, the bucket and
the daily hour (in the reader's zone), and the runs before."""
from steward.backup import job
from steward.core.formatting import zone
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
hour = job.shown_hour(int(settings.get("backup.schedule_hour", 3)),
zone(settings.get("backup.schedule_tz")), zone(session.get("tz")))
return await render_template(
"settings/backups.html", settings=settings, flash=flash, shown_hour=hour,
verdict=job.verdict(settings, job.is_running()),
history=job.history_rows(settings.get("backup.history")),
has_secret=bool(settings.get("backup.s3_secret_key")))
@settings_bp.get("/backups/")
@require_role(UserRole.admin)
async def backups():
return await _backups_page()
@settings_bp.post("/backups/")
@require_role(UserRole.admin)
async def save_backups():
"""The hour is what the reader picked on their own clock, so it is kept in
their zone. A blank secret key keeps the stored one."""
form = await request.form
endpoint = form.get("backup.s3_endpoint", "").strip()
if endpoint and not endpoint.startswith(("http://", "https://")):
return await _backups_page({"ok": False, "message":
"The endpoint starts with http:// or https://, e.g. http://archives:8081."})
try:
hour = int(form.get("backup.schedule_hour", 3))
except ValueError:
hour = 3
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "backup.enabled", "backup.enabled" in form)
await set_setting(db, "backup.s3_endpoint", endpoint)
await set_setting(db, "backup.s3_bucket", form.get("backup.s3_bucket", "").strip())
await set_setting(db, "backup.s3_region", form.get("backup.s3_region", "").strip() or "us-east-1")
await set_setting(db, "backup.s3_access_key", form.get("backup.s3_access_key", "").strip())
secret = form.get("backup.s3_secret_key", "")
if secret.strip():
await set_setting(db, "backup.s3_secret_key", secret.strip())
elif "backup.clear_secret" in form:
await set_setting(db, "backup.s3_secret_key", "")
await set_setting(db, "backup.s3_verify_tls", "backup.s3_verify_tls" in form)
await set_setting(db, "backup.schedule_hour", min(max(hour, 0), 23))
await set_setting(db, "backup.schedule_tz", session.get("tz") or "")
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"settings.saved", detail={"section": "backups"})
return redirect(url_for("settings.backups"))
@settings_bp.post("/backups/test/")
@require_role(UserRole.admin)
async def test_backups():
"""Reach the saved bucket with the saved keys (the operator asked, so the
target is contacted)."""
from steward.backup import job
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
target = job.target_from(settings)
if target is None:
return await _backups_page({"ok": False, "message":
"Save the endpoint, bucket and both keys first."})
ok, message = await job.test_target(target)
return await _backups_page({"ok": ok, "message": message})
@settings_bp.post("/backups/now/")
@require_role(UserRole.admin)
async def backup_now():
from steward.backup import job
started = job.start_backup(current_app._get_current_object(),
by=session.get("username", "") or "an admin",
user_id=session.get("user_id"))
message = ("Making a backup now; it shows under Recent backups when it is sent."
if started else "A backup is already being made.")
return await _backups_page({"ok": started, "message": message})
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
"""Extract config values for one plugin from a form submission."""
name = plugin["_dir"]