diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 441d58d..b6399dc 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -34,6 +34,7 @@ def all_blueprints() -> list[Blueprint]: from .sources import sources_bp from .suggestions import suggestions_bp from .system_activity import system_activity_bp + from .system_backup import system_backup_bp from .tags import tags_bp return [ api_bp, @@ -46,6 +47,7 @@ def all_blueprints() -> list[Blueprint]: showcase_bp, settings_bp, system_activity_bp, + system_backup_bp, import_admin_bp, migrate_bp, suggestions_bp, diff --git a/backend/app/api/system_backup.py b/backend/app/api/system_backup.py new file mode 100644 index 0000000..f65fe3b --- /dev/null +++ b/backend/app/api/system_backup.py @@ -0,0 +1,263 @@ +"""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 + +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 _bad(error: str, *, status: int = 400, **extra): + body = {"error": error}; body.update(extra) + return jsonify(body), status + + +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 session.execute( + select(ImportSettings).where(ImportSettings.id == 1) + )).scalar_one() + 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("/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 session.execute( + select(ImportSettings).where(ImportSettings.id == 1) + )).scalar_one() + for field in _BACKUP_SETTINGS_FIELDS: + if field in body: + setattr(row, field, body[field]) + await session.commit() + return await get_settings()