CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 27s
CI and images / backend-lint-and-test (push) Successful in 40s
CI and images / integration (push) Failing after 19s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Settings → Maintenance → Storage gains "Off-site backup destination": the
address (with its port), bucket, region, access key and secret key of an S3
bucket such as Fabled Archives. The secret key is sealed with the credential
key, never returned by the API, and kept when the field is left blank. Test
destination lists the bucket with the fields as typed and writes nothing, and
names the cause when it fails ("nothing is listening there (check the port)",
"the secret key is wrong", "answered, but not as S3").
services/backup_s3 is a standard-library SigV4 client (put, get, paged list)
ported from Grimoire's; it is held to AWS's published signing examples and
exercised against a local HTTP server. Migration 0118 adds the columns; no
destination is set up until someone saves one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""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/<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 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()
|