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
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:
@@ -315,6 +315,120 @@ async def run_db_maintenance_now():
|
||||
return jsonify(summary)
|
||||
|
||||
|
||||
# ── Scheduled backups (milestone 523) ────────────────────────────────────────
|
||||
# One dated, uncompressed tar per run to a folder or an S3 bucket — see
|
||||
# services/backup_file.py for why it is shaped that way, backup_destination.py
|
||||
# for where it goes, backup_job.py for when.
|
||||
|
||||
# How long "Test" waits on the destination. A folder on a hung NFS mount has
|
||||
# no timeout of its own (rule 156), and the request must come back either way.
|
||||
_BACKUP_CHECK_TIMEOUT = 45.0
|
||||
|
||||
|
||||
async def _backups_payload() -> dict:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scribe.services import backup_destination as dest
|
||||
from scribe.services import backup_job as job
|
||||
|
||||
config = await dest.load_config()
|
||||
secret, stored = await dest.load_secret()
|
||||
runs = await job.list_runs()
|
||||
return {
|
||||
"config": config.to_dict(),
|
||||
# Never the secret itself: whether one is kept, and whether this
|
||||
# instance can still open it (it can't after a restore onto another
|
||||
# SECRET_KEY, and then it has to be entered again).
|
||||
"secret": {"set": stored, "readable": secret is not None},
|
||||
"problems": config.problems(has_secret=secret is not None),
|
||||
"running": job.is_running(),
|
||||
"runs": runs,
|
||||
**job.summarise(runs, enabled=config.enabled, now=datetime.now(timezone.utc)),
|
||||
}
|
||||
|
||||
|
||||
@admin_bp.route("/backups", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_backups():
|
||||
"""The backup destination, its schedule, and the recent runs."""
|
||||
return jsonify(await _backups_payload())
|
||||
|
||||
|
||||
@admin_bp.route("/backups", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_backups():
|
||||
"""Change the destination or schedule. `s3_secret` replaces the stored
|
||||
secret when it is sent and isn't the mask; `clear_s3_secret` removes it."""
|
||||
from scribe.services import backup_destination as dest
|
||||
|
||||
data = await request.get_json() or {}
|
||||
try:
|
||||
config = dest.apply_changes(await dest.load_config(), data)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
await dest.save_config(config)
|
||||
secret = data.get("s3_secret")
|
||||
if data.get("clear_s3_secret"):
|
||||
await dest.save_secret("")
|
||||
elif isinstance(secret, str) and secret and secret != SECRET_MASK:
|
||||
await dest.save_secret(secret)
|
||||
uid = get_current_user_id()
|
||||
await log_audit(
|
||||
"backup_config", user_id=uid, username=g.user.username,
|
||||
ip_address=request.remote_addr,
|
||||
details={**config.to_dict(), "secret_changed": bool(
|
||||
data.get("clear_s3_secret") or (isinstance(secret, str) and secret and secret != SECRET_MASK)
|
||||
)},
|
||||
)
|
||||
return jsonify(await _backups_payload())
|
||||
|
||||
|
||||
@admin_bp.route("/backups/check", methods=["POST"])
|
||||
@admin_required
|
||||
async def check_backups_destination():
|
||||
"""Ask the destination whether it would take a backup, without sending
|
||||
one. The body may carry unsaved form values, so a destination can be
|
||||
tested before it is saved; the stored secret is used unless one is sent."""
|
||||
from scribe.services import backup_destination as dest
|
||||
|
||||
data = await request.get_json() or {}
|
||||
try:
|
||||
config = dest.apply_changes(await dest.load_config(), data)
|
||||
except ValueError as exc:
|
||||
return jsonify({"ok": False, "message": str(exc)})
|
||||
secret = data.get("s3_secret")
|
||||
if not (isinstance(secret, str) and secret and secret != SECRET_MASK):
|
||||
secret, _ = await dest.load_secret()
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
dest.check_destination(config, secret), _BACKUP_CHECK_TIMEOUT,
|
||||
)
|
||||
except dest.DestinationError as exc:
|
||||
return jsonify({"ok": False, "message": str(exc)})
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
return jsonify({"ok": False, "message": (
|
||||
f"The destination didn't answer within {int(_BACKUP_CHECK_TIMEOUT)} seconds."
|
||||
)})
|
||||
return jsonify({"ok": True, "message": message})
|
||||
|
||||
|
||||
@admin_bp.route("/backups/run", methods=["POST"])
|
||||
@admin_required
|
||||
async def run_backup_now():
|
||||
"""Start a backup now. It runs in the background; the page follows it
|
||||
through GET /backups (`running`, then the newest run)."""
|
||||
from scribe.services import backup_job as job
|
||||
|
||||
try:
|
||||
job.start_backup_now()
|
||||
except job.BackupBusy:
|
||||
return jsonify({"error": "A backup is already running."}), 409
|
||||
uid = get_current_user_id()
|
||||
await log_audit("backup_run", user_id=uid, username=g.user.username,
|
||||
ip_address=request.remote_addr, details={})
|
||||
return jsonify({"status": "started"}), 202
|
||||
|
||||
|
||||
@admin_bp.route("/invitations", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_invite():
|
||||
|
||||
Reference in New Issue
Block a user