From 4da9b13a861d6c05f19678b343d77278a9f1e409 Mon Sep 17 00:00:00 2001 From: Alexandr Date: Fri, 3 Apr 2026 08:30:56 +0300 Subject: [PATCH] =?UTF-8?q?chore:=20release=20v1.0.0=20=E2=80=94=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20FastAPI,=20ingress=20Grafana,?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?,=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .env.example | 33 ++++++++ .gitignore | 16 ++++ CHANGELOG.md | 25 ++++++ Makefile | 7 ++ README.md | 75 ++++++++++++++++++ docs/AI_CONTEXT.md | 35 +++++++++ docs/ARCHITECTURE.md | 55 +++++++++++++ docs/VERSIONING.md | 43 ++++++++++ onguard24/__init__.py | 3 + onguard24/config.py | 37 +++++++++ onguard24/db.py | 32 ++++++++ onguard24/ingress/__init__.py | 1 + onguard24/ingress/grafana.py | 43 ++++++++++ onguard24/integrations/__init__.py | 1 + onguard24/integrations/forgejo_api.py | 103 ++++++++++++++++++++++++ onguard24/integrations/grafana_api.py | 58 ++++++++++++++ onguard24/main.py | 108 ++++++++++++++++++++++++++ onguard24/modules/__init__.py | 1 + onguard24/modules/contacts.py | 12 +++ onguard24/modules/schedules.py | 12 +++ onguard24/modules/statusboard.py | 13 ++++ onguard24/root_html.py | 88 +++++++++++++++++++++ onguard24/status_snapshot.py | 77 ++++++++++++++++++ onguard24/vaultcheck.py | 18 +++++ pyproject.toml | 28 +++++++ web/index.html | 12 +++ web/package.json | 22 ++++++ web/src/App.tsx | 32 ++++++++ web/src/index.css | 5 ++ web/src/main.tsx | 10 +++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 20 +++++ web/tsconfig.node.json | 10 +++ web/vite.config.ts | 13 ++++ 34 files changed, 1049 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/AI_CONTEXT.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/VERSIONING.md create mode 100644 onguard24/__init__.py create mode 100644 onguard24/config.py create mode 100644 onguard24/db.py create mode 100644 onguard24/ingress/__init__.py create mode 100644 onguard24/ingress/grafana.py create mode 100644 onguard24/integrations/__init__.py create mode 100644 onguard24/integrations/forgejo_api.py create mode 100644 onguard24/integrations/grafana_api.py create mode 100644 onguard24/main.py create mode 100644 onguard24/modules/__init__.py create mode 100644 onguard24/modules/contacts.py create mode 100644 onguard24/modules/schedules.py create mode 100644 onguard24/modules/statusboard.py create mode 100644 onguard24/root_html.py create mode 100644 onguard24/status_snapshot.py create mode 100644 onguard24/vaultcheck.py create mode 100644 pyproject.toml create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8e4230 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Скопируй в .env и подставь значения (файл .env в git не коммитится) + +HTTP_ADDR=0.0.0.0:8080 +LOG_LEVEL=info + +# Опционально: если задан — POST /api/v1/ingress/grafana требует заголовок X-OnGuard-Secret +# GRAFANA_WEBHOOK_SECRET= + +# --- Grafana HTTP API (service account, не пароль admin) --- +# URL без завершающего слэша. Токен: Grafana → Administration → Service accounts → onguard24 → Add service account token +GRAFANA_URL=https://grafana.pvenode.ru +# GRAFANA_SERVICE_ACCOUNT_TOKEN= + +# --- PostgreSQL (onGuard24) --- +DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/DBNAME?sslmode=disable + +# Альтернатива по отдельным полям (если приложение читает так) +# PGHOST= +# PGPORT=5432 +# PGUSER= +# PGPASSWORD= +# PGDATABASE= +# PGSSLMODE=disable + +# --- HashiCorp Vault --- +VAULT_ADDR=https://vault.pvenode.ru +# Только для dev: предпочтительно AppRole / отдельный токен с ограниченными правами, не root +VAULT_TOKEN= + +# --- Forgejo (Gitea API) — репозиторий, CI, тот же токен для git clone по HTTPS при необходимости --- +# Токен: Forgejo → Settings → Applications → Generate New Token (или старый PAT) +FORGEJO_URL=https://forgejo.pvenode.ru +# FORGEJO_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..250cc3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.env +.env.local +*.pem +dist/ +bin/ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +web/node_modules/ +web/dist/ +.idea/ +onguard24/.idea/ +.vscode/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cba109e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +Формат: семантическое версионирование `MAJOR.MINOR.PATCH`. Git-теги `v1.0.0`, `v1.1.0` и т.д. — см. [docs/VERSIONING.md](docs/VERSIONING.md). + +## [1.0.0] — 2026-04-03 + +Первый зафиксированный релиз **каркаса** (scaffold). + +### Что входит + +- **Backend:** FastAPI, uvicorn, конфиг из `.env` (путь к `.env` от корня репозитория). +- **БД:** PostgreSQL через asyncpg, пул, миграция `ingress_events` (сырой SQL в `onguard24/db.py`). +- **Ingress:** `POST /api/v1/ingress/grafana` — сохранение JSON алерта в БД; опционально `X-OnGuard-Secret` + `GRAFANA_WEBHOOK_SECRET`. +- **Статус:** `GET /`, `GET /api/v1/status` — проверки database, Vault, Grafana (SA token), Forgejo (PAT + fallback без `read:user`). +- **Модули-заглушки:** `schedules`, `contacts`, `statusboard` под префиксом `/api/v1/modules/...`. +- **Фронт:** Vite + React в `web/` (прокси на API). +- **Документация:** README, `.env.example`, `docs/ARCHITECTURE.md`. + +### Не входит (следующие версии) + +- Alembic / полноценные миграции. +- Авторизация публичных API (кроме секрета webhook). +- Бизнес-логика IRM (эскалации, дежурства, светофор) — только заготовки модулей. + +Тег в репозитории: `v1.0.0`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b26d748 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: run install lint + +install: + python3 -m venv .venv && . .venv/bin/activate && pip install -e . + +run: + . .venv/bin/activate && python -m uvicorn onguard24.main:app --reload --host 0.0.0.0 --port 8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4fa915 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# onGuard24 + +**Версия: 1.0.0** · Модульный монолит на **Python (FastAPI)**: ядро, приём алертов из Grafana, заготовки модулей (дежурства, контакты, светофор), PostgreSQL, проверки Vault / Grafana / Forgejo. + +| Документ | Назначение | +|----------|------------| +| [CHANGELOG.md](CHANGELOG.md) | История версий | +| [docs/VERSIONING.md](docs/VERSIONING.md) | Теги, откат к предыдущей версии | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Структура кода, куда что класть | +| [docs/AI_CONTEXT.md](docs/AI_CONTEXT.md) | Краткий контекст для доработок | + +**Репозиторий:** [forgejo.pvenode.ru/admin/onGuard24](https://forgejo.pvenode.ru/admin/onGuard24) + +--- + +## Что уже есть (функционал v1) + +- Запуск HTTP API (`uvicorn`), конфиг из `.env`. +- **PostgreSQL:** пул asyncpg, таблица `ingress_events` для сырых тел webhook Grafana. +- **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, доменная модель инцидентов, эскалации, фоновые задачи. + +## Быстрый старт + +```bash +cd onGuard24 +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e . +``` + +Скопируй `.env.example` в `.env` и заполни секреты (см. ниже). + +```bash +python -m uvicorn onguard24.main:app --reload --host 0.0.0.0 --port 8080 +``` + +Или: `python -m onguard24.main` (читает `HTTP_ADDR` из `.env`). + +## API + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/` | HTML: проверки **database / vault / grafana / forgejo** | +| GET | `/?format=json` | Тот же статус + ссылки в JSON | +| GET | `/health`, `/api/v1/health` | Liveness | +| GET | `/api/v1/status` | JSON: интеграции + БД | +| POST | `/api/v1/ingress/grafana` | Webhook Grafana (JSON), опционально `X-OnGuard-Secret` | +| GET | `/api/v1/modules/schedules/` | Заглушка модуля дежурств | +| GET | `/api/v1/modules/contacts/` | Заглушка контактов | +| GET | `/api/v1/modules/statusboard/` | Заглушка «светофора» | + +## Переменные окружения + +См. `.env.example`. Основные: `DATABASE_URL`, `HTTP_ADDR`, `VAULT_*`, `GRAFANA_*`, `FORGEJO_*`, опционально `GRAFANA_WEBHOOK_SECRET`. + +### Grafana + +- **`GRAFANA_URL`**, **`GRAFANA_SERVICE_ACCOUNT_TOKEN`** — HTTP API (service account), не пароль пользователя. + +### Forgejo (Gitea API) + +- **`FORGEJO_URL`**, **`FORGEJO_TOKEN`** — PAT; рекомендуется scope **`read:user`** для полного ответа `/api/v1/user` (см. README в предыдущих версиях и `integrations/forgejo_api.py`). + +## Фронтенд (опционально) + +```bash +cd web && npm install && npm run dev +``` + +Vite проксирует `/api` на `http://127.0.0.1:8080` (см. `web/vite.config.ts`). diff --git a/docs/AI_CONTEXT.md b/docs/AI_CONTEXT.md new file mode 100644 index 0000000..463bea7 --- /dev/null +++ b/docs/AI_CONTEXT.md @@ -0,0 +1,35 @@ +# Контекст для доработок (агент / разработчик) + +Кратко, что нужно знать перед изменениями в репозитории **onGuard24**. + +## Продукт + +- **onGuard24** — сервис класса **IRM** (дежурства, эскалации, инциденты), модульный монолит. +- **v1.0.0** — только каркас: API, БД, webhook Grafana, заглушки модулей, страница статусов. + +## Стек + +- Python **≥3.11**, **FastAPI**, **asyncpg**, **httpx**, **pydantic-settings**. +- Фронт опционально: **Vite + React** в `web/`. + +## Правила + +1. **Секреты** — только `.env` (gitignore). В репо — `.env.example` без значений. +2. **Версия** — синхронизировать: `pyproject.toml`, `onguard24/__init__.py`, тег git, `CHANGELOG.md`. +3. **Новые модули** — пакет `onguard24/modules/`, роутер подключать в `main.py` с префиксом `/api/v1/modules/<имя>`. +4. **Миграции БД** — пока правки в `onguard24/db.py` (константа SQL); не ломать таблицу `ingress_events` без миграционного плана. +5. **Статус интеграций** — логика в `status_snapshot.py` и `integrations/*`. + +## Точки входа в код + +| Задача | Файл | +|--------|------| +| Новый эндпоинт | `main.py` или `modules/*.py` | +| Настройки | `config.py` | +| Webhook Grafana | `ingress/grafana.py` | +| Проверка Vault/Grafana/Forgejo | `status_snapshot.py`, `integrations/` | +| HTML главной | `root_html.py` | + +## Откат + +См. [VERSIONING.md](VERSIONING.md). Теги `v1.0.0`, `v1.1.0`, … diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..de76744 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,55 @@ +# Архитектура onGuard24 (для разработки и доработок) + +Цель продукта: **модульный монолит** в духе IRM — ядро + подключаемые области (дежурства, контакты, «светофор» по сервисам). Текущая версия — **каркас v1**: HTTP, БД, ingress Grafana, проверки интеграций. + +## Дерево пакетов + +``` +onGuard24/ +├── onguard24/ +│ ├── main.py # FastAPI app, lifespan, маршруты верхнего уровня +│ ├── config.py # Settings: .env из корня репозитория (не от cwd) +│ ├── db.py # asyncpg pool, миграция ingress_events +│ ├── status_snapshot.py # Единый сборщик JSON для /api/v1/status +│ ├── root_html.py # HTML главной страницы со статусами +│ ├── vaultcheck.py # Vault /v1/sys/health +│ ├── ingress/grafana.py # POST webhook Grafana → INSERT ingress_events +│ ├── integrations/ +│ │ ├── grafana_api.py # Grafana HTTP API (Bearer SA) +│ │ └── forgejo_api.py # Forgejo/Gitea API (token + probe/fallback) +│ └── modules/ # Заглушки: schedules, contacts, statusboard +├── web/ # Vite + React (опционально) +├── pyproject.toml +├── CHANGELOG.md +└── docs/ +``` + +## Поток данных (сейчас) + +1. **Grafana** (отдельно настроенный contact point) шлёт **POST** на `/api/v1/ingress/grafana` → тело JSON пишется в **`ingress_events`**. +2. **Параллельно** Grafana может слать в Mattermost — это вне этого репозитория (конфиг Grafana). +3. **Статус страницы** не ходит в Grafana за алертами — только **проверка доступности API** (токен SA). + +## Куда класть новый функционал + +| Задача | Место | +|--------|--------| +| Новый HTTP-роут модуля | `onguard24/modules/.py` + `include_router` в `main.py` | +| Общая логика инцидентов / событий | позже: `onguard24/core/` или сервисный слой + события из БД | +| Новая таблица БД | пока: SQL в `db.py` (MIGRATION_00N); позже: Alembic | +| Новая внешняя интеграция | `onguard24/integrations/.py`, вызов из `status_snapshot` при необходимости | + +## Конфигурация + +Все секреты только через **переменные окружения** / `.env` (файл **не в git**). Список ключей — `.env.example`. + +## Зависимости между компонентами + +- `status_snapshot.build(request)` читает `request.app.state.pool` и `request.app.state.settings` (устанавливаются в `lifespan`). +- Модули **не** зависят друг от друга; общий контракт позже можно ввести через **таблицы БД** и **внутренние события** (ещё не реализовано). + +## Известные ограничения v1 + +- Нет единой модели «инцидент» в БД — только сырой ingest в `ingress_events`. +- Нет очереди/воркеров для эскалаций. +- Нет auth на GET `/api/v1/status` (только для внутренней сети / за reverse proxy с ограничением). diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md new file mode 100644 index 0000000..bb2ca0a --- /dev/null +++ b/docs/VERSIONING.md @@ -0,0 +1,43 @@ +# Версии и откат + +## Соглашение + +| Что | Как | +|-----|-----| +| Версия в коде | `pyproject.toml` → `[project] version`, `onguard24/__init__.py` → `__version__` | +| Git-теги | Аннотированные: `v1.0.0`, `v1.1.0` (`git tag -a v1.0.0 -m "..."`) | +| Ветка по умолчанию | `main` | + +Семвер ориентир: + +- **MAJOR** — несовместимые изменения API или схемы БД. +- **MINOR** — новые фичи, обратная совместимость. +- **PATCH** — исправления без смены контракта. + +## Откат к предыдущей версии + +```bash +git fetch --tags +git checkout v1.0.0 # или нужный тег +# отладка / hotfix от тега +``` + +Новая ветка от старого тега: + +```bash +git checkout -b hotfix/v1.0.1 v1.0.0 +``` + +Сравнение версий: + +```bash +git log v1.0.0..main --oneline +git diff v1.0.0..main +``` + +## Выпуск новой версии (чеклист) + +1. Обновить `CHANGELOG.md`, `pyproject.toml`, `onguard24/__init__.py`. +2. `git commit -m "chore: release vX.Y.Z"`. +3. `git tag -a vX.Y.Z -m "Release vX.Y.Z"`. +4. `git push origin main --tags`. diff --git a/onguard24/__init__.py b/onguard24/__init__.py new file mode 100644 index 0000000..3fa9712 --- /dev/null +++ b/onguard24/__init__.py @@ -0,0 +1,3 @@ +"""onGuard24 — модульный монолит (ядро + модули).""" + +__version__ = "1.0.0" diff --git a/onguard24/config.py b/onguard24/config.py new file mode 100644 index 0000000..f1a2832 --- /dev/null +++ b/onguard24/config.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from dotenv import load_dotenv +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +# .env рядом с корнём проекта (не зависит от текущей директории при запуске uvicorn) +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +load_dotenv(_PROJECT_ROOT / ".env") + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=str(_PROJECT_ROOT / ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + http_addr: str = Field(default="0.0.0.0:8080", validation_alias="HTTP_ADDR") + database_url: str = Field(default="", validation_alias="DATABASE_URL") + grafana_webhook_secret: str = Field(default="", validation_alias="GRAFANA_WEBHOOK_SECRET") + # HTTP API (service account): Grafana → Administration → Service accounts → токен + grafana_url: str = Field(default="", validation_alias="GRAFANA_URL") + grafana_service_account_token: str = Field( + default="", + validation_alias="GRAFANA_SERVICE_ACCOUNT_TOKEN", + ) + vault_addr: str = Field(default="", validation_alias="VAULT_ADDR") + vault_token: str = Field(default="", validation_alias="VAULT_TOKEN") + # Forgejo (Gitea-совместимый API): Settings → Applications → токен, или при создании PAT + forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL") + forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN") + log_level: str = Field(default="info", validation_alias="LOG_LEVEL") + + +def get_settings() -> Settings: + return Settings() diff --git a/onguard24/db.py b/onguard24/db.py new file mode 100644 index 0000000..e2ff351 --- /dev/null +++ b/onguard24/db.py @@ -0,0 +1,32 @@ +import asyncpg + +from onguard24.config import Settings + + +def normalize_dsn(url: str) -> str: + if url.startswith("postgres://"): + return url.replace("postgres://", "postgresql://", 1) + return url + + +async def create_pool(settings: Settings) -> asyncpg.Pool | None: + if not settings.database_url.strip(): + 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/ingress/__init__.py b/onguard24/ingress/__init__.py new file mode 100644 index 0000000..4cbf96a --- /dev/null +++ b/onguard24/ingress/__init__.py @@ -0,0 +1 @@ +"""Входящие интеграции (Grafana и др.).""" diff --git a/onguard24/ingress/grafana.py b/onguard24/ingress/grafana.py new file mode 100644 index 0000000..35cc5e1 --- /dev/null +++ b/onguard24/ingress/grafana.py @@ -0,0 +1,43 @@ +import json +import logging + +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from starlette.responses import Response + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["ingress"]) + + +async def get_pool(request: Request): + return getattr(request.app.state, "pool", None) + + +@router.post("/ingress/grafana", status_code=202) +async def grafana_webhook( + request: Request, + pool=Depends(get_pool), + x_onguard_secret: str | None = Header(default=None, alias="X-OnGuard-Secret"), +): + settings = request.app.state.settings + if settings.grafana_webhook_secret and x_onguard_secret != settings.grafana_webhook_secret: + raise HTTPException(status_code=401, detail="unauthorized") + + raw = await request.body() + if len(raw) > 1_000_000: + raise HTTPException(status_code=400, detail="body too large") + try: + body = json.loads(raw.decode() or "{}") + except json.JSONDecodeError: + body = {} + + if pool is None: + logger.warning("ingress: database not configured, event not persisted") + return Response(status_code=202) + + async with pool.acquire() as conn: + await conn.execute( + "INSERT INTO ingress_events (source, body) VALUES ($1, $2::jsonb)", + "grafana", + json.dumps(body), + ) + return Response(status_code=202) diff --git a/onguard24/integrations/__init__.py b/onguard24/integrations/__init__.py new file mode 100644 index 0000000..24bc677 --- /dev/null +++ b/onguard24/integrations/__init__.py @@ -0,0 +1 @@ +"""Внешние интеграции (Grafana, Vault, …).""" diff --git a/onguard24/integrations/forgejo_api.py b/onguard24/integrations/forgejo_api.py new file mode 100644 index 0000000..a2cde6f --- /dev/null +++ b/onguard24/integrations/forgejo_api.py @@ -0,0 +1,103 @@ +"""Forgejo / Gitea HTTP API: Authorization: token .""" + +import httpx + + +def _auth(token: str) -> dict[str, str]: + return { + "Authorization": f"token {token}", + "Accept": "application/json", + } + + +async def probe(base_url: str, token: str) -> dict: + """ + Проверка токена. Сначала GET /api/v1/user (нужен scope read:user). + При 403 из‑за scope — fallback: GET /api/v1/admin/config (часто доступен с write:admin). + """ + if not base_url.strip() or not token.strip(): + return {"status": "error", "detail": "forgejo url or token empty"} + + base = base_url.rstrip("/") + h = _auth(token) + + try: + async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client: + ru = await client.get(f"{base}/api/v1/user", headers=h) + if ru.status_code == 200: + try: + u = ru.json() + except Exception: + u = {} + login = u.get("login") + out: dict = { + "status": "ok", + "url": base, + "api": "authenticated", + "scope": "read:user", + } + if login: + out["login"] = login + return out + + if ru.status_code == 403: + for path, name in ( + ("/api/v1/admin/config", "admin_config"), + ("/api/v1/notifications?limit=1", "notifications"), + ): + rx = await client.get(f"{base}{path}", headers=h) + if rx.status_code == 200: + return { + "status": "ok", + "url": base, + "api": "authenticated", + "scope_note": ( + "в токене нет scope read:user — в Forgejo включи read:user у PAT " + "или создай новый токен с read:user, чтобы отображался login" + ), + "fallback": name, + } + + body = (ru.text or "")[:500] + return { + "status": "error", + "url": base, + "detail": f"http {ru.status_code}: {body}", + } + except Exception as e: + return {"status": "error", "detail": str(e), "url": base_url.rstrip("/")} + + +async def ping(base_url: str, token: str) -> tuple[bool, str | None]: + """Обёртка для совместимости: True если status ok.""" + r = await probe(base_url, token) + if r.get("status") == "ok": + return True, None + return False, r.get("detail", "unknown") + + +async def get_user(base_url: str, token: str) -> tuple[dict | None, str | None]: + base = base_url.rstrip("/") + try: + async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client: + r = await client.get(f"{base}/api/v1/user", headers=_auth(token)) + except Exception as e: + return None, str(e) + if r.status_code != 200: + return None, f"http {r.status_code}" + try: + return r.json(), None + except Exception: + return None, "invalid json" + + +async def health_public(base_url: str) -> tuple[bool, str | None]: + base = base_url.rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0, verify=True) as client: + r = await client.get(f"{base}/api/v1/version") + except Exception as e: + return False, str(e) + if r.status_code == 200: + return True, None + return False, f"http {r.status_code}" diff --git a/onguard24/integrations/grafana_api.py b/onguard24/integrations/grafana_api.py new file mode 100644 index 0000000..16704e9 --- /dev/null +++ b/onguard24/integrations/grafana_api.py @@ -0,0 +1,58 @@ +"""HTTP API Grafana: service account token (Bearer), не пароль пользователя.""" + +import httpx + +# Минимальный набор для проверки и будущих вызовов (алерты, папки и т.д.). + + +async def ping(base_url: str, token: str) -> tuple[bool, str | None]: + if not base_url.strip() or not token.strip(): + return False, "grafana url or token empty" + base = base_url.rstrip("/") + try: + async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client: + r = await client.get( + f"{base}/api/org", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + }, + ) + except Exception as e: + return False, str(e) + if r.status_code == 200: + return True, None + body = (r.text or "")[:300] + return False, f"http {r.status_code}: {body}" + + +async def get_signed_in_user(base_url: str, token: str) -> tuple[dict | None, str | None]: + """GET /api/user — удобно убедиться, что токен от service account.""" + base = base_url.rstrip("/") + try: + async with httpx.AsyncClient(timeout=15.0, verify=True, follow_redirects=True) as client: + r = await client.get( + f"{base}/api/user", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + except Exception as e: + return None, str(e) + if r.status_code != 200: + return None, f"http {r.status_code}" + try: + return r.json(), None + except Exception: + return None, "invalid json" + + +async def health_live(base_url: str) -> tuple[bool, str | None]: + """GET /api/health — без авторизации, проверка что инстанс отвечает.""" + base = base_url.rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0, verify=True) as client: + r = await client.get(f"{base}/api/health") + except Exception as e: + return False, str(e) + if r.status_code == 200: + return True, None + return False, f"http {r.status_code}" diff --git a/onguard24/main.py b/onguard24/main.py new file mode 100644 index 0000000..d88954e --- /dev/null +++ b/onguard24/main.py @@ -0,0 +1,108 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +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.ingress import grafana as grafana_ingress +from onguard24.modules import contacts, schedules, statusboard +from onguard24.root_html import render_root_page +from onguard24.status_snapshot import build as build_status +from onguard24 import __version__ as app_version + +logging.basicConfig(level=logging.INFO) +logging.getLogger("httpx").setLevel(logging.WARNING) +log = logging.getLogger("onguard24") + + +def parse_addr(http_addr: str) -> tuple[str, int]: + s = http_addr.strip() + if s.startswith(":"): + return "0.0.0.0", int(s[1:]) + if ":" in s: + h, p = s.rsplit(":", 1) + return (h or "0.0.0.0"), int(p) + return "0.0.0.0", int(s) + + +@asynccontextmanager +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") + yield + if pool: + await pool.close() + log.info("database pool closed") + + +def create_app() -> FastAPI: + app = FastAPI(title="onGuard24", version=app_version, lifespan=lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], + ) + + @app.get("/") + async def root(request: Request): + if request.query_params.get("format") == "json": + data = await build_status(request) + data["links"] = { + "docs": "/docs", + "openapi": "/openapi.json", + "health": "/health", + "status_api": "/api/v1/status", + } + return data + return HTMLResponse(await render_root_page(request)) + + @app.get("/favicon.ico", include_in_schema=False) + async def favicon(): + return Response(status_code=204) + + @app.get("/health") + @app.get("/api/v1/health") + async def health(): + return {"status": "ok", "service": "onGuard24"} + + @app.get("/api/v1/status") + async def status(request: Request): + return await build_status(request) + + app.include_router(grafana_ingress.router, prefix="/api/v1") + app.include_router(schedules.router, prefix="/api/v1/modules/schedules") + app.include_router(contacts.router, prefix="/api/v1/modules/contacts") + app.include_router(statusboard.router, prefix="/api/v1/modules/statusboard") + + return app + + +app = create_app() + + +def run() -> None: + import uvicorn + + settings = get_settings() + host, port = parse_addr(settings.http_addr) + lvl = settings.log_level.upper() + uvicorn.run( + "onguard24.main:app", + host=host, + port=port, + log_level=lvl.lower() if lvl in {"DEBUG", "INFO", "WARNING", "ERROR"} else "info", + ) + + +if __name__ == "__main__": + run() diff --git a/onguard24/modules/__init__.py b/onguard24/modules/__init__.py new file mode 100644 index 0000000..c516d9c --- /dev/null +++ b/onguard24/modules/__init__.py @@ -0,0 +1 @@ +"""Подключаемые модули onGuard24.""" diff --git a/onguard24/modules/contacts.py b/onguard24/modules/contacts.py new file mode 100644 index 0000000..a6c29b7 --- /dev/null +++ b/onguard24/modules/contacts.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["module-contacts"]) + + +@router.get("/") +async def contacts_root(): + return { + "module": "contacts", + "status": "stub", + "note": "люди, группы, каналы доставки", + } diff --git a/onguard24/modules/schedules.py b/onguard24/modules/schedules.py new file mode 100644 index 0000000..9c768c2 --- /dev/null +++ b/onguard24/modules/schedules.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["module-schedules"]) + + +@router.get("/") +async def schedules_root(): + return { + "module": "schedules", + "status": "stub", + "note": "календарь и смены — следующий этап", + } diff --git a/onguard24/modules/statusboard.py b/onguard24/modules/statusboard.py new file mode 100644 index 0000000..7495232 --- /dev/null +++ b/onguard24/modules/statusboard.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["module-statusboard"]) + + +@router.get("/") +async def statusboard_root(): + return { + "module": "statusboard", + "status": "stub", + "note": "светофор по сервисам — агрегация по алертам", + "demo": [], + } diff --git a/onguard24/root_html.py b/onguard24/root_html.py new file mode 100644 index 0000000..9b57ec1 --- /dev/null +++ b/onguard24/root_html.py @@ -0,0 +1,88 @@ +import html +import json + +from onguard24.status_snapshot import build + + +def _row(name: str, value: object) -> str: + label = html.escape(name) + if value == "disabled": + badge = 'не настроено' + return f"{label}{badge}" + + if isinstance(value, dict): + st = value.get("status", "?") + if st == "ok": + badge = 'OK' + elif st == "reachable": + badge = 'доступен' + elif st == "error": + badge = 'ошибка' + else: + badge = f'{html.escape(str(st))}' + extra = {k: v for k, v in value.items() if k != "status"} + detail_html = "" + if extra: + detail_html = ( + f'
'
+                f"{html.escape(json.dumps(extra, ensure_ascii=False, indent=2))}"
+                f"
" + ) + return f"{label}{badge}{detail_html}" + + badge = html.escape(str(value)) + return f"{label}{badge}" + + +async def render_root_page(request) -> str: + data = await build(request) + rows = "" + for key in ("database", "vault", "grafana", "forgejo"): + if key in data: + rows += _row(key, data[key]) + + payload = html.escape(json.dumps(data, ensure_ascii=False, indent=2)) + + return f""" + + + + + onGuard24 + + + +

