chore: release v1.0.0 — каркас FastAPI, ingress Grafana, интеграции, документация
Made-with: Cursor
This commit is contained in:
33
.env.example
Normal file
33
.env.example
Normal 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
16
.gitignore
vendored
Normal 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
25
CHANGELOG.md
Normal 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
7
Makefile
Normal 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
75
README.md
Normal 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
35
docs/AI_CONTEXT.md
Normal 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
55
docs/ARCHITECTURE.md
Normal 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
43
docs/VERSIONING.md
Normal 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
3
onguard24/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""onGuard24 — модульный монолит (ядро + модули)."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
37
onguard24/config.py
Normal file
37
onguard24/config.py
Normal 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
32
onguard24/db.py
Normal 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)
|
||||||
1
onguard24/ingress/__init__.py
Normal file
1
onguard24/ingress/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Входящие интеграции (Grafana и др.)."""
|
||||||
43
onguard24/ingress/grafana.py
Normal file
43
onguard24/ingress/grafana.py
Normal 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)
|
||||||
1
onguard24/integrations/__init__.py
Normal file
1
onguard24/integrations/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Внешние интеграции (Grafana, Vault, …)."""
|
||||||
103
onguard24/integrations/forgejo_api.py
Normal file
103
onguard24/integrations/forgejo_api.py
Normal 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}"
|
||||||
58
onguard24/integrations/grafana_api.py
Normal file
58
onguard24/integrations/grafana_api.py
Normal 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
108
onguard24/main.py
Normal 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()
|
||||||
1
onguard24/modules/__init__.py
Normal file
1
onguard24/modules/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Подключаемые модули onGuard24."""
|
||||||
12
onguard24/modules/contacts.py
Normal file
12
onguard24/modules/contacts.py
Normal 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": "люди, группы, каналы доставки",
|
||||||
|
}
|
||||||
12
onguard24/modules/schedules.py
Normal file
12
onguard24/modules/schedules.py
Normal 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": "календарь и смены — следующий этап",
|
||||||
|
}
|
||||||
13
onguard24/modules/statusboard.py
Normal file
13
onguard24/modules/statusboard.py
Normal 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
88
onguard24/root_html.py
Normal 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>"""
|
||||||
77
onguard24/status_snapshot.py
Normal file
77
onguard24/status_snapshot.py
Normal 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
18
onguard24/vaultcheck.py
Normal 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
28
pyproject.toml
Normal 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
12
web/index.html
Normal 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
22
web/package.json
Normal 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
32
web/src/App.tsx
Normal 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
5
web/src/index.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #18181b;
|
||||||
|
}
|
||||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal 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
10
web/tsconfig.node.json
Normal 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
13
web/vite.config.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user