"""M15 backup, restore and disaster-recovery service. The control plane owns its own recovery: classified assets, versioned policies, immutable verified backup sets, guarded restore plans and a durable restore journal. Nothing here can silently lose provenance, resurrect stale runtime truth, or bypass supply-chain verification. """ from __future__ import annotations import hashlib import json import os import re import shutil import time import uuid from collections.abc import Iterable, Iterator from datetime import UTC, datetime, timedelta from functools import lru_cache from pathlib import Path from typing import Any, cast from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session from modelforge_api import __version__ from modelforge_api.domain.audit import ( normalise_audit_timestamp, ) from modelforge_api.domain.recovery import ( POINT_IN_TIME_SUPPORT, RESTORE_ELIGIBLE_STATES, RESTORE_PHASE_ORDER, ArtifactRecoveryClass, ArtifactRecoveryCreate, ArtifactRecoveryResponse, ArtifactRecoveryState, BackupCapacityEstimate, BackupManifestEntryResponse, BackupMethod, BackupSetCreate, BackupSetResponse, BackupState, RecoveryAssetClass, RecoveryAssetResponse, RecoveryDashboard, RecoveryFailureCode, RecoveryPolicyCreate, RecoveryPolicyResponse, RecoveryReadiness, RecoveryReadinessEntry, RestoreAdvanceRequest, RestoreMode, RestoreOperationEventResponse, RestoreOperationResponse, RestorePlanCreate, RestorePlanResponse, RestorePlanState, RestoreState, SecretRecoveryClass, redact_database_url, ) from modelforge_api.persistence.models import ( ArtifactRecoveryOperation, ArtifactSet, ArtifactSetMember, AuditEvent, BackupManifestEntry, BackupSet, DerivedArtifactSource, Model, ModelArtifact, ModelRevision, RecoveryAssetRecord, RecoveryPolicyRevision, RestoreOperation, RestoreOperationEvent, RestorePlan, StorageRoot, UpstreamSnapshot, ) from modelforge_api.services.audit import ( AuditChainCheckpoint, AuditEventLike, AuditEventRecord, AuditWriter, audit_chain_violations, ) from modelforge_api.services.recovery_crypto import ( AesGcmBackupCipher, BackupCryptoError, ) from modelforge_api.services.recovery_fingerprint import ( CURRENT_TRUTH_TABLES, audit_fingerprint_differences_are_compatible, diff_fingerprints, fingerprint_compatibility, fingerprint_session, semantic_fingerprint, ) from modelforge_api.services.recovery_paths import ( RecoveryPathError, capacity_bytes, free_bytes, normalise_root, resolve_within, sha256_bytes, sha256_file, ) from modelforge_api.services.recovery_postgres import ( PostgresEngine, PostgresTarget, PostgresToolError, parse_target, ) from modelforge_api.settings import Settings, get_settings DATABASE_OBJECT = "database.dump" ENCRYPTED_DATABASE_OBJECT = "database.dump.enc" ARTIFACT_MANIFEST_OBJECT = "artifacts.json" CONFIGURATION_MANIFEST_OBJECT = "configuration.json" MANIFEST_OBJECT = "manifest.json" MANIFEST_SCHEMA_VERSION = "m15.1" AUDIT_RESOURCE = "recovery" # Terminal stages of the M12 lifecycle and M13 cutover journals. A restored operation outside # these stages was in flight at backup time and must be reconciled exactly once, never replayed. LIFECYCLE_TERMINAL_STAGES = ("COMMITTED", "ROLLED_BACK", "FAILED") CUTOVER_TERMINAL_STAGES = ("COMMITTED", "ROLLED_BACK", "FAILED") def _quoted(values: tuple[str, ...]) -> str: return ",".join("'" + value.replace("'", "''") + "'" for value in values) def _sql_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" ACTIVE_BACKUP_STATES = (BackupState.CREATING.value, BackupState.VERIFYING.value) ACTIVE_RESTORE_STATES = tuple( item.value for item in RestoreState if item not in {RestoreState.READY, RestoreState.FAILED, RestoreState.MANUAL_INTERVENTION_REQUIRED} ) def _now() -> datetime: return datetime.now(UTC) def _aware(value: datetime | None) -> datetime | None: if value is None: return None return value if value.tzinfo else value.replace(tzinfo=UTC) def _psql_true(value: str) -> bool: """Accept PostgreSQL's native boolean text and explicit long-form test fixtures.""" return value.strip().lower() in {"t", "true", "1"} def _hash(payload: dict[str, Any]) -> str: return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode() ).hexdigest() class RecoveryError(Exception): def __init__( self, status_code: int, code: str, message: str, details: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.status_code = status_code self.code = code self.message = message self.details = details or {} class RecoveryService: def __init__( self, session: Session, settings: Settings | None = None, actor_type: str = "operator", actor_id: str = "recovery", ) -> None: self.session = session self.settings = settings or get_settings() self.audit = AuditWriter(session, actor_type, actor_id) self.engine = PostgresEngine( pg_dump_path=self.settings.backup_pg_dump_path, pg_restore_path=self.settings.backup_pg_restore_path, psql_path=self.settings.backup_psql_path, timeout_seconds=self.settings.backup_command_timeout_seconds, base_environment=dict(os.environ), ) # ------------------------------------------------------------------ policies def ensure_defaults(self) -> None: """Seed the versioned recovery policies and the classified asset inventory.""" for request in _DEFAULT_POLICIES: existing = self.session.scalar( select(RecoveryPolicyRevision).where( RecoveryPolicyRevision.key == request.key, RecoveryPolicyRevision.active.is_(True), ) ) if existing is None: self._create_policy(request, audit=False) self.session.flush() policies = { item.key: item for item in self.session.scalars( select(RecoveryPolicyRevision).where(RecoveryPolicyRevision.active.is_(True)) ) } for spec in _DEFAULT_ASSETS: policy = policies.get(spec["policy_key"]) if policy is None: continue record = self.session.scalar( select(RecoveryAssetRecord).where(RecoveryAssetRecord.key == spec["key"]) ) if record is None: record = RecoveryAssetRecord(key=str(spec["key"])) self.session.add(record) record.name = str(spec["name"]) record.asset_class = policy.asset_class record.owner = str(spec["owner"]) record.location = str(spec["location"]) record.backup_method = policy.backup_method record.restore_method = str(spec["restore_method"]) record.rebuild_method = spec.get("rebuild_method") record.rpo_seconds = policy.rpo_seconds record.readiness = str(spec["readiness"]) record.dependencies = list(spec.get("dependencies", [])) record.notes = str(spec.get("notes", "")) record.policy_revision_id = policy.id self.session.commit() def policies(self) -> list[RecoveryPolicyResponse]: return [ self._policy_response(item) for item in self.session.scalars( select(RecoveryPolicyRevision).order_by( RecoveryPolicyRevision.key, RecoveryPolicyRevision.revision ) ) ] def create_policy(self, request: RecoveryPolicyCreate) -> RecoveryPolicyResponse: revision = self._create_policy(request, audit=True) self.session.commit() return self._policy_response(revision) def _create_policy( self, request: RecoveryPolicyCreate, *, audit: bool ) -> RecoveryPolicyRevision: current = self.session.scalar( select(RecoveryPolicyRevision) .where(RecoveryPolicyRevision.key == request.key) .order_by(RecoveryPolicyRevision.revision.desc()) .limit(1) ) if current is not None: current.active = False payload = request.model_dump(mode="json", exclude={"created_by"}) revision_number = (current.revision + 1) if current else 1 record = RecoveryPolicyRevision( key=request.key, revision=revision_number, name=request.name, asset_class=request.asset_class.value, backup_method=request.backup_method.value, retention_days=request.retention_days, minimum_verified_backups=request.minimum_verified_backups, rpo_seconds=request.rpo_seconds, rto_target_seconds=request.rto_target_seconds, restore_verification=request.restore_verification, encryption_required=request.encryption_required, external_dependency=request.external_dependency, rehydration_allowed=request.rehydration_allowed, secret_class=request.secret_class.value if request.secret_class else None, rationale=request.rationale, active=True, fingerprint=_hash({**payload, "revision": revision_number}), created_by=request.created_by, ) self.session.add(record) self.session.flush() if audit: self.audit.write( "RECOVERY_POLICY_CREATED", AUDIT_RESOURCE, str(record.id), {"key": record.key, "revision": record.revision, "asset_class": record.asset_class}, ) return record def assets(self) -> list[RecoveryAssetResponse]: rows = self.session.scalars( select(RecoveryAssetRecord).order_by( RecoveryAssetRecord.asset_class, RecoveryAssetRecord.key ) ) return [ RecoveryAssetResponse( id=item.id, key=item.key, name=item.name, asset_class=RecoveryAssetClass(item.asset_class), owner=item.owner, location=item.location, backup_method=BackupMethod(item.backup_method), restore_method=item.restore_method, rebuild_method=item.rebuild_method, rpo_seconds=item.rpo_seconds, readiness=RecoveryReadiness(item.readiness), dependencies=list(item.dependencies or []), notes=item.notes, policy_key=item.policy_revision.key, updated_at=item.updated_at, ) for item in rows ] # ------------------------------------------------------------------ backups def _database_policy(self) -> RecoveryPolicyRevision: policy = self.session.scalar( select(RecoveryPolicyRevision).where( RecoveryPolicyRevision.key == "control-plane.database", RecoveryPolicyRevision.active.is_(True), ) ) if policy is None: self.ensure_defaults() policy = self.session.scalar( select(RecoveryPolicyRevision).where( RecoveryPolicyRevision.key == "control-plane.database", RecoveryPolicyRevision.active.is_(True), ) ) if policy is None: # pragma: no cover - defaults always seed this policy raise RecoveryError(500, "recovery_policy_missing", "no active database backup policy") return policy def _cipher(self, *, required: bool) -> AesGcmBackupCipher | None: secret = self.settings.backup_encryption_key if secret is None or not secret.get_secret_value().strip(): if required: raise RecoveryError( 409, RecoveryFailureCode.ENCRYPTION_KEY_UNAVAILABLE.value, "the active recovery policy requires encryption but no backup key is configured", ) return None return AesGcmBackupCipher( secret.get_secret_value(), self.settings.backup_encryption_key_id ) def _backup_root(self) -> Path: try: return normalise_root(self.settings.backup_root) except (RecoveryPathError, OSError) as error: raise RecoveryError( 500, RecoveryFailureCode.PATH_NOT_ALLOWED.value, str(error) ) from error def estimate_capacity(self) -> BackupCapacityEstimate: """Backups check headroom before writing; they never fill a disk to find out.""" root = self._backup_root() database_bytes = self._estimate_database_bytes() rehydratable = int( self.session.scalar(select(func.coalesce(func.sum(ModelArtifact.size_bytes), 0))) or 0 ) available = free_bytes(root) capacity = capacity_bytes(root) required = int(database_bytes * self.settings.backup_capacity_headroom_ratio) + ( self.settings.backup_minimum_free_bytes ) sufficient = available is None or available >= required detail = ( "capacity is unknown for this destination; the backup will still verify its payload" if available is None else ( f"{available} bytes available for an estimated {required} byte requirement" ) ) return BackupCapacityEstimate( bytes_to_copy=database_bytes, bytes_manifest_only=rehydratable, estimated_protected_bytes=database_bytes, available_bytes=available, capacity_bytes=capacity, sufficient=sufficient, detail=detail, ) def _estimate_database_bytes(self) -> int: try: value = self.session.scalar( select(func.pg_database_size(func.current_database())) ) return int(value or 0) except Exception: # noqa: BLE001 - non-PostgreSQL test databases have no such function self.session.rollback() return 0 def _guard_no_concurrent_operation(self) -> None: active_backup = self.session.scalar( select(BackupSet).where(BackupSet.state.in_(ACTIVE_BACKUP_STATES)).limit(1) ) if active_backup is not None: raise RecoveryError( 409, RecoveryFailureCode.CONCURRENT_OPERATION.value, f"backup {active_backup.backup_id} is already {active_backup.state}", ) active_restore = self.session.scalar( select(RestoreOperation) .where(RestoreOperation.state.in_(ACTIVE_RESTORE_STATES)) .limit(1) ) if active_restore is not None: raise RecoveryError( 409, RecoveryFailureCode.CONCURRENT_OPERATION.value, f"restore operation {active_restore.id} is still {active_restore.state}", ) def plan_backup(self, request: BackupSetCreate) -> BackupSet: existing = self.session.scalar( select(BackupSet).where(BackupSet.backup_id == request.backup_id) ) if existing is not None: raise RecoveryError( 409, "backup_already_exists", f"backup {request.backup_id} already exists" ) self._guard_no_concurrent_operation() policy = self._database_policy() record = BackupSet( backup_id=request.backup_id, state=BackupState.PLANNED.value, policy_revision_id=policy.id, modelforge_version=__version__, modelforge_commit=_source_commit(), source_repository=_source_repository(), source_reference=_source_reference(), schema_revision=None, environment_fingerprint={}, database_identity={}, destination_root=str(self._backup_root()), included_asset_classes=[ RecoveryAssetClass.AUTHORITATIVE.value, RecoveryAssetClass.REBUILDABLE.value, ], excluded_asset_classes=[ RecoveryAssetClass.EPHEMERAL.value, RecoveryAssetClass.EXTERNAL.value, RecoveryAssetClass.SECRET.value, ], verification_details={}, milestone=request.milestone, legal_hold=request.legal_hold, reason=request.reason, created_by=request.created_by, ) self.session.add(record) self.session.flush() self.audit.write( "BACKUP_PLANNED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id, "policy_revision": policy.revision}, ) self.session.commit() return record def create_backup(self, request: BackupSetCreate) -> BackupSetResponse: record = self.plan_backup(request) return self.execute_backup(record.id, include_artifact_manifest=request.include_artifact_manifest) def execute_backup( self, backup_set_id: uuid.UUID, *, include_artifact_manifest: bool = True ) -> BackupSetResponse: record = self._backup_row(backup_set_id) if record.state != BackupState.PLANNED.value: raise RecoveryError( 409, "backup_not_planned", f"backup {record.backup_id} is {record.state} and cannot be created again", ) self._guard_no_concurrent_operation() capacity = self.estimate_capacity() if not capacity.sufficient: self._fail_backup( record, RecoveryFailureCode.INSUFFICIENT_CAPACITY, capacity.detail ) return self.backup(record.id) record.state = BackupState.CREATING.value record.started_at = _now() self.audit.write( "BACKUP_STARTED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id} ) self.session.commit() # Creating the destination directory is part of the backup, not a precondition of it: an # unwritable or vanished backup root must fail this backup closed with a typed code, not # escape as an unhandled error that leaves the set stuck in CREATING. directory: Path | None = None try: directory = self._backup_directory(record.backup_id) payload = self._write_backup_payload( record, directory, include_artifact_manifest=include_artifact_manifest ) except (PostgresToolError, BackupCryptoError, RecoveryPathError) as error: if directory is not None: shutil.rmtree(directory, ignore_errors=True) self._fail_backup(record, RecoveryFailureCode(error.code), error.message) return self.backup(record.id) except OSError as error: if directory is not None: shutil.rmtree(directory, ignore_errors=True) self._fail_backup( record, RecoveryFailureCode.DESTINATION_UNAVAILABLE, f"the backup destination could not be written: {error}", ) return self.backup(record.id) policy = record.policy_revision record.state = BackupState.CREATED.value record.completed_at = _now() record.expires_at = record.completed_at + timedelta(days=policy.retention_days) record.immutable_at = record.completed_at self.audit.write( "BACKUP_CREATED", AUDIT_RESOURCE, str(record.id), { "backup_id": record.backup_id, "payload_bytes": record.payload_bytes, "manifest_sha256": record.manifest_sha256, "encrypted": record.encrypted, "schema_revision": record.schema_revision, "duration_seconds": payload["duration_seconds"], }, ) self.session.commit() return self.backup(record.id) def _backup_directory(self, backup_id: str) -> Path: root = self._backup_root() directory = resolve_within(root, backup_id) directory.mkdir(parents=True, exist_ok=True) return directory def _write_backup_payload( self, record: BackupSet, directory: Path, *, include_artifact_manifest: bool ) -> dict[str, Any]: started = time.perf_counter() target = parse_target(self.settings.database_url) identity = self.engine.server_identity(target) alembic_revision = self.engine.alembic_revision(target) tool_version = self.engine.tool_version(self.settings.backup_pg_dump_path) # A fingerprint on each side of the dump bounds the concurrent-write window honestly. fingerprint_before = fingerprint_session(self.session) dump_path = directory / DATABASE_OBJECT dump_result = self.engine.dump(target, dump_path) fingerprint_after = fingerprint_session(self.session) plaintext_sha = sha256_file(dump_path) plaintext_bytes = dump_path.stat().st_size stored_object = DATABASE_OBJECT cipher = self._cipher(required=record.policy_revision.encryption_required) if cipher is not None: encrypted_path = directory / ENCRYPTED_DATABASE_OBJECT cipher.encrypt_file(dump_path, encrypted_path) dump_path.unlink(missing_ok=True) stored_object = ENCRYPTED_DATABASE_OBJECT record.encrypted = True record.encryption_algorithm = cipher.algorithm record.encryption_key_id = cipher.key_id stored_path = encrypted_path else: stored_path = dump_path entries: list[dict[str, Any]] = [ { "logical_asset_type": "control_plane_database", "object_name": stored_object, "relative_path": f"{record.backup_id}/{stored_object}", "size_bytes": stored_path.stat().st_size, "sha256": sha256_file(stored_path), "source_generation": f"pg_dump:{alembic_revision or 'unknown'}", "schema_version": alembic_revision, "dependency_refs": { "postgres_major": identity["major_version"], "extensions": identity["extensions"], "plaintext_sha256": plaintext_sha, "plaintext_bytes": plaintext_bytes, }, } ] if include_artifact_manifest: artifact_manifest = self._artifact_manifest() entries.append( self._write_json_object( directory, record, ARTIFACT_MANIFEST_OBJECT, "artifact_inventory", artifact_manifest, {"rehydratable_bytes": artifact_manifest["totals"]["rehydratable_bytes"]}, ) ) configuration = self._configuration_manifest() entries.append( self._write_json_object( directory, record, CONFIGURATION_MANIFEST_OBJECT, "configuration_reference", configuration, {"repository": configuration["repository"], "commit": configuration["commit"]}, ) ) record.payload_relative_path = f"{record.backup_id}/{stored_object}" record.payload_sha256 = entries[0]["sha256"] record.payload_bytes = sum(int(item["size_bytes"]) for item in entries) record.schema_revision = alembic_revision record.database_identity = { "server_version": identity["server_version"], "server_version_num": identity["server_version_num"], "major_version": identity["major_version"], "database": identity["database"], "extensions": identity["extensions"], "backup_tool": tool_version, "backup_method": BackupMethod.POSTGRES_LOGICAL_CUSTOM.value, "connection": target.redacted, } record.environment_fingerprint = { "modelforge_version": __version__, "modelforge_commit": record.modelforge_commit, "source_reference": record.source_reference, "python": os.environ.get("PYTHON_VERSION", ""), "env": self.settings.env, "artifact_root": self.settings.artifact_root, "point_in_time_support": POINT_IN_TIME_SUPPORT, } manifest = { "manifest_schema_version": MANIFEST_SCHEMA_VERSION, "backup_id": record.backup_id, "created_at": _now().isoformat(), "modelforge_version": __version__, "modelforge_commit": record.modelforge_commit, "source_repository": record.source_repository, "source_reference": record.source_reference, "schema_revision": alembic_revision, "database_identity": record.database_identity, "environment_fingerprint": record.environment_fingerprint, "included_asset_classes": list(record.included_asset_classes), "excluded_asset_classes": list(record.excluded_asset_classes), "encryption": { "encrypted": record.encrypted, "algorithm": record.encryption_algorithm, "key_id": record.encryption_key_id, }, "entries": entries, } manifest_bytes = json.dumps(manifest, sort_keys=True, indent=2).encode("utf-8") manifest_path = directory / MANIFEST_OBJECT manifest_path.write_bytes(manifest_bytes) record.manifest_relative_path = f"{record.backup_id}/{MANIFEST_OBJECT}" record.manifest_sha256 = sha256_bytes(manifest_bytes) for entry in entries: self.session.add( BackupManifestEntry( backup_set_id=record.id, logical_asset_type=entry["logical_asset_type"], object_name=entry["object_name"], relative_path=entry["relative_path"], size_bytes=int(entry["size_bytes"]), sha256=entry["sha256"], source_generation=entry["source_generation"], schema_version=entry["schema_version"], dependency_refs=entry["dependency_refs"], ) ) duration = time.perf_counter() - started stable_groups = sorted( group for group in fingerprint_before["groups"] if fingerprint_before["groups"][group]["digest"] == fingerprint_after["groups"][group]["digest"] ) record.verification_details = { "dump_duration_seconds": dump_result.duration_seconds, "backup_duration_seconds": duration, "source_fingerprint": fingerprint_after, "source_fingerprint_before_dump": { "digest": fingerprint_before["digest"], "groups": { key: value["digest"] for key, value in fingerprint_before["groups"].items() }, }, "write_window_stable_groups": stable_groups, "write_window_active_groups": sorted( set(fingerprint_before["groups"]) - set(stable_groups) ), } return {"duration_seconds": duration} def _write_json_object( self, directory: Path, record: BackupSet, object_name: str, logical_type: str, payload: dict[str, Any], dependency_refs: dict[str, Any], ) -> dict[str, Any]: body = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") path = directory / object_name path.write_bytes(body) return { "logical_asset_type": logical_type, "object_name": object_name, "relative_path": f"{record.backup_id}/{object_name}", "size_bytes": len(body), "sha256": sha256_bytes(body), "source_generation": f"modelforge:{__version__}", "schema_version": MANIFEST_SCHEMA_VERSION, "dependency_refs": dependency_refs, } def _artifact_manifest(self) -> dict[str, Any]: """Large model weights are protected by exact identity, not by copying gigabytes.""" sets: list[dict[str, Any]] = [] rehydratable_bytes = 0 non_rehydratable_bytes = 0 for artifact_set in self.session.scalars(select(ArtifactSet)): revision = self.session.get(ModelRevision, artifact_set.revision_id) snapshot = self.session.get(UpstreamSnapshot, artifact_set.snapshot_id) members = self.session.scalars( select(ArtifactSetMember).where( ArtifactSetMember.artifact_set_id == artifact_set.id ) ) files: list[dict[str, Any]] = [] for member in members: artifact = self.session.get(ModelArtifact, member.artifact_id) if artifact is None: continue files.append( { "artifact_id": str(artifact.id), "filename": artifact.filename, "sha256": artifact.sha256, "size_bytes": artifact.size_bytes, "artifact_type": artifact.artifact_type, "serialization_format": artifact.serialization_format, "security_status": artifact.security_status, "license_status": artifact.license_status, } ) commit_sha = (revision.resolved_commit_sha if revision else None) or ( snapshot.resolved_commit_sha if snapshot else None ) repository = snapshot.repository_id if snapshot else None if repository is None and revision is not None: model = self.session.get(Model, revision.model_id) repository = model.upstream_source if model else None recovery_class = ( ArtifactRecoveryClass.REHYDRATABLE if commit_sha and repository else ArtifactRecoveryClass.NON_REHYDRATABLE ) total = sum(int(item["size_bytes"]) for item in files) if recovery_class is ArtifactRecoveryClass.REHYDRATABLE: rehydratable_bytes += total else: non_rehydratable_bytes += total sets.append( { "artifact_set_id": str(artifact_set.id), "revision_id": str(artifact_set.revision_id), "recovery_class": recovery_class.value, "upstream_repository": repository, "upstream_commit_sha": commit_sha, "file_count": len(files), "total_bytes": total, "files": sorted(files, key=lambda item: item["filename"]), } ) return { "artifact_sets": sorted(sets, key=lambda item: item["artifact_set_id"]), "totals": { "artifact_set_count": len(sets), "rehydratable_bytes": rehydratable_bytes, "non_rehydratable_bytes": non_rehydratable_bytes, }, } def _configuration_manifest(self) -> dict[str, Any]: config_root = Path(self.settings.config_root) files: list[dict[str, Any]] = [] if config_root.is_dir(): for path in sorted(config_root.rglob("*")): if path.is_file(): files.append( { "relative_path": path.relative_to(config_root).as_posix(), "sha256": sha256_file(path), "size_bytes": path.stat().st_size, "classification": "SOURCE_CONTROLLED", } ) return { "repository": _source_repository(), "commit": _source_commit(), "reference": _source_reference(), "config_root": str(config_root), "files": files, "host_local": [ {"key": "MODELFORGE_BACKUP_ROOT", "classification": "HOST_LOCAL"}, {"key": "MODELFORGE_ARTIFACT_ROOT", "classification": "HOST_LOCAL"}, {"key": "MODELFORGE_QUARANTINE_ROOT", "classification": "HOST_LOCAL"}, ], "secrets": [ {"key": "MODELFORGE_OPERATOR_API_KEY", "recovery": "ROTATABLE_SECRET"}, {"key": "MODELFORGE_BACKUP_ENCRYPTION_KEY", "recovery": "NON_EXPORTABLE_SECRET"}, {"key": "MODELFORGE_HF_TOKEN", "recovery": "ROTATABLE_SECRET"}, ], } def _fail_backup( self, record: BackupSet, code: RecoveryFailureCode, reason: str ) -> None: record.state = BackupState.FAILED.value record.failure_code = code.value record.failure_reason = reason[:2000] record.completed_at = _now() self.audit.write( "BACKUP_FAILED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id, "failure_code": code.value}, outcome="failure", ) self.session.commit() def verify_backup(self, backup_set_id: uuid.UUID) -> BackupSetResponse: record = self._backup_row(backup_set_id) if record.state in {BackupState.PLANNED.value, BackupState.DELETED.value}: raise RecoveryError( 409, "backup_not_verifiable", f"backup {record.backup_id} is {record.state}" ) record.state = BackupState.VERIFYING.value self.audit.write( "BACKUP_VERIFICATION_STARTED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id}, ) self.session.commit() outcome = self._verify_payload(record) details = dict(record.verification_details or {}) details["verification"] = outcome record.verification_details = details if outcome["ok"]: record.state = BackupState.VERIFIED.value record.verified_at = _now() record.failure_code = None record.failure_reason = None self.audit.write( "BACKUP_VERIFIED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id, "checks": outcome["checks"]}, ) else: record.state = BackupState.FAILED.value record.failure_code = outcome["failure_code"] record.failure_reason = outcome["failure_reason"] record.verified_at = None self.audit.write( "BACKUP_VERIFICATION_FAILED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id, "failure_code": outcome["failure_code"]}, outcome="failure", ) self.session.commit() return self.backup(record.id) def _verify_payload(self, record: BackupSet) -> dict[str, Any]: checks: dict[str, str] = {} root = self._backup_root() try: manifest_path = resolve_within( root, record.manifest_relative_path or "", must_exist=True ) except RecoveryPathError as error: return _verification_failure( RecoveryFailureCode.PAYLOAD_MISSING, f"manifest unavailable: {error.message}", checks ) manifest_bytes = manifest_path.read_bytes() if sha256_bytes(manifest_bytes) != record.manifest_sha256: return _verification_failure( RecoveryFailureCode.MANIFEST_HASH_MISMATCH, "manifest hash does not match the recorded manifest identity", checks, ) checks["manifest_hash"] = "MATCH" try: manifest = json.loads(manifest_bytes.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as error: return _verification_failure( RecoveryFailureCode.MANIFEST_INCOMPLETE, f"manifest is unreadable: {error}", checks ) recorded = { item.object_name: item for item in self.session.scalars( select(BackupManifestEntry).where(BackupManifestEntry.backup_set_id == record.id) ) } manifest_entries = {item["object_name"]: item for item in manifest.get("entries", [])} if set(recorded) != set(manifest_entries) or not manifest_entries: return _verification_failure( RecoveryFailureCode.MANIFEST_INCOMPLETE, "manifest objects do not match the journaled backup manifest entries", checks, ) if not any( item["logical_asset_type"] == "control_plane_database" for item in manifest_entries.values() ): return _verification_failure( RecoveryFailureCode.MANIFEST_INCOMPLETE, "manifest has no control-plane database payload", checks, ) checks["manifest_completeness"] = "COMPLETE" database_entry: dict[str, Any] | None = None for name, entry in sorted(manifest_entries.items()): journaled = recorded[name] if entry["sha256"] != journaled.sha256 or int(entry["size_bytes"]) != journaled.size_bytes: return _verification_failure( RecoveryFailureCode.MANIFEST_HASH_MISMATCH, f"manifest entry {name} disagrees with the journaled entry", checks, ) try: path = resolve_within(root, entry["relative_path"], must_exist=True) except RecoveryPathError as error: return _verification_failure( RecoveryFailureCode(error.code), f"{name}: {error.message}", checks ) if path.stat().st_size != int(entry["size_bytes"]): return _verification_failure( RecoveryFailureCode.HASH_MISMATCH, f"{name} has an unexpected size", checks ) if sha256_file(path) != entry["sha256"]: return _verification_failure( RecoveryFailureCode.HASH_MISMATCH, f"{name} payload hash does not match the manifest", checks, ) if entry["logical_asset_type"] == "control_plane_database": database_entry = {**entry, "path": path} checks["payload_hashes"] = "MATCH" if database_entry is None: # pragma: no cover - guarded above return _verification_failure( RecoveryFailureCode.MANIFEST_INCOMPLETE, "no database payload", checks ) try: readable = self._probe_database_payload(record, database_entry) except RecoveryError as error: return _verification_failure( RecoveryFailureCode(error.code) if error.code in RecoveryFailureCode.__members__.values() else RecoveryFailureCode.BACKUP_TOOL_FAILED, error.message, checks, ) checks["archive_structure"] = f"READABLE:{readable}" return { "ok": True, "checks": checks, "failure_code": None, "failure_reason": None, "verified_at": _now().isoformat(), } def _probe_database_payload(self, record: BackupSet, entry: dict[str, Any]) -> int: path: Path = entry["path"] if not record.encrypted: return self.engine.list_dump_contents(path) cipher = self._cipher(required=True) if cipher is None: # pragma: no cover - _cipher raises when required raise RecoveryError( 409, RecoveryFailureCode.ENCRYPTION_KEY_UNAVAILABLE.value, "no key available" ) staging = path.with_name(path.name + f".verify-{uuid.uuid4().hex[:8]}") try: cipher.decrypt_file(path, staging) expected = (entry.get("dependency_refs") or {}).get("plaintext_sha256") if expected and sha256_file(staging) != expected: raise RecoveryError( 409, RecoveryFailureCode.HASH_MISMATCH.value, "decrypted payload does not match the recorded plaintext hash", ) return self.engine.list_dump_contents(staging) except BackupCryptoError as error: raise RecoveryError(409, error.code, error.message) from error except PostgresToolError as error: raise RecoveryError(409, error.code, error.message) from error finally: staging.unlink(missing_ok=True) def reconcile_interrupted_backups(self) -> int: """A process that died mid-backup must never leave a usable-looking backup behind.""" interrupted = list( self.session.scalars(select(BackupSet).where(BackupSet.state.in_(ACTIVE_BACKUP_STATES))) ) for record in interrupted: record.state = BackupState.FAILED.value record.failure_code = RecoveryFailureCode.MANIFEST_INCOMPLETE.value record.failure_reason = ( "backup was interrupted before completion and is not restore eligible" ) record.completed_at = _now() self.audit.write( "BACKUP_FAILED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id, "reason": "interrupted"}, outcome="failure", ) if interrupted: self.session.commit() return len(interrupted) def apply_retention(self, observed_at: datetime | None = None) -> dict[str, int]: """Expire aged backups, but never drop the last verified recovery point.""" now = observed_at or _now() expired = 0 protected = 0 verified = [ item for item in self.session.scalars( select(BackupSet) .where(BackupSet.state == BackupState.VERIFIED.value) .order_by(BackupSet.verified_at.desc()) ) ] if self.session.scalar( select(RestoreOperation) .where(RestoreOperation.state.in_(ACTIVE_RESTORE_STATES)) .limit(1) ): return {"expired": 0, "protected": len(verified), "skipped_active_restore": 1} policy = self._database_policy() keep = max(policy.minimum_verified_backups, 1) protected_ids = {item.id for item in verified[:keep]} for record in self.session.scalars( select(BackupSet).where( BackupSet.state.in_( (BackupState.VERIFIED.value, BackupState.CREATED.value, BackupState.FAILED.value) ) ) ): expires_at = _aware(record.expires_at) if record.legal_hold or record.milestone: protected += 1 continue if record.id in protected_ids: protected += 1 continue if expires_at is None or expires_at > now: continue record.state = BackupState.EXPIRED.value expired += 1 self.audit.write( "BACKUP_EXPIRED", AUDIT_RESOURCE, str(record.id), {"backup_id": record.backup_id, "expired_at": now.isoformat()}, ) self.session.commit() return {"expired": expired, "protected": protected, "skipped_active_restore": 0} def backups(self, limit: int = 100) -> list[BackupSetResponse]: rows = self.session.scalars( select(BackupSet).order_by(BackupSet.created_at.desc()).limit(limit) ) return [self._backup_response(item) for item in rows] def backup(self, backup_set_id: uuid.UUID) -> BackupSetResponse: return self._backup_response(self._backup_row(backup_set_id)) def _backup_row(self, backup_set_id: uuid.UUID) -> BackupSet: record = self.session.get(BackupSet, backup_set_id) if record is None: raise RecoveryError(404, "backup_not_found", "backup set does not exist") return record def _backup_response(self, record: BackupSet) -> BackupSetResponse: entries = self.session.scalars( select(BackupManifestEntry) .where(BackupManifestEntry.backup_set_id == record.id) .order_by(BackupManifestEntry.object_name) ) return BackupSetResponse( id=record.id, backup_id=record.backup_id, state=BackupState(record.state), policy_key=record.policy_revision.key, policy_revision=record.policy_revision.revision, modelforge_version=record.modelforge_version, modelforge_commit=record.modelforge_commit, schema_revision=record.schema_revision, environment_fingerprint=dict(record.environment_fingerprint or {}), database_identity=dict(record.database_identity or {}), destination_root=record.destination_root, manifest_relative_path=record.manifest_relative_path, manifest_sha256=record.manifest_sha256, included_asset_classes=list(record.included_asset_classes or []), excluded_asset_classes=list(record.excluded_asset_classes or []), payload_bytes=record.payload_bytes, encrypted=record.encrypted, encryption_algorithm=record.encryption_algorithm, encryption_key_id=record.encryption_key_id, verification_details=_public_verification(record.verification_details), verified_at=record.verified_at, failure_code=record.failure_code, failure_reason=record.failure_reason, milestone=record.milestone, legal_hold=record.legal_hold, restore_eligible=BackupState(record.state) in RESTORE_ELIGIBLE_STATES, reason=record.reason, created_by=record.created_by, started_at=record.started_at, completed_at=record.completed_at, expires_at=record.expires_at, created_at=record.created_at, entries=[BackupManifestEntryResponse.model_validate(item) for item in entries], ) def _policy_response(self, record: RecoveryPolicyRevision) -> RecoveryPolicyResponse: return RecoveryPolicyResponse( id=record.id, key=record.key, name=record.name, revision=record.revision, asset_class=RecoveryAssetClass(record.asset_class), backup_method=BackupMethod(record.backup_method), retention_days=record.retention_days, minimum_verified_backups=record.minimum_verified_backups, rpo_seconds=record.rpo_seconds, rto_target_seconds=record.rto_target_seconds, restore_verification=record.restore_verification, # type: ignore[arg-type] encryption_required=record.encryption_required, external_dependency=record.external_dependency, rehydration_allowed=record.rehydration_allowed, secret_class=SecretRecoveryClass(record.secret_class) if record.secret_class else None, rationale=record.rationale, created_by=record.created_by, active=record.active, fingerprint=record.fingerprint, created_at=record.created_at, ) # ------------------------------------------------------------------ restore def create_restore_plan(self, request: RestorePlanCreate) -> RestorePlanResponse: backup = self._backup_row(request.backup_set_id) if BackupState(backup.state) not in RESTORE_ELIGIBLE_STATES: raise RecoveryError( 409, RecoveryFailureCode.BACKUP_NOT_RESTORE_ELIGIBLE.value, f"backup {backup.backup_id} is {backup.state} and is not restore eligible", ) if request.target_environment == "PRODUCTION" and not ( self.settings.restore_allow_production_target ): raise RecoveryError( 409, RecoveryFailureCode.DESTINATION_NOT_ISOLATED.value, "production restore targets are disabled; use the operator runbook and CLI", ) if _same_database(request.database_destination, self.settings.database_url): raise RecoveryError( 409, RecoveryFailureCode.DESTINATION_NOT_ISOLATED.value, "a restore may not target the database this control plane is running on", ) fingerprint = _hash( { "backup": str(backup.id), "mode": request.mode.value, "target": redact_database_url(request.database_destination), "label": request.target_label, } ) existing = self.session.scalar( select(RestorePlan).where(RestorePlan.fingerprint == fingerprint) ) if existing is not None: return self._plan_response(existing) record = RestorePlan( backup_set_id=backup.id, mode=request.mode.value, state=RestorePlanState.DRAFT.value, target_environment=request.target_environment, target_label=request.target_label, database_destination=request.database_destination, artifact_strategy=request.artifact_strategy, secret_strategy=request.secret_strategy, node_strategy=request.node_strategy, expected_modelforge_version=request.expected_modelforge_version, preflight={}, validation_requirements=dict(request.validation_requirements), fingerprint=fingerprint, reason=request.reason, created_by=request.created_by, ) self.session.add(record) self.session.flush() self.audit.write( "RESTORE_PLANNED", AUDIT_RESOURCE, str(record.id), { "backup_id": backup.backup_id, "mode": record.mode, "target": redact_database_url(record.database_destination), }, ) self.session.commit() return self._plan_response(record) def restore_plan(self, plan_id: uuid.UUID) -> RestorePlanResponse: return self._plan_response(self._plan_row(plan_id)) def restore_plans(self, limit: int = 100) -> list[RestorePlanResponse]: return [ self._plan_response(item) for item in self.session.scalars( select(RestorePlan).order_by(RestorePlan.created_at.desc()).limit(limit) ) ] def _plan_row(self, plan_id: uuid.UUID) -> RestorePlan: record = self.session.get(RestorePlan, plan_id) if record is None: raise RecoveryError(404, "restore_plan_not_found", "restore plan does not exist") return record def _plan_response(self, record: RestorePlan) -> RestorePlanResponse: return RestorePlanResponse( id=record.id, backup_set_id=record.backup_set_id, backup_id=record.backup_set.backup_id, mode=RestoreMode(record.mode), state=RestorePlanState(record.state), target_environment=record.target_environment, target_label=record.target_label, database_destination_redacted=redact_database_url(record.database_destination), artifact_strategy=record.artifact_strategy, secret_strategy=record.secret_strategy, node_strategy=record.node_strategy, expected_modelforge_version=record.expected_modelforge_version, preflight=dict(record.preflight or {}), validation_requirements=dict(record.validation_requirements or {}), fingerprint=record.fingerprint, reason=record.reason, created_by=record.created_by, created_at=record.created_at, ) def preflight(self, plan_id: uuid.UUID) -> RestorePlanResponse: plan = self._plan_row(plan_id) backup = plan.backup_set checks: list[dict[str, Any]] = [] failures: list[str] = [] def record_check(name: str, ok: bool, detail: str, code: str | None = None) -> None: checks.append({"check": name, "status": "PASS" if ok else "FAIL", "detail": detail}) if not ok and code: failures.append(code) record_check( "backup_verified", BackupState(backup.state) in RESTORE_ELIGIBLE_STATES, f"backup state is {backup.state}", RecoveryFailureCode.BACKUP_NOT_RESTORE_ELIGIBLE.value, ) verification: dict[str, Any] = ( self._verify_payload(backup) if backup.manifest_sha256 else { "ok": False, "failure_code": RecoveryFailureCode.MANIFEST_INCOMPLETE.value, "failure_reason": "backup has no manifest", "checks": {}, } ) record_check( "payload_hashes", bool(verification["ok"]), str(verification.get("failure_reason") or "all payload hashes match the manifest"), str(verification.get("failure_code") or RecoveryFailureCode.HASH_MISMATCH.value), ) schema_ok, schema_detail = self._schema_compatibility(backup.schema_revision) record_check( "schema_compatibility", schema_ok, schema_detail, RecoveryFailureCode.SCHEMA_TOO_NEW.value, ) key_ok = (not backup.encrypted) or self.settings.backup_encryption_key is not None record_check( "encryption_key", key_ok, "encryption key available" if key_ok else "no decryption key configured", RecoveryFailureCode.ENCRYPTION_KEY_UNAVAILABLE.value, ) isolated = not _same_database(plan.database_destination, self.settings.database_url) record_check( "destination_isolation", isolated, redact_database_url(plan.database_destination), RecoveryFailureCode.DESTINATION_NOT_ISOLATED.value, ) identity: dict[str, Any] = {} try: target = parse_target(plan.database_destination) reachable = True exists = self.engine.database_exists(target) empty = self.engine.table_is_empty(target) if exists else True identity = dict(self.engine.server_identity(target)) if exists else {} except PostgresToolError as error: reachable = False exists = False empty = False record_check("destination_reachable", False, error.message, error.code) if reachable: record_check( "destination_reachable", True, "destination PostgreSQL responded to a probe" ) record_check( "destination_empty", empty or plan.mode != RestoreMode.VALIDATION.value, "destination public schema is empty" if empty else "destination already has tables", RecoveryFailureCode.DESTINATION_NOT_EMPTY.value, ) source_major = int((backup.database_identity or {}).get("major_version") or 0) target_major = int(identity.get("major_version") or source_major) record_check( "postgres_compatibility", target_major >= source_major, f"source major {source_major} → destination major {target_major}", RecoveryFailureCode.POSTGRES_VERSION_INCOMPATIBLE.value, ) root = self._backup_root() available = free_bytes(root) record_check( "disk_space", available is None or available >= backup.payload_bytes, f"{available} bytes available for a {backup.payload_bytes} byte payload", RecoveryFailureCode.INSUFFICIENT_CAPACITY.value, ) commit = (backup.environment_fingerprint or {}).get("modelforge_commit") record_check( "source_control_revision", bool(commit), f"backup records ModelForge commit {commit}" if commit else "backup has no recorded source-control revision", RecoveryFailureCode.SOURCE_CONTROL_REVISION_UNAVAILABLE.value, ) record_check( "external_dependencies", True, "ExampleRAG, ExampleVision and external GPU workloads are never modified by a restore", ) passed = not failures plan.preflight = { "observed_at": _now().isoformat(), "status": "PASS" if passed else "FAIL", "checks": checks, "failure_codes": sorted(set(failures)), } plan.state = ( RestorePlanState.PREFLIGHT_PASSED.value if passed else RestorePlanState.PREFLIGHT_FAILED.value ) self.session.commit() return self._plan_response(plan) def _schema_compatibility(self, schema_revision: str | None) -> tuple[bool, str]: if not schema_revision: return False, "backup does not record an Alembic revision" known = _known_alembic_revisions(self.settings.alembic_directory) if not known: return False, "this ModelForge build ships no Alembic tree: SCHEMA_UNKNOWN" if schema_revision not in known: head_text = known[-1] if known else "unknown" return False, ( f"backup schema {schema_revision} is unknown to this ModelForge build " f"(head {head_text}): SCHEMA_TOO_NEW" ) head = known[-1] if schema_revision == head: return True, f"backup schema {schema_revision} matches the current head" return True, f"backup schema {schema_revision} will be migrated forward to {head}" def start_restore( self, plan_id: uuid.UUID, request: RestoreAdvanceRequest ) -> RestoreOperationResponse: plan = self._plan_row(plan_id) if plan.state != RestorePlanState.PREFLIGHT_PASSED.value: raise RecoveryError( 409, "restore_preflight_required", f"restore plan is {plan.state}; a passing preflight is required", ) existing = self.session.scalar( select(RestoreOperation) .where(RestoreOperation.plan_id == plan.id) .order_by(RestoreOperation.started_at.desc()) .limit(1) ) if existing is not None and RestoreState(existing.state) not in { RestoreState.FAILED, RestoreState.READY, }: # Idempotent retry: resume the journalled operation instead of creating a second one. return self._operation_response(existing) self._guard_no_concurrent_operation() attempt = (existing.attempt + 1) if existing else 1 record = RestoreOperation( plan_id=plan.id, backup_set_id=plan.backup_set_id, state=RestoreState.PLANNED.value, attempt=attempt, idempotency_key=_hash({"plan": str(plan.id), "attempt": attempt}), preflight_result=dict(plan.preflight or {}), phase_durations={}, source_fingerprint=( (plan.backup_set.verification_details or {}).get("source_fingerprint") or {} ), restored_fingerprint={}, fingerprint_diff={}, validation_result={}, ) self.session.add(record) self.session.flush() self._journal( record, None, RestoreState.PLANNED, "planned", request.actor, request.reason, {"attempt": attempt, "backup_id": plan.backup_set.backup_id}, ) self.audit.write( "RESTORE_STARTED", AUDIT_RESOURCE, str(record.id), { "backup_id": plan.backup_set.backup_id, "mode": plan.mode, "attempt": attempt, "target": redact_database_url(plan.database_destination), }, ) self.session.commit() return self._operation_response(record) def advance_restore( self, operation_id: uuid.UUID, request: RestoreAdvanceRequest ) -> RestoreOperationResponse: record = self._operation_row(operation_id) if RestoreState(record.state) in {RestoreState.READY, RestoreState.FAILED}: return self._operation_response(record) plan = record.plan durations: dict[str, Any] = dict(record.phase_durations or {}) for phase in RESTORE_PHASE_ORDER: # READY is a fresh transition proof, never a cached inference from a journaled # VALIDATING duration (or even a stale READY duration left by malformed state). if phase is not RestoreState.READY and phase.value in durations: continue started = time.perf_counter() previous = RestoreState(record.state) record.state = phase.value self.session.flush() try: evidence = self._run_restore_phase(record, plan, phase) except (PostgresToolError, BackupCryptoError, RecoveryPathError) as error: self._fail_restore(record, previous, phase, error.code, error.message, request) return self._operation_response(record) except RecoveryError as error: self._fail_restore( record, previous, phase, error.code, error.message, request ) return self._operation_response(record) elapsed = time.perf_counter() - started durations[phase.value] = round(elapsed, 6) record.phase_durations = dict(durations) self._journal( record, previous, phase, phase.value, request.actor, request.reason, evidence ) self.session.commit() if request.simulate_interruption_after is phase: # A deliberately interrupted rehearsal leaves a resumable journal, not a lie. self._journal( record, phase, phase, "interrupted", request.actor, "rehearsal interruption requested by the operator", {"resumable": True}, ) self.session.commit() return self._operation_response(record) if request.stop_after is phase: return self._operation_response(record) record.rto_seconds = round(sum(float(value) for value in durations.values()), 6) record.ready_at = _now() self.audit.write( "RECOVERY_COMPLETED", AUDIT_RESOURCE, str(record.id), { "backup_id": plan.backup_set.backup_id, "rto_seconds": record.rto_seconds, "rpo_seconds": record.rpo_seconds, }, ) plan.state = RestorePlanState.CONSUMED.value self.session.commit() return self._operation_response(record) def _run_restore_phase( self, record: RestoreOperation, plan: RestorePlan, phase: RestoreState ) -> dict[str, Any]: if phase is RestoreState.PREFLIGHT: refreshed = self.preflight(plan.id) if refreshed.preflight.get("status") != "PASS": raise RecoveryError( 409, RecoveryFailureCode.BACKUP_NOT_RESTORE_ELIGIBLE.value, "restore preflight failed at execution time", refreshed.preflight, ) record.preflight_result = dict(refreshed.preflight) return {"checks": len(refreshed.preflight.get("checks", []))} if phase is RestoreState.RESTORING_DATABASE: return self._restore_database(record, plan) if phase is RestoreState.RESTORING_CONFIGURATION: return self._restore_configuration(record, plan) if phase is RestoreState.REHYDRATING_ARTIFACTS: return self._plan_artifact_recovery(record, plan) if phase is RestoreState.RECONCILING: return self._reconcile_restored(record, plan) if phase is RestoreState.VALIDATING: return self._validate_restored(record, plan) if phase is RestoreState.READY: checkpoint = self._verify_restored_audit_chain( parse_target(plan.database_destination) ) return { "state": phase.value, "final_audit_verification": "PASS", "audit_event_count": checkpoint.event_count, "audit_last_sequence": checkpoint.last_sequence, "audit_last_event_hash": checkpoint.last_event_hash, } return {"state": phase.value} def _restore_database(self, record: RestoreOperation, plan: RestorePlan) -> dict[str, Any]: backup = plan.backup_set root = self._backup_root() entry = self.session.scalar( select(BackupManifestEntry).where( BackupManifestEntry.backup_set_id == backup.id, BackupManifestEntry.logical_asset_type == "control_plane_database", ) ) if entry is None: raise RecoveryError( 409, RecoveryFailureCode.MANIFEST_INCOMPLETE.value, "no database payload recorded" ) stored = resolve_within(root, entry.relative_path, must_exist=True) if sha256_file(stored) != entry.sha256: raise RecoveryError( 409, RecoveryFailureCode.HASH_MISMATCH.value, "database payload hash changed since the backup was verified", ) target = parse_target(plan.database_destination) if not self.engine.database_exists(target): self.engine.create_database(target) if not self.engine.table_is_empty(target): # Idempotent retry: a completed database phase is never replayed onto live data. raise RecoveryError( 409, RecoveryFailureCode.DESTINATION_NOT_EMPTY.value, "restore destination already contains tables; drop or recreate it before retrying", ) staging: Path | None = None try: if backup.encrypted: cipher = self._cipher(required=True) if cipher is None: # pragma: no cover raise RecoveryError( 409, RecoveryFailureCode.ENCRYPTION_KEY_UNAVAILABLE.value, "no decryption key", ) staging = stored.with_name(stored.name + f".restore-{uuid.uuid4().hex[:8]}") cipher.decrypt_file(stored, staging) expected = (entry.dependency_refs or {}).get("plaintext_sha256") if expected and sha256_file(staging) != expected: raise RecoveryError( 409, RecoveryFailureCode.HASH_MISMATCH.value, "decrypted payload does not match the recorded plaintext hash", ) payload = staging else: payload = stored result = self.engine.restore(target, payload) finally: if staging is not None: staging.unlink(missing_ok=True) restored_revision = self.engine.alembic_revision(target) self.audit.write( "DATABASE_RESTORED", AUDIT_RESOURCE, str(record.id), { "backup_id": backup.backup_id, "target": redact_database_url(plan.database_destination), "schema_revision": restored_revision, }, ) return { "restore_seconds": result.duration_seconds, "schema_revision": restored_revision, "expected_schema_revision": backup.schema_revision, "database": target.redacted, } def _restore_configuration( self, record: RestoreOperation, plan: RestorePlan ) -> dict[str, Any]: backup = plan.backup_set root = self._backup_root() # Schema migration belongs to the restore phase, not to the optional presence of a # configuration manifest. Reconciliation relies on the current audit checkpoint schema. schema_target = self._migrate_destination(plan) entry = self.session.scalar( select(BackupManifestEntry).where( BackupManifestEntry.backup_set_id == backup.id, BackupManifestEntry.logical_asset_type == "configuration_reference", ) ) if entry is None: return { "configuration": "NOT_INCLUDED", "schema_after_migration": schema_target, } path = resolve_within(root, entry.relative_path, must_exist=True) payload = json.loads(path.read_text("utf-8")) current_root = Path(self.settings.config_root) matched = 0 drifted: list[str] = [] for item in payload.get("files", []): candidate = current_root / item["relative_path"] if candidate.is_file() and sha256_file(candidate) == item["sha256"]: matched += 1 else: drifted.append(item["relative_path"]) return { "repository": payload.get("repository"), "commit": payload.get("commit"), "reference": payload.get("reference"), "config_files_matched": matched, "config_files_drifted": drifted[:20], "secret_strategy": plan.secret_strategy, "schema_after_migration": schema_target, } def _migrate_destination(self, plan: RestorePlan) -> str | None: """Run only the forward migrations this build knows; never silently downgrade.""" target = parse_target(plan.database_destination) current = self.engine.alembic_revision(target) known = _known_alembic_revisions(self.settings.alembic_directory) if current is None: return None if current not in known: raise RecoveryError( 409, RecoveryFailureCode.SCHEMA_TOO_NEW.value, f"restored schema {current} is newer than this ModelForge build supports", ) head = known[-1] if current == head: return current _run_alembic_upgrade(plan.database_destination, self.settings.alembic_directory) return self.engine.alembic_revision(target) def _plan_artifact_recovery( self, record: RestoreOperation, plan: RestorePlan ) -> dict[str, Any]: if plan.artifact_strategy == "NONE": return {"artifact_strategy": "NONE", "coverage": "NOT_EVALUATED"} root = self._backup_root() entry = self.session.scalar( select(BackupManifestEntry).where( BackupManifestEntry.backup_set_id == plan.backup_set_id, BackupManifestEntry.logical_asset_type == "artifact_inventory", ) ) if entry is None: return {"artifact_strategy": plan.artifact_strategy, "coverage": "NO_MANIFEST"} payload = json.loads( resolve_within(root, entry.relative_path, must_exist=True).read_text("utf-8") ) rehydratable = [ item for item in payload.get("artifact_sets", []) if item["recovery_class"] == ArtifactRecoveryClass.REHYDRATABLE.value ] non_rehydratable = [ item for item in payload.get("artifact_sets", []) if item["recovery_class"] != ArtifactRecoveryClass.REHYDRATABLE.value ] self.audit.write( "ARTIFACT_REHYDRATION_STARTED", AUDIT_RESOURCE, str(record.id), { "artifact_sets": len(rehydratable), "strategy": plan.artifact_strategy, }, ) return { "artifact_strategy": plan.artifact_strategy, "rehydratable_sets": len(rehydratable), "non_rehydratable_sets": len(non_rehydratable), "rehydratable_bytes": payload.get("totals", {}).get("rehydratable_bytes", 0), "non_rehydratable_bytes": payload.get("totals", {}).get("non_rehydratable_bytes", 0), "coverage": "MANIFEST_RECORDED" if plan.artifact_strategy == "MANIFEST_ONLY" else "REHYDRATION_REQUIRED", } def _restored_audit_checkpoint(self, target: PostgresTarget) -> AuditChainCheckpoint: rows = self.engine.query_rows( target, "select singleton_id::text as singleton_id, event_count::text as event_count, " "last_sequence::text as last_sequence, coalesce(last_event_hash, '') as " "last_event_hash, (last_event_hash is null)::text as last_event_hash_is_null, " "hash_format, v2_start_sequence::text as v2_start_sequence, " "legacy_prefix_count::text as legacy_prefix_count, legacy_prefix_seal " "from audit_chain_heads where singleton_id = 1", max_rows=1, ) if len(rows) != 1: raise RecoveryError( 409, RecoveryFailureCode.AUDIT_CHAIN_CORRUPT.value, "restored audit checkpoint is missing or not a singleton", ) row = rows[0] return AuditChainCheckpoint( singleton_id=int(row["singleton_id"]), event_count=int(row["event_count"]), last_sequence=int(row["last_sequence"]), last_event_hash=( None if _psql_true(row["last_event_hash_is_null"]) else row["last_event_hash"] ), hash_format=row["hash_format"], v2_start_sequence=int(row["v2_start_sequence"]), legacy_prefix_count=int(row["legacy_prefix_count"]), legacy_prefix_seal=row["legacy_prefix_seal"], ) def _restored_audit_events(self, target: PostgresTarget) -> Iterator[AuditEventRecord]: after_cursor: tuple[int, str] | None = None page_size = 500 while True: cursor_sql = "" if after_cursor is not None: after_sequence, after_id = after_cursor cursor_sql = ( "where (sequence > " f"{after_sequence} or (sequence = {after_sequence} and " f"id > {_sql_literal(after_id)}::uuid)) " ) audit_page_sql = ( "select id::text as id, sequence::text as sequence, " # noqa: S608 "to_char(occurred_at at time zone 'UTC', " "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') as occurred_at, " "correlation_id, actor_type, actor_id, action, resource_type, " "coalesce(resource_id, '') as resource_id, " "(resource_id is null)::text as resource_id_is_null, outcome, " "details::text as details, coalesce(previous_event_hash, '') as " "previous_event_hash, (previous_event_hash is null)::text as " "previous_event_hash_is_null, event_hash, hash_format, " "coalesce(canonical_payload, '') as canonical_payload, " "(canonical_payload is null)::text as canonical_payload_is_null " "from audit_events " f"{cursor_sql}order by sequence, id limit {page_size}" ) rows = self.engine.query_rows( target, audit_page_sql, max_rows=page_size, ) if not rows: return for row in rows: details = json.loads(row["details"]) if not isinstance(details, dict): raise ValueError("restored audit details are not an object") sequence = int(row["sequence"]) event_id = str(uuid.UUID(row["id"])) yield AuditEventRecord( id=event_id, sequence=sequence, occurred_at=row["occurred_at"], correlation_id=row["correlation_id"], actor_type=row["actor_type"], actor_id=row["actor_id"], action=row["action"], resource_type=row["resource_type"], resource_id=( None if _psql_true(row["resource_id_is_null"]) else row["resource_id"] ), outcome=row["outcome"], details=details, previous_event_hash=( None if _psql_true(row["previous_event_hash_is_null"]) else row["previous_event_hash"] ), event_hash=row["event_hash"], hash_format=row["hash_format"], canonical_payload=( None if _psql_true(row.get("canonical_payload_is_null", "t")) else row.get("canonical_payload", "") ), ) after_cursor = (sequence, event_id) if len(rows) < page_size: return def _restored_audit_identity_counts( self, target: PostgresTarget ) -> tuple[int, int, int]: rows = self.engine.query_rows( target, "select count(*)::text as event_count, " "count(distinct id)::text as distinct_event_ids, " "count(distinct sequence)::text as distinct_sequences from audit_events", max_rows=1, ) if len(rows) != 1: raise ValueError("restored audit identity counts are unavailable") row = rows[0] return ( int(row["event_count"]), int(row["distinct_event_ids"]), int(row["distinct_sequences"]), ) def _verify_restored_audit_chain( self, target: PostgresTarget ) -> AuditChainCheckpoint: try: checkpoint = self._restored_audit_checkpoint(target) event_count, distinct_event_ids, distinct_sequences = ( self._restored_audit_identity_counts(target) ) violations: list[str] = [] if distinct_event_ids != event_count: violations.append("restored audit event UUIDs are not unique") if distinct_sequences != event_count: violations.append("restored audit event sequences are not unique") violations.extend( audit_chain_violations( cast( Iterable[AuditEventLike], self._restored_audit_events(target), ), checkpoint, ) ) except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: raise RecoveryError( 409, RecoveryFailureCode.AUDIT_CHAIN_CORRUPT.value, f"restored audit chain cannot be parsed: {error}", ) from error if violations: raise RecoveryError( 409, RecoveryFailureCode.AUDIT_CHAIN_CORRUPT.value, "restored audit chain or checkpoint failed strict verification", {"violations": violations[:25]}, ) return checkpoint def _append_restored_audit_marker( self, target: PostgresTarget, *, resource_id: str, expected_checkpoint: AuditChainCheckpoint, ) -> str: """Append a canonical marker to the isolated restore target without forking its chain. The target is reached through the recovery CLI adapter rather than this process' SQLAlchemy session. A transaction-scoped advisory lock and compare-and-set head predicate keep that exceptional path compatible with normal PostgreSQL audit writers. """ resource_id = str(uuid.UUID(resource_id)) correlation_id = str(uuid.uuid4()) event_id = str(uuid.uuid4()) occurred_at = datetime.now(UTC) sequence = expected_checkpoint.last_sequence + 1 previous_sql = ( "NULL" if expected_checkpoint.last_event_hash is None else _sql_literal(expected_checkpoint.last_event_hash) ) timestamp_sql = _sql_literal(normalise_audit_timestamp(occurred_at)) # The isolated restore uses the same database-owned operation as the live writer. The # function, not this process, constructs/hashes the canonical bytes and atomically advances # the locked checkpoint. insert_sql = ( "select sequence::text || ':' || event_hash " # noqa: S608 "from modelforge_audit.append_event_v2(" f"{_sql_literal(event_id)}::uuid, {timestamp_sql}::timestamptz, " f"{_sql_literal(correlation_id)}, 'control_plane', 'recovery', " "'RECOVERY_RECONCILIATION_COMPLETED', " f"{_sql_literal(AUDIT_RESOURCE)}, {_sql_literal(resource_id)}, 'success', " "'{}'::jsonb, " f"{expected_checkpoint.event_count}, {expected_checkpoint.last_sequence}, " f"{previous_sql}, {_sql_literal(expected_checkpoint.hash_format)}, " f"{expected_checkpoint.v2_start_sequence}, " f"{expected_checkpoint.legacy_prefix_count}, " f"{_sql_literal(expected_checkpoint.legacy_prefix_seal)})" ) inserted = self.engine.scalar(target, insert_sql) inserted_sequence, separator, event_hash = inserted.partition(":") if separator != ":" or inserted_sequence != str(sequence) or len(event_hash) != 64: raise PostgresToolError( "BACKUP_TOOL_FAILED", "restored audit marker did not advance event and checkpoint" ) return event_hash def _reconcile_restored(self, record: RestoreOperation, plan: RestorePlan) -> dict[str, Any]: """Clear stale current-truth rows exactly once; history is never rewritten.""" target = parse_target(plan.database_destination) # Fail before the idempotency marker lookup and before any target mutation. A valid marker # on a corrupted chain is not authority to skip reconciliation. self._verify_restored_audit_chain(target) self.audit.write( "RECOVERY_RECONCILIATION_STARTED", AUDIT_RESOURCE, str(record.id), {"backup_id": plan.backup_set.backup_id}, ) # The marker must identify *this* restore operation. A backup legitimately carries the # reconciliation events of earlier restores, so matching on the action alone would make # every later restore believe it had already reconciled and silently keep stale leases. marker = self.engine.scalar( target, "select count(*) from audit_events where action = 'RECOVERY_RECONCILIATION_COMPLETED' " # noqa: S608 f"and resource_id = '{record.id}'", ) if marker.strip() not in {"0", ""}: return { "reconciliation": "ALREADY_APPLIED", "applied_once": True, "restore_operation_id": str(record.id), } cleared: dict[str, int] = {} for table in sorted(CURRENT_TRUTH_TABLES): exists = self.engine.scalar( target, "select count(*) from information_schema.tables where table_schema='public' " # noqa: S608 f"and table_name='{table}'", ) if exists.strip() in {"0", ""}: continue # The table name comes from the module-level CURRENT_TRUTH_TABLES allowlist. before = self.engine.scalar(target, f"select count(*) from {table}") # noqa: S608 self.engine.scalar(target, f"delete from {table}") # noqa: S608 cleared[table] = int(before or 0) # Lifecycle and migration operations use a `stage` vocabulary; anything that is not a # terminal stage was in flight when the backup was taken and needs reconciliation. pending_lifecycle = int( self.engine.scalar( target, "select count(*) from lifecycle_operations where stage not in " # noqa: S608 - literal stage allowlist f"({_quoted(LIFECYCLE_TERMINAL_STAGES)})", ) or 0 ) pending_migrations = int( self.engine.scalar( target, "select count(*) from migration_cutover_operations where stage not in " # noqa: S608 - literal stage allowlist f"({_quoted(CUTOVER_TERMINAL_STAGES)})", ) or 0 ) in_flight_lifecycle = [ row for row in self.engine.scalar( target, "select coalesce(string_agg(id || ':' || stage, ','), '') from " # noqa: S608 - literal stage allowlist "lifecycle_operations where stage not in " f"({_quoted(LIFECYCLE_TERMINAL_STAGES)})", ).split(",") if row ] in_flight_migrations = [ row for row in self.engine.scalar( target, "select coalesce(string_agg(id || ':' || stage, ','), '') from " # noqa: S608 - literal stage allowlist "migration_cutover_operations where stage not in " f"({_quoted(CUTOVER_TERMINAL_STAGES)})", ).split(",") if row ] firing_alerts = int( self.engine.scalar( target, "select count(*) from operational_alerts where state in ('PENDING','FIRING')", ) or 0 ) # A restored alert is history, not current truth: it must be re-derived after startup. self.engine.scalar( target, "update operational_alerts set state='SUPPRESSED', " "suppressed_until = now() + interval '1 hour' where state in ('PENDING','FIRING')", ) # Re-read immediately before the marker so concurrent target activity cannot make the # earlier proof stale. The marker CAS binds to this exact verified head. checkpoint = self._verify_restored_audit_chain(target) self._append_restored_audit_marker( target, resource_id=str(record.id), expected_checkpoint=checkpoint, ) self.audit.write( "RECOVERY_RECONCILIATION_COMPLETED", AUDIT_RESOURCE, str(record.id), { "cleared_current_truth_rows": sum(cleared.values()), "pending_lifecycle_operations": pending_lifecycle, "pending_migration_cutovers": pending_migrations, "suppressed_alerts": firing_alerts, }, ) return { "reconciliation": "APPLIED", "applied_once": True, "cleared_current_truth": cleared, "pending_lifecycle_operations": pending_lifecycle, "pending_migration_cutovers": pending_migrations, "in_flight_lifecycle_operations": in_flight_lifecycle, "in_flight_migration_cutovers": in_flight_migrations, "restored_firing_alerts_suppressed": firing_alerts, "outcome": "RECOVERY_RECONCILIATION_REQUIRED" if pending_lifecycle or pending_migrations else "RECONCILED", } def _validate_restored(self, record: RestoreOperation, plan: RestorePlan) -> dict[str, Any]: target = parse_target(plan.database_destination) # This is an independent READY gate, not an inference from row-count fingerprints or the # fact that reconciliation happened earlier in the journal. audit_checkpoint = self._verify_restored_audit_chain(target) destination = create_engine(plan.database_destination, pool_pre_ping=True) try: with destination.connect() as connection: restored = semantic_fingerprint(connection) finally: destination.dispose() source = dict(record.source_fingerprint or {}) diff: dict[str, Any] = ( diff_fingerprints(source, restored) if source else {"identical": False} ) fingerprint_contract = fingerprint_compatibility( source, restored, source_schema_revision=plan.backup_set.schema_revision, ) record.restored_fingerprint = restored record.fingerprint_diff = diff constraint_count = int( self.engine.scalar( target, "select count(*) from information_schema.table_constraints " "where constraint_schema='public'", ) or 0 ) sequence_count = int( self.engine.scalar( target, "select count(*) from information_schema.sequences where sequence_schema='public'" ) or 0 ) index_count = int( self.engine.scalar(target, "select count(*) from pg_indexes where schemaname='public'") or 0 ) table_count = int( self.engine.scalar( target, "select count(*) from information_schema.tables where table_schema='public'", ) or 0 ) audit_max = self.engine.scalar(target, "select coalesce(max(sequence),0) from audit_events") restored_latest_audit = self.engine.scalar( target, "select coalesce(to_char(max(occurred_at) at time zone 'UTC', " "'YYYY-MM-DD\"T\"HH24:MI:SS.USOF'), '') from audit_events " "where action <> 'RECOVERY_RECONCILIATION_COMPLETED'", ) source_latest_audit = self.session.scalar(select(func.max(AuditEvent.occurred_at))) rpo_seconds: float | None = None restore_point = _aware(plan.backup_set.completed_at) if restored_latest_audit and source_latest_audit is not None: try: restored_moment = datetime.fromisoformat(restored_latest_audit) except ValueError: restored_moment = None restored_aware = _aware(restored_moment) source_aware = _aware(source_latest_audit) if restored_aware is not None and source_aware is not None: rpo_seconds = max( 0.0, source_aware.timestamp() - restored_aware.timestamp() ) record.rpo_seconds = rpo_seconds requirements = dict(plan.validation_requirements or {}) differences: list[dict[str, Any]] = list(diff.get("differences") or []) minimum_tables = int(requirements.get("minimum_tables", 100)) stale_current_truth = { table: int(self.engine.scalar(target, f"select count(*) from {table}") or 0) # noqa: S608 for table in sorted(CURRENT_TRUTH_TABLES) if self.engine.scalar( target, "select count(*) from information_schema.tables where table_schema='public' " # noqa: S608 f"and table_name='{table}'", ).strip() not in {"0", ""} } gate = { "database_validated": table_count >= minimum_tables and constraint_count > 0, # A restore is not READY while a stale lease or NVML snapshot is still presented as # present-day truth; reconciliation must have actually run. "current_truth_reconciled": not any(stale_current_truth.values()), "schema_current": self.engine.alembic_revision(target) == _known_alembic_revisions(self.settings.alembic_directory)[-1], "audit_available": int(audit_max or 0) > 0, "audit_chain_intact": True, "fingerprint_compatible": fingerprint_contract.compatible, "no_unknown_corruption": audit_fingerprint_differences_are_compatible( source, restored, fingerprint_contract ), } result: dict[str, Any] = { "table_count": table_count, "constraint_count": constraint_count, "sequence_count": sequence_count, "index_count": index_count, "audit_max_sequence": int(audit_max or 0), "audit_checkpoint_event_count": audit_checkpoint.event_count, "audit_hash_format": audit_checkpoint.hash_format, "audit_v2_start_sequence": audit_checkpoint.v2_start_sequence, "restore_point": restore_point.isoformat() if restore_point else None, "observed_rpo_seconds": rpo_seconds, "fingerprint_identical": bool(diff.get("identical")), "fingerprint_compatibility_mode": fingerprint_contract.mode, "fingerprint_compatibility_reason": fingerprint_contract.reason, "differing_groups": diff.get("differing_groups", []), "difference_count": len(differences), "residual_current_truth_rows": stale_current_truth, "gate": gate, } record.validation_result = result if not all(gate.values()): raise RecoveryError( 409, RecoveryFailureCode.RECOVERY_RECONCILIATION_REQUIRED.value, "restore validation gate did not pass", result, ) self.audit.write( "RESTORE_VALIDATED", AUDIT_RESOURCE, str(record.id), { "backup_id": plan.backup_set.backup_id, "table_count": table_count, "fingerprint_identical": result["fingerprint_identical"], }, ) return result def _fail_restore( self, record: RestoreOperation, previous: RestoreState, phase: RestoreState, code: str, message: str, request: RestoreAdvanceRequest, ) -> None: terminal = ( RestoreState.MANUAL_INTERVENTION_REQUIRED if code in { RecoveryFailureCode.AUDIT_CHAIN_CORRUPT.value, RecoveryFailureCode.RECOVERY_RECONCILIATION_REQUIRED.value, RecoveryFailureCode.DESTINATION_NOT_EMPTY.value, } else RestoreState.FAILED ) record.state = terminal.value record.failure_code = code record.failure_reason = message[:2000] self._journal( record, previous, terminal, phase.value, request.actor, message, {"failure_code": code} ) self.audit.write( "RESTORE_FAILED", AUDIT_RESOURCE, str(record.id), {"phase": phase.value, "failure_code": code}, outcome="failure", ) self.session.commit() def _journal( self, record: RestoreOperation, previous: RestoreState | None, to_state: RestoreState, phase: str, actor: str, reason: str, evidence: dict[str, Any], ) -> None: self.session.add( RestoreOperationEvent( restore_operation_id=record.id, from_state=previous.value if previous else None, to_state=to_state.value, phase=phase, actor=actor, reason=reason[:2000], evidence=evidence, ) ) self.session.flush() def restore_operations(self, limit: int = 100) -> list[RestoreOperationResponse]: return [ self._operation_response(item) for item in self.session.scalars( select(RestoreOperation).order_by(RestoreOperation.started_at.desc()).limit(limit) ) ] def restore_operation(self, operation_id: uuid.UUID) -> RestoreOperationResponse: return self._operation_response(self._operation_row(operation_id)) def restore_events(self, operation_id: uuid.UUID) -> list[RestoreOperationEventResponse]: rows = self.session.scalars( select(RestoreOperationEvent) .where(RestoreOperationEvent.restore_operation_id == operation_id) .order_by(RestoreOperationEvent.occurred_at, RestoreOperationEvent.id) ) return [RestoreOperationEventResponse.model_validate(item) for item in rows] def _operation_row(self, operation_id: uuid.UUID) -> RestoreOperation: record = self.session.get(RestoreOperation, operation_id) if record is None: raise RecoveryError( 404, "restore_operation_not_found", "restore operation does not exist" ) return record def _operation_response(self, record: RestoreOperation) -> RestoreOperationResponse: return RestoreOperationResponse( id=record.id, plan_id=record.plan_id, backup_set_id=record.backup_set_id, backup_id=record.backup_set.backup_id, state=RestoreState(record.state), mode=RestoreMode(record.plan.mode), attempt=record.attempt, idempotency_key=record.idempotency_key, preflight_result=dict(record.preflight_result or {}), phase_durations={ key: float(value) for key, value in (record.phase_durations or {}).items() }, source_fingerprint=_fingerprint_summary(record.source_fingerprint), restored_fingerprint=_fingerprint_summary(record.restored_fingerprint), fingerprint_diff=dict(record.fingerprint_diff or {}), validation_result=dict(record.validation_result or {}), rpo_seconds=record.rpo_seconds, rto_seconds=record.rto_seconds, failure_code=record.failure_code, failure_reason=record.failure_reason, started_at=record.started_at, updated_at=record.updated_at, ready_at=record.ready_at, ) def reconcile_interrupted_restores(self) -> int: """A restore that died mid-phase is resumable, never silently 'done'.""" pending = list( self.session.scalars( select(RestoreOperation).where( RestoreOperation.state.in_(ACTIVE_RESTORE_STATES), RestoreOperation.state != RestoreState.PLANNED.value, ) ) ) for record in pending: previous = RestoreState(record.state) record.state = RestoreState.MANUAL_INTERVENTION_REQUIRED.value record.failure_code = RecoveryFailureCode.RECOVERY_RECONCILIATION_REQUIRED.value record.failure_reason = ( f"restore was interrupted during {previous.value} and requires operator review" ) self._journal( record, previous, RestoreState.MANUAL_INTERVENTION_REQUIRED, previous.value, "control-plane", "startup reconciliation found an interrupted restore", {"resumable_from": previous.value}, ) if pending: self.session.commit() return len(pending) # ------------------------------------------------------------------ artifacts def plan_artifact_recovery( self, request: ArtifactRecoveryCreate ) -> ArtifactRecoveryResponse: artifact_set = self.session.get(ArtifactSet, request.artifact_set_id) if artifact_set is None: raise RecoveryError(404, "artifact_set_not_found", "artifact set does not exist") storage_root = self.session.get(StorageRoot, request.target_storage_root_id) if storage_root is None: raise RecoveryError(404, "storage_root_not_found", "storage root does not exist") revision = self.session.get(ModelRevision, artifact_set.revision_id) if revision is None: # pragma: no cover - FK guarantees this raise RecoveryError(404, "revision_not_found", "model revision does not exist") repository, commit_sha = _revision_identity(self.session, revision) members = list( self.session.scalars( select(ArtifactSetMember).where( ArtifactSetMember.artifact_set_id == artifact_set.id ) ) ) expected: list[dict[str, Any]] = [] for member in members: artifact = self.session.get(ModelArtifact, member.artifact_id) if artifact is None: continue expected.append( { "artifact_id": str(artifact.id), "filename": artifact.filename, "sha256": artifact.sha256, "size_bytes": artifact.size_bytes, "security_status": artifact.security_status, } ) recovery_class = ( ArtifactRecoveryClass.REHYDRATABLE if repository and commit_sha else ArtifactRecoveryClass.NON_REHYDRATABLE ) record = ArtifactRecoveryOperation( restore_operation_id=request.restore_operation_id, artifact_set_id=artifact_set.id, model_revision_id=revision.id, recovery_class=recovery_class.value, state=ArtifactRecoveryState.PLANNED.value, upstream_repository=repository, upstream_commit_sha=commit_sha, target_storage_root_id=storage_root.id, expected_files=sorted(expected, key=lambda item: item["filename"]), verified_files=[], bytes_total=sum(int(item["size_bytes"]) for item in expected), lineage=_artifact_lineage(self.session, artifact_set, revision), reason=request.reason, created_by=request.created_by, ) if recovery_class is ArtifactRecoveryClass.NON_REHYDRATABLE: record.state = ArtifactRecoveryState.BLOCKED.value record.failure_code = RecoveryFailureCode.ARTIFACT_NOT_REHYDRATABLE.value record.failure_reason = ( "artifact set has no exact upstream revision; it must be protected by a payload " "backup rather than rehydration" ) self.session.add(record) self.session.flush() self.audit.write( "ARTIFACT_REHYDRATION_STARTED", AUDIT_RESOURCE, str(record.id), { "artifact_set_id": str(artifact_set.id), "recovery_class": recovery_class.value, "commit_sha": commit_sha, "file_count": len(expected), }, ) self.session.commit() return self._artifact_response(record) def record_artifact_recovery( self, operation_id: uuid.UUID, *, state: ArtifactRecoveryState, verified_files: list[dict[str, Any]], bytes_recovered: int, duration_seconds: float | None, download_plan_id: uuid.UUID | None = None, artifact_job_id: uuid.UUID | None = None, failure_code: str | None = None, failure_reason: str | None = None, ) -> ArtifactRecoveryResponse: record = self.session.get(ArtifactRecoveryOperation, operation_id) if record is None: raise RecoveryError( 404, "artifact_recovery_not_found", "artifact recovery operation does not exist" ) expected = {item["filename"]: item for item in record.expected_files or []} mismatches = [ item["filename"] for item in verified_files if item["filename"] in expected and item.get("sha256") != expected[item["filename"]]["sha256"] ] if mismatches and state is ArtifactRecoveryState.RECOVERED: state = ArtifactRecoveryState.FAILED failure_code = RecoveryFailureCode.ARTIFACT_HASH_MISMATCH.value failure_reason = f"rehydrated files did not match provenance: {', '.join(mismatches)}" record.state = state.value record.verified_files = verified_files record.bytes_recovered = bytes_recovered record.duration_seconds = duration_seconds record.download_plan_id = download_plan_id record.artifact_job_id = artifact_job_id record.failure_code = failure_code record.failure_reason = failure_reason record.completed_at = _now() if state in { ArtifactRecoveryState.RECOVERED, ArtifactRecoveryState.FAILED, ArtifactRecoveryState.BLOCKED, } else None self.audit.write( "ARTIFACT_REHYDRATED" if state is ArtifactRecoveryState.RECOVERED else "RESTORE_FAILED", AUDIT_RESOURCE, str(record.id), { "artifact_set_id": str(record.artifact_set_id), "state": state.value, "bytes_recovered": bytes_recovered, "failure_code": failure_code, }, outcome="success" if state is ArtifactRecoveryState.RECOVERED else "failure", ) self.session.commit() return self._artifact_response(record) def artifact_recoveries(self, limit: int = 100) -> list[ArtifactRecoveryResponse]: return [ self._artifact_response(item) for item in self.session.scalars( select(ArtifactRecoveryOperation) .order_by(ArtifactRecoveryOperation.started_at.desc()) .limit(limit) ) ] def _artifact_response(self, record: ArtifactRecoveryOperation) -> ArtifactRecoveryResponse: return ArtifactRecoveryResponse( id=record.id, restore_operation_id=record.restore_operation_id, artifact_set_id=record.artifact_set_id, model_revision_id=record.model_revision_id, recovery_class=ArtifactRecoveryClass(record.recovery_class), state=ArtifactRecoveryState(record.state), upstream_repository=record.upstream_repository, upstream_commit_sha=record.upstream_commit_sha, target_storage_root_id=record.target_storage_root_id, expected_files=list(record.expected_files or []), verified_files=list(record.verified_files or []), bytes_total=record.bytes_total, bytes_recovered=record.bytes_recovered, download_plan_id=record.download_plan_id, artifact_job_id=record.artifact_job_id, lineage=dict(record.lineage or {}), duration_seconds=record.duration_seconds, failure_code=record.failure_code, failure_reason=record.failure_reason, started_at=record.started_at, completed_at=record.completed_at, ) # ------------------------------------------------------------------ readiness def fingerprint(self) -> dict[str, Any]: return fingerprint_session(self.session) def readiness(self) -> list[RecoveryReadinessEntry]: latest = self._latest_verified_backup() entries: list[RecoveryReadinessEntry] = [] for record in self.session.scalars( select(RecoveryAssetRecord).order_by( RecoveryAssetRecord.asset_class, RecoveryAssetRecord.key ) ): declared = RecoveryReadiness(record.readiness) asset_class = RecoveryAssetClass(record.asset_class) readiness = declared detail = record.notes or record.restore_method if asset_class is RecoveryAssetClass.AUTHORITATIVE: if latest is None: readiness = RecoveryReadiness.UNPROTECTED detail = "no verified backup exists yet" else: detail = f"protected by verified backup {latest.backup_id}" entries.append( RecoveryReadinessEntry( asset_key=record.key, asset_name=record.name, asset_class=asset_class, readiness=readiness, policy_key=record.policy_revision.key, rpo_seconds=record.rpo_seconds, detail=detail, ) ) return entries def _latest_verified_backup(self) -> BackupSet | None: return self.session.scalar( select(BackupSet) .where(BackupSet.state == BackupState.VERIFIED.value) .order_by(BackupSet.verified_at.desc()) .limit(1) ) def dashboard(self) -> RecoveryDashboard: now = _now() latest = self._latest_verified_backup() verified_at = _aware(latest.verified_at) if latest else None age = (now - verified_at).total_seconds() if verified_at else None states: dict[str, int] = {} for state, count in self.session.execute( select(BackupSet.state, func.count()).group_by(BackupSet.state) ): states[str(state)] = int(count) readiness = self.readiness() unprotected = [ item.asset_key for item in readiness if item.readiness is RecoveryReadiness.UNPROTECTED ] protected = sum( item.readiness in { RecoveryReadiness.PROTECTED, RecoveryReadiness.REHYDRATABLE, RecoveryReadiness.EXTERNAL_DEPENDENCY, } for item in readiness ) last_rehearsal = self.session.scalar( select(RestoreOperation).order_by(RestoreOperation.started_at.desc()).limit(1) ) capacity = self.estimate_capacity() artifact_bytes = int( self.session.scalar(select(func.coalesce(func.sum(ModelArtifact.size_bytes), 0))) or 0 ) return RecoveryDashboard( observed_at=now, point_in_time_support=POINT_IN_TIME_SUPPORT, latest_verified_backup_id=latest.backup_id if latest else None, latest_verified_backup_at=verified_at, latest_verified_backup_age_seconds=age, latest_verified_schema_revision=latest.schema_revision if latest else None, backup_states=states, verified_backup_count=states.get(BackupState.VERIFIED.value, 0), stale_backup=age is None or age > self.settings.backup_stale_after_seconds, backup_staleness_threshold_seconds=self.settings.backup_stale_after_seconds, last_restore_rehearsal_at=_aware(last_rehearsal.started_at) if last_rehearsal else None, last_restore_rehearsal_state=last_rehearsal.state if last_rehearsal else None, observed_restore_seconds=last_rehearsal.rto_seconds if last_rehearsal else None, observed_rpo_seconds=last_rehearsal.rpo_seconds if last_rehearsal else None, protected_asset_count=protected, unprotected_assets=unprotected, readiness=readiness, coverage_ratio=round(protected / len(readiness), 4) if readiness else 0.0, estimated_protected_bytes=capacity.estimated_protected_bytes, estimated_rehydratable_bytes=artifact_bytes, destination_capacity_bytes=capacity.capacity_bytes, destination_free_bytes=capacity.available_bytes, ) def _RECONCILE_MARKER_QUERY_MARKER() -> str: """The exactly-once reconciliation marker is scoped to one restore operation id.""" return ( "select count(*) from audit_events where action = 'RECOVERY_RECONCILIATION_COMPLETED' " "and resource_id = ''" ) def _verification_failure( code: RecoveryFailureCode, reason: str, checks: dict[str, str] ) -> dict[str, Any]: return { "ok": False, "checks": checks, "failure_code": code.value, "failure_reason": reason, "verified_at": None, } def _public_verification(details: dict[str, Any] | None) -> dict[str, Any]: """Fingerprints are large; the API exposes their digests rather than every table row hash.""" payload = dict(details or {}) source = payload.get("source_fingerprint") if isinstance(source, dict): payload["source_fingerprint"] = _fingerprint_summary(source) return payload def _fingerprint_summary(fingerprint: dict[str, Any] | None) -> dict[str, Any]: payload = dict(fingerprint or {}) if not payload: return {} groups = payload.get("groups", {}) return { "version": payload.get("version"), "digest": payload.get("digest"), "row_total": payload.get("row_total"), "groups": { key: {"digest": value.get("digest"), "row_count": value.get("row_count")} for key, value in groups.items() }, } def _same_database(left: str, right: str) -> bool: try: first = parse_target(left) second = parse_target(right) except PostgresToolError: return left.strip() == right.strip() return (first.host, first.port, first.database) == (second.host, second.port, second.database) def _revision_identity(session: Session, revision: ModelRevision) -> tuple[str | None, str | None]: """Rehydration needs the exact upstream repository and commit, never a floating ref.""" commit_sha: str | None = revision.resolved_commit_sha or None snapshot = session.scalar( select(UpstreamSnapshot) .where( UpstreamSnapshot.model_id == revision.model_id, UpstreamSnapshot.resolved_commit_sha == revision.resolved_commit_sha, ) .order_by(UpstreamSnapshot.observed_at.desc()) .limit(1) ) if snapshot is not None: return snapshot.repository_id, commit_sha or snapshot.resolved_commit_sha model = session.get(Model, revision.model_id) return (model.upstream_source if model else None), commit_sha def _artifact_lineage( session: Session, artifact_set: ArtifactSet, revision: ModelRevision ) -> dict[str, Any]: derived = [ { "derived_artifact_id": str(link.derived_artifact_id), "source_artifact_id": str(link.source_artifact_id), "source_sha256": link.source_sha256, } for link in session.scalars( select(DerivedArtifactSource).join( ModelArtifact, ModelArtifact.id == DerivedArtifactSource.source_artifact_id ).where(ModelArtifact.revision_id == revision.id) ) ] return { "artifact_set_id": str(artifact_set.id), "artifact_set_variant": artifact_set.variant_key, "snapshot_id": str(artifact_set.snapshot_id), "model_revision_id": str(revision.id), "model_id": str(revision.model_id), "upstream_revision": revision.upstream_revision, "resolved_commit_sha": revision.resolved_commit_sha, "derived_lineage": derived, "lineage_preserved": True, } _REVISION_PATTERN = re.compile(r"^revision(?::\s*str)?\s*=\s*[\"']([^\"']+)[\"']") _DOWN_REVISION_PATTERN = re.compile( r"^down_revision(?::[^=]*)?\s*=\s*(?:[\"']([^\"']+)[\"']|None)" ) def _alembic_root(configured: Path | None = None) -> Path | None: """Locate the shipped Alembic tree in both the source checkout and the installed image.""" candidates: list[Path] = [] if configured is not None: candidates.append(Path(configured)) candidates.extend( [ Path.cwd() / "alembic", Path(__file__).resolve().parents[3] / "alembic", Path(__file__).resolve().parents[4] / "backend" / "alembic", Path("/app/alembic"), ] ) for candidate in candidates: if (candidate / "versions").is_dir(): return candidate return None @lru_cache(maxsize=8) def _known_alembic_revisions(root: Path | None = None) -> tuple[str, ...]: """The Alembic chain this build ships, oldest first, walked by down_revision. Filename order is not migration order, and a backup taken from a newer build must be recognised as SCHEMA_TOO_NEW rather than silently accepted. An empty chain means the build cannot prove compatibility at all, which is itself a fail-closed condition. """ resolved = _alembic_root(root) if resolved is None: return () directory = resolved / "versions" if not directory.is_dir(): return () parents: dict[str, str | None] = {} for path in sorted(directory.glob("*.py")): revision: str | None = None down: str | None = None for line in path.read_text("utf-8").splitlines(): match = _REVISION_PATTERN.match(line) if match and revision is None: revision = match.group(1) continue down_match = _DOWN_REVISION_PATTERN.match(line) if down_match and revision is not None: down = down_match.group(1) break if revision: parents[revision] = down children = {down: revision for revision, down in parents.items() if down is not None} ordered: list[str] = [] current = next((revision for revision, down in parents.items() if down is None), None) while current is not None and current not in ordered: ordered.append(current) current = children.get(current) if len(ordered) != len(parents): # pragma: no cover - a broken chain is a packaging error return tuple(sorted(parents)) return tuple(ordered) def _run_alembic_upgrade(database_url: str, root: Path | None = None) -> None: """Alembic is imported lazily so it stays out of the request hot path.""" from alembic import command, config resolved = _alembic_root(root) if resolved is None: raise RecoveryError( 500, RecoveryFailureCode.SCHEMA_UNKNOWN.value, "this ModelForge build ships no Alembic tree and cannot migrate a restored database", ) alembic_config = config.Config(str(resolved.parent / "alembic.ini")) alembic_config.set_main_option("script_location", str(resolved)) alembic_config.set_main_option("sqlalchemy.url", database_url) command.upgrade(alembic_config, "head") def _git(*args: str) -> str | None: import subprocess root = Path(__file__).resolve().parents[4] try: completed = subprocess.run( # noqa: S603 - fixed argv, no shell ["git", "-C", str(root), *args], # noqa: S607 - git resolves from PATH capture_output=True, text=True, timeout=15, check=False, ) except (FileNotFoundError, OSError, ValueError): return None value = (completed.stdout or "").strip() return value if completed.returncode == 0 and value else None def _source_commit() -> str | None: return os.environ.get("MODELFORGE_SOURCE_COMMIT") or _git("rev-parse", "HEAD") def _source_reference() -> str | None: return os.environ.get("MODELFORGE_SOURCE_REFERENCE") or _git( "rev-parse", "--abbrev-ref", "HEAD" ) def _source_repository() -> str | None: return os.environ.get("MODELFORGE_SOURCE_REPOSITORY") or _git( "config", "--get", "remote.origin.url" ) _DEFAULT_POLICIES: tuple[RecoveryPolicyCreate, ...] = ( RecoveryPolicyCreate( key="control-plane.database", name="Control-plane PostgreSQL", asset_class=RecoveryAssetClass.AUTHORITATIVE, backup_method=BackupMethod.POSTGRES_LOGICAL_CUSTOM, retention_days=30, minimum_verified_backups=2, rpo_seconds=86_400, rto_target_seconds=3_600, restore_verification="FULL_RESTORE", encryption_required=True, rationale=( "Provenance, lifecycle, migration, approval and audit truth exist nowhere else. " "A consistent pg_dump snapshot bounds data loss to the backup interval and every " "backup must be restorable into a fresh database before it counts." ), created_by="modelforge", ), RecoveryPolicyCreate( key="artifacts.rehydratable", name="Rehydratable Hugging Face artifacts", asset_class=RecoveryAssetClass.REBUILDABLE, backup_method=BackupMethod.MANIFEST_ONLY, retention_days=30, minimum_verified_backups=2, rpo_seconds=None, rto_target_seconds=7_200, restore_verification="MANIFEST_ONLY", encryption_required=False, rehydration_allowed=True, rationale=( "Model weights are large and reproducible from an exact upstream commit plus the " "recorded per-file SHA-256. Recovery redownloads into quarantine and re-runs the " "supply-chain checks rather than copying hundreds of gigabytes on every backup." ), created_by="modelforge", ), RecoveryPolicyCreate( key="artifacts.non-rehydratable", name="Local and derived artifacts", asset_class=RecoveryAssetClass.AUTHORITATIVE, backup_method=BackupMethod.FILE_COPY, retention_days=30, minimum_verified_backups=1, rpo_seconds=604_800, rto_target_seconds=7_200, restore_verification="HASH_ONLY", encryption_required=True, rationale=( "Artifacts without an exact upstream identity, including derived and quantized " "outputs, cannot be redownloaded and therefore require a payload backup that " "preserves their lineage back to the exact source artifacts." ), created_by="modelforge", ), RecoveryPolicyCreate( key="runtime.ephemeral", name="Queues, leases and runtime residency", asset_class=RecoveryAssetClass.EPHEMERAL, backup_method=BackupMethod.NOT_BACKED_UP, retention_days=1, minimum_verified_backups=1, rpo_seconds=None, rto_target_seconds=300, restore_verification="NOT_APPLICABLE", encryption_required=False, rationale=( "Redis queues, GPU leases, residency and NVML telemetry describe the present, not " "history. After a restore they must be re-measured; restoring them would present a " "stale snapshot as current truth and could replay finished work." ), created_by="modelforge", ), RecoveryPolicyCreate( key="external.projects", name="External project and workload state", asset_class=RecoveryAssetClass.EXTERNAL, backup_method=BackupMethod.NOT_BACKED_UP, retention_days=1, minimum_verified_backups=1, rpo_seconds=None, rto_target_seconds=None, restore_verification="NOT_APPLICABLE", encryption_required=False, external_dependency=True, rationale=( "ExampleRAG Qdrant, ExampleVision, Ollama, Plex, Tdarr and Gitea are owned by their own " "projects. ModelForge records the dependency and never claims to back them up or " "modifies them during a recovery." ), created_by="modelforge", ), RecoveryPolicyCreate( key="secrets.credentials", name="Node, project and operator credentials", asset_class=RecoveryAssetClass.SECRET, backup_method=BackupMethod.MANIFEST_ONLY, retention_days=30, minimum_verified_backups=1, rpo_seconds=None, rto_target_seconds=1_800, restore_verification="NOT_APPLICABLE", encryption_required=True, secret_class=SecretRecoveryClass.ROTATABLE_SECRET, rationale=( "ModelForge stores only credential hashes. A restore reinstates the hashes as " "authoritative state, but plaintext cannot be recovered: consumers rotate. Backup " "encryption keys and operator break-glass secrets are never written into a backup." ), created_by="modelforge", ), RecoveryPolicyCreate( key="configuration.source-controlled", name="Source-controlled configuration and deployment manifests", asset_class=RecoveryAssetClass.REBUILDABLE, backup_method=BackupMethod.SOURCE_CONTROL_REFERENCE, retention_days=30, minimum_verified_backups=1, rpo_seconds=None, rto_target_seconds=900, restore_verification="HASH_ONLY", encryption_required=False, rehydration_allowed=True, rationale=( "Compose files, capability/project manifests and policy files live in Git. The " "backup records the canonical repository, commit and per-file hashes so a rebuild " "can prove it started from the same configuration." ), created_by="modelforge", ), ) _DEFAULT_ASSETS: tuple[dict[str, Any], ...] = ( { "key": "postgres.modelforge", "name": "ModelForge PostgreSQL database", "policy_key": "control-plane.database", "owner": "modelforge-control-plane", "location": "postgres:5432/modelforge", "restore_method": "pg_restore of a verified custom-format dump into a fresh database", "rebuild_method": None, "readiness": RecoveryReadiness.PROTECTED.value, "dependencies": ["backup.destination"], "notes": "Provenance, lifecycle, migration, approvals, audit and observability history.", }, { "key": "artifacts.huggingface", "name": "Hugging Face model artifacts", "policy_key": "artifacts.rehydratable", "owner": "node-agent", "location": "GPU Node storage roots under the configured artifact root", "restore_method": "exact-revision redownload into quarantine, verify, then promote", "rebuild_method": "ModelRevision + commit SHA + expected file list + SHA-256", "readiness": RecoveryReadiness.REHYDRATABLE.value, "dependencies": ["postgres.modelforge", "external.huggingface"], "notes": "Never rehydrated from a floating revision; upstream outage reports blocked.", }, { "key": "artifacts.derived", "name": "Derived and local-only artifacts", "policy_key": "artifacts.non-rehydratable", "owner": "node-agent", "location": "GPU Node storage roots", "restore_method": "payload restore preserving derived lineage to exact source artifacts", "rebuild_method": "re-derivation from the exact source artifacts and transformation identity", "readiness": RecoveryReadiness.PROTECTED.value, "dependencies": ["postgres.modelforge"], "notes": "No derived artifact may lose its lineage during a rebuild.", }, { "key": "redis.queues", "name": "Redis queues and transient payloads", "policy_key": "runtime.ephemeral", "owner": "modelforge-control-plane", "location": "redis:6379/0", "restore_method": "not restored; queues start empty and leases are reconciled", "rebuild_method": "reconciliation from authoritative serving state", "readiness": RecoveryReadiness.PROTECTED.value, "dependencies": [], "notes": "Contains no authoritative state; a restore must not replay stale jobs.", }, { "key": "runtime.residency", "name": "GPU leases, residency and NVML telemetry", "policy_key": "runtime.ephemeral", "owner": "scheduler", "location": "control-plane database current-truth tables", "restore_method": "cleared during reconciliation and re-measured from live GPU Node state", "rebuild_method": "Node Agent inventory and NVML polling", "readiness": RecoveryReadiness.PROTECTED.value, "dependencies": ["node.gpu_node"], "notes": "Restoring a stale NVML snapshot as current truth is explicitly forbidden.", }, { "key": "node.gpu_node", "name": "GPU Node Node Agent identity and credential", "policy_key": "secrets.credentials", "owner": "node-agent", "location": "compute_nodes / node_credentials (hashes only)", "restore_method": "restored credential hash stays valid, or revoke and re-enroll", "rebuild_method": "operator-issued enrollment token and a new node credential", "readiness": RecoveryReadiness.ROTATION_REQUIRED.value, "dependencies": ["postgres.modelforge"], "notes": "Persisted node identity prevents a duplicate node claiming the same hardware.", }, { "key": "credentials.project", "name": "Project gateway credentials", "policy_key": "secrets.credentials", "owner": "modelforge-control-plane", "location": "service_clients / service_credentials (hashes only)", "restore_method": "hashes restore as authoritative state; plaintext is unrecoverable", "rebuild_method": "issue a new scoped credential to the consuming project", "readiness": RecoveryReadiness.ROTATION_REQUIRED.value, "dependencies": ["postgres.modelforge"], "notes": "A consumer that lost its secret needs a new one; the hash alone cannot serve it.", }, { "key": "credentials.operator", "name": "Operator API key and backup encryption key", "policy_key": "secrets.credentials", "owner": "operator", "location": "deployment secret mounts and environment", "restore_method": "never included in a backup; supplied by the operator during recovery", "rebuild_method": "break-glass bootstrap documented in the control-plane loss runbook", "readiness": RecoveryReadiness.ROTATION_REQUIRED.value, "dependencies": [], "notes": "There is no default administrator password anywhere in ModelForge.", }, { "key": "configuration.repository", "name": "ModelForge source and deployment manifests", "policy_key": "configuration.source-controlled", "owner": "operator", "location": "canonical Gitea repository", "restore_method": "checkout of the exact commit recorded in the backup set", "rebuild_method": "git clone of the canonical repository at the recorded commit", "readiness": RecoveryReadiness.REHYDRATABLE.value, "dependencies": ["external.gitea"], "notes": "ModelForge does not claim to protect Gitea itself; that is platform operations.", }, { "key": "external.examplerag", "name": "ExampleRAG Qdrant collections and aliases", "policy_key": "external.projects", "owner": "examplerag", "location": "ExampleRAG-owned Qdrant instance", "restore_method": "owned by ExampleRAG; ModelForge recovery never writes to it", "rebuild_method": "ExampleRAG reindex using a ModelForge capability", "readiness": RecoveryReadiness.EXTERNAL_DEPENDENCY.value, "dependencies": [], "notes": "A ModelForge restore must leave the production alias untouched.", }, { "key": "external.examplevision", "name": "ExampleVision recognition state", "policy_key": "external.projects", "owner": "examplevision", "location": "ExampleVision-owned services", "restore_method": "owned by ExampleVision; ModelForge recovery never mutates it", "rebuild_method": "ExampleVision-owned procedures", "readiness": RecoveryReadiness.EXTERNAL_DEPENDENCY.value, "dependencies": [], "notes": "Production recognizer and VISION_AUTO_ACCEPT stay unchanged during recovery.", }, { "key": "external.huggingface", "name": "Hugging Face upstream availability", "policy_key": "external.projects", "owner": "huggingface", "location": "https://huggingface.co", "restore_method": "not owned by ModelForge", "rebuild_method": None, "readiness": RecoveryReadiness.EXTERNAL_DEPENDENCY.value, "dependencies": [], "notes": "If upstream is unavailable, rehydration reports ARTIFACT_REHYDRATION_BLOCKED.", }, { "key": "external.gitea", "name": "Canonical Gitea repository host", "policy_key": "external.projects", "owner": "platform-operations", "location": "ssh://git@192.0.2.10:222", "restore_method": "platform operations; a working clone plus remote suffices for DR drills", "rebuild_method": None, "readiness": RecoveryReadiness.EXTERNAL_DEPENDENCY.value, "dependencies": [], "notes": "ModelForge documents this dependency instead of pretending to protect Gitea.", }, { "key": "backup.destination", "name": "Backup destination root", "policy_key": "configuration.source-controlled", "owner": "operator", "location": "configured MODELFORGE_BACKUP_ROOT", "restore_method": "operator-provided storage; paths are allowlisted and traversal-safe", "rebuild_method": "provision new storage and re-run a verified backup", "readiness": RecoveryReadiness.PROTECTED.value, "dependencies": [], "notes": "No developer path is hardcoded; the root is deployment configuration.", }, )