v1.1.0: Alembic, pytest, домен и документация

- Миграции PostgreSQL через Alembic; DDL убран из lifespan приложения.
- Тесты: health, status, ingress Grafana; моки Vault/Grafana/Forgejo.
- Пакет onguard24/domain/ (сущности, шина событий), docs/DOMAIN.md.
- Обновлены README, CHANGELOG, ARCHITECTURE.

Made-with: Cursor
This commit is contained in:
Alexandr
2026-04-03 08:36:35 +03:00
parent 4da9b13a86
commit 85eb61b576
21 changed files with 611 additions and 32 deletions

View File

@ -0,0 +1,23 @@
"""Доменные сущности и шина событий (задел под модули)."""
from onguard24.domain.entities import Alert, EscalationPolicy, EscalationStep, Incident, Severity
from onguard24.domain.events import (
AlertReceived,
DomainEvent,
EventBus,
InMemoryEventBus,
Module,
)
__all__ = [
"Severity",
"Alert",
"Incident",
"EscalationPolicy",
"EscalationStep",
"DomainEvent",
"AlertReceived",
"Module",
"EventBus",
"InMemoryEventBus",
]

View File

@ -0,0 +1,59 @@
"""Сущности домена (пока без таблиц БД — контракт для следующих версий)."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
class Severity(str, Enum):
"""Грубая шкала для алертов и инцидентов."""
INFO = "info"
WARNING = "warning"
CRITICAL = "critical"
class Alert(BaseModel):
"""Нормализованный алерт после парсинга ingress (Grafana и др.)."""
id: UUID = Field(default_factory=uuid4)
source: str = Field(..., description="grafana, manual, …")
external_ref: str | None = Field(None, description="uid правила, fingerprint")
title: str = ""
severity: Severity = Severity.WARNING
labels: dict[str, str] = Field(default_factory=dict)
payload: dict[str, Any] = Field(default_factory=dict)
received_at: datetime | None = None
class Incident(BaseModel):
"""Инцидент в продукте (отдельно от сырого ingress_events)."""
id: UUID = Field(default_factory=uuid4)
title: str = ""
status: str = Field("open", description="open, acknowledged, resolved, …")
severity: Severity = Severity.WARNING
alert_ids: list[UUID] = Field(default_factory=list)
created_at: datetime | None = None
updated_at: datetime | None = None
class EscalationStep(BaseModel):
"""Один шаг цепочки (уведомление, пауза, повтор)."""
order: int = 0
kind: str = Field(..., description="notify, wait, repeat, …")
config: dict[str, Any] = Field(default_factory=dict)
class EscalationPolicy(BaseModel):
"""Политика эскалации, привязанная к команде/сервису."""
id: UUID = Field(default_factory=uuid4)
name: str = ""
steps: list[EscalationStep] = Field(default_factory=list)

View File

@ -0,0 +1,64 @@
"""События домена и подписка модулей (задел; пока in-memory)."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Protocol
from uuid import UUID
from onguard24.domain.entities import Alert
@dataclass
class DomainEvent:
"""Базовый тип события."""
name: str = "domain.generic"
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class AlertReceived(DomainEvent):
"""Алерт принят в систему (после нормализации)."""
name: str = "alert.received"
alert: Alert | None = None
raw_payload_ref: UUID | None = None
Handler = Callable[[DomainEvent], Awaitable[None]]
class Module(Protocol):
"""Модуль (schedules, contacts, …) может подписаться на события."""
@property
def name(self) -> str: ...
async def on_event(self, event: DomainEvent) -> None: ...
class EventBus(Protocol):
async def publish(self, event: DomainEvent) -> None: ...
def subscribe(self, event_name: str, handler: Handler) -> None: ...
class InMemoryEventBus:
"""Простая шина для тестов и раннего прототипа."""
def __init__(self) -> None:
self._subs: dict[str, list[Handler]] = {}
def subscribe(self, event_name: str, handler: Handler) -> None:
self._subs.setdefault(event_name, []).append(handler)
async def publish(self, event: DomainEvent) -> None:
for h in self._subs.get(event.name, []):
await h(event)
async def publish_alert_received(self, alert: Alert, raw_payload_ref: UUID | None = None) -> None:
ev = AlertReceived(alert=alert, raw_payload_ref=raw_payload_ref)
await self.publish(ev)