CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 25s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Failing after 44s
CI and images / integration (push) Failing after 2m28s
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
With an off-site destination set up, the library's files are sent to the bucket's media/ folder as media/ab/cd/<sha256><ext>, beside the database dumps under database/. Each file goes once: a new backup_blob table records what each destination holds (migration 0121), and a run's work list is every image and attachment sha with no row yet, so runs are incremental and a new bucket starts its copy from the beginning. - The bucket checks each file against the sha recorded at import; a file over 1 GiB goes in parts and is checked before the upload is completed. A file missing or changed on disk is reported, not stored. - A run is chunked: each 45-minute chunk commits after every file and queues the next, so other long-maintenance jobs get turns. One run at a time. The stall sweep judges a media run by its progress heartbeat (backup_run.last_progress_at), not its start. - A file that can't be sent is listed on the run (status ok, with the error line) and tried again by the next run. A refused put for an object already in an append-only bucket counts as sent. - Nightly, with a destination set, runs the dump and then the media. - Weekly reconcile lists media/ and re-sends what the bucket lost, and adopts what it already holds (a restored database sends nothing twice). - Uploads use the bandwidth limit; read transactions end before transfers. - Backups card: with a destination, an "Images and attachments" section shows coverage (files and bytes in the bucket), "Send new files now", and the media runs. Without one, the local tarball section is unchanged. - Media run history keeps the latest 60 rows. Task #6879, milestone 586. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
409 lines
16 KiB
Python
409 lines
16 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", "media"})
|
|
_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 {},
|
|
# Where an off-site dump went ({endpoint, bucket, key}); None for a
|
|
# backup kept on this server.
|
|
"offsite": (r.manifest or {}).get("offsite"),
|
|
}
|
|
|
|
|
|
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("/media", methods=["POST"])
|
|
async def trigger_media_backup():
|
|
"""Send the images and attachments not yet in the off-site bucket. Refused
|
|
when no destination is set up; a run already going is left to finish."""
|
|
from ..services import backup_destination as dest
|
|
|
|
async with get_session() as session:
|
|
row = await ImportSettings.load(session)
|
|
if not dest.describe(row)["configured"]:
|
|
return _bad("no_destination",
|
|
detail="set up an off-site destination before sending files")
|
|
from ..tasks.backup import backup_media_task
|
|
backup_media_task.delay(triggered_by="manual")
|
|
return jsonify({"status": "dispatched"}), 202
|
|
|
|
|
|
@system_backup_bp.route("/media", methods=["GET"])
|
|
async def media_coverage():
|
|
"""How much of the library is in the off-site bucket: files and bytes,
|
|
sent and total, each file counted once however many posts hold it."""
|
|
from ..services import backup_destination as dest
|
|
from ..services import backup_media
|
|
|
|
async with get_session() as session:
|
|
row = await ImportSettings.load(session)
|
|
f = dest.fields_of(row)
|
|
configured = dest.describe(row)["configured"]
|
|
tid = backup_media.destination_id(f.endpoint, f.bucket) if configured else None
|
|
counts = await session.run_sync(lambda s: backup_media.coverage(s, tid))
|
|
return jsonify({"configured": configured, **counts})
|
|
|
|
|
|
@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",
|
|
)
|
|
if (row.manifest or {}).get("offsite"):
|
|
return _bad(
|
|
"not_restorable",
|
|
detail=("this dump is kept in the off-site bucket, not on this server, "
|
|
"so it can't be restored from here"),
|
|
)
|
|
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, upload_limit_mbps}; an empty field clears it, and a missing
|
|
upload_limit_mbps keeps the saved limit. 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")
|
|
limit = body.get("upload_limit_mbps")
|
|
if limit is not None and (
|
|
not isinstance(limit, int) or isinstance(limit, bool) or not 0 <= limit <= 100_000
|
|
):
|
|
return _bad("invalid_destination",
|
|
detail="upload_limit_mbps must be a whole number from 0 (no limit) up")
|
|
|
|
async with get_session() as session:
|
|
row = await ImportSettings.load(session)
|
|
if limit is not None:
|
|
row.backup_upload_limit_mbps = limit
|
|
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()
|