diff --git a/backend/app/api/migrate.py b/backend/app/api/migrate.py index 81e1239..8a67fc6 100644 --- a/backend/app/api/migrate.py +++ b/backend/app/api/migrate.py @@ -1,14 +1,11 @@ """FC-5: /api/migrate — trigger and poll migration runs. Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an -`export_file` field. All other kinds accept JSON. Apply-without-backup -guard rejects non-dry-run ingests unless a pre_migration-tagged backup -exists in the last 24h (override with body.force=true). +`export_file` field. All other kinds accept JSON. Backup + rollback +were retired in FC-3h (2026-05-24); use /api/system/backup/* instead. """ import json -from datetime import UTC, datetime, timedelta -from pathlib import Path from quart import Blueprint, jsonify, request from sqlalchemy import select @@ -19,17 +16,12 @@ from ..tasks.migration import run_migration migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate") +# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*. _VALID_KINDS = frozenset({ - "backup", "gs_ingest", "ir_ingest", "tag_apply", - "ml_queue", "verify", "rollback", "cleanup", + "gs_ingest", "ir_ingest", "tag_apply", + "ml_queue", "verify", "cleanup", }) _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): @@ -38,19 +30,6 @@ def _bad(error: str, *, status: int = 400, **extra): 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: return { "id": run.id, @@ -83,7 +62,6 @@ async def create_run(kind: str): except (UnicodeDecodeError, json.JSONDecodeError) as exc: return _bad("invalid_export_file", detail=str(exc)) 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} else: body = await request.get_json() @@ -92,20 +70,8 @@ async def create_run(kind: str): if not isinstance(body, dict): return _bad("invalid_body") dry_run = bool(body.get("dry_run", False)) - force = bool(body.get("force", False)) 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: run = MigrationRun(kind=kind, status="pending", dry_run=dry_run) session.add(run) diff --git a/backend/app/tasks/migration.py b/backend/app/tasks/migration.py index d08b68b..a59c8b2 100644 --- a/backend/app/tasks/migration.py +++ b/backend/app/tasks/migration.py @@ -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' 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 @@ -20,10 +21,8 @@ from ..celery_app import celery from ..config import get_config from ..models import MigrationRun 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 gs_ingest, ir_ingest, ml_queue, tag_apply, verify -from ..services.migrators import rollback as rollback_mod 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: await _update_run(db, run_id, status="running") try: - if kind == "backup": - manifest = backup_mod.create_backup( - db_url=get_config().database_url_sync, - images_root=IMAGES_ROOT, - tag=params.get("tag", "manual"), + if kind in ("backup", "rollback"): + raise ValueError( + f"kind {kind!r} retired in FC-3h; " + "use /api/system/backup/* instead" ) - 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": fc_crypto = CredentialCrypto(_KEY_PATH) @@ -166,21 +155,6 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict: ) 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: raise ValueError(f"unknown kind: {kind}")