Files
awg-dash/backend/awg_api/store/apply.py
T
embeddevio 249f32e3f9 fix(#20): потокобезопасные чтения, BaseException в apply, old_value внутри транзакции (#54, #55)
Закрывает замечания ревью QA по PR #50:

* #54: все чтения Store идут через Database.read() под тем же RLock, что
  транзакции; контракт «одно соединение, все операции под локом»
  задокументирован в db.py и README модуля. Устраняет TypeError/IndexError
  на общем соединении, грязные чтения и выдачу состояния, которое затем
  откатывается падающим apply.
* #55 D-1: apply ловит BaseException — отмена/CancelledError/
  KeyboardInterrupt вызывают Applier.revert(previous) и пишут failed в
  audit_log (без «висящей» записи staged); падение revert попадает в detail.
* #55 D-2: set_geoip_schedule читает old_value внутри той же транзакции.

Refs #20, Refs #54, Refs #55
2026-10-28 21:12:09 +03:00

326 lines
14 KiB
Python

"""Атомарное применение состояния: ``validate -> stage -> apply -> rollback`` (issue #20).
Гарантии:
* **validate** — несостоятельное желаемое состояние не доходит до БД;
* **stage** — перед мутацией сохраняется снимок текущего состояния (durable,
в отдельной таблице ``state_snapshots``), поэтому откат возможен в любой момент;
* **apply** — мутация БД и запись в ``audit_log`` выполняются в одной транзакции,
внешнее применение (iptables/awg) вызывается внутри неё: падение внешнего
применителя откатывает транзакцию, то есть БД возвращается к предыдущему
состоянию целиком, без частичных изменений;
* **rollback** — явный возврат к снимку по id.
Ошибка применения поднимает :class:`ApplyError` (поле ``restored=True``): API
должен ответить ошибкой, не «полуприменённой» конфигурацией. Ловится
``BaseException`` (#55 D-1), поэтому отмена запроса тоже вызывает
``Applier.revert(previous)`` и закрывает запись аудита исходом ``failed`` —
«висящих» записей ``staged`` без исхода не остаётся.
"""
from __future__ import annotations
from collections.abc import Mapping
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`` означает,
что предыдущее состояние подтверждённо восстановлено.
Ловится ``BaseException``, не только ``Exception`` (#55 D-1): отмена
запроса (``asyncio.CancelledError``), ``KeyboardInterrupt`` и
``SystemExit`` тоже обязаны вернуть внешнюю подсистему в предыдущее
состояние и закрыть запись аудита исходом ``failed``. Иначе iptables
останется в новом состоянии, тогда как ``audit_log`` сохранит
«висящую» запись ``staged`` без исхода. Исключения из ``Exception``
преобразуются в :class:`ApplyError`, остальные пробрасываются как есть
(семантика отмены сохраняется).
"""
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 BaseException as exc:
restored_ok = self._rollback_failed_apply(plan, exc)
if not isinstance(exc, Exception):
raise
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: BaseException) -> bool:
"""Вернуть внешнюю подсистему и убедиться, что БД = предыдущее состояние.
Откат внешней подсистемы — best-effort: падение ``revert`` не мешает
восстановить БД и записать исход в журнал (причина падения revert
попадает в ``detail``), и наоборот — падение revert не должно скрывать
исходное исключение.
"""
revert_error: BaseException | None = None
try:
self._applier.revert(plan.previous)
except BaseException as revert_exc: # откат best-effort: исход важнее падения revert
revert_error = revert_exc
detail = f"{type(exc).__name__}: {exc}"
if revert_error is not None:
detail += f"; revert: {type(revert_error).__name__}: {revert_error}"
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=detail,
)
with self._store.database.read() as conn:
return state.capture_state(conn) == plan.previous
def _last_failed_audit_id(self, plan: StagedPlan) -> int | None:
with self._store.database.read() as conn:
entries = audit.list_entries(
conn, 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