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:
@ -1,3 +1,3 @@
|
||||
"""onGuard24 — модульный монолит (ядро + модули)."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
@ -14,19 +14,3 @@ async def create_pool(settings: Settings) -> asyncpg.Pool | None:
|
||||
return None
|
||||
dsn = normalize_dsn(settings.database_url.strip())
|
||||
return await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=10)
|
||||
|
||||
|
||||
MIGRATION_001 = """
|
||||
CREATE TABLE IF NOT EXISTS ingress_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source text NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
body jsonb NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ingress_events_received_at_idx ON ingress_events (received_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
async def migrate(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(MIGRATION_001)
|
||||
|
||||
23
onguard24/domain/__init__.py
Normal file
23
onguard24/domain/__init__.py
Normal 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",
|
||||
]
|
||||
59
onguard24/domain/entities.py
Normal file
59
onguard24/domain/entities.py
Normal 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)
|
||||
64
onguard24/domain/events.py
Normal file
64
onguard24/domain/events.py
Normal 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)
|
||||
@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import HTMLResponse, Response
|
||||
|
||||
from onguard24.config import get_settings
|
||||
from onguard24.db import create_pool, migrate
|
||||
from onguard24.db import create_pool
|
||||
from onguard24.ingress import grafana as grafana_ingress
|
||||
from onguard24.modules import contacts, schedules, statusboard
|
||||
from onguard24.root_html import render_root_page
|
||||
@ -32,8 +32,6 @@ def parse_addr(http_addr: str) -> tuple[str, int]:
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
pool = await create_pool(settings)
|
||||
if pool:
|
||||
await migrate(pool)
|
||||
app.state.pool = pool
|
||||
app.state.settings = settings
|
||||
log.info("onGuard24 started, db=%s", "ok" if pool else "disabled")
|
||||
|
||||
Reference in New Issue
Block a user