"""FC-3h: /api/system/backup — create/list/restore/delete/tag for DB + image backups. Read endpoints are public on FC (operator-facing internal API; same posture as /api/system/activity). Write endpoints take a typed `confirm` body field that must match a server-generated token for that backup row, to prevent click-to-destroy by stale browser tabs or accidental cURL. """ from __future__ import annotations from quart import Blueprint, jsonify, request from sqlalchemy import desc, select from ..extensions import get_session from ..models import BackupRun, ImportSettings from ._responses import error_response as _bad system_backup_bp = Blueprint( "system_backup", __name__, url_prefix="/api/system/backup", ) _KINDS = frozenset({"db", "images"}) _TAG_MAX_LEN = 64 _BACKUP_SETTINGS_FIELDS = ( "backup_db_nightly_enabled", "backup_db_nightly_hour_utc", "backup_db_keep_last_n", "backup_images_keep_last_n", ) def _row_to_dict(r: BackupRun) -> dict: return { "id": r.id, "kind": r.kind, "status": r.status, "tag": r.tag, "triggered_by": r.triggered_by, "started_at": r.started_at.isoformat() if r.started_at else None, "finished_at": r.finished_at.isoformat() if r.finished_at else None, "duration_seconds": ( int((r.finished_at - r.started_at).total_seconds()) if r.finished_at and r.started_at else None ), "sql_path": r.sql_path, "tar_path": r.tar_path, "size_bytes": r.size_bytes, "error": r.error, "restored_from_id": r.restored_from_id, "manifest": r.manifest or {}, } def _validate_tag(tag): if tag is None: return None if not isinstance(tag, str): return _bad("invalid_tag", detail="tag must be string or null") tag = tag.strip() if not tag: return None if len(tag) > _TAG_MAX_LEN: return _bad("invalid_tag", detail=f"tag too long (max {_TAG_MAX_LEN})") return tag def _validate_backup_settings_patch(body: dict): if "backup_db_nightly_enabled" in body and not isinstance( body["backup_db_nightly_enabled"], bool, ): return _bad("invalid_value", detail="backup_db_nightly_enabled must be bool") if "backup_db_nightly_hour_utc" in body: v = body["backup_db_nightly_hour_utc"] if not isinstance(v, int) or isinstance(v, bool) or not (0 <= v <= 23): return _bad("invalid_value", detail="backup_db_nightly_hour_utc must be 0..23") if "backup_db_keep_last_n" in body: v = body["backup_db_keep_last_n"] if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 365): return _bad("invalid_value", detail="backup_db_keep_last_n must be 1..365") if "backup_images_keep_last_n" in body: v = body["backup_images_keep_last_n"] if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 100): return _bad("invalid_value", detail="backup_images_keep_last_n must be 1..100") return None @system_backup_bp.route("/db", methods=["POST"]) async def trigger_db_backup(): body = await request.get_json(silent=True) or {} tag = _validate_tag(body.get("tag")) if isinstance(tag, tuple): return tag from ..tasks.backup import backup_db_task backup_db_task.delay(tag=tag, triggered_by="manual") return jsonify({"status": "dispatched"}), 202 @system_backup_bp.route("/images", methods=["POST"]) async def trigger_images_backup(): body = await request.get_json(silent=True) or {} tag = _validate_tag(body.get("tag")) if isinstance(tag, tuple): return tag from ..tasks.backup import backup_images_task backup_images_task.delay(tag=tag, triggered_by="manual") return jsonify({"status": "dispatched"}), 202 @system_backup_bp.route("/runs", methods=["GET"]) async def list_runs(): try: limit = min(int(request.args.get("limit", "50")), 200) except ValueError: return _bad("invalid_limit") if limit < 1: return _bad("invalid_limit") kind = request.args.get("kind") if kind is not None and kind not in _KINDS: return _bad("invalid_kind", detail=f"kind must be one of {sorted(_KINDS)}") before_id_raw = request.args.get("before_id") before_id = int(before_id_raw) if before_id_raw else None async with get_session() as session: stmt = select(BackupRun).order_by(desc(BackupRun.id)) if kind: stmt = stmt.where(BackupRun.kind == kind) if before_id is not None: stmt = stmt.where(BackupRun.id < before_id) stmt = stmt.limit(limit + 1) rows = (await session.execute(stmt)).scalars().all() has_more = len(rows) > limit rows = rows[:limit] return jsonify({ "runs": [_row_to_dict(r) for r in rows], "next_cursor": rows[-1].id if has_more and rows else None, }) @system_backup_bp.route("/runs/", methods=["GET"]) async def get_run(run_id: int): async with get_session() as session: row = await session.get(BackupRun, run_id) if row is None: return _bad("not_found", status=404) return jsonify(_row_to_dict(row)) @system_backup_bp.route("/runs/", methods=["PATCH"]) async def patch_run(run_id: int): body = await request.get_json(silent=True) or {} if "tag" not in body: return _bad("invalid_body", detail="tag required") tag = _validate_tag(body["tag"]) if isinstance(tag, tuple): return tag async with get_session() as session: row = await session.get(BackupRun, run_id) if row is None: return _bad("not_found", status=404) row.tag = tag await session.commit() await session.refresh(row) return jsonify(_row_to_dict(row)) @system_backup_bp.route("/runs//restore", methods=["POST"]) async def trigger_restore(run_id: int): body = await request.get_json(silent=True) or {} supplied = body.get("confirm", "") async with get_session() as session: row = await session.get(BackupRun, run_id) if row is None: return _bad("not_found", status=404) if row.status != "ok": return _bad( "not_restorable", detail=f"source backup status={row.status!r}; only 'ok' rows are restorable", ) expected = f"restore-{row.kind}-{row.id}" if supplied != expected: return _bad( "confirm_mismatch", detail=f"confirm must equal {expected!r}", expected=expected, ) kind = row.kind if kind == "db": from ..tasks.backup import restore_db_task restore_db_task.delay(source_backup_run_id=run_id) else: # 'images' (the only other value _KINDS allows via the trigger path) from ..tasks.backup import restore_images_task restore_images_task.delay(source_backup_run_id=run_id) return jsonify({"status": "dispatched", "kind": kind}), 202 @system_backup_bp.route("/runs/", methods=["DELETE"]) async def delete_run(run_id: int): body = await request.get_json(silent=True) or {} supplied = body.get("confirm", "") async with get_session() as session: row = await session.get(BackupRun, run_id) if row is None: return _bad("not_found", status=404) expected = f"delete-{row.kind}-{row.id}" if supplied != expected: return _bad( "confirm_mismatch", detail=f"confirm must equal {expected!r}", expected=expected, ) from ..services import backup_service backup_service.unlink_artifact_files( sql_path=row.sql_path, tar_path=row.tar_path, manifest_path=(row.manifest or {}).get("manifest_path"), ) await session.delete(row) await session.commit() return "", 204 @system_backup_bp.route("/settings", methods=["GET"]) async def get_settings(): async with get_session() as session: row = await ImportSettings.load(session) return jsonify({ "backup_db_nightly_enabled": row.backup_db_nightly_enabled, "backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc, "backup_db_keep_last_n": row.backup_db_keep_last_n, "backup_images_keep_last_n": row.backup_images_keep_last_n, }) @system_backup_bp.route("/destination", methods=["GET"]) async def get_destination(): """The off-site destination as the settings screen shows it. The secret key is never returned, only whether one is saved.""" from ..services import backup_destination as dest async with get_session() as session: row = await ImportSettings.load(session) return jsonify(dest.describe(row)) @system_backup_bp.route("/destination", methods=["PATCH"]) async def patch_destination(): """Save the destination. Body {endpoint, bucket, access_key, region, secret_key}; an empty field clears it. A missing or empty secret_key keeps the saved one, so the screen can save the other fields without the secret being typed again.""" from ..services import backup_destination as dest body = await request.get_json(silent=True) if not isinstance(body, dict): return _bad("invalid_body", detail="body must be a JSON object") fields, why = dest.clean_fields(body) if fields is None: return _bad("invalid_destination", detail=why) secret = body.get("secret_key") if secret is not None and not isinstance(secret, str): return _bad("invalid_destination", detail="secret_key must be text") async with get_session() as session: row = await ImportSettings.load(session) row.backup_s3_endpoint = fields.endpoint row.backup_s3_bucket = fields.bucket row.backup_s3_access_key = fields.access_key row.backup_s3_region = fields.region if secret and secret.strip(): row.backup_s3_secret_sealed = dest.seal(secret.strip()) await session.commit() return jsonify(dest.describe(row)) @system_backup_bp.route("/destination", methods=["DELETE"]) async def delete_destination(): """Forget the destination, secret included. What was already sent stays in the bucket: FabledCurator never deletes there.""" from ..services import backup_destination as dest async with get_session() as session: row = await ImportSettings.load(session) row.backup_s3_endpoint = None row.backup_s3_bucket = None row.backup_s3_access_key = None row.backup_s3_secret_sealed = None row.backup_s3_region = dest.DEFAULT_REGION await session.commit() return jsonify(dest.describe(row)) @system_backup_bp.route("/destination/test", methods=["POST"]) async def try_destination(): """Reach the bucket with the fields in the body (the form as typed, saved or not); a missing secret_key uses the saved one. Lists the bucket and writes nothing. Answers {ok, message} either way: a destination that can't be reached is a result to show, not an API error.""" import asyncio from ..services import backup_destination as dest from ..services.backup_s3 import S3Error from ..services.credential_crypto import InvalidCredentialBlob body = await request.get_json(silent=True) or {} if not isinstance(body, dict): return _bad("invalid_body", detail="body must be a JSON object") fields, why = dest.clean_fields(body) if fields is None: return jsonify({"ok": False, "message": f"Not tested: {why}."}) secret = body.get("secret_key") secret = secret.strip() if isinstance(secret, str) else "" if not secret: async with get_session() as session: row = await ImportSettings.load(session) sealed = row.backup_s3_secret_sealed if sealed: try: secret = dest.unseal(sealed) except InvalidCredentialBlob: return jsonify({"ok": False, "message": ( "Not tested: the saved secret key can't be opened on this server. " "Enter the secret key again." )}) target = dest.target_for(fields, secret or None) if target is None: missing = [name for name, v in ( ("address", fields.endpoint), ("bucket", fields.bucket), ("access key", fields.access_key), ("secret key", secret), ) if not v] return jsonify({"ok": False, "message": f"Not tested: fill in the {', '.join(missing)}."}) try: return jsonify(await asyncio.to_thread(dest.check_target, target)) except S3Error as exc: return jsonify({"ok": False, "message": str(exc)}) @system_backup_bp.route("/settings", methods=["PATCH"]) async def patch_settings(): body = await request.get_json(silent=True) if not isinstance(body, dict): return _bad("invalid_body", detail="body must be a JSON object") err = _validate_backup_settings_patch(body) if err is not None: return err async with get_session() as session: row = await ImportSettings.load(session) for field in _BACKUP_SETTINGS_FIELDS: if field in body: setattr(row, field, body[field]) await session.commit() return await get_settings()