"""Атомарное применение состояния: ``validate -> stage -> apply -> rollback`` (issue #20). Гарантии: * **validate** — несостоятельное желаемое состояние не доходит до БД; * **stage** — перед мутацией сохраняется снимок текущего состояния (durable, в отдельной таблице ``state_snapshots``), поэтому откат возможен в любой момент; * **apply** — мутация БД и запись в ``audit_log`` выполняются в одной транзакции, внешнее применение (iptables/awg) вызывается внутри неё: падение внешнего применителя откатывает транзакцию, то есть БД возвращается к предыдущему состоянию целиком, без частичных изменений; * **rollback** — явный возврат к снимку по id. Ошибка применения поднимает :class:`ApplyError` (поле ``restored=True``): API должен ответить ошибкой, не «полуприменённой» конфигурацией. """ from __future__ import annotations from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass, field from typing import Any, Protocol, runtime_checkable from . import audit, state, validation from .errors import ApplyError, ValidationError from .models import ( AUDIT_STATUS_FAILED, AUDIT_STATUS_OK, AUDIT_STATUS_STAGED, Entity, GeoIpSchedule, Mode, Rule, RuleException, ) from .repository import Store from .state import StateSnapshot @dataclass(frozen=True, slots=True) class DesiredState: """Полное желаемое состояние шлюза (то, что применяется как единое целое).""" mode: Mode rules: tuple[Rule, ...] = () geoip: GeoIpSchedule = field(default_factory=GeoIpSchedule) exceptions: tuple[RuleException, ...] = () def to_payload(self) -> dict[str, Any]: return { "mode": self.mode.value, "geoip": self.geoip.as_dict(), "rules": [ { "name": rule.name, "kind": rule.kind, "match": rule.match, "target": rule.target, "priority": rule.priority, "enabled": rule.enabled, } for rule in self.rules ], "exceptions": [ { "match": exception.match, "reason": exception.reason, "enabled": exception.enabled, } for exception in self.exceptions ], } @classmethod def from_payload(cls, payload: Mapping[str, Any]) -> DesiredState: """Разобрать желаемое состояние из JSON-совместимого словаря.""" raw_rules = payload.get("rules", []) if not isinstance(raw_rules, (list, tuple)): raise ValidationError("rules", "ожидается список правил") rules = tuple(_rule_from_mapping(item) for item in raw_rules) raw_exceptions = payload.get("exceptions", []) if not isinstance(raw_exceptions, (list, tuple)): raise ValidationError("exceptions", "ожидается список исключений") exceptions = tuple(_exception_from_mapping(item) for item in raw_exceptions) geoip_raw = payload.get("geoip") or {} if not isinstance(geoip_raw, Mapping): raise ValidationError("geoip", "ожидается объект расписания") geoip = GeoIpSchedule( enabled=bool(geoip_raw.get("enabled", True)), cron=str(geoip_raw.get("cron", "0 4 * * *")), provider=str(geoip_raw.get("provider", "ripe")), ) return cls( mode=validation.validate_mode(str(payload.get("mode", Mode.OFF.value))), rules=rules, geoip=geoip, exceptions=exceptions, ) def _rule_from_mapping(item: Any) -> Rule: if not isinstance(item, Mapping): raise ValidationError("rules", f"ожидается объект правила, получено {item!r}") try: return Rule( name=str(item["name"]), kind=str(item["kind"]), match=str(item["match"]), target=str(item["target"]), priority=int(item.get("priority", 100)), enabled=bool(item.get("enabled", True)), ) except KeyError as exc: raise ValidationError("rules", f"в правиле нет обязательного поля {exc.args[0]!r}") from exc def _exception_from_mapping(item: Any) -> RuleException: if not isinstance(item, Mapping): raise ValidationError("exceptions", f"ожидается объект исключения, получено {item!r}") try: return RuleException( match=str(item["match"]), reason=str(item.get("reason", "")), enabled=bool(item.get("enabled", True)), ) except KeyError as exc: raise ValidationError( "exceptions", f"в исключении нет обязательного поля {exc.args[0]!r}" ) from exc @runtime_checkable class Applier(Protocol): """Внешний применитель состояния (awg/iptables/ipset).""" def apply(self, payload: Mapping[str, Any]) -> None: """Применить состояние во внешней подсистеме. Падение -> ApplyError.""" def revert(self, payload: Mapping[str, Any]) -> None: """Вернуть внешнюю подсистему к состоянию ``payload`` при откате.""" @dataclass(frozen=True, slots=True) class StagedPlan: """Промежуточный план: снимок предыдущего состояния + желаемое состояние.""" plan_id: int desired: DesiredState previous: dict[str, Any] actor: str reason: str @dataclass(frozen=True, slots=True) class ApplyResult: """Успешный результат применения.""" ok: bool plan_id: int audit_id: int message: str class NullApplier: """Заглушка внешнего применителя (для тестов и dry-run режима).""" def apply(self, payload: Mapping[str, Any]) -> None: return None def revert(self, payload: Mapping[str, Any]) -> None: return None class ApplyEngine: """Оркестратор ``validate -> stage -> apply -> rollback``.""" def __init__( self, store: Store, applier: Applier | None = None, *, actor: str = "system" ) -> None: self._store = store self._applier: Applier = applier if applier is not None else NullApplier() self._actor = actor # -- стадии --------------------------------------------------------- def validate(self, desired: DesiredState) -> DesiredState: """Проверить желаемое состояние, не касаясь БД.""" validation.validate_mode(desired.mode) validation.validate_cron(desired.geoip.cron) validation.validate_provider(desired.geoip.provider) seen: set[str] = set() for rule in desired.rules: validation.validate_name("name", rule.name) kind = validation.validate_rule_kind(rule.kind) validation.validate_match(kind, rule.match) validation.validate_target(rule.target) validation.validate_priority(rule.priority) if rule.name in seen: raise ValidationError("rules", f"дублирующееся имя правила: {rule.name!r}") seen.add(rule.name) for exception in desired.exceptions: validation.validate_exception_match(exception.match) return desired def stage(self, desired: DesiredState, *, reason: str = "") -> StagedPlan: """Проверить состояние и сохранить снимок предыдущего состояния.""" self.validate(desired) payload = desired.to_payload() with self._store.database.transaction() as conn: previous = state.capture_state(conn) plan_id = state.save_snapshot(conn, previous, reason=reason or "pre-apply") audit.record( conn, actor=self._actor, action="stage", entity=Entity.APPLY.value, entity_id=plan_id, old_value=previous, new_value=payload, status=AUDIT_STATUS_STAGED, detail=reason, ) return StagedPlan( plan_id=plan_id, desired=desired, previous=previous, actor=self._actor, reason=reason ) def apply(self, plan: StagedPlan) -> ApplyResult: """Применить план. При падении — откатить БД и внешнюю подсистему. :raises ApplyError: если применитель упал; ``restored=True`` означает, что предыдущее состояние подтверждённо восстановлено. """ payload = plan.desired.to_payload() audit_id: int try: with self._store.database.transaction() as conn: state.restore_state(conn, payload) self._applier.apply(payload) audit_id = audit.record( conn, actor=plan.actor, action="apply", entity=Entity.APPLY.value, entity_id=plan.plan_id, old_value=plan.previous, new_value=payload, status=AUDIT_STATUS_OK, detail=plan.reason, ) except Exception as exc: restored_ok = self._rollback_failed_apply(plan, exc) raise ApplyError( f"применение состояния не удалось: {exc}", restored=restored_ok, audit_id=self._last_failed_audit_id(plan), ) from exc return ApplyResult( ok=True, plan_id=plan.plan_id, audit_id=audit_id, message="состояние применено" ) def apply_payload(self, payload: Mapping[str, Any], *, reason: str = "") -> ApplyResult: """validate + stage + apply одной командой (для сервисного слоя).""" desired = self.validate(DesiredState.from_payload(payload)) return self.apply(self.stage(desired, reason=reason)) def rollback(self, plan_id: int, *, reason: str = "") -> StateSnapshot: """Явно вернуть состояние к снимку плана.""" with self._store.database.transaction() as conn: return state.rollback_to_snapshot(conn, plan_id, actor=self._actor, reason=reason) # -- внутреннее ----------------------------------------------------- def _rollback_failed_apply(self, plan: StagedPlan, exc: Exception) -> bool: """Вернуть внешнюю подсистему и убедиться, что БД = предыдущее состояние.""" with suppress(Exception): # откат внешней системы — best-effort self._applier.revert(plan.previous) with self._store.database.transaction() as conn: state.restore_state(conn, plan.previous) audit.record( conn, actor=plan.actor, action="apply", entity=Entity.APPLY.value, entity_id=plan.plan_id, old_value=plan.desired.to_payload(), new_value=plan.previous, status=AUDIT_STATUS_FAILED, detail=f"{type(exc).__name__}: {exc}", ) return state.capture_state(self._store.raw()) == plan.previous def _last_failed_audit_id(self, plan: StagedPlan) -> int | None: entries = audit.list_entries( self._store.raw(), entity=Entity.APPLY.value, entity_id=plan.plan_id, limit=1 ) if not entries: return None last = entries[0] return last.id if last.status == AUDIT_STATUS_FAILED else None