From 916326578269ff9e5a40fba3bfc1f55aa1593a6e Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:42:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(store):=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B/?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8/=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=81=D0=BB=D0=BE=D1=8F=20=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/awg_api/store/__init__.py | 103 +++++++++++++++++ backend/awg_api/store/clock.py | 16 +++ backend/awg_api/store/errors.py | 33 ++++++ backend/awg_api/store/models.py | 186 ++++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 backend/awg_api/store/__init__.py create mode 100644 backend/awg_api/store/clock.py create mode 100644 backend/awg_api/store/errors.py create mode 100644 backend/awg_api/store/models.py diff --git a/backend/awg_api/store/__init__.py b/backend/awg_api/store/__init__.py new file mode 100644 index 0000000..c13009f --- /dev/null +++ b/backend/awg_api/store/__init__.py @@ -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", +] diff --git a/backend/awg_api/store/clock.py b/backend/awg_api/store/clock.py new file mode 100644 index 0000000..0b3f33d --- /dev/null +++ b/backend/awg_api/store/clock.py @@ -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") diff --git a/backend/awg_api/store/errors.py b/backend/awg_api/store/errors.py new file mode 100644 index 0000000..4e2c752 --- /dev/null +++ b/backend/awg_api/store/errors.py @@ -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 diff --git a/backend/awg_api/store/models.py b/backend/awg_api/store/models.py new file mode 100644 index 0000000..f095955 --- /dev/null +++ b/backend/awg_api/store/models.py @@ -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, + }