feat(store): миграции схемы v1, репозиторий с аудитом, атомарный apply/rollback (#20)
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""Атомарное применение состояния: ``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
|
||||
Reference in New Issue
Block a user