from __future__ import annotations import hashlib import json import logging from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum from typing import Any logger = logging.getLogger(__name__) class AtomicWritePhase(StrEnum): PLAN = "plan" PROJECT = "project" VALIDATE = "validate" APPLY = "apply" @dataclass class ConfigFileSnapshot: path: str raw: str config_hash: str @classmethod def capture(cls, path: str, raw: str) -> ConfigFileSnapshot: config_hash = hashlib.sha256(raw.encode()).hexdigest()[:12] return cls(path=path, raw=raw, config_hash=config_hash) @dataclass class AtomicWritePlan: phase: AtomicWritePhase = AtomicWritePhase.PLAN target_state: dict[str, Any] = field(default_factory=dict) changes: list[dict[str, Any]] = field(default_factory=list) issues: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) snapshots: dict[str, ConfigFileSnapshot] = field(default_factory=dict) dry_run: bool = True @property def ok(self) -> bool: return len(self.issues) == 0 @dataclass class AtomicWriteResult: success: bool phase: AtomicWritePhase issues: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) applied_changes: int = 0 @classmethod def ok(cls, phase: AtomicWritePhase, applied: int = 0, warnings: list[str] | None = None): return cls(success=True, phase=phase, applied_changes=applied, warnings=warnings or []) @classmethod def failed(cls, phase: AtomicWritePhase, issues: list[str], warnings: list[str] | None = None): return cls(success=False, phase=phase, issues=issues, warnings=warnings or []) async def atomic_write_config( plan: AtomicWritePlan, apply_fn: Callable[[dict[str, Any]], Any], validate_fn: Callable[[dict[str, Any]], list[str]] | None = None, capture_fn: Callable[[], dict[str, ConfigFileSnapshot]] | None = None, restore_fn: Callable[[dict[str, ConfigFileSnapshot]], Any] | None = None, ) -> AtomicWriteResult: """Plan → Project → Validate → Apply with snapshot/rollback.""" # Phase 1: Plan — generate target state plan.phase = AtomicWritePhase.PLAN if not plan.target_state: return AtomicWriteResult.failed(AtomicWritePhase.PLAN, ["No target state provided"]) # Phase 2: Project — simulate changes, capture snapshots plan.phase = AtomicWritePhase.PROJECT if capture_fn: plan.snapshots = capture_fn() # Phase 3: Validate — pre-check target state plan.phase = AtomicWritePhase.VALIDATE if validate_fn: validation_issues = validate_fn(plan.target_state) if validation_issues: plan.issues.extend(validation_issues) return AtomicWriteResult.failed( AtomicWritePhase.VALIDATE, validation_issues, plan.warnings, ) if plan.dry_run: logger.info( "AtomicWrite: dry-run complete, planned %d changes, %d issues, %d warnings", len(plan.changes), len(plan.issues), len(plan.warnings), ) return AtomicWriteResult.ok(AtomicWritePhase.VALIDATE, warnings=plan.warnings) # Phase 4: Apply — execute with rollback on failure plan.phase = AtomicWritePhase.APPLY try: await _maybe_await(apply_fn, plan.target_state) applied = len(plan.changes) logger.info("AtomicWrite: applied %d changes successfully", applied) return AtomicWriteResult.ok(AtomicWritePhase.APPLY, applied=applied, warnings=plan.warnings) except Exception as e: logger.exception("AtomicWrite: apply failed, rolling back") apply_issues: list[str] = [f"Apply failed: {e}"] if restore_fn and plan.snapshots: try: await _maybe_await(restore_fn, plan.snapshots) logger.info("AtomicWrite: rollback successful") except Exception as rollback_err: logger.exception("AtomicWrite: rollback failed: %s", rollback_err) apply_issues.insert(0, f"Rollback failed: {rollback_err}") return AtomicWriteResult.failed(AtomicWritePhase.APPLY, apply_issues, plan.warnings) async def _maybe_await(fn: Callable, *args, **kwargs) -> Any: import inspect result = fn(*args, **kwargs) if inspect.isawaitable(result): return await result return result def capture_db_snapshot(config: dict[str, Any]) -> ConfigFileSnapshot: raw = json.dumps(config, sort_keys=True, default=str, ensure_ascii=False) config_hash = hashlib.sha256(raw.encode()).hexdigest()[:12] return ConfigFileSnapshot(path="db:channel_config", raw=raw, config_hash=config_hash)