feat(store): схемы/модели/ошибки слоя хранения (#20)

This commit is contained in:
2026-10-21 23:42:32 +03:00
parent f7936fa882
commit 9163265782
4 changed files with 338 additions and 0 deletions
+103
View File
@@ -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",
]
+16
View File
@@ -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")
+33
View File
@@ -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
+186
View File
@@ -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,
}