From 85eb61b576998dc5649dfc20a49918528109d06f Mon Sep 17 00:00:00 2001 From: Alexandr Date: Fri, 3 Apr 2026 08:36:35 +0300 Subject: [PATCH] =?UTF-8?q?v1.1.0:=20Alembic,=20pytest,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Миграции PostgreSQL через Alembic; DDL убран из lifespan приложения. - Тесты: health, status, ingress Grafana; моки Vault/Grafana/Forgejo. - Пакет onguard24/domain/ (сущности, шина событий), docs/DOMAIN.md. - Обновлены README, CHANGELOG, ARCHITECTURE. Made-with: Cursor --- CHANGELOG.md | 17 +++++ README.md | 30 +++++++-- alembic.ini | 40 ++++++++++++ alembic/env.py | 57 +++++++++++++++++ alembic/script.py.mako | 26 ++++++++ .../versions/001_initial_ingress_events.py | 40 ++++++++++++ docs/ARCHITECTURE.md | 19 ++++-- docs/DOMAIN.md | 34 ++++++++++ onguard24/__init__.py | 2 +- onguard24/db.py | 16 ----- onguard24/domain/__init__.py | 23 +++++++ onguard24/domain/entities.py | 59 +++++++++++++++++ onguard24/domain/events.py | 64 +++++++++++++++++++ onguard24/main.py | 4 +- pyproject.toml | 12 +++- pytest.ini | 5 ++ tests/conftest.py | 39 +++++++++++ tests/test_domain.py | 22 +++++++ tests/test_health.py | 13 ++++ tests/test_ingress.py | 60 +++++++++++++++++ tests/test_status.py | 61 ++++++++++++++++++ 21 files changed, 611 insertions(+), 32 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/001_initial_ingress_events.py create mode 100644 docs/DOMAIN.md create mode 100644 onguard24/domain/__init__.py create mode 100644 onguard24/domain/entities.py create mode 100644 onguard24/domain/events.py create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/test_domain.py create mode 100644 tests/test_health.py create mode 100644 tests/test_ingress.py create mode 100644 tests/test_status.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cba109e..882f45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md). +## [1.1.0] — 2026-04-03 + +Инфраструктура разработки и задел под домен IRM. + +### Добавлено + +- **Миграции:** Alembic (`alembic.ini`, `alembic/env.py`, ревизии в `alembic/versions/`). Начальная схема: таблица `ingress_events` (как раньше в коде). Команда: `alembic upgrade head`. DDL при старте приложения убран — только пул asyncpg. +- **Тесты:** `pytest`, `pytest-asyncio`, моки интеграций; тесты API: `/health`, `/api/v1/status`, `POST /api/v1/ingress/grafana` (в т.ч. секрет webhook). Установка: `pip install -e ".[dev]"`. +- **Домен (задел):** пакет `onguard24/domain/` — сущности `Alert`, `Incident`, эскалация; `EventBus` / `InMemoryEventBus`, протокол `Module` для подписки на события. Описание: [docs/DOMAIN.md](docs/DOMAIN.md). + +### Зависимости + +- Прод: `sqlalchemy`, `alembic`, `psycopg[binary]` (для CLI миграций). +- Dev (optional): `pytest`, `pytest-asyncio`, `respx`. + +Тег в репозитории (после публикации): `v1.1.0`. + ## [1.0.0] — 2026-04-03 Первый зафиксированный релиз **каркаса** (scaffold). diff --git a/README.md b/README.md index a4fa915..f92ebc7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # onGuard24 -**Версия: 1.0.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. +**Версия: 1.1.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. | Документ | Назначение | |----------|------------| @@ -8,6 +8,7 @@ | [docs/VERSIONING.md](docs/VERSIONING.md) | Теги, откат к предыдущей версии | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Структура кода, куда что класть | | [docs/AI_CONTEXT.md](docs/AI_CONTEXT.md) | Краткий контекст для доработок | +| [docs/DOMAIN.md](docs/DOMAIN.md) | Сущности (инцидент, алерт, эскалация), шина событий | **Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24) @@ -16,13 +17,13 @@ ## Что уже есть (функционал v1) - Запуск HTTP API (`uvicorn`), конфиг из `.env`. -- **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana. +- **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana; схема через **Alembic** (отдельные ревизии в `alembic/versions/`). - **POST `/api/v1/ingress/grafana`** — приём JSON алерта (опционально защита `X-OnGuard-Secret`). - **GET `/`**, **GET `/api/v1/status`** — проверки: БД, Vault, Grafana (service account), Forgejo (PAT). - **Модули-заглушки:** `/api/v1/modules/schedules|contacts|statusboard/`. - **Фронт (опционально):** `web/` — Vite + React, прокси на API. -Чего **ещё нет** (следующие версии): Alembic, авторизация API, доменная модель инцидентов, эскалации, фоновые задачи. +Чего **ещё нет** (следующие версии): авторизация публичных API (кроме секрета webhook), полноценная бизнес-логика IRM в коде (эскалации, дежурства, светофор), фоновые задачи. Доменные сущности и задел под модули описаны в [docs/DOMAIN.md](docs/DOMAIN.md). ## Быстрый старт @@ -33,7 +34,11 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e . ``` -Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). +Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). Перед первым запуском с БД примените миграции: + +```bash +alembic upgrade head +``` ```bash python -m uvicorn onguard24.main:app --reload --host 0.0.0.0 --port 8080 @@ -73,3 +78,20 @@ cd web && npm install && npm run dev ``` Vite проксирует `/api` на `http://127.0.0.1:8080` (см. `web/vite.config.ts`). + +## Миграции БД (Alembic) + +- URL БД: переменная **`DATABASE_URL`** (как у приложения; в `alembic/env.py` используется синхронный драйвер `postgresql+psycopg`). +- Применить схему: `alembic upgrade head`. +- Новая ревизия: `alembic revision -m "описание"` и правка файла в `alembic/versions/`. + +Приложение **не** выполняет DDL при старте — только пул соединений. + +## Тесты + +```bash +pip install -e ".[dev]" +pytest +``` + +Покрытие: `/health`, `/api/v1/status`, webhook Grafana; внешние вызовы (Vault, Grafana, Forgejo) в тестах статуса подменяются моками. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..371d60d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..990ae2b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,57 @@ +"""Alembic: синхронный движок SQLAlchemy + psycopg3 (отдельно от asyncpg в рантайме).""" + +from __future__ import annotations + +import os +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from dotenv import load_dotenv +from sqlalchemy import create_engine, pool + +ROOT = Path(__file__).resolve().parent.parent +load_dotenv(ROOT / ".env") + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = None + + +def get_sync_url() -> str: + url = os.environ.get("DATABASE_URL", "").strip() + if not url: + raise RuntimeError("Задай DATABASE_URL для alembic upgrade") + if url.startswith("postgres://"): + url = url.replace("postgres://", "postgresql://", 1) + if url.startswith("postgresql://") and "+psycopg" not in url and "+asyncpg" not in url: + url = url.replace("postgresql://", "postgresql+psycopg://", 1) + if "+asyncpg" in url: + url = url.replace("+asyncpg", "+psycopg") + return url + + +def run_migrations_offline() -> None: + context.configure( + url=get_sync_url(), + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = create_engine(get_sync_url(), poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure(connection=connection) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_initial_ingress_events.py b/alembic/versions/001_initial_ingress_events.py new file mode 100644 index 0000000..00d7250 --- /dev/null +++ b/alembic/versions/001_initial_ingress_events.py @@ -0,0 +1,40 @@ +"""initial ingress_events + +Revision ID: 001_initial +Revises: +Create Date: 2026-04-03 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + 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 + ); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS ingress_events_received_at_idx + ON ingress_events (received_at DESC); + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ingress_events_received_at_idx;") + op.execute("DROP TABLE IF EXISTS ingress_events;") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de76744..dd87d93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,15 +1,18 @@ # Архитектура onGuard24 (для разработки и доработок) -Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1**: HTTP, БД, ingress Grafana, проверки интеграций. +Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1.1**: HTTP, БД, ingress Grafana, проверки интеграций, Alembic, задел домена в `onguard24/domain/`. ## Дерево пакетов ``` onGuard24/ +├── alembic/ # Ревизии миграций PostgreSQL (Alembic) +├── alembic.ini ├── onguard24/ │ ├── main.py # FastAPI app, lifespan, маршруты верхнего уровня │ ├── config.py # Settings: .env из корня репозитория (не от cwd) -│ ├── db.py # asyncpg pool, миграция ingress_events +│ ├── db.py # asyncpg pool (без DDL) +│ ├── domain/ # Сущности и шина событий (задел под модули) │ ├── status_snapshot.py # Единый сборщик JSON для /api/v1/status │ ├── root_html.py # HTML главной страницы со статусами │ ├── vaultcheck.py # Vault /v1/sys/health @@ -20,6 +23,8 @@ onGuard24/ │ └── modules/ # Заглушки: schedules, contacts, statusboard ├── web/ # Vite + React (опционально) ├── pyproject.toml +├── pytest.ini +├── tests/ # pytest: health, status, ingress ├── CHANGELOG.md └── docs/ ``` @@ -35,8 +40,8 @@ onGuard24/ | Задача | Место | |--------|--------| | Новый HTTP-роут модуля | `onguard24/modules/.py` + `include_router` в `main.py` | -| Общая логика инцидентов / событий | позже: `onguard24/core/` или сервисный слой + события из БД | -| Новая таблица БД | пока: SQL в `db.py` (MIGRATION_00N); позже: Alembic | +| Общая логика инцидентов / событий | задел: `onguard24/domain/` + [DOMAIN.md](DOMAIN.md); позже сервисный слой и БД | +| Новая таблица БД | Alembic: `alembic revision`, правка `alembic/versions/`, `alembic upgrade head` | | Новая внешняя интеграция | `onguard24/integrations/.py`, вызов из `status_snapshot` при необходимости | ## Конфигурация @@ -46,10 +51,10 @@ onGuard24/ ## Зависимости между компонентами - `status_snapshot.build(request)` читает `request.app.state.pool` и `request.app.state.settings` (устанавливаются в `lifespan`). -- Модули **не** зависят друг от друга; общий контракт позже можно ввести через **таблицы БД** и **внутренние события** (ещё не реализовано). +- Модули **не** зависят друг от друга; контракт заделан через **доменные события** (`domain/events.py`, `EventBus`) и описан в [DOMAIN.md](DOMAIN.md); проводка в HTTP пока не подключена. -## Известные ограничения v1 +## Известные ограничения -- Нет единой модели «инцидент» в БД — только сырой ingest в `ingress_events`. +- Нет единой модели «инцидент» в БД — только сырой ingest в `ingress_events` (в коде есть Pydantic-модели в `domain/entities.py` как задел). - Нет очереди/воркеров для эскалаций. - Нет auth на GET `/api/v1/status` (только для внутренней сети / за reverse proxy с ограничением). diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md new file mode 100644 index 0000000..481b699 --- /dev/null +++ b/docs/DOMAIN.md @@ -0,0 +1,34 @@ +# Доменная модель onGuard24 + +Версия **1.1.0** вводит явные сущности и задел под **события** между модулями. Таблицы БД для инцидентов пока не добавлены — см. [Alembic](../alembic/versions/). + +## Сущности (код: `onguard24/domain/entities.py`) + +| Сущность | Назначение | +|----------|------------| +| **Alert** | Нормализованный алерт после парсинга webhook (Grafana и др.): `severity`, `labels`, `payload`. | +| **Incident** | Жизненный цикл инцидента: статус, связь с алертами (`alert_ids`). | +| **EscalationPolicy** / **EscalationStep** | Цепочка эскалаций (уведомления, паузы) — задел под модуль schedules/IRM. | + +**Severity** — перечисление: `info`, `warning`, `critical`. + +## События (код: `onguard24/domain/events.py`) + +| Событие | Когда | +|---------|--------| +| **AlertReceived** (`name=alert.received`) | Алерт принят и (в будущем) сохранён/сопоставлен. | + +**EventBus** — протокол; **InMemoryEventBus** — простая реализация для тестов и прототипа. + +### Как модули подписываются (план) + +1. Модуль реализует **`Module`**: свойство `name`, метод `on_event(event)`. +2. При старте приложения модуль регистрируется: `bus.subscribe("alert.received", handler)`. +3. После успешного INSERT в `ingress_events` (или нормализации) ядро вызывает `await bus.publish(AlertReceived(...))`. + +Сейчас **ingress** ещё не публикует в шину — подключение в следующих версиях. + +## Связь с БД + +- **ingress_events** — сырой JSON от Grafana (`alembic` миграция `001_initial`). +- Сущности **Alert** / **Incident** — пока только в памяти; позже — таблицы и маппинг. diff --git a/onguard24/__init__.py b/onguard24/__init__.py index 3fa9712..b483487 100644 --- a/onguard24/__init__.py +++ b/onguard24/__init__.py @@ -1,3 +1,3 @@ """onGuard24 — модульный монолит (ядро + модули).""" -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/onguard24/db.py b/onguard24/db.py index e2ff351..b8a0db2 100644 --- a/onguard24/db.py +++ b/onguard24/db.py @@ -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) diff --git a/onguard24/domain/__init__.py b/onguard24/domain/__init__.py new file mode 100644 index 0000000..bcda853 --- /dev/null +++ b/onguard24/domain/__init__.py @@ -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", +] diff --git a/onguard24/domain/entities.py b/onguard24/domain/entities.py new file mode 100644 index 0000000..48533f3 --- /dev/null +++ b/onguard24/domain/entities.py @@ -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) diff --git a/onguard24/domain/events.py b/onguard24/domain/events.py new file mode 100644 index 0000000..ba6077f --- /dev/null +++ b/onguard24/domain/events.py @@ -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) diff --git a/onguard24/main.py b/onguard24/main.py index d88954e..999b13d 100644 --- a/onguard24/main.py +++ b/onguard24/main.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index ef37aa4..7343f5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onguard24" -version = "1.0.0" +version = "1.1.0" description = "onGuard24 — модульный сервис (аналог IRM)" readme = "README.md" requires-python = ">=3.11" @@ -11,6 +11,16 @@ dependencies = [ "python-dotenv>=1.0.1", "asyncpg>=0.30.0", "httpx>=0.28.0", + "sqlalchemy>=2.0.0", + "alembic>=1.14.0", + "psycopg[binary]>=3.2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "respx>=0.22.0", ] [project.scripts] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a940ff3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +filterwarnings = + ignore::DeprecationWarning diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..698be3a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +"""Изоляция тестов от локального .env: секреты сбрасываются до импорта приложения.""" + +from __future__ import annotations + +import os + +# Не ходим в реальные Vault/Grafana/Forgejo/Postgres при прогоне тестов +for key in ( + "DATABASE_URL", + "VAULT_ADDR", + "VAULT_TOKEN", + "GRAFANA_URL", + "GRAFANA_SERVICE_ACCOUNT_TOKEN", + "FORGEJO_URL", + "FORGEJO_TOKEN", + "GRAFANA_WEBHOOK_SECRET", +): + os.environ.pop(key, None) +os.environ["DATABASE_URL"] = "" +os.environ["VAULT_ADDR"] = "" +os.environ["GRAFANA_URL"] = "" +os.environ["FORGEJO_URL"] = "" + +import pytest +from fastapi.testclient import TestClient + +from onguard24.main import app + + +def pytest_configure() -> None: + """Дополнительно: гарантировать пустые интеграции.""" + os.environ.setdefault("DATABASE_URL", "") + + +@pytest.fixture +def client() -> TestClient: + """Контекстный менеджер — отрабатывает lifespan (pool, settings в state).""" + with TestClient(app) as c: + yield c diff --git a/tests/test_domain.py b/tests/test_domain.py new file mode 100644 index 0000000..957ef2c --- /dev/null +++ b/tests/test_domain.py @@ -0,0 +1,22 @@ +import pytest + +from onguard24.domain import Alert, AlertReceived, InMemoryEventBus, Severity + + +def test_alert_model() -> None: + a = Alert(source="grafana", severity=Severity.CRITICAL, title="x") + assert a.source == "grafana" + + +@pytest.mark.asyncio +async def test_event_bus_alert_received() -> None: + seen: list[str] = [] + + async def h(ev: AlertReceived) -> None: + seen.append(ev.name) + + bus = InMemoryEventBus() + bus.subscribe("alert.received", h) # type: ignore[arg-type] + a = Alert(source="grafana") + await bus.publish_alert_received(a) + assert seen == ["alert.received"] diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..dd34671 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient + + +def test_health(client: TestClient) -> None: + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + assert r.json()["service"] == "onGuard24" + + +def test_health_api_v1(client: TestClient) -> None: + r = client.get("/api/v1/health") + assert r.status_code == 200 diff --git a/tests/test_ingress.py b/tests/test_ingress.py new file mode 100644 index 0000000..19cf484 --- /dev/null +++ b/tests/test_ingress.py @@ -0,0 +1,60 @@ +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.testclient import TestClient + + +def test_grafana_webhook_no_db(client: TestClient) -> None: + """Без пула БД — 202, запись не падает.""" + r = client.post( + "/api/v1/ingress/grafana", + content=json.dumps({"title": "t"}), + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 202 + + +def test_grafana_webhook_unauthorized_when_secret_set(client: TestClient) -> None: + app = client.app + real = app.state.settings.grafana_webhook_secret + app.state.settings.grafana_webhook_secret = "s3cr3t" + try: + r = client.post( + "/api/v1/ingress/grafana", + content=b"{}", + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 401 + r2 = client.post( + "/api/v1/ingress/grafana", + content=b"{}", + headers={"Content-Type": "application/json", "X-OnGuard-Secret": "s3cr3t"}, + ) + assert r2.status_code == 202 + finally: + app.state.settings.grafana_webhook_secret = real + + +def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None: + mock_conn = AsyncMock() + mock_conn.execute = AsyncMock() + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_conn) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mock_pool = MagicMock() + mock_pool.acquire = MagicMock(return_value=mock_cm) + + app = client.app + real_pool = app.state.pool + app.state.pool = mock_pool + try: + r = client.post( + "/api/v1/ingress/grafana", + content=json.dumps({"a": 1}), + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 202 + mock_conn.execute.assert_called_once() + finally: + app.state.pool = real_pool diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..52a3661 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,61 @@ +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient + + +def test_status_without_integrations(client: TestClient) -> None: + """Без БД и без URL внешних сервисов — всё disabled.""" + r = client.get("/api/v1/status") + assert r.status_code == 200 + data = r.json() + assert data["service"] == "onGuard24" + assert data["database"] == "disabled" + assert data["vault"] == "disabled" + assert data["grafana"] == "disabled" + assert data["forgejo"] == "disabled" + + +def test_status_with_mocks(client: TestClient) -> None: + """Моки внешних вызовов — ok-ветки без сети.""" + with ( + patch("onguard24.status_snapshot.vault_ping", new_callable=AsyncMock) as vp, + patch("onguard24.status_snapshot.grafana_api.ping", new_callable=AsyncMock) as gp, + patch( + "onguard24.status_snapshot.grafana_api.get_signed_in_user", + new_callable=AsyncMock, + ) as gu, + patch("onguard24.status_snapshot.forgejo_api.probe", new_callable=AsyncMock) as fp, + ): + vp.return_value = (True, None) + gp.return_value = (True, None) + gu.return_value = ({"login": "tester", "email": "t@x"}, None) + fp.return_value = {"status": "ok", "url": "https://x", "api": "authenticated", "login": "u"} + + # Подмена полей settings (pydantic-settings иначе тянет env поверх конструктора) + from types import SimpleNamespace + + app = client.app + real = app.state.settings + app.state.settings = SimpleNamespace( + database_url="", + vault_addr="https://vault.example", + vault_token="t", + grafana_url="https://grafana.example", + grafana_service_account_token="g", + forgejo_url="https://git.example", + forgejo_token="f", + grafana_webhook_secret="", + http_addr="0.0.0.0:8080", + log_level="info", + ) + try: + r = client.get("/api/v1/status") + finally: + app.state.settings = real + + assert r.status_code == 200 + d = r.json() + assert d["vault"]["status"] == "ok" + assert d["grafana"]["status"] == "ok" + assert d["grafana"].get("service_account_login") == "tester" + assert d["forgejo"]["status"] == "ok"