chore: release v1.0.0 — каркас FastAPI, ingress Grafana, интеграции, документация

Made-with: Cursor
This commit is contained in:
Alexandr
2026-04-03 08:30:56 +03:00
commit 11ff49d2e9
34 changed files with 1049 additions and 0 deletions

33
.env.example Normal file
View File

@ -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=

16
.gitignore vendored Normal file
View File

@ -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

25
CHANGELOG.md Normal file
View File

@ -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`.

7
Makefile Normal file
View File

@ -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

75
README.md Normal file
View File

@ -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`).

35
docs/AI_CONTEXT.md Normal file
View File

@ -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`, …

55
docs/ARCHITECTURE.md Normal file
View File

@ -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/<name>.py` + `include_router` в `main.py` |
| Общая логика инцидентов / событий | позже: `onguard24/core/` или сервисный слой + события из БД |
| Новая таблица БД | пока: SQL в `db.py` (MIGRATION_00N); позже: Alembic |
| Новая внешняя интеграция | `onguard24/integrations/<name>.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 с ограничением).

43
docs/VERSIONING.md Normal file
View File

@ -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`.

3
onguard24/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""onGuard24 — модульный монолит (ядро + модули)."""
__version__ = "1.0.0"

37
onguard24/config.py Normal file
View File

@ -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()

32
onguard24/db.py Normal file
View File

@ -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)

View File

@ -0,0 +1 @@
"""Входящие интеграции (Grafana и др.)."""

View File

@ -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)

View File

@ -0,0 +1 @@
"""Внешние интеграции (Grafana, Vault, …)."""

View File

@ -0,0 +1,103 @@
"""Forgejo / Gitea HTTP API: Authorization: token <secret>."""
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}"

View File

@ -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}"

108
onguard24/main.py Normal file
View File

@ -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()

View File

@ -0,0 +1 @@
"""Подключаемые модули onGuard24."""

View File

@ -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": "люди, группы, каналы доставки",
}

View File

@ -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": "календарь и смены — следующий этап",
}

View File

@ -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": [],
}

88
onguard24/root_html.py Normal file
View File

@ -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 = '<span class="badge muted">не настроено</span>'
return f"<tr><th>{label}</th><td>{badge}</td></tr>"
if isinstance(value, dict):
st = value.get("status", "?")
if st == "ok":
badge = '<span class="badge ok">OK</span>'
elif st == "reachable":
badge = '<span class="badge warn">доступен</span>'
elif st == "error":
badge = '<span class="badge err">ошибка</span>'
else:
badge = f'<span class="badge muted">{html.escape(str(st))}</span>'
extra = {k: v for k, v in value.items() if k != "status"}
detail_html = ""
if extra:
detail_html = (
f'<tr class="sub"><td colspan="2"><pre class="detail">'
f"{html.escape(json.dumps(extra, ensure_ascii=False, indent=2))}"
f"</pre></td></tr>"
)
return f"<tr><th>{label}</th><td>{badge}</td></tr>{detail_html}"
badge = html.escape(str(value))
return f"<tr><th>{label}</th><td>{badge}</td></tr>"
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"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>onGuard24</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 2rem; background: #fafafa; color: #18181b; }}
h1 {{ margin-top: 0; }}
.badge {{ display: inline-block; padding: 0.15rem 0.5rem; border-radius: 6px; font-size: 0.85rem; font-weight: 600; }}
.ok {{ background: #dcfce7; color: #166534; }}
.warn {{ background: #fef9c3; color: #854d0e; }}
.err {{ background: #fee2e2; color: #991b1b; }}
.muted {{ background: #e4e4e7; color: #52525b; }}
table {{ border-collapse: collapse; width: 100%; max-width: 56rem; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px #0001; }}
th {{ text-align: left; padding: 0.75rem 1rem; width: 10rem; vertical-align: top; border-bottom: 1px solid #e4e4e7; }}
td {{ padding: 0.75rem 1rem; border-bottom: 1px solid #e4e4e7; }}
tr.sub .detail {{ margin: 0; font-size: 0.8rem; max-height: 10rem; overflow: auto; }}
.links a {{ margin-right: 1rem; }}
.json {{ margin-top: 2rem; max-width: 56rem; }}
.json pre {{ background: #18181b; color: #e4e4e7; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.8rem; }}
</style>
</head>
<body>
<h1>onGuard24</h1>
<p class="links">
<a href="/docs">Swagger</a>
<a href="/openapi.json">OpenAPI</a>
<a href="/health">/health</a>
<a href="/api/v1/status">JSON статус</a>
</p>
<h2>Проверки доступа</h2>
<table>
<tbody>
{rows}
</tbody>
</table>
<div class="json">
<h3>Полный ответ <code>/api/v1/status</code></h3>
<pre>{payload}</pre>
</div>
</body>
</html>"""

View File

@ -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

18
onguard24/vaultcheck.py Normal file
View File

@ -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}"

28
pyproject.toml Normal file
View File

@ -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"

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>onGuard24</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
web/package.json Normal file
View File

@ -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"
}
}

32
web/src/App.tsx Normal file
View File

@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
type Status = Record<string, unknown>;
export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [err, setErr] = useState<string | null>(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 (
<div style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 640 }}>
<h1>onGuard24</h1>
<p>Модульный монолит: API на FastAPI, модули заглушки.</p>
<h2>Статус</h2>
{err && <p style={{ color: "crimson" }}>Ошибка: {err}</p>}
{status && (
<pre style={{ background: "#f4f4f5", padding: "1rem", borderRadius: 8 }}>
{JSON.stringify(status, null, 2)}
</pre>
)}
<p style={{ marginTop: "2rem", color: "#666" }}>
Бэкенд: <code>uvicorn onguard24.main:app --port 8080</code>
</p>
</div>
);
}

5
web/src/index.css Normal file
View File

@ -0,0 +1,5 @@
body {
margin: 0;
background: #fafafa;
color: #18181b;
}

10
web/src/main.tsx Normal file
View File

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
web/tsconfig.json Normal file
View File

@ -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"]
}

10
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

13
web/vite.config.ts Normal file
View File

@ -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 },
},
},
});