chore: release v1.0.0 — каркас FastAPI, ingress Grafana, интеграции, документация
Made-with: Cursor
This commit is contained in:
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}"
|
||||
Reference in New Issue
Block a user