onGuard24

+ +

Проверки доступа

+ + + {rows} + +
+
+

Полный ответ /api/v1/status

+
{payload}
+
+ +""" diff --git a/onguard24/status_snapshot.py b/onguard24/status_snapshot.py new file mode 100644 index 0000000..edd2221 --- /dev/null +++ b/onguard24/status_snapshot.py @@ -0,0 +1,77 @@ +"""Единая сборка ответа /api/v1/status (БД, Vault, Grafana, Forgejo).""" + +from fastapi import Request + +from onguard24.config import Settings +from onguard24.integrations import forgejo_api, grafana_api +from onguard24.vaultcheck import ping as vault_ping + + +async def build(request: Request) -> dict: + out: dict = {"service": "onGuard24"} + pool = getattr(request.app.state, "pool", None) + settings: Settings = request.app.state.settings + + if pool: + try: + async with pool.acquire() as conn: + await conn.fetchval("SELECT 1") + out["database"] = {"status": "ok"} + except Exception as e: + out["database"] = {"status": "error", "detail": str(e)} + else: + out["database"] = "disabled" + + if settings.vault_addr and settings.vault_token: + ok, err = await vault_ping(settings.vault_addr, settings.vault_token) + if ok: + out["vault"] = {"status": "ok", "url": settings.vault_addr} + else: + out["vault"] = {"status": "error", "detail": err, "url": settings.vault_addr} + else: + out["vault"] = "disabled" + + gu = settings.grafana_url.strip() + if not gu: + out["grafana"] = "disabled" + elif settings.grafana_service_account_token.strip(): + ok, err = await grafana_api.ping(gu, settings.grafana_service_account_token) + if ok: + user, _ = await grafana_api.get_signed_in_user(gu, settings.grafana_service_account_token) + entry: dict = {"status": "ok", "url": gu, "api": "authenticated"} + if user: + login = user.get("login") or user.get("email") + if login: + entry["service_account_login"] = login + out["grafana"] = entry + else: + out["grafana"] = {"status": "error", "detail": err, "url": gu} + else: + live_ok, live_err = await grafana_api.health_live(gu) + if live_ok: + out["grafana"] = { + "status": "reachable", + "url": gu, + "detail": "задай GRAFANA_SERVICE_ACCOUNT_TOKEN для вызовов API", + } + else: + out["grafana"] = {"status": "error", "detail": live_err, "url": gu} + + fj = settings.forgejo_url.strip() + if not fj: + out["forgejo"] = "disabled" + elif settings.forgejo_token.strip(): + entry_fj = await forgejo_api.probe(fj, settings.forgejo_token) + out["forgejo"] = entry_fj + else: + pub_ok, pub_err = await forgejo_api.health_public(fj) + if pub_ok: + out["forgejo"] = { + "status": "reachable", + "url": fj, + "detail": "задай FORGEJO_TOKEN (Personal Access Token в Forgejo)", + } + else: + out["forgejo"] = {"status": "error", "detail": pub_err, "url": fj} + + return out diff --git a/onguard24/vaultcheck.py b/onguard24/vaultcheck.py new file mode 100644 index 0000000..0aa55a7 --- /dev/null +++ b/onguard24/vaultcheck.py @@ -0,0 +1,18 @@ +import httpx + + +async def ping(addr: str, token: str) -> tuple[bool, str | None]: + if not addr or not token: + return False, "vault addr or token empty" + base = addr.rstrip("/") + try: + async with httpx.AsyncClient(timeout=5.0, verify=True) as client: + r = await client.get( + f"{base}/v1/sys/health", + headers={"X-Vault-Token": token}, + ) + except Exception as e: + return False, str(e) + if r.status_code in (200, 429, 503, 501): + return True, None + return False, f"http {r.status_code}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ef37aa4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "onguard24" +version = "1.0.0" +description = "onGuard24 — модульный сервис (аналог IRM)" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic-settings>=2.6.0", + "python-dotenv>=1.0.1", + "asyncpg>=0.30.0", + "httpx>=0.28.0", +] + +[project.scripts] +onguard = "onguard24.main:run" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["onguard24"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6c7e108 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + onGuard24 + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8de368b --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "onguard24-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "~5.6.2", + "vite": "^5.4.10" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..211bc0f --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; + +type Status = Record; + +export default function App() { + const [status, setStatus] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + fetch("/api/v1/status") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then(setStatus) + .catch((e) => setErr(String(e))); + }, []); + + return ( +
+

onGuard24

+

Модульный монолит: API на FastAPI, модули — заглушки.

+

Статус

+ {err &&

Ошибка: {err}

} + {status && ( +
+          {JSON.stringify(status, null, 2)}
+        
+ )} +

+ Бэкенд: uvicorn onguard24.main:app --port 8080 +

+
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..3522f14 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,5 @@ +body { + margin: 0; + background: #fafafa; + color: #18181b; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..27481e0 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d580611 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-js", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..7366cef --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler" + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..1f0c64d --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { target: "http://127.0.0.1:8080", changeOrigin: true }, + "/health": { target: "http://127.0.0.1:8080", changeOrigin: true }, + }, + }, +});