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:
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user