commit 6e244234eb721b134bdacd22d8003d7c9edad26e Author: patrick Date: Thu Nov 5 23:38:38 2026 +0100 feat: Phase 1 scaffold (secrets, db, Checkmk/Ollama clients, health) Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..271c07d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +.pytest_cache/ +*.db +*.db-wal +*.db-shm +master.key +DEVLOG.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4419268 --- /dev/null +++ b/CLAUDE.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/ +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audit.py b/app/audit.py new file mode 100644 index 0000000..f196f90 --- /dev/null +++ b/app/audit.py @@ -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] diff --git a/app/checkmk.py b/app/checkmk.py new file mode 100644 index 0000000..e7ca2f8 --- /dev/null +++ b/app/checkmk.py @@ -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() diff --git a/app/constants.py b/app/constants.py new file mode 100644 index 0000000..5a28ae6 --- /dev/null +++ b/app/constants.py @@ -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" diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..98b743b --- /dev/null +++ b/app/crypto.py @@ -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() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..82e3b7f --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/fileconfig.py b/app/fileconfig.py new file mode 100644 index 0000000..9d29a11 --- /dev/null +++ b/app/fileconfig.py @@ -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 diff --git a/app/health.py b/app/health.py new file mode 100644 index 0000000..9a6ef95 --- /dev/null +++ b/app/health.py @@ -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} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..37c8e91 --- /dev/null +++ b/app/main.py @@ -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 diff --git a/app/ollama.py b/app/ollama.py new file mode 100644 index 0000000..8257f9d --- /dev/null +++ b/app/ollama.py @@ -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() diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..e9a7b99 --- /dev/null +++ b/app/settings.py @@ -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) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..464391a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8 +pytest-asyncio>=0.24 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0ee1d65 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_checkmk.py b/tests/test_checkmk.py new file mode 100644 index 0000000..0cb57bd --- /dev/null +++ b/tests/test_checkmk.py @@ -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() diff --git a/tests/test_crypto_settings.py b/tests/test_crypto_settings.py new file mode 100644 index 0000000..0851e6b --- /dev/null +++ b/tests/test_crypto_settings.py @@ -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") diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..819a8f3 --- /dev/null +++ b/tests/test_health.py @@ -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"} diff --git a/tests/test_ollama.py b/tests/test_ollama.py new file mode 100644 index 0000000..91b3931 --- /dev/null +++ b/tests/test_ollama.py @@ -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