fc3h: /api/system/backup blueprint — trigger, list, get, patch, restore, delete, settings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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/<int:run_id>", 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/<int:run_id>", 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/<int:run_id>/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/<int:run_id>", 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()
|
||||
Reference in New Issue
Block a user