Закрывает замечания ревью 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
This commit is contained in:
@@ -12,13 +12,15 @@
|
||||
* **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 contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
@@ -229,6 +231,15 @@ class ApplyEngine:
|
||||
|
||||
: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
|
||||
@@ -247,8 +258,10 @@ class ApplyEngine:
|
||||
status=AUDIT_STATUS_OK,
|
||||
detail=plan.reason,
|
||||
)
|
||||
except Exception as exc:
|
||||
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,
|
||||
@@ -269,10 +282,22 @@ class ApplyEngine:
|
||||
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
|
||||
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(
|
||||
@@ -284,14 +309,16 @@ class ApplyEngine:
|
||||
old_value=plan.desired.to_payload(),
|
||||
new_value=plan.previous,
|
||||
status=AUDIT_STATUS_FAILED,
|
||||
detail=f"{type(exc).__name__}: {exc}",
|
||||
detail=detail,
|
||||
)
|
||||
return state.capture_state(self._store.raw()) == plan.previous
|
||||
with self._store.database.read() as conn:
|
||||
return state.capture_state(conn) == 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
|
||||
)
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user