#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:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user