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:
patrickandClaude Sonnet 5.5 committed 2026-11-05 23:38:38 +01:00
commit 6e244234eb
21 files changed
+788

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.venv/
__pycache__/
.pytest_cache/
*.db
*.db-wal
*.db-shm
master.key
DEVLOG.md
+45
View File
@@ -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/
```
View File
Whitespace-only changes.
+19
View File
@@ -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
View File
@@ -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()
+18
View File
@@ -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"
+24
View File
@@ -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()
+58
View File
@@ -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()
+41
View File
@@ -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
+59
View File
@@ -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
View File
@@ -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
+79
View File
@@ -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()
+38
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8
pytest-asyncio>=0.24
+8
View File
@@ -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
View File
Whitespace-only changes.
+62
View File
@@ -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()
+70
View File
@@ -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")
+41
View File
@@ -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"}
+81
View File
@@ -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