feat: Phase 1 scaffold (secrets, db, Checkmk/Ollama clients, health)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6e244234eb
21 files changed
+788
No files matched your search
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
master.key
|
||||
DEVLOG.md
|
||||
@@ -0,0 +1,45 @@
|
||||
# checkbot
|
||||
|
||||
Checkmk-Bot für Debian-LXC (Proxmox). Überwacht Checkmk per REST-API, lässt Alarme von externem Ollama analysieren, meldet in Telegram und/oder Element (Matrix). Konfiguration komplett über Web-UI.
|
||||
|
||||
Vollständiger Plan: `/home/sysops/.claude/plans/ja-alle-bernehmen-eager-lark.md`
|
||||
|
||||
## Arbeitsregeln
|
||||
- Auf diesem Rechner wird nichts installiert (kein venv, kein pip, keine Pakete). Code nur schreiben, Ausführung und Tests passieren im Ziel-LXC.
|
||||
- Greenfield, Stand: nur Plan, noch kein Code.
|
||||
- Code-Kommentare nur bei echtem WHY.
|
||||
|
||||
## Festgelegte Entscheidungen
|
||||
- Plattform: Debian-LXC, unprivileged, 1 Core, 1 GB RAM, Python 3, venv im LXC, systemd, Caddy (HTTPS, interne CA). Kein Docker.
|
||||
- Checkmk 2.3+: nur REST-API (Automation-User, Lese-Rolle). Kein Webhook, kein Notification-Script, kein eingehender Alarm-Port.
|
||||
- Poller: 30 s (UI einstellbar), nur Nicht-OK, Zustandsvergleich in SQLite, nur Hard-States, Mindestdauer einstellbar (Default 0 s), Flapping gebündelt.
|
||||
- Ollama extern `office.sysops.de:11434`. Zwei Modell-Einstellungen (Alarmtext, Agent), Start `qwen3`, Hermes zum Vergleich.
|
||||
- Chat: Adapter-Interface. Telegram (Long-Polling) und Matrix (`matrix-nio`, eigener Homeserver, unverschlüsselter Raum, Bot-User). Parallel, einzeln abschaltbar, Ziel pro Routing-Regel.
|
||||
- Alarm-Flow: Rohalarm sofort, KI-Analyse per Edit nachgereicht (Telegram editMessage, Matrix m.replace). Max 8 Zeilen DE. Timeout 60 s, 2 Retries, sonst "KI nicht verfügbar". Bündelung pro Host 60 s, max 10 Prompts/min, Recovery ohne KI. Downtime: kein Push, Verlauf "unterdrückt". UI-Stummschaltung mit Ablauf.
|
||||
- Befehle: gemeinsamer Handler, `/` in Telegram, `!` in Matrix, Allowlist pro Backend.
|
||||
- Agent-Loop nur für `/ask`, `/explain`, Tiefenanalyse. 5 feste Lese-Tools (`get_service_status`, `get_host_services`, `get_state_history`, `get_downtimes`, `get_problems`), max 6 Schritte, 90 s, Tool-Output untrusted, alle Aufrufe im Audit-Log.
|
||||
- Bot schreibt nicht in Checkmk.
|
||||
- UI: FastAPI, Jinja2, HTMX, deutsch, hell/dunkel. Lokale Nutzer (Argon2, optional TOTP), Rollen admin/viewer, `checkbot create-admin`, Setup-Assistent.
|
||||
- Secrets in SQLite mit AES-GCM. Nur `master.key`, Session-Secret, Bind-Adresse in `/etc/checkbot/` (chmod 600). UI zeigt Secrets nie an.
|
||||
- Audit-Log ohne Secret-Werte. Prompts versioniert mit Rollback, Testbereich.
|
||||
- Retention: Verlauf/KI 90 Tage, Audit 1 Jahr. `/health` plus Checkmk-Agent im LXC.
|
||||
- Plugin-Output ist untrusted Input (Prompt Injection), KI löst keine Aktionen aus.
|
||||
|
||||
## Phasen
|
||||
1. Gerüst: Schema, `crypto.py`, Checkmk- und Ollama-Client, `/health`
|
||||
2. UI-Basis: Login, Rollen, Setup-Assistent, Verbindungstests, Audit
|
||||
3. Chat-Interface, Telegram, Poller, Alarm-Flow
|
||||
4. Matrix-Adapter, Routing, Stummschaltung
|
||||
5. Command-Handler, Agent-Loop
|
||||
6. Prompt-Versionierung, Testbereich, Verlauf, Retention
|
||||
7. Deployment (`setup-lxc.sh`, Caddy, systemd, Checkmk-Agent)
|
||||
8. Tests
|
||||
|
||||
## Geplante Struktur
|
||||
```
|
||||
app/ main.py fileconfig.py db.py models.py crypto.py checkmk.py poller.py
|
||||
ollama.py pipeline.py agent.py tools.py commands.py audit.py
|
||||
retention.py health.py chat/{base,telegram,matrix}.py web/
|
||||
deploy/ setup-lxc.sh systemd/checkbot.service Caddyfile
|
||||
tests/
|
||||
```
|
||||
Whitespace-only changes.
@@ -0,0 +1,19 @@
|
||||
import time
|
||||
|
||||
from .db import Database
|
||||
|
||||
|
||||
def record(db: Database, user: str, action: str, target: str,
|
||||
old: str | None = None, new: str | None = None) -> None:
|
||||
db.execute(
|
||||
"INSERT INTO audit(ts, user, action, target, old_value, new_value) VALUES (?,?,?,?,?,?)",
|
||||
(int(time.time()), user, action, target, old, new),
|
||||
)
|
||||
|
||||
|
||||
def recent(db: Database, limit: int = 100) -> list[dict]:
|
||||
rows = db.query(
|
||||
"SELECT ts, user, action, target, old_value, new_value FROM audit ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from .constants import (CHECKMK_CA_FILE, CHECKMK_SECRET, CHECKMK_SITE, CHECKMK_URL,
|
||||
CHECKMK_USER, CHECKMK_VERIFY_TLS, FLAG_OFF, FLAG_ON)
|
||||
|
||||
API_PATH = "/check_mk/api/1.0"
|
||||
SERVICES_PATH = "/domain-types/service/collections/all"
|
||||
VERSION_PATH = "/version"
|
||||
DEFAULT_TIMEOUT = 15.0
|
||||
STATE_OK = 0
|
||||
|
||||
COLUMNS = [
|
||||
"host_name",
|
||||
"description",
|
||||
"state",
|
||||
"state_type",
|
||||
"plugin_output",
|
||||
"last_state_change",
|
||||
"scheduled_downtime_depth",
|
||||
"is_flapping",
|
||||
]
|
||||
|
||||
NOT_OK_QUERY = {"op": "!=", "left": "state", "right": str(STATE_OK)}
|
||||
|
||||
|
||||
class CheckmkError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceState:
|
||||
host: str
|
||||
service: str
|
||||
state: int
|
||||
hard: bool
|
||||
output: str
|
||||
last_state_change: int
|
||||
in_downtime: bool
|
||||
flapping: bool
|
||||
|
||||
|
||||
def _is_hard(value) -> bool:
|
||||
return str(value).lower() in ("1", "hard")
|
||||
|
||||
|
||||
def _parse_service(extensions: dict) -> ServiceState:
|
||||
return ServiceState(
|
||||
host=extensions["host_name"],
|
||||
service=extensions["description"],
|
||||
state=int(extensions["state"]),
|
||||
hard=_is_hard(extensions.get("state_type", 1)),
|
||||
output=extensions.get("plugin_output", ""),
|
||||
last_state_change=int(extensions.get("last_state_change", 0)),
|
||||
in_downtime=int(extensions.get("scheduled_downtime_depth", 0)) > 0,
|
||||
flapping=bool(int(extensions.get("is_flapping", 0))),
|
||||
)
|
||||
|
||||
|
||||
class CheckmkClient:
|
||||
def __init__(self, base_url: str, site: str, user: str, secret: str,
|
||||
verify: bool | str = True, timeout: float = DEFAULT_TIMEOUT,
|
||||
transport: httpx.AsyncBaseTransport | None = None):
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=f"{base_url.rstrip('/')}/{site}{API_PATH}",
|
||||
headers={"Authorization": f"Bearer {user} {secret}", "Accept": "application/json"},
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, store) -> "CheckmkClient | None":
|
||||
url, site = store.get(CHECKMK_URL), store.get(CHECKMK_SITE)
|
||||
user, secret = store.get(CHECKMK_USER), store.get(CHECKMK_SECRET)
|
||||
if not (url and site and user and secret):
|
||||
return None
|
||||
if store.get(CHECKMK_VERIFY_TLS, FLAG_ON) == FLAG_OFF:
|
||||
verify: bool | str = False
|
||||
else:
|
||||
verify = store.get(CHECKMK_CA_FILE) or True
|
||||
return cls(url, site, user, secret, verify=verify)
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
try:
|
||||
response = await self._client.request(method, path, **kwargs)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CheckmkError(f"{type(exc).__name__}: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise CheckmkError(f"HTTP {response.status_code} on {path}: {response.text[:200]}")
|
||||
return response.json()
|
||||
|
||||
async def version(self) -> dict:
|
||||
return await self._request("GET", VERSION_PATH)
|
||||
|
||||
async def problem_services(self) -> list[ServiceState]:
|
||||
body = await self._request(
|
||||
"POST",
|
||||
SERVICES_PATH,
|
||||
json={"query": NOT_OK_QUERY, "columns": COLUMNS},
|
||||
)
|
||||
return [_parse_service(item["extensions"]) for item in body.get("value", [])]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,18 @@
|
||||
CHECKMK_URL = "checkmk.url"
|
||||
CHECKMK_SITE = "checkmk.site"
|
||||
CHECKMK_USER = "checkmk.user"
|
||||
CHECKMK_SECRET = "checkmk.secret"
|
||||
CHECKMK_CA_FILE = "checkmk.ca_file"
|
||||
CHECKMK_VERIFY_TLS = "checkmk.verify_tls"
|
||||
|
||||
OLLAMA_URL = "ollama.url"
|
||||
OLLAMA_TOKEN = "ollama.token"
|
||||
OLLAMA_TIMEOUT = "ollama.timeout"
|
||||
OLLAMA_CA_FILE = "ollama.ca_file"
|
||||
|
||||
HEALTH_PING = "health.ping"
|
||||
|
||||
FLAG_ON = "1"
|
||||
FLAG_OFF = "0"
|
||||
|
||||
DEFAULT_OLLAMA_TIMEOUT = "60"
|
||||
@@ -0,0 +1,24 @@
|
||||
import base64
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
NONCE_LEN = 12
|
||||
|
||||
|
||||
class SecretBox:
|
||||
def __init__(self, key: bytes):
|
||||
self._aes = AESGCM(key)
|
||||
|
||||
def encrypt(self, plaintext: str, context: str) -> str:
|
||||
nonce = os.urandom(NONCE_LEN)
|
||||
ciphertext = self._aes.encrypt(nonce, plaintext.encode(), context.encode())
|
||||
return base64.b64encode(nonce + ciphertext).decode()
|
||||
|
||||
def decrypt(self, token: str, context: str) -> str:
|
||||
raw = base64.b64decode(token)
|
||||
return self._aes.decrypt(raw[:NONCE_LEN], raw[NONCE_LEN:], context.encode()).decode()
|
||||
|
||||
|
||||
def generate_master_key() -> str:
|
||||
return base64.b64encode(os.urandom(32)).decode()
|
||||
@@ -0,0 +1,58 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
MIGRATIONS = [
|
||||
"""
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT
|
||||
);
|
||||
CREATE INDEX audit_ts ON audit(ts);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path):
|
||||
if str(path) != ":memory:":
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
self._lock = threading.Lock()
|
||||
self._migrate()
|
||||
|
||||
def _migrate(self) -> None:
|
||||
with self._lock:
|
||||
version = self._conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
for index in range(version, len(MIGRATIONS)):
|
||||
self._conn.executescript(MIGRATIONS[index])
|
||||
self._conn.execute(f"PRAGMA user_version={index + 1}")
|
||||
self._conn.commit()
|
||||
|
||||
def execute(self, sql: str, params: tuple = ()) -> int:
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(sql, params)
|
||||
self._conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
def query(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
|
||||
with self._lock:
|
||||
return self._conn.execute(sql, params).fetchall()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
@@ -0,0 +1,41 @@
|
||||
import base64
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ENV_CONFIG_DIR = "CHECKBOT_CONFIG_DIR"
|
||||
ENV_DB_PATH = "CHECKBOT_DB"
|
||||
ENV_BIND_HOST = "CHECKBOT_BIND_HOST"
|
||||
ENV_BIND_PORT = "CHECKBOT_BIND_PORT"
|
||||
|
||||
DEFAULT_CONFIG_DIR = "/etc/checkbot"
|
||||
DEFAULT_DB_PATH = "/var/lib/checkbot/checkbot.db"
|
||||
DEFAULT_BIND_HOST = "127.0.0.1"
|
||||
DEFAULT_BIND_PORT = "8080"
|
||||
|
||||
MASTER_KEY_FILE = "master.key"
|
||||
MASTER_KEY_LEN = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileConfig:
|
||||
config_dir: Path
|
||||
db_path: Path
|
||||
bind_host: str
|
||||
bind_port: int
|
||||
|
||||
|
||||
def load_file_config() -> FileConfig:
|
||||
return FileConfig(
|
||||
config_dir=Path(os.environ.get(ENV_CONFIG_DIR, DEFAULT_CONFIG_DIR)),
|
||||
db_path=Path(os.environ.get(ENV_DB_PATH, DEFAULT_DB_PATH)),
|
||||
bind_host=os.environ.get(ENV_BIND_HOST, DEFAULT_BIND_HOST),
|
||||
bind_port=int(os.environ.get(ENV_BIND_PORT, DEFAULT_BIND_PORT)),
|
||||
)
|
||||
|
||||
|
||||
def load_master_key(path: Path) -> bytes:
|
||||
key = base64.b64decode(path.read_text().strip())
|
||||
if len(key) != MASTER_KEY_LEN:
|
||||
raise ValueError(f"master key in {path} must be {MASTER_KEY_LEN} bytes")
|
||||
return key
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from .checkmk import CheckmkClient
|
||||
from .constants import HEALTH_PING
|
||||
from .ollama import OllamaClient
|
||||
|
||||
CHECK_TIMEOUT = 10.0
|
||||
|
||||
Check = Callable[[], Awaitable[str]]
|
||||
|
||||
|
||||
class NotConfigured(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def run_checks(checks: dict[str, Check]) -> dict:
|
||||
async def run_one(check: Check) -> dict:
|
||||
try:
|
||||
detail = await asyncio.wait_for(check(), CHECK_TIMEOUT)
|
||||
return {"ok": True, "detail": detail}
|
||||
except NotConfigured:
|
||||
return {"ok": False, "detail": "nicht konfiguriert"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "detail": "Timeout"}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": f"{type(exc).__name__}: {exc}"[:200]}
|
||||
|
||||
names = list(checks)
|
||||
results = await asyncio.gather(*(run_one(checks[name]) for name in names))
|
||||
by_name = dict(zip(names, results))
|
||||
return {"ok": all(r["ok"] for r in results), "checks": by_name}
|
||||
|
||||
|
||||
def build_checks(store) -> dict[str, Check]:
|
||||
async def db() -> str:
|
||||
store.get(HEALTH_PING)
|
||||
return "ok"
|
||||
|
||||
async def checkmk() -> str:
|
||||
client = CheckmkClient.from_settings(store)
|
||||
if client is None:
|
||||
raise NotConfigured
|
||||
try:
|
||||
info = await client.version()
|
||||
return f"Checkmk {info.get('versions', {}).get('checkmk', '?')}"
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
async def ollama() -> str:
|
||||
client = OllamaClient.from_settings(store)
|
||||
if client is None:
|
||||
raise NotConfigured
|
||||
try:
|
||||
return f"Ollama {await client.version()}"
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
return {"db": db, "checkmk": checkmk, "ollama": ollama}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .crypto import SecretBox
|
||||
from .db import Database
|
||||
from .fileconfig import MASTER_KEY_FILE, load_file_config, load_master_key
|
||||
from .health import build_checks, run_checks
|
||||
from .settings import SettingsStore
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
config = load_file_config()
|
||||
box = SecretBox(load_master_key(config.config_dir / MASTER_KEY_FILE))
|
||||
db = Database(config.db_path)
|
||||
store = SettingsStore(db, box)
|
||||
|
||||
app = FastAPI(title="checkbot")
|
||||
app.state.store = store
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
result = await run_checks(build_checks(store))
|
||||
return JSONResponse(result, status_code=200 if result["ok"] else 503)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,79 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from .constants import DEFAULT_OLLAMA_TIMEOUT, OLLAMA_CA_FILE, OLLAMA_TIMEOUT, OLLAMA_TOKEN, OLLAMA_URL
|
||||
|
||||
VERSION_PATH = "/api/version"
|
||||
TAGS_PATH = "/api/tags"
|
||||
CHAT_PATH = "/api/chat"
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
DEFAULT_RETRIES = 2
|
||||
DEFAULT_BACKOFF = 1.0
|
||||
SERVER_ERROR_MIN = 500
|
||||
CLIENT_ERROR_MIN = 400
|
||||
|
||||
|
||||
class OllamaError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self, base_url: str, token: str | None = None, timeout: float = DEFAULT_TIMEOUT,
|
||||
retries: int = DEFAULT_RETRIES, backoff: float = DEFAULT_BACKOFF,
|
||||
verify: bool | str = True,
|
||||
transport: httpx.AsyncBaseTransport | None = None):
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url.rstrip("/"), headers=headers, timeout=timeout,
|
||||
verify=verify, transport=transport,
|
||||
)
|
||||
self._retries = retries
|
||||
self._backoff = backoff
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, store) -> "OllamaClient | None":
|
||||
url = store.get(OLLAMA_URL)
|
||||
if not url:
|
||||
return None
|
||||
timeout = float(store.get(OLLAMA_TIMEOUT, DEFAULT_OLLAMA_TIMEOUT))
|
||||
verify: bool | str = store.get(OLLAMA_CA_FILE) or True
|
||||
return cls(url, store.get(OLLAMA_TOKEN), timeout=timeout, verify=verify)
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
last: Exception | None = None
|
||||
for attempt in range(self._retries + 1):
|
||||
if attempt:
|
||||
await asyncio.sleep(self._backoff * attempt)
|
||||
try:
|
||||
response = await self._client.request(method, path, **kwargs)
|
||||
except httpx.HTTPError as exc:
|
||||
last = OllamaError(f"{type(exc).__name__}: {exc}")
|
||||
continue
|
||||
if response.status_code >= SERVER_ERROR_MIN:
|
||||
last = OllamaError(f"HTTP {response.status_code} on {path}")
|
||||
continue
|
||||
if response.status_code >= CLIENT_ERROR_MIN:
|
||||
raise OllamaError(f"HTTP {response.status_code} on {path}: {response.text[:200]}")
|
||||
return response.json()
|
||||
raise last or OllamaError("request failed")
|
||||
|
||||
async def version(self) -> str:
|
||||
return (await self._request("GET", VERSION_PATH))["version"]
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
body = await self._request("GET", TAGS_PATH)
|
||||
return [model["name"] for model in body.get("models", [])]
|
||||
|
||||
async def chat(self, model: str, messages: list[dict], tools: list[dict] | None = None,
|
||||
options: dict | None = None) -> dict:
|
||||
payload: dict = {"model": model, "messages": messages, "stream": False}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
if options:
|
||||
payload["options"] = options
|
||||
body = await self._request("POST", CHAT_PATH, json=payload)
|
||||
return body["message"]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,38 @@
|
||||
import time
|
||||
|
||||
from . import audit
|
||||
from .crypto import SecretBox
|
||||
from .db import Database
|
||||
|
||||
MASK = "***"
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
def __init__(self, db: Database, box: SecretBox):
|
||||
self._db = db
|
||||
self._box = box
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
rows = self._db.query("SELECT value, is_secret FROM settings WHERE key=?", (key,))
|
||||
if not rows:
|
||||
return default
|
||||
value, is_secret = rows[0]["value"], rows[0]["is_secret"]
|
||||
return self._box.decrypt(value, key) if is_secret else value
|
||||
|
||||
def is_set(self, key: str) -> bool:
|
||||
return bool(self._db.query("SELECT 1 FROM settings WHERE key=?", (key,)))
|
||||
|
||||
def set(self, key: str, value: str, user: str, secret: bool = False) -> None:
|
||||
existed = self.is_set(key)
|
||||
old = self.get(key)
|
||||
stored = self._box.encrypt(value, key) if secret else value
|
||||
self._db.execute(
|
||||
"INSERT INTO settings(key, value, is_secret, updated_at) VALUES (?,?,?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, "
|
||||
"is_secret=excluded.is_secret, updated_at=excluded.updated_at",
|
||||
(key, stored, int(secret), int(time.time())),
|
||||
)
|
||||
if secret:
|
||||
audit.record(self._db, user, "setting.set", key, MASK if existed else None, MASK)
|
||||
else:
|
||||
audit.record(self._db, user, "setting.set", key, old, value)
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.24
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
httpx>=0.27
|
||||
cryptography>=43
|
||||
jinja2>=3.1
|
||||
argon2-cffi>=23.1
|
||||
itsdangerous>=2.2
|
||||
python-multipart>=0.0.9
|
||||
Whitespace-only changes.
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.checkmk import CheckmkClient, CheckmkError
|
||||
|
||||
|
||||
def make_client(handler):
|
||||
return CheckmkClient("https://cmk.local", "prod", "bot", "s3cret",
|
||||
transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
async def test_problem_services_parsed_and_request_shape():
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers["authorization"]
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"value": [
|
||||
{"extensions": {
|
||||
"host_name": "web01", "description": "CPU load", "state": 2,
|
||||
"state_type": 1, "plugin_output": "CRIT - load 9",
|
||||
"last_state_change": 1730000000, "scheduled_downtime_depth": 1,
|
||||
"is_flapping": 0,
|
||||
}},
|
||||
{"extensions": {
|
||||
"host_name": "db01", "description": "Disk", "state": 1,
|
||||
"state_type": 0, "plugin_output": "WARN", "last_state_change": 5,
|
||||
"scheduled_downtime_depth": 0, "is_flapping": 1,
|
||||
}},
|
||||
]})
|
||||
|
||||
services = await make_client(handler).problem_services()
|
||||
|
||||
assert seen["url"] == "https://cmk.local/prod/check_mk/api/1.0/domain-types/service/collections/all"
|
||||
assert seen["auth"] == "Bearer bot s3cret"
|
||||
assert seen["body"]["query"] == {"op": "!=", "left": "state", "right": "0"}
|
||||
web, db = services
|
||||
assert (web.host, web.service, web.state, web.hard, web.in_downtime, web.flapping) == \
|
||||
("web01", "CPU load", 2, True, True, False)
|
||||
assert (db.hard, db.in_downtime, db.flapping) == (False, False, True)
|
||||
|
||||
|
||||
async def test_version():
|
||||
client = make_client(lambda r: httpx.Response(200, json={"versions": {"checkmk": "2.3.0p5"}}))
|
||||
assert (await client.version())["versions"]["checkmk"] == "2.3.0p5"
|
||||
|
||||
|
||||
async def test_http_error_raises():
|
||||
client = make_client(lambda r: httpx.Response(401, text="unauthorized"))
|
||||
with pytest.raises(CheckmkError, match="401"):
|
||||
await client.version()
|
||||
|
||||
|
||||
async def test_transport_error_raises():
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
with pytest.raises(CheckmkError, match="ConnectError"):
|
||||
await make_client(handler).version()
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidTag
|
||||
|
||||
from app import audit
|
||||
from app.constants import CHECKMK_SECRET, OLLAMA_URL
|
||||
from app.crypto import SecretBox
|
||||
from app.db import Database
|
||||
from app.settings import SettingsStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def box():
|
||||
return SecretBox(os.urandom(32))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(box):
|
||||
return SettingsStore(Database(":memory:"), box)
|
||||
|
||||
|
||||
def test_roundtrip(box):
|
||||
token = box.encrypt("geheim", "ctx")
|
||||
assert "geheim" not in token
|
||||
assert box.decrypt(token, "ctx") == "geheim"
|
||||
|
||||
|
||||
def test_context_mismatch_fails(box):
|
||||
token = box.encrypt("geheim", "a")
|
||||
with pytest.raises(InvalidTag):
|
||||
box.decrypt(token, "b")
|
||||
|
||||
|
||||
def test_tampered_token_fails(box):
|
||||
token = box.encrypt("geheim", "ctx")
|
||||
other = SecretBox(os.urandom(32))
|
||||
with pytest.raises(InvalidTag):
|
||||
other.decrypt(token, "ctx")
|
||||
|
||||
|
||||
def test_plain_setting_roundtrip(store):
|
||||
store.set(OLLAMA_URL, "http://x:11434", user="admin")
|
||||
assert store.get(OLLAMA_URL) == "http://x:11434"
|
||||
assert store.get("missing", "dflt") == "dflt"
|
||||
|
||||
|
||||
def test_secret_encrypted_at_rest(store):
|
||||
store.set(CHECKMK_SECRET, "topsecret", user="admin", secret=True)
|
||||
raw = store._db.query("SELECT value, is_secret FROM settings WHERE key=?", (CHECKMK_SECRET,))[0]
|
||||
assert raw["is_secret"] == 1
|
||||
assert "topsecret" not in raw["value"]
|
||||
assert store.get(CHECKMK_SECRET) == "topsecret"
|
||||
|
||||
|
||||
def test_audit_masks_secrets(store):
|
||||
store.set(CHECKMK_SECRET, "one", user="admin", secret=True)
|
||||
store.set(CHECKMK_SECRET, "two", user="admin", secret=True)
|
||||
entries = audit.recent(store._db)
|
||||
dumped = str(entries)
|
||||
assert "one" not in dumped and "two" not in dumped
|
||||
assert entries[0]["old_value"] == "***" and entries[0]["new_value"] == "***"
|
||||
assert entries[1]["old_value"] is None and entries[1]["new_value"] == "***"
|
||||
|
||||
|
||||
def test_audit_records_plain_change(store):
|
||||
store.set(OLLAMA_URL, "http://a", user="admin")
|
||||
store.set(OLLAMA_URL, "http://b", user="admin")
|
||||
latest = audit.recent(store._db)[0]
|
||||
assert (latest["old_value"], latest["new_value"], latest["user"]) == ("http://a", "http://b", "admin")
|
||||
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
|
||||
from app import health
|
||||
from app.health import NotConfigured, run_checks
|
||||
|
||||
|
||||
async def test_all_ok():
|
||||
async def ok():
|
||||
return "fine"
|
||||
|
||||
result = await run_checks({"a": ok, "b": ok})
|
||||
assert result["ok"] is True
|
||||
assert result["checks"]["a"] == {"ok": True, "detail": "fine"}
|
||||
|
||||
|
||||
async def test_failure_and_not_configured_flip_overall():
|
||||
async def ok():
|
||||
return "fine"
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("kaputt")
|
||||
|
||||
async def missing():
|
||||
raise NotConfigured
|
||||
|
||||
result = await run_checks({"ok": ok, "boom": boom, "missing": missing})
|
||||
assert result["ok"] is False
|
||||
assert result["checks"]["boom"] == {"ok": False, "detail": "RuntimeError: kaputt"}
|
||||
assert result["checks"]["missing"]["detail"] == "nicht konfiguriert"
|
||||
assert result["checks"]["ok"]["ok"] is True
|
||||
|
||||
|
||||
async def test_timeout(monkeypatch):
|
||||
monkeypatch.setattr(health, "CHECK_TIMEOUT", 0.05)
|
||||
|
||||
async def slow():
|
||||
await asyncio.sleep(1)
|
||||
return "never"
|
||||
|
||||
result = await run_checks({"slow": slow})
|
||||
assert result["checks"]["slow"] == {"ok": False, "detail": "Timeout"}
|
||||
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.ollama import OllamaClient, OllamaError
|
||||
|
||||
|
||||
def make_client(handler, **kwargs):
|
||||
return OllamaClient("http://ollama:11434", token="tok", backoff=0,
|
||||
transport=httpx.MockTransport(handler), **kwargs)
|
||||
|
||||
|
||||
async def test_chat_payload_and_message():
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["auth"] = request.headers["authorization"]
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"message": {"role": "assistant", "content": "hi"}})
|
||||
|
||||
msg = await make_client(handler).chat("qwen3", [{"role": "user", "content": "x"}])
|
||||
|
||||
assert msg["content"] == "hi"
|
||||
assert seen["auth"] == "Bearer tok"
|
||||
assert seen["body"]["stream"] is False and seen["body"]["model"] == "qwen3"
|
||||
assert "tools" not in seen["body"]
|
||||
|
||||
|
||||
async def test_retry_on_5xx_then_success():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(1)
|
||||
if len(calls) < 3:
|
||||
return httpx.Response(503)
|
||||
return httpx.Response(200, json={"version": "0.5.0"})
|
||||
|
||||
assert await make_client(handler).version() == "0.5.0"
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
async def test_gives_up_after_retries():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(1)
|
||||
return httpx.Response(500)
|
||||
|
||||
with pytest.raises(OllamaError, match="500"):
|
||||
await make_client(handler).version()
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
async def test_no_retry_on_4xx():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(1)
|
||||
return httpx.Response(404, text="model not found")
|
||||
|
||||
with pytest.raises(OllamaError, match="404"):
|
||||
await make_client(handler).chat("nope", [])
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_list_models():
|
||||
client = make_client(lambda r: httpx.Response(200, json={"models": [{"name": "qwen3"}, {"name": "hermes3"}]}))
|
||||
assert await client.list_models() == ["qwen3", "hermes3"]
|
||||
|
||||
|
||||
async def test_transport_error_retried_then_raises():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(1)
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
with pytest.raises(OllamaError, match="ConnectError"):
|
||||
await make_client(handler).version()
|
||||
assert len(calls) == 3
|
||||
Reference in new issue
Block a user