3209 lines
133 KiB
Python
3209 lines
133 KiB
Python
"""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 = '<restore_operation_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.",
|
|
},
|
|
)
|