Sprint 0 / #20: хранилище состояния — SQLite WAL, миграции, audit log, атомарный apply/rollback #50
@@ -0,0 +1,103 @@
|
|||||||
|
"""Слой хранения состояния awg-dash (issue #20).
|
||||||
|
|
||||||
|
Состав: SQLite (WAL) в ``/data/awg-dash.db``, версионированные миграции,
|
||||||
|
проверка схемы при старте, журнал аудита «кто/когда/что/old→new» и движок
|
||||||
|
атомарного применения ``validate -> stage -> apply -> rollback``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .apply import (
|
||||||
|
Applier,
|
||||||
|
ApplyEngine,
|
||||||
|
ApplyResult,
|
||||||
|
DesiredState,
|
||||||
|
NullApplier,
|
||||||
|
StagedPlan,
|
||||||
|
)
|
||||||
|
from .audit import count as audit_count
|
||||||
|
from .audit import last_entry as audit_last_entry
|
||||||
|
from .audit import list_entries as list_audit_entries
|
||||||
|
from .audit import record as record_audit
|
||||||
|
from .db import DB_PATH_ENV, DEFAULT_DB_PATH, Database, resolve_db_path
|
||||||
|
from .errors import ApplyError, SchemaError, StorageError, ValidationError
|
||||||
|
from .migrations import (
|
||||||
|
EXPECTED_TABLES,
|
||||||
|
LATEST_VERSION,
|
||||||
|
MIGRATIONS,
|
||||||
|
SCHEMA_MIGRATIONS,
|
||||||
|
Migration,
|
||||||
|
MigrationReport,
|
||||||
|
current_version,
|
||||||
|
migrate,
|
||||||
|
verify_schema,
|
||||||
|
)
|
||||||
|
from .models import (
|
||||||
|
AUDIT_STATUS_FAILED,
|
||||||
|
AUDIT_STATUS_OK,
|
||||||
|
AUDIT_STATUS_STAGED,
|
||||||
|
DEVICE_POLICIES,
|
||||||
|
RULE_KINDS,
|
||||||
|
AuditEntry,
|
||||||
|
ConfigMeta,
|
||||||
|
Device,
|
||||||
|
Entity,
|
||||||
|
GeoIpSchedule,
|
||||||
|
Mode,
|
||||||
|
Rule,
|
||||||
|
RuleException,
|
||||||
|
)
|
||||||
|
from .repository import Store
|
||||||
|
from .service import Storage, open_storage, rollback_to
|
||||||
|
from .state import StateSnapshot, capture_state, restore_state, rollback_to_snapshot
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AUDIT_STATUS_FAILED",
|
||||||
|
"AUDIT_STATUS_OK",
|
||||||
|
"AUDIT_STATUS_STAGED",
|
||||||
|
"DB_PATH_ENV",
|
||||||
|
"DEFAULT_DB_PATH",
|
||||||
|
"DEVICE_POLICIES",
|
||||||
|
"EXPECTED_TABLES",
|
||||||
|
"LATEST_VERSION",
|
||||||
|
"MIGRATIONS",
|
||||||
|
"RULE_KINDS",
|
||||||
|
"SCHEMA_MIGRATIONS",
|
||||||
|
"Applier",
|
||||||
|
"ApplyEngine",
|
||||||
|
"ApplyError",
|
||||||
|
"ApplyResult",
|
||||||
|
"AuditEntry",
|
||||||
|
"ConfigMeta",
|
||||||
|
"Database",
|
||||||
|
"DesiredState",
|
||||||
|
"Device",
|
||||||
|
"Entity",
|
||||||
|
"GeoIpSchedule",
|
||||||
|
"Migration",
|
||||||
|
"MigrationReport",
|
||||||
|
"Mode",
|
||||||
|
"NullApplier",
|
||||||
|
"Rule",
|
||||||
|
"RuleException",
|
||||||
|
"SchemaError",
|
||||||
|
"StagedPlan",
|
||||||
|
"StateSnapshot",
|
||||||
|
"Storage",
|
||||||
|
"StorageError",
|
||||||
|
"Store",
|
||||||
|
"ValidationError",
|
||||||
|
"audit_count",
|
||||||
|
"audit_last_entry",
|
||||||
|
"capture_state",
|
||||||
|
"current_version",
|
||||||
|
"list_audit_entries",
|
||||||
|
"migrate",
|
||||||
|
"open_storage",
|
||||||
|
"record_audit",
|
||||||
|
"resolve_db_path",
|
||||||
|
"restore_state",
|
||||||
|
"rollback_to",
|
||||||
|
"rollback_to_snapshot",
|
||||||
|
"verify_schema",
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Источник времени для audit_log и снимков состояния.
|
||||||
|
|
||||||
|
Отдельный модуль — чтобы тесты могли подменять время детерминированно
|
||||||
|
(``monkeypatch.setattr(clock, "utcnow", ...)``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
__all__ = ["utcnow"]
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> str:
|
||||||
|
"""Текущее время в UTC: ISO-8601, миллисекунды, суффикс ``Z``."""
|
||||||
|
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Иерархия ошибок хранилища awg-dash."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class StorageError(RuntimeError):
|
||||||
|
"""Базовая ошибка слоя хранения состояния."""
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaError(StorageError):
|
||||||
|
"""Схема БД не соответствует ожидаемой версии (или требует отката)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(StorageError):
|
||||||
|
"""Данные не прошли валидацию до применения (стадия validate)."""
|
||||||
|
|
||||||
|
def __init__(self, field: str, message: str) -> None:
|
||||||
|
super().__init__(f"{field}: {message}")
|
||||||
|
self.field = field
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyError(StorageError):
|
||||||
|
"""Ошибка применения конфигурации.
|
||||||
|
|
||||||
|
``restored`` — предыдущее состояние подтверждённо восстановлено,
|
||||||
|
``audit_id`` — id записи в audit_log, статус ``failed``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, restored: bool, audit_id: int | None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.restored = restored
|
||||||
|
self.audit_id = audit_id
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Модель данных хранилища awg-dash (issue #20).
|
||||||
|
|
||||||
|
Каждая сущность описана неизменяемым dataclass-ом; идентификатор ``None``
|
||||||
|
означает «ещё не сохранено». Строки времени — UTC ISO-8601, суффикс ``Z``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
RULE_KINDS: tuple[str, ...] = ("domain", "cidr", "ipset")
|
||||||
|
|
||||||
|
DEVICE_POLICIES: tuple[str, ...] = ("default", "tunnel", "direct", "block")
|
||||||
|
|
||||||
|
AUDIT_STATUS_OK = "ok"
|
||||||
|
AUDIT_STATUS_FAILED = "failed"
|
||||||
|
AUDIT_STATUS_STAGED = "staged"
|
||||||
|
|
||||||
|
|
||||||
|
class Mode(StrEnum):
|
||||||
|
"""Режим маршрутизации шлюза."""
|
||||||
|
|
||||||
|
OFF = "off"
|
||||||
|
SPLIT = "split"
|
||||||
|
FULL = "full"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def values(cls) -> tuple[str, ...]:
|
||||||
|
return tuple(member.value for member in cls)
|
||||||
|
|
||||||
|
|
||||||
|
class Entity(StrEnum):
|
||||||
|
"""Имена сущностей для audit_log и снимков состояния."""
|
||||||
|
|
||||||
|
RULE = "rule"
|
||||||
|
EXCEPTION = "exception"
|
||||||
|
MODE = "mode"
|
||||||
|
GEOIP_SCHEDULE = "geoip_schedule"
|
||||||
|
CONFIG = "config"
|
||||||
|
DEVICE = "device"
|
||||||
|
APPLY = "apply"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Rule:
|
||||||
|
"""Правило маршрутизации (domain / cidr / ipset -> target)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
match: str
|
||||||
|
target: str
|
||||||
|
priority: int = 100
|
||||||
|
enabled: bool = True
|
||||||
|
id: int | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"kind": self.kind,
|
||||||
|
"match": self.match,
|
||||||
|
"target": self.target,
|
||||||
|
"priority": self.priority,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuleException:
|
||||||
|
"""Исключение из правил (host/domain, который никогда не маршрутизируется в туннель)."""
|
||||||
|
|
||||||
|
match: str
|
||||||
|
reason: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
id: int | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"match": self.match,
|
||||||
|
"reason": self.reason,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GeoIpSchedule:
|
||||||
|
"""Расписание обновления GeoIP-наборов."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
cron: str = "0 4 * * *"
|
||||||
|
provider: str = "ripe"
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"cron": self.cron,
|
||||||
|
"provider": self.provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConfigMeta:
|
||||||
|
"""Метаданные сгенерированного конфига (сам файл живёт в /config)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
version: int = 1
|
||||||
|
checksum: str = ""
|
||||||
|
size_bytes: int = 0
|
||||||
|
applied: bool = False
|
||||||
|
id: int | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"path": self.path,
|
||||||
|
"version": self.version,
|
||||||
|
"checksum": self.checksum,
|
||||||
|
"size_bytes": self.size_bytes,
|
||||||
|
"applied": self.applied,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Device:
|
||||||
|
"""Известный клиент шлюза."""
|
||||||
|
|
||||||
|
mac: str
|
||||||
|
name: str
|
||||||
|
address: str = ""
|
||||||
|
policy: str = "default"
|
||||||
|
last_seen: str | None = None
|
||||||
|
id: int | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"mac": self.mac,
|
||||||
|
"name": self.name,
|
||||||
|
"address": self.address,
|
||||||
|
"policy": self.policy,
|
||||||
|
"last_seen": self.last_seen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuditEntry:
|
||||||
|
"""Запись журнала «кто/когда/что/old->new»."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
ts: str
|
||||||
|
actor: str
|
||||||
|
action: str
|
||||||
|
entity: str
|
||||||
|
entity_id: str | None
|
||||||
|
old_value: Any
|
||||||
|
new_value: Any
|
||||||
|
status: str
|
||||||
|
detail: str | None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"ts": self.ts,
|
||||||
|
"actor": self.actor,
|
||||||
|
"action": self.action,
|
||||||
|
"entity": self.entity,
|
||||||
|
"entity_id": self.entity_id,
|
||||||
|
"old_value": self.old_value,
|
||||||
|
"new_value": self.new_value,
|
||||||
|
"status": self.status,
|
||||||
|
"detail": self.detail,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user