fc3h: retire backup + rollback from migrate API/task — moved to /api/system/backup/* per FC-3h

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 22:59:11 -04:00
parent 7d42cddb11
commit d3d4320ed5
2 changed files with 11 additions and 71 deletions
+5 -39
View File
@@ -1,14 +1,11 @@
"""FC-5: /api/migrate — trigger and poll migration runs. """FC-5: /api/migrate — trigger and poll migration runs.
Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an
`export_file` field. All other kinds accept JSON. Apply-without-backup `export_file` field. All other kinds accept JSON. Backup + rollback
guard rejects non-dry-run ingests unless a pre_migration-tagged backup were retired in FC-3h (2026-05-24); use /api/system/backup/* instead.
exists in the last 24h (override with body.force=true).
""" """
import json import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import select from sqlalchemy import select
@@ -19,17 +16,12 @@ from ..tasks.migration import run_migration
migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate") migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*.
_VALID_KINDS = frozenset({ _VALID_KINDS = frozenset({
"backup", "gs_ingest", "ir_ingest", "tag_apply", "gs_ingest", "ir_ingest", "tag_apply",
"ml_queue", "verify", "rollback", "cleanup", "ml_queue", "verify", "cleanup",
}) })
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"}) _INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
# Backup gate retired 2026-05-24 — operator-flagged the speculative-safety
# requirement was actively blocking the UI ingest path (backup itself is
# unreliable on large NFS-backed image libraries) and FC-3h will rewrite
# the backup surface as a first-class feature with its own scheduling
# + recovery. Leaving the constant for historical grep.
_APPLY_KINDS: frozenset[str] = frozenset()
def _bad(error: str, *, status: int = 400, **extra): def _bad(error: str, *, status: int = 400, **extra):
@@ -38,19 +30,6 @@ def _bad(error: str, *, status: int = 400, **extra):
return jsonify(body), status return jsonify(body), status
def _has_recent_pre_migration_backup() -> bool:
from ..services.migrators import backup as backup_mod
images_root = Path("/images")
manifest = backup_mod.find_latest_backup(images_root, tag="pre_migration")
if manifest is None:
return False
created_at_str = manifest.get("created_at")
if not created_at_str:
return False
created_at = datetime.fromisoformat(created_at_str)
return (datetime.now(UTC) - created_at) < timedelta(hours=24)
def _run_to_dict(run: MigrationRun) -> dict: def _run_to_dict(run: MigrationRun) -> dict:
return { return {
"id": run.id, "id": run.id,
@@ -83,7 +62,6 @@ async def create_run(kind: str):
except (UnicodeDecodeError, json.JSONDecodeError) as exc: except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return _bad("invalid_export_file", detail=str(exc)) return _bad("invalid_export_file", detail=str(exc))
dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes") dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes")
force = str(form.get("force", "false")).lower() in ("true", "1", "yes")
params: dict = {"data": data, "dry_run": dry_run} params: dict = {"data": data, "dry_run": dry_run}
else: else:
body = await request.get_json() body = await request.get_json()
@@ -92,20 +70,8 @@ async def create_run(kind: str):
if not isinstance(body, dict): if not isinstance(body, dict):
return _bad("invalid_body") return _bad("invalid_body")
dry_run = bool(body.get("dry_run", False)) dry_run = bool(body.get("dry_run", False))
force = bool(body.get("force", False))
params = dict(body) params = dict(body)
is_apply = (kind in _APPLY_KINDS) and not dry_run
if is_apply and not force and not _has_recent_pre_migration_backup():
return _bad(
"no_backup",
detail="apply action requires a pre_migration-tagged backup "
"in the last 24h (or force=true).",
)
if kind == "backup":
params.setdefault("tag", "pre_migration")
async with get_session() as session: async with get_session() as session:
run = MigrationRun(kind=kind, status="pending", dry_run=dry_run) run = MigrationRun(kind=kind, status="pending", dry_run=dry_run)
session.add(run) session.add(run)
+6 -32
View File
@@ -4,7 +4,8 @@ Dispatches to the right migrator based on `kind`. Updates MigrationRun
row's status/counts/finished_at as it runs. Failures set status='error' row's status/counts/finished_at as it runs. Failures set status='error'
with the error message preserved. with the error message preserved.
kinds: backup, gs_ingest, ir_ingest, tag_apply, ml_queue, verify, rollback, cleanup kinds: gs_ingest, ir_ingest, tag_apply, ml_queue, verify, cleanup
(backup + rollback retired 2026-05-24 → see /api/system/backup/*)
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,10 +21,8 @@ from ..celery_app import celery
from ..config import get_config from ..config import get_config
from ..models import MigrationRun from ..models import MigrationRun
from ..services.credential_crypto import CredentialCrypto from ..services.credential_crypto import CredentialCrypto
from ..services.migrators import backup as backup_mod
from ..services.migrators import cleanup as cleanup_mod from ..services.migrators import cleanup as cleanup_mod
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
from ..services.migrators import rollback as rollback_mod
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -65,21 +64,11 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict:
async with factory() as db: async with factory() as db:
await _update_run(db, run_id, status="running") await _update_run(db, run_id, status="running")
try: try:
if kind == "backup": if kind in ("backup", "rollback"):
manifest = backup_mod.create_backup( raise ValueError(
db_url=get_config().database_url_sync, f"kind {kind!r} retired in FC-3h; "
images_root=IMAGES_ROOT, "use /api/system/backup/* instead"
tag=params.get("tag", "manual"),
) )
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": 0, "rows_inserted": 0,
"rows_skipped": 0, "files_copied": 0,
"bytes_copied": 0, "conflicts": 0},
finished_at=datetime.now(UTC),
metadata_patch={"manifest": manifest},
)
return manifest
elif kind == "gs_ingest": elif kind == "gs_ingest":
fc_crypto = CredentialCrypto(_KEY_PATH) fc_crypto = CredentialCrypto(_KEY_PATH)
@@ -166,21 +155,6 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict:
) )
return result return result
elif kind == "rollback":
result = rollback_mod.rollback_to_pre_migration(
db_url=get_config().database_url_sync,
images_root=IMAGES_ROOT,
)
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": 0, "rows_inserted": 0,
"rows_skipped": 0, "files_copied": 0,
"bytes_copied": 0, "conflicts": 0},
finished_at=datetime.now(UTC),
metadata_patch={"rollback_result": result},
)
return result
else: else:
raise ValueError(f"unknown kind: {kind}") raise ValueError(f"unknown kind: {kind}")