feat(523): the backup job - an hourly tick that runs at the chosen hour, Back up now, and the last 30 runs
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 23s

services/backup_job.py reads its config when it fires, so startup reads
nothing (#4181) and a changed hour needs no reschedule. A run is held to
a 60-minute deadline (a hung NFS mount would otherwise hold the lock and
skip every later backup), never runs twice at once, and keeps its outcome
- file, size, duration, or the reason in words - in the bounded
backup_runs setting, which no backup carries. summarise() gives the page
its verdict: overdue when on and no success for 26h.

Admin API: GET/PUT /api/admin/backups, POST /backups/check (tests unsaved
form values, 45s deadline), POST /backups/run (202, background).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 17:34:01 -04:00
co-authored by Claude Opus 5.5
parent ff4f2ee5c2
commit 4248b450ea
6 changed files with 563 additions and 1 deletions
+26
View File
@@ -108,6 +108,32 @@ class BackupConfig:
return out
def apply_changes(current: BackupConfig, data: dict) -> BackupConfig:
"""`current` with the fields an admin sent changed, checked strictly.
`from_dict` forgives a stored value it can't read; a value a person just
typed is refused instead, with the reason, so a typo in the hour does not
silently become 02:00. Keys that aren't config fields (the secret, say)
are ignored here.
"""
names = {f.name for f in fields(BackupConfig)}
changes = {k: v for k, v in (data or {}).items() if k in names}
if "hour" in changes:
try:
hour = int(changes["hour"])
except (TypeError, ValueError):
raise ValueError("The hour must be a whole number from 0 to 23.") from None
if not 0 <= hour <= 23:
raise ValueError("The hour must be from 0 to 23 (UTC).")
changes["hour"] = hour
if "destination" in changes and changes["destination"] not in DESTINATION_KINDS:
raise ValueError("The destination must be a folder or an S3 bucket.")
for name in ("enabled", "s3_verify_tls"):
if name in changes and not isinstance(changes[name], bool):
raise ValueError(f"{name} must be true or false.")
return BackupConfig.from_dict({**current.to_dict(), **changes})
async def load_config() -> BackupConfig:
raw = await get_admin_setting(CONFIG_SETTING, "")
try: