Files

175 lines
6.2 KiB
Python

"""Снимки состояния и восстановление после отката (rollback, issue #20).
Снимок — это JSON-копия состояния (режим, расписание GeoIP, правила,
исключения) на момент перед применением. Снимок сохраняется ДО мутации,
поэтому даже при неудачном apply (внешний iptables упал) есть источник для
восстановления.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from typing import Any
from . import audit
from .clock import utcnow
from .errors import StorageError
from .models import Entity
@dataclass(frozen=True, slots=True)
class StateSnapshot:
"""Сохранённый снимок состояния."""
id: int
entity: str
entity_id: str
created_at: str
payload: dict[str, Any]
reason: str
def capture_state(conn: sqlite3.Connection, *, include_exceptions: bool = True) -> dict[str, Any]:
"""Снять текущее состояние в виде JSON-совместимого словаря."""
mode_row = conn.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
geoip_row = conn.execute(
"SELECT enabled, cron, provider FROM geoip_schedule WHERE id = 1"
).fetchone()
rules = [
{
"name": str(row["name"]),
"kind": str(row["kind"]),
"match": str(row["match"]),
"target": str(row["target"]),
"priority": int(row["priority"]),
"enabled": bool(row["enabled"]),
}
for row in conn.execute(
"SELECT name, kind, match, target, priority, enabled FROM routing_rules"
" ORDER BY priority, id"
).fetchall()
]
payload: dict[str, Any] = {
"mode": "off" if mode_row is None else str(mode_row["mode"]),
"geoip": {
"enabled": True if geoip_row is None else bool(geoip_row["enabled"]),
"cron": "0 4 * * *" if geoip_row is None else str(geoip_row["cron"]),
"provider": "ripe" if geoip_row is None else str(geoip_row["provider"]),
},
"rules": rules,
}
if include_exceptions:
payload["exceptions"] = [
{
"match": str(row["match"]),
"reason": str(row["reason"]),
"enabled": bool(row["enabled"]),
}
for row in conn.execute(
"SELECT match, reason, enabled FROM rule_exceptions ORDER BY id"
).fetchall()
]
return payload
def save_snapshot(
conn: sqlite3.Connection,
payload: dict[str, Any],
*,
entity: str = Entity.APPLY.value,
entity_id: str = "state",
reason: str = "",
) -> int:
"""Сохранить снимок состояния и вернуть идентификатор."""
cursor = conn.execute(
"INSERT INTO state_snapshots (entity, entity_id, created_at, payload, reason)"
" VALUES (?, ?, ?, ?, ?)",
(entity, entity_id, utcnow(), json.dumps(payload, sort_keys=True), reason),
)
return int(cursor.lastrowid or 0)
def load_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> StateSnapshot:
"""Прочитать снимок по id."""
row = conn.execute("SELECT * FROM state_snapshots WHERE id = ?", (snapshot_id,)).fetchone()
if row is None:
raise StorageError(f"снимок состояния {snapshot_id} не найден")
return StateSnapshot(
id=int(row["id"]),
entity=str(row["entity"]),
entity_id=str(row["entity_id"]),
created_at=str(row["created_at"]),
payload=json.loads(str(row["payload"])),
reason=str(row["reason"]),
)
def restore_state(conn: sqlite3.Connection, payload: dict[str, Any]) -> None:
"""Полностью заменить изменяемые таблицы состоянием из снимка."""
conn.execute("DELETE FROM routing_rules")
for rule in payload.get("rules", []):
conn.execute(
"INSERT INTO routing_rules (name, kind, match, target, priority, enabled,"
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
rule["name"],
rule["kind"],
rule["match"],
rule["target"],
int(rule["priority"]),
1 if rule["enabled"] else 0,
utcnow(),
utcnow(),
),
)
if "exceptions" in payload:
conn.execute("DELETE FROM rule_exceptions")
for exception in payload["exceptions"]:
conn.execute(
"INSERT INTO rule_exceptions (match, reason, enabled, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?)",
(
exception["match"],
exception.get("reason", ""),
1 if exception["enabled"] else 0,
utcnow(),
utcnow(),
),
)
geoip = payload.get("geoip", {})
conn.execute(
"UPDATE routing_mode SET mode = ?, updated_at = ? WHERE id = 1",
(payload.get("mode", "off"), utcnow()),
)
conn.execute(
"UPDATE geoip_schedule SET enabled = ?, cron = ?, provider = ?,"
" updated_at = ? WHERE id = 1",
(
1 if geoip.get("enabled", True) else 0,
geoip.get("cron", "0 4 * * *"),
geoip.get("provider", "ripe"),
utcnow(),
),
)
def rollback_to_snapshot(
conn: sqlite3.Connection, snapshot_id: int, *, actor: str, reason: str = ""
) -> StateSnapshot:
"""Восстановить состояние из снимка и записать это в журнал аудита."""
snapshot = load_snapshot(conn, snapshot_id)
audit.record(
conn,
actor=actor,
action="rollback",
entity=snapshot.entity,
entity_id=snapshot.entity_id,
old_value=capture_state(conn),
new_value=snapshot.payload,
detail=reason or f"rollback к снимку #{snapshot.id}",
)
restore_state(conn, snapshot.payload)
return snapshot