feat: Phase 6d API-Selbsttest gegen Checkmk
Lesender Selbsttest (Version, Services, Hosts, Downtimes) beim Start und täglich 04:00, Seite /selftest, Teilcheck checkmk_api in /health, Chat-Meldung bei Wechsel. Toleranter Antwort-Parser (value/members, state_type int/String), Fallbacks für Host-Abfrage und Downtime-Liste, Fehlerhinweise je HTTP-Status. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cdcf7f673a
commit
453c077386
31 files changed
+1249
-31
No files matched your search
@@ -6,7 +6,7 @@ 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.
|
||||
- Stand: Phase 1 bis 6, 6b und 6c als Code, Phase 7 als Dateien geschrieben, nichts ausgeführt oder getestet (siehe README-Statustabelle).
|
||||
- Stand: Phase 1 bis 6, 6b, 6c und 6d als Code, Phase 7 als Dateien geschrieben, nichts ausgeführt oder getestet (siehe README-Statustabelle).
|
||||
- Code-Kommentare nur bei echtem WHY.
|
||||
|
||||
## Festgelegte Entscheidungen
|
||||
@@ -91,6 +91,17 @@ tests/
|
||||
- Annahmen: Host-Abruf `POST /domain-types/host/collections/all` mit Query `state != 0` und Spalten `name`, `state`, `plugin_output`, `last_state_change`, `scheduled_downtime_depth`, `acknowledged`; Service-Spalte `acknowledged`; Host-State 0/1/2 = UP/DOWN/UNREACH (intern 0/2/3); Hosts gelten immer als hart. Alles ungeprüft gegen 2.5 (Phase 6d prüft).
|
||||
- Abweichung: Datenschutzmodus gilt je Backend, nicht je einzelnem Ziel (Ziele stehen in Regeln als freie Zeilen). Ein vom Sendelimit verworfenes Problem zählt im Speicher des Gates als gemeldet (die Recovery kommt).
|
||||
|
||||
## Phase 6d API-Selbsttest (Code geschrieben, ungetestet)
|
||||
- Migration Index 9 (zehnter Eintrag in `app/migrations.py`): Tabelle `selftest_state` (Einzelzeile id=1: ts, version, checks als JSON-Liste `{name, status, text}`), analog zu `retention_state`.
|
||||
- Neues Modul `app/selftest/`: `constants.py` (Status `ok`/`abweichend`/`fehler`, Teilcheck-Namen `version`/`services`/`hosts`/`downtimes`, Zeitplan-Konstanten), `model.py` (`CheckResult`, `SelftestRun` mit `overall_status`/`ok`), `repository.py` (`SelftestRepository`, liest/schreibt die Einzelzeile), `checks.py` (die vier Teilchecks, rein lesend, unterscheiden `nicht erreichbar` von `Antwort unerwartet` über `CheckmkUnreachable`/`CheckmkError`, prüfen Pflicht- und optionale Spalten im rohen JSON vor dem Parsen), `service.py` (`SelftestService.run()` ruft die vier Teilchecks auf, vergleicht die Version mit dem letzten Lauf, speichert), `notify.py` (`message_for`: Text nur bei Statuswechsel, Rückkehr zu ok oder Versionswechsel, sonst `None`), `runner.py` (`SelftestRunner`: Zeitplan wie `RetentionRunner`, aber Start-Verzögerung 20 s statt 60 s und täglich ab 04:00 statt 03:00; manueller Lauf über `run_manual` mit Audit `selftest.run`, Rate-Limit 1 pro 30 s über `SlidingWindowLimiter`).
|
||||
- `app/checkmk.py`: `_items`/`extract_extensions` akzeptieren `value` oder `members`; `_is_hard` über die benannte Konstante `HARD_STATE_TYPES` (`"1"`/`"hard"`, alles andere inkl. `None` gilt als soft, stürzt nicht ab); `CheckmkError` bekommt zusätzlich `.hint` (lesbarer Hinweis je HTTP-Status 401/403/404/422/5xx über `status_hint`), `.brief` bleibt wortgleich wie bisher, damit bestehende Tests und Chat-Texte unverändert bleiben. Neue, nur für den Selbsttest gedachte Zusatzpfade, die das reguläre Verhalten nicht verändern: `problem_hosts_with_fallback`/`selftest_hosts` (POST scheitert → GET mit `columns=` und clientseitigem Filter `state != 0`, der funktionierende Weg wird in `_host_query_mode` gemerkt und von `problem_hosts()` beim nächsten Aufruf zuerst versucht), `selftest_services` (rohe Zeilen plus best-effort Parse, Parsefehler werden abgefangen statt den Selbsttest abzubrechen), `downtimes_with_fallback` (scheitert `downtime_type` → GET mit `query` als JSON-String, `is_service` 0/1), `downtimes_from_states` (reine Funktion, leitet Downtimes aus `scheduled_downtime_depth` der schon gelesenen Service-/Host-Listen ab, letzter Ausweg).
|
||||
- `app/health.py`: neuer Teilcheck `checkmk_api`, liest nur den letzten gespeicherten Selbsttest-Lauf (kein eigener API-Aufruf). Checkmk nicht konfiguriert oder kein `selftest_repo` übergeben → `NotConfigured`; noch kein Lauf → `NotConfigured("noch nicht gelaufen")` (gleiche Behandlung wie "nicht konfiguriert", dafür liest `run_checks` jetzt den Text aus der Exception statt ihn fest zu verdrahten); Status `abweichend` oder `fehler` → Fehler (503 in `/health`). `build_checks(store, poller_status=None, selftest_repo=None)` ist rückwärtskompatibel (fehlender `selftest_repo` ergibt stets "nicht konfiguriert").
|
||||
- `app/runtime.py`: `Runtime.selftest_repo`/`.selftest`/`.selftest_runner`, Task im Lifespan wie `retention_task` (Cancel in `finally`). `app/alerts/system.py` bekommt `SystemNotifier.selftest(ok, text)` (eigener Service-Name `Selbsttest` in `system_text.py`, Art `system`, wie die Checkmk-Ausfallmeldung).
|
||||
- Settings `selftest.enabled` (Default an, schaltet nur den automatischen Zeitplan ab, ein manueller Lauf funktioniert immer) und `selftest.notify` (Default an) in neuer Section "Selbsttest" (`app/web/selftest_section.py`, `/settings/selftest`).
|
||||
- UI `/selftest` (`app/web/selftest_pages.py` + `selftest.html`): Tabelle der vier Teilchecks mit Ampel-Badge, Checkmk-Version, Zeitpunkt des letzten Laufs; Fallback-Hinweise stehen im Text des jeweiligen Teilchecks. Lesbar für alle angemeldeten Rollen, Button "Selbsttest jetzt ausführen" nur für Admins (CSRF über die globale FastAPI-Dependency, Rate-Limit 1 pro 30 s). Menüeinträge `/selftest` (alle) und `/settings/selftest` (Admins).
|
||||
- `deploy/checkmk/checkbot-local-check`: neuer Service `Checkbot_checkmk_api`, Detailtext `noch nicht gelaufen` wird wie `nicht konfiguriert` als WARN gewertet (`WARN_DETAILS`).
|
||||
- Annahmen/Abweichungen: "sofort bei Versionswechsel" löst keinen zusätzlichen Sofort-Lauf aus, sondern markiert nur den laufenden Lauf als Versionswechsel für die Chat-Meldung (im Auftrag so vorgesehen: "sofort erneut laufen lassen bzw. den aktuellen Lauf kennzeichnen"). Fehlt der Parser-Vergleich an Spalten, weil aktuell keine Problem-Services/-Hosts vorliegen (leere Ergebnisliste), kann der jeweilige Teilcheck die Spalten nicht prüfen und meldet `ok`, auch wenn eine Spalte in Wahrheit fehlen würde – eine Grenze des Ansatzes, der nur die echten Problem-Listen liest statt eines eigenen Schema-Endpunkts. `.hint` an `CheckmkError` ist zusätzlich zu `.brief`, nicht statt dessen, damit bestehende Tests mit exaktem `brief`-Text (z. B. `"HTTP 401"`) unverändert bleiben.
|
||||
|
||||
## Weiterer Weg (entschieden)
|
||||
Reihenfolge: Testbasis, erster Lauf im Test-LXC (`pytest`), Korrekturrunde, Phase 6, Phase 6b, Phase 6c, Phase 8, Abnahme, Parallelbetrieb, Tag `v0.1.0`. Verfeinerungen passieren am Live-System, nicht vorab.
|
||||
- Testbasis: `pytest` manuell im Test-LXC (Dev-venv mit `requirements-dev.txt`), dazu `.gitea/workflows/test.yml` für einen späteren Gitea-Actions-Runner. Logging als Zeilenlog nach journald, Level über `CHECKBOT_LOG_LEVEL` (Default INFO), nie Secrets in Logs (Test für die Adapter). Zeitzone über `CHECKBOT_TZ` im Setup-Skript, Default `Europe/Berlin`.
|
||||
@@ -113,9 +124,9 @@ Reihenfolge: Testbasis, erster Lauf im Test-LXC (`pytest`), Korrekturrunde, Phas
|
||||
- Delta in der Alarmnachricht (Phase 6b): `Event` bekommt Vorzustand und Dauer. Alarmzeile zeigt Übergang und Dauer (`WARN → CRIT, seit 18:05 (vorher WARN seit 2 h)`), Recovery die Gesamtdauer (`wieder OK nach 3 h 12 min (zuletzt CRIT)`), Erinnerungen `weiter CRIT seit 4 h`.
|
||||
- Recovery-Meldungen sind in der UI schaltbar: globaler Schalter `alerts.recovery_enabled` (Default an) in der Section "Alarmierung" und pro Regel über die Ereignisarten (`kinds` enthält `recovery` oder nicht). Greift zusätzlich zur Regel "Recovery nur, wenn das Problem vorher gemeldet wurde". Ist Recovery aus, bleibt sie im Verlauf sichtbar, wird aber nicht gesendet.
|
||||
- Verzögerung (Phase 6b): Section "Alarmierung" bekommt `alerts.default_delay_minutes` (Werkseinstellung 15; ersetzt `poll.min_duration`). Jede Regel kann die Verzögerung überschreiben, auch mit 0. Passen mehrere Regeln, gilt die kleinste. Sie zählt ab der letzten Statusänderung, eine Verschlechterung startet sie neu; wird das Problem vorher OK, gibt es weder Alarm noch Recovery. Das Dashboard zeigt wartende Probleme ("wartet noch 8 min"). Mute und Downtime wirken auch während der Wartezeit. Host-Alarme und der Checkmk-Ausfall (Phase 6c) kommen immer sofort, Host-Verzögerung `alerts.host_delay_minutes` Standard 0. Die vorinstallierte Regel "ZFS Zpool" (`*zpool*`) hat Verzögerung 0, damit gilt ab Werk: Zpool sofort, alles andere nach 15 min.
|
||||
- Phase 6d (nach 6c): API-Selbsttest. Lesend beim Start, täglich 04:00 und sofort bei Checkmk-Versionswechsel: `/version`, Service- und Host-Abfrage (alle benötigten Spalten in der Antwort), Downtime-Abruf (GET). Ergebnis auf einer UI-Seite, Teilcheck `checkmk_api` in `/health`, Chat-Meldung bei Fehlschlag oder Versionswechsel (und bei Rückkehr zu ok). Unterscheidet "nicht erreichbar" von "Antwort unerwartet".
|
||||
- Aufgenommen nach Prüfung des Referenzrepos `jlk/checkmk-llm-server` (MIT, nur als Referenz; es belegt weder Host-Live-Status noch die Downtime-Liste): (a) toleranter Antwort-Parser, der `value` oder `members` akzeptiert und `state_type` als int oder String (`hard`/`soft`) zulässt; (b) Downtime-Fallback: scheitert `GET /domain-types/downtime/collections/all`, ermittelt der Bot Downtimes aus `scheduled_downtime_depth > 0` der Service- und Host-Abfrage und meldet das im Selbsttest als Abweichung; (c) lesbare Fehlerhinweise je Status (401, 403, 404, 422, 5xx) statt nur `HTTP <code>`. Die Spalten `last_state_change`, `is_flapping`, `state_type` und der Host-Endpunkt bleiben bis zum Selbsttest gegen 2.5 ungeprüft.
|
||||
- Zweite Referenz `chexma/vibeMK` (GPL-3.0, nur Wissen, kein Code): Host-Live-Status dort per **GET** `/domain-types/host/collections/all` mit `columns=name,state,acknowledged,scheduled_downtime_depth`, State 0 clientseitig verworfen, Host-State 0/1/2 = UP/DOWN/UNREACH (bestätigt); Downtime-Liste per GET `/domain-types/downtime/collections/all` mit `query` (`host_name`, `is_service`), `downtime_type` als Listenfilter dort nicht belegt; `state_type` int 0 = soft, 1 = hard. Fallback im Selbsttest: scheitert der POST der Host-Abfrage, GET mit `columns` und clientseitigem Filter; scheitert der Downtime-Abruf mit `downtime_type`, GET mit `query` und `is_service`.
|
||||
- Phase 6d (nach 6c): API-Selbsttest. Lesend beim Start, täglich 04:00 und sofort bei Checkmk-Versionswechsel: `/version`, Service- und Host-Abfrage (alle benötigten Spalten in der Antwort), Downtime-Abruf (GET). Ergebnis auf einer UI-Seite, Teilcheck `checkmk_api` in `/health`, Chat-Meldung bei Fehlschlag oder Versionswechsel (und bei Rückkehr zu ok). Unterscheidet "nicht erreichbar" von "Antwort unerwartet". **Erledigt (Code geschrieben, ungetestet), Details und Annahmen siehe Abschnitt "Phase 6d API-Selbsttest" oben.**
|
||||
- Aufgenommen nach Prüfung des Referenzrepos `jlk/checkmk-llm-server` (MIT, nur als Referenz; es belegt weder Host-Live-Status noch die Downtime-Liste): (a) toleranter Antwort-Parser, der `value` oder `members` akzeptiert und `state_type` als int oder String (`hard`/`soft`) zulässt; (b) Downtime-Fallback: scheitert `GET /domain-types/downtime/collections/all`, ermittelt der Bot Downtimes aus `scheduled_downtime_depth > 0` der Service- und Host-Abfrage und meldet das im Selbsttest als Abweichung; (c) lesbare Fehlerhinweise je Status (401, 403, 404, 422, 5xx) statt nur `HTTP <code>`. Die Spalten `last_state_change`, `is_flapping`, `state_type` und der Host-Endpunkt bleiben bis zum Selbsttest gegen 2.5 ungeprüft. (umgesetzt: `extract_extensions`, `_is_hard`/`HARD_STATE_TYPES`, `downtimes_from_states`, `status_hint` in `app/checkmk.py`.)
|
||||
- Zweite Referenz `chexma/vibeMK` (GPL-3.0, nur Wissen, kein Code): Host-Live-Status dort per **GET** `/domain-types/host/collections/all` mit `columns=name,state,acknowledged,scheduled_downtime_depth`, State 0 clientseitig verworfen, Host-State 0/1/2 = UP/DOWN/UNREACH (bestätigt); Downtime-Liste per GET `/domain-types/downtime/collections/all` mit `query` (`host_name`, `is_service`), `downtime_type` als Listenfilter dort nicht belegt; `state_type` int 0 = soft, 1 = hard. Fallback im Selbsttest: scheitert der POST der Host-Abfrage, GET mit `columns` und clientseitigem Filter; scheitert der Downtime-Abruf mit `downtime_type`, GET mit `query` und `is_service`. (umgesetzt: `problem_hosts_with_fallback`/`selftest_hosts`, `downtimes_with_fallback`/`_downtimes_by_query` in `app/checkmk.py`.)
|
||||
- Entschieden nach Phase 6b: Erinnerung pro Regel: leer = Standard, 0 = aus, >0 = Stunden. Keine Recovery ohne vorherigen Alarm bleibt so. Migrationen laufen künftig in einer Transaktion pro Migration mit Rollback. `deploy/first-run.sh` richtet das Dev-venv ein, lässt `pytest` laufen und packt Log, Python-Version, Paketliste und Git-Stand ohne Secrets in ein Archiv. Zugriff auf den Test-LXC per SSH nur dort, eigener Benutzer ohne Root, jeder Befehl mit Freigabe. Nach dem ersten grünen `pytest` und vor der Abnahme: `docs/THREAT_MODEL.md` (STRIDE) und ein einmaliger Sicherheits-Review des gesamten Codes.
|
||||
- Vorgemerkt, noch NICHT entschieden (Phase 10, Idee): Chat mit dem Agenten ("Hermes") in der UI. Heute ist der Agent nur per Telegram/Matrix (`/ask`, `/explain`, Tiefenanalyse) und als Einzelfrage-Testlauf unter `/prompts` erreichbar. Vorschläge zur späteren Entscheidung: nur Admins (Schalter `ui.chat_viewers_allowed`, Standard aus); 6 Runden Kontext, Gespräche mit Gesprächs-ID in `agent_runs`, Nutzer sehen eigene Gespräche, Admins alle unter `/agent`, Aufbewahrung 90 Tage, Rate-Limit 5 pro Minute; Antwort nach Abschluss ohne Streaming ("läuft …" mit Abbrechen, max. 90 s), Tool-Schritte aufklappbar. Das Agentenmodell ist in der Section "Ollama" einstellbar (auch ein Hermes-Modell). Erst nach der Abnahme angehen.
|
||||
- Aktionen: Der Bot führt nie Shell-Befehle aus (weder lokal noch per SSH). Webhook pro Regel (POST JSON an Allowlist-URLs, Audit) kommt erst nach der Abnahme.
|
||||
|
||||
@@ -86,6 +86,12 @@ Bot für Checkmk. Er liest den Zustand per REST-API, lässt Probleme von einem e
|
||||
- Datenschutz je Backend: Telegram und Matrix haben die Einstellung "Inhalt der Alarmnachrichten": `voll` (Standard) oder `kurz` (Host, Service, Status und Verlaufszeile, ohne Plugin-Output und ohne KI-Text). Für `kurz`-Ziele wird keine KI-Analyse nachgereicht. Eine Überschreibung pro einzelnem Ziel gibt es nicht, weil Ziele in Regeln als freie Zeilen stehen; der Modus gilt je Backend.
|
||||
- Link zur Oberfläche: Mit gesetzter "Basis-URL dieser Oberfläche" (Alarmierung, ohne abschließenden Schrägstrich gespeichert) enthält jede Alarmnachricht einen Link "Details" auf `/alerts#alert-<id>` (Telegram als URL-Button in der Button-Reihe, Matrix als Zeile im Text). Lehnt Telegram die URL ab (z. B. localhost), wird die Nachricht ohne Link-Button gesendet. Leer = kein Link.
|
||||
|
||||
### API-Selbsttest (Phase 6d)
|
||||
- Prüft rein lesend, ob Checkmk wie erwartet antwortet: `/version`, Service-Abfrage, Host-Abfrage, Downtime-Abruf. Jeder Teilcheck meldet `ok`, `abweichend` (z. B. optionale Spalte fehlt, Fallback auf GET statt POST oder auf `query` statt `downtime_type`, Downtimes aus `scheduled_downtime_depth` abgeleitet) oder `fehler` (Pflichtspalte fehlt, Checkmk nicht erreichbar oder Antwort unerwartet), nie mit Antwortinhalt im Text.
|
||||
- Läuft kurz nach dem Start, danach täglich um 04:00 Uhr lokaler Zeit und markiert einen Lauf zusätzlich als Versionswechsel, wenn sich `versions.checkmk` gegenüber dem letzten Lauf geändert hat. Button "Selbsttest jetzt ausführen" (UI `/selftest`, nur Admins, höchstens 1x pro 30 s, Audit-Eintrag `selftest.run`).
|
||||
- Ergebnis auf `/selftest` (für alle Rollen lesbar): Status je Teilcheck, Checkmk-Version, Zeitpunkt des letzten Laufs. Teilcheck `checkmk_api` in `/health` liest nur den letzten gespeicherten Lauf (kein eigener API-Aufruf); "noch nicht gelaufen" zählt wie "nicht konfiguriert".
|
||||
- Chat-Meldung (Art `system`, wie die Checkmk-Ausfallmeldung) bei Wechsel auf abweichend/fehler, bei Rückkehr zu ok und bei Versionswechsel, nie bei gleichem Ergebnis zum Vorlauf. Schalter `selftest.enabled` (Zeitplan) und `selftest.notify` (Chat-Meldung) unter "Selbsttest-Einstellungen", beide Standard an.
|
||||
|
||||
### Web-UI
|
||||
- Anmeldung mit lokalen Nutzern (Argon2), optional TOTP. Rollen `admin` und `viewer`.
|
||||
- Einstellungen mit Verbindungstest: Checkmk, Ollama, Telegram, Matrix, Checkmk Downtime. Dazu die Bereiche Alarmierung (Abfrageintervall, Standard-Verzögerung, Host-Verzögerung, Recovery-Schalter, Standard-Erinnerung, Bündelungsfenster, KI-Analysen pro Minute, Sendelimit, Basis-URL, Baseline-Button) und Aufbewahrung.
|
||||
@@ -119,6 +125,7 @@ Web-UI (FastAPI, Jinja2) ---- SQLite (Einstellungen, Verlauf, Audit, Nutzer)
|
||||
| Phase 6: Prompt-Versionierung mit Rollback und Testbereich, Retention | Code geschrieben, ungetestet |
|
||||
| Phase 6b: Regeln (Ausgabefilter, Verzögerung, KI-Hinweis, Erinnerung, laut/leise), Delta, Erinnerungen, Recovery-Schalter, Zpool-Standardregel | Code geschrieben, ungetestet |
|
||||
| Phase 6c: Baseline, Bestätigungen, Host-Alarme, Ausfallmeldung, Sendelimit, Datenschutz je Backend, UI-Link; atomare Migrationen; Erinnerung je Regel | Code geschrieben, ungetestet |
|
||||
| Phase 6d: API-Selbsttest (Version, Service-/Host-Abfrage, Downtime-Abruf, Fallbacks, `/health`-Teilcheck `checkmk_api`, UI `/selftest`) | Code geschrieben, ungetestet |
|
||||
| Phase 7: Deployment (`setup-lxc.sh`, Caddy, systemd, Backup, Checkmk-Local-Check) | Dateien geschrieben, nicht auf einem System ausprobiert |
|
||||
| Phase 8: Integrationstests | geplant |
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ from .delivery import AdapterProvider, Deliverer
|
||||
from .extras import AlertActions
|
||||
from .repository import AlertRepository
|
||||
from .sendlimit import SendLimiter
|
||||
from .system_text import (SERVICE_BASELINE, SERVICE_CHECKMK, SYSTEM_HOST, baseline_message, baseline_plain,
|
||||
outage_plain, restored_plain, wrap)
|
||||
from .system_text import (SERVICE_BASELINE, SERVICE_CHECKMK, SERVICE_SELFTEST, SYSTEM_HOST, baseline_message,
|
||||
baseline_plain, outage_plain, restored_plain, wrap)
|
||||
|
||||
|
||||
class SystemNotifier:
|
||||
@@ -39,6 +39,9 @@ class SystemNotifier:
|
||||
self._on_baseline([row.key for row in rows])
|
||||
await self._post(SERVICE_BASELINE, STATE_OK, baseline_plain(rows), baseline_message(rows))
|
||||
|
||||
async def selftest(self, ok: bool, plain: str) -> None:
|
||||
await self._post(SERVICE_SELFTEST, STATE_OK if ok else STATE_CRIT, plain)
|
||||
|
||||
async def _post(self, service: str, state: int, plain: str, message: str | None = None) -> None:
|
||||
event = Event(ts=int(self._clock()), host=SYSTEM_HOST, service=service, kind=KIND_SYSTEM, state=state,
|
||||
output=plain, ai_enabled=False)
|
||||
|
||||
@@ -12,6 +12,7 @@ SYSTEM_HOST = "checkbot"
|
||||
SERVICE_CHECKMK = "Checkmk-Verbindung"
|
||||
SERVICE_BASELINE = "Baseline"
|
||||
SERVICE_SEND_LIMIT = "Sendelimit"
|
||||
SERVICE_SELFTEST = "Selbsttest"
|
||||
TOP_COUNT = 10
|
||||
BULLET = "•"
|
||||
|
||||
|
||||
+116
-10
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -47,14 +49,36 @@ FAILURE_UNEXPECTED = "unexpected"
|
||||
|
||||
NOT_OK_QUERY = {"op": "!=", "left": "state", "right": str(STATE_OK)}
|
||||
|
||||
HOST_QUERY_POST = "post"
|
||||
HOST_QUERY_GET = "get"
|
||||
|
||||
DOWNTIME_MODE_TYPE = "downtime_type"
|
||||
DOWNTIME_MODE_QUERY = "query"
|
||||
|
||||
# Lesbare Hinweise je HTTP-Status, nur für Menschen (nie Teil von .brief, das bleibt wortgleich wie bisher).
|
||||
STATUS_HINTS = {
|
||||
401: "Zugangsdaten prüfen",
|
||||
403: "Rechte des Automation-Users prüfen",
|
||||
404: "Pfad oder Site prüfen",
|
||||
422: "Anfrage von Checkmk abgelehnt (Format/Spalten prüfen)",
|
||||
}
|
||||
SERVER_ERROR_HINT = "Checkmk-Serverfehler"
|
||||
|
||||
|
||||
def status_hint(status: int) -> str:
|
||||
if status >= 500:
|
||||
return SERVER_ERROR_HINT
|
||||
return STATUS_HINTS.get(status, "")
|
||||
|
||||
|
||||
class CheckmkError(Exception):
|
||||
kind = FAILURE_UNEXPECTED
|
||||
|
||||
def __init__(self, message: str, brief: str = ""):
|
||||
def __init__(self, message: str, brief: str = "", hint: str = ""):
|
||||
super().__init__(message)
|
||||
# Kurztext ohne Antwortinhalt, damit er gefahrlos in Chat-Meldungen steht.
|
||||
self.brief = brief or type(self).__name__
|
||||
self.hint = hint
|
||||
|
||||
|
||||
class CheckmkUnreachable(CheckmkError):
|
||||
@@ -113,8 +137,11 @@ def _equals(column: str, value: str) -> dict:
|
||||
return {"op": "=", "left": column, "right": value}
|
||||
|
||||
|
||||
HARD_STATE_TYPES = frozenset({"1", "hard"})
|
||||
|
||||
|
||||
def _is_hard(value) -> bool:
|
||||
return str(value).lower() in ("1", "hard")
|
||||
return str(value).lower() in HARD_STATE_TYPES
|
||||
|
||||
|
||||
def _parse_service(extensions: dict) -> ServiceState:
|
||||
@@ -142,14 +169,26 @@ def _parse_host(extensions: dict) -> HostState:
|
||||
)
|
||||
|
||||
|
||||
def extract_extensions(body: dict) -> list[dict]:
|
||||
# Checkmk nennt die Collection je Endpunkt mal "value", mal "members".
|
||||
rows = body.get("value", body.get("members", []))
|
||||
return [item["extensions"] for item in rows]
|
||||
|
||||
|
||||
def _items(body, parser) -> list:
|
||||
try:
|
||||
return [parser(item["extensions"]) for item in body.get("value", [])]
|
||||
return [parser(extensions) for extensions in extract_extensions(body)]
|
||||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||
raise CheckmkError(f"Unerwartetes Antwortformat ({type(exc).__name__})",
|
||||
brief="Antwortformat unerwartet") from exc
|
||||
|
||||
|
||||
def downtimes_from_states(services: Sequence[ServiceState], hosts: Sequence[HostState]) -> list[Downtime]:
|
||||
host_downtimes = [Downtime(h.host, None, "", "", "", "") for h in hosts if h.in_downtime]
|
||||
service_downtimes = [Downtime(s.host, s.service, "", "", "", "") for s in services if s.in_downtime]
|
||||
return host_downtimes + service_downtimes
|
||||
|
||||
|
||||
def is_configured(store) -> bool:
|
||||
return all(store.get(key) for key in (CHECKMK_URL, CHECKMK_SITE, CHECKMK_USER, CHECKMK_SECRET))
|
||||
|
||||
@@ -165,6 +204,8 @@ class CheckmkClient:
|
||||
timeout=timeout,
|
||||
transport=transport,
|
||||
)
|
||||
# Merkt sich den zuletzt funktionierenden Weg der Host-Abfrage (siehe problem_hosts_with_fallback).
|
||||
self._host_query_mode = HOST_QUERY_POST
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, store) -> "CheckmkClient | None":
|
||||
@@ -186,7 +227,8 @@ class CheckmkClient:
|
||||
status = response.status_code
|
||||
if status >= 400:
|
||||
error = CheckmkUnreachable if status >= 500 else CheckmkError
|
||||
raise error(f"HTTP {status} on {path}: {response.text[:200]}", brief=f"HTTP {status}")
|
||||
raise error(f"HTTP {status} on {path}: {response.text[:200]}", brief=f"HTTP {status}",
|
||||
hint=status_hint(status))
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
@@ -203,13 +245,58 @@ class CheckmkClient:
|
||||
)
|
||||
return _items(body, _parse_service)
|
||||
|
||||
async def _fetch_hosts_post(self) -> dict:
|
||||
return await self._request("POST", HOSTS_PATH, json={"query": NOT_OK_QUERY, "columns": HOST_COLUMNS})
|
||||
|
||||
async def _fetch_hosts_get(self) -> dict:
|
||||
return await self._request("GET", HOSTS_PATH, params={"columns": ",".join(HOST_COLUMNS)})
|
||||
|
||||
async def problem_hosts(self) -> list[HostState]:
|
||||
body = await self._request(
|
||||
"POST",
|
||||
HOSTS_PATH,
|
||||
json={"query": NOT_OK_QUERY, "columns": HOST_COLUMNS},
|
||||
)
|
||||
return _items(body, _parse_host)
|
||||
if self._host_query_mode == HOST_QUERY_GET:
|
||||
hosts = _items(await self._fetch_hosts_get(), _parse_host)
|
||||
return [h for h in hosts if h.state != STATE_OK]
|
||||
return _items(await self._fetch_hosts_post(), _parse_host)
|
||||
|
||||
async def _scan_hosts(self) -> tuple[list[dict], list[HostState], str]:
|
||||
order = (self._host_query_mode,
|
||||
HOST_QUERY_GET if self._host_query_mode == HOST_QUERY_POST else HOST_QUERY_POST)
|
||||
last_exc: CheckmkError | None = None
|
||||
for mode in order:
|
||||
try:
|
||||
body = await (self._fetch_hosts_get() if mode == HOST_QUERY_GET else self._fetch_hosts_post())
|
||||
except CheckmkError as exc:
|
||||
last_exc = exc
|
||||
continue
|
||||
rows = extract_extensions(body)
|
||||
try:
|
||||
hosts = _items(body, _parse_host)
|
||||
except CheckmkError:
|
||||
hosts = []
|
||||
if mode == HOST_QUERY_GET:
|
||||
rows = [r for r in rows if str(r.get("state", "")) != "0"]
|
||||
hosts = [h for h in hosts if h.state != STATE_OK]
|
||||
self._host_query_mode = mode
|
||||
return rows, hosts, mode
|
||||
raise last_exc
|
||||
|
||||
async def problem_hosts_with_fallback(self) -> tuple[list[HostState], str]:
|
||||
_, hosts, mode = await self._scan_hosts()
|
||||
return hosts, mode
|
||||
|
||||
async def selftest_hosts(self) -> tuple[list[dict], list[HostState], str]:
|
||||
return await self._scan_hosts()
|
||||
|
||||
async def raw_services(self) -> dict:
|
||||
return await self._request("POST", SERVICES_PATH, json={"query": NOT_OK_QUERY, "columns": COLUMNS})
|
||||
|
||||
async def selftest_services(self) -> tuple[list[dict], list[ServiceState]]:
|
||||
body = await self.raw_services()
|
||||
rows = extract_extensions(body)
|
||||
try:
|
||||
services = _items(body, _parse_service)
|
||||
except CheckmkError:
|
||||
services = []
|
||||
return rows, services
|
||||
|
||||
async def problems(self) -> list[ServiceState]:
|
||||
return await self.problem_services()
|
||||
@@ -236,5 +323,24 @@ class CheckmkClient:
|
||||
downtimes = [d for d in downtimes if d.host.casefold() == wanted]
|
||||
return downtimes
|
||||
|
||||
async def _downtimes_by_query(self, host: str | None = None) -> list[Downtime]:
|
||||
downtimes: list[Downtime] = []
|
||||
for is_service in ("0", "1"):
|
||||
query: dict = {"op": "=", "left": "is_service", "right": is_service}
|
||||
if host:
|
||||
query = {"op": "and", "expr": [query, _equals("host_name", host)]}
|
||||
body = await self._request("GET", DOWNTIMES_PATH, params={"query": json.dumps(query)})
|
||||
downtimes += _items(body, _parse_downtime)
|
||||
if host:
|
||||
wanted = host.casefold()
|
||||
downtimes = [d for d in downtimes if d.host.casefold() == wanted]
|
||||
return downtimes
|
||||
|
||||
async def downtimes_with_fallback(self, host: str | None = None) -> tuple[list[Downtime], str]:
|
||||
try:
|
||||
return await self.downtimes(host), DOWNTIME_MODE_TYPE
|
||||
except CheckmkError:
|
||||
return await self._downtimes_by_query(host), DOWNTIME_MODE_QUERY
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -77,6 +77,9 @@ CHECKMK_DT_MAX_HOURS = "checkmk_dt.max_hours"
|
||||
DEFAULT_DT_MAX_HOURS = 24
|
||||
DT_MAX_HOURS_CEILING = 168
|
||||
|
||||
SELFTEST_ENABLED = "selftest.enabled"
|
||||
SELFTEST_NOTIFY = "selftest.notify"
|
||||
|
||||
RETENTION_ALERTS_DAYS = "retention.alerts_days"
|
||||
RETENTION_AGENT_RUNS_DAYS = "retention.agent_runs_days"
|
||||
RETENTION_STATE_LOG_DAYS = "retention.state_log_days"
|
||||
|
||||
+17
-4
@@ -7,6 +7,7 @@ from .checkmk import CheckmkClient, is_configured
|
||||
from .constants import DEFAULT_POLL_INTERVAL, FLAG_ON, HEALTH_PING, MATRIX_ENABLED, TELEGRAM_ENABLED
|
||||
from .ollama import OllamaClient
|
||||
from .poller import PollerStatus, read_poll_options
|
||||
from .selftest import STATUS_OK as SELFTEST_STATUS_OK, SelftestRepository
|
||||
|
||||
CHECK_TIMEOUT = 10.0
|
||||
POLLER_STALE_FACTOR = 3
|
||||
@@ -23,8 +24,8 @@ async def run_checks(checks: dict[str, 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 NotConfigured as exc:
|
||||
return {"ok": False, "detail": str(exc) or "nicht konfiguriert"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "detail": "Timeout"}
|
||||
except Exception as exc:
|
||||
@@ -36,7 +37,8 @@ async def run_checks(checks: dict[str, Check]) -> dict:
|
||||
return {"ok": all(r["ok"] for r in results), "checks": by_name}
|
||||
|
||||
|
||||
def build_checks(store, poller_status: PollerStatus | None = None) -> dict[str, Check]:
|
||||
def build_checks(store, poller_status: PollerStatus | None = None,
|
||||
selftest_repo: SelftestRepository | None = None) -> dict[str, Check]:
|
||||
async def db() -> str:
|
||||
store.get(HEALTH_PING)
|
||||
return "ok"
|
||||
@@ -70,6 +72,17 @@ def build_checks(store, poller_status: PollerStatus | None = None) -> dict[str,
|
||||
raise RuntimeError(f"letzter erfolgreicher Zyklus vor {int(age)} s{reason}")
|
||||
return f"letzter erfolgreicher Zyklus vor {int(age)} s"
|
||||
|
||||
async def checkmk_api() -> str:
|
||||
if selftest_repo is None or not is_configured(store):
|
||||
raise NotConfigured
|
||||
run = await asyncio.to_thread(selftest_repo.last_run)
|
||||
if run is None:
|
||||
raise NotConfigured("noch nicht gelaufen")
|
||||
if run.overall_status != SELFTEST_STATUS_OK:
|
||||
failing = ", ".join(f"{c.name}: {c.text}" for c in run.checks if c.status != SELFTEST_STATUS_OK)
|
||||
raise RuntimeError(f"Selbsttest {run.overall_status}: {failing}"[:200])
|
||||
return f"Selbsttest ok, Checkmk {run.version or '?'}"
|
||||
|
||||
async def telegram() -> str:
|
||||
adapter = build_telegram(store)
|
||||
if adapter is None:
|
||||
@@ -88,7 +101,7 @@ def build_checks(store, poller_status: PollerStatus | None = None) -> dict[str,
|
||||
finally:
|
||||
await adapter.aclose()
|
||||
|
||||
checks = {"db": db, "checkmk": checkmk, "ollama": ollama, "poller": poller}
|
||||
checks = {"db": db, "checkmk": checkmk, "ollama": ollama, "poller": poller, "checkmk_api": checkmk_api}
|
||||
if store.get(TELEGRAM_ENABLED) == FLAG_ON:
|
||||
checks["telegram"] = telegram
|
||||
if store.get(MATRIX_ENABLED) == FLAG_ON:
|
||||
|
||||
+4
-2
@@ -18,7 +18,8 @@ from .runtime import Runtime
|
||||
from .settings import SettingsStore
|
||||
from .users import UserStore
|
||||
from .web import (account, agent_pages, alerts, auth, baseline_pages, matrix_section, mutes_pages, pages,
|
||||
prompts_pages, prompts_test_pages, retention_pages, routing_pages, telegram_section)
|
||||
prompts_pages, prompts_test_pages, retention_pages, routing_pages, selftest_pages,
|
||||
telegram_section)
|
||||
from .web.deps import LoginRequired, redirect, templates, verify_csrf
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "web" / "static"
|
||||
@@ -75,7 +76,7 @@ def create_app(config: FileConfig | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
result = await run_checks(build_checks(app.state.store, runtime.status))
|
||||
result = await run_checks(build_checks(app.state.store, runtime.status, runtime.selftest_repo))
|
||||
return JSONResponse(result, status_code=200 if result["ok"] else 503)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
@@ -91,5 +92,6 @@ def create_app(config: FileConfig | None = None) -> FastAPI:
|
||||
app.include_router(prompts_pages.router)
|
||||
app.include_router(prompts_test_pages.router)
|
||||
app.include_router(retention_pages.router)
|
||||
app.include_router(selftest_pages.router)
|
||||
app.include_router(pages.router)
|
||||
return app
|
||||
@@ -171,4 +171,12 @@ MIGRATIONS = [
|
||||
"""
|
||||
UPDATE routes SET reminder_hours = NULL WHERE reminder_hours = 0;
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE selftest_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
ts INTEGER NOT NULL,
|
||||
version TEXT,
|
||||
checks TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
]
|
||||
+13
-1
@@ -11,7 +11,8 @@ from .alerts import (AlertActions, AlertAnalyzer, AlertPipeline, AlertRepository
|
||||
from .chat import AdapterRegistry
|
||||
from .chatops import ChatOps
|
||||
from .constants import (ALERTS_BUNDLE_SECONDS, ALERTS_RECOVERY_ENABLED, ALERTS_REMINDER_HOURS, DEFAULT_BUNDLE_SECONDS,
|
||||
DEFAULT_REMINDER_HOURS, FLAG_OFF, FLAG_ON, MAX_REMINDER_HOURS)
|
||||
DEFAULT_REMINDER_HOURS, FLAG_OFF, FLAG_ON, MAX_REMINDER_HOURS, SELFTEST_ENABLED,
|
||||
SELFTEST_NOTIFY)
|
||||
from .db import Database
|
||||
from .agent_runs import configured_secrets
|
||||
from .mutes import MuteService
|
||||
@@ -22,6 +23,7 @@ from .prompts import (PROMPT_AGENT, PROMPT_ALERT, PromptRepository, PromptServic
|
||||
from .retention import RetentionRepository, RetentionRunner, RetentionService, policy_from
|
||||
from .reminders import ReminderWorker
|
||||
from .routing import RouteService, seed_default_rules
|
||||
from .selftest import SelftestRepository, SelftestRunner, SelftestService
|
||||
|
||||
|
||||
class Runtime:
|
||||
@@ -54,6 +56,13 @@ class Runtime:
|
||||
on_baseline=self.pipeline.reset_reported)
|
||||
self.poller = Poller(store, self.state, self.pipeline.handle, self.status,
|
||||
rules=self.routes.enabled_routes, notifier=self.notifier, baseline=self.baseline)
|
||||
self.selftest_repo = SelftestRepository(db)
|
||||
self.selftest = SelftestService(store, self.selftest_repo)
|
||||
self.selftest_runner = SelftestRunner(
|
||||
db, self.selftest, self.notifier,
|
||||
enabled=lambda: store.get(SELFTEST_ENABLED, FLAG_ON) != FLAG_OFF,
|
||||
notify_enabled=lambda: store.get(SELFTEST_NOTIFY, FLAG_ON) != FLAG_OFF,
|
||||
)
|
||||
self.reminders = ReminderWorker(
|
||||
self.state.load, self.routes.enabled_routes, self.mutes.active,
|
||||
lambda: min(store.get_int(ALERTS_REMINDER_HOURS, DEFAULT_REMINDER_HOURS), MAX_REMINDER_HOURS),
|
||||
@@ -73,6 +82,7 @@ class Runtime:
|
||||
pipeline_task = asyncio.create_task(self.pipeline.run())
|
||||
listener_tasks = [asyncio.create_task(run()) for run in self.chatops.listener_runners()]
|
||||
retention_task = asyncio.create_task(self.retention_runner.run())
|
||||
selftest_task = asyncio.create_task(self.selftest_runner.run())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -81,6 +91,8 @@ class Runtime:
|
||||
await asyncio.gather(*listener_tasks, return_exceptions=True)
|
||||
retention_task.cancel()
|
||||
await asyncio.gather(retention_task, return_exceptions=True)
|
||||
selftest_task.cancel()
|
||||
await asyncio.gather(selftest_task, return_exceptions=True)
|
||||
poller_task.cancel()
|
||||
reminder_task.cancel()
|
||||
await asyncio.gather(poller_task, reminder_task, return_exceptions=True)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from .checks import check_downtimes, check_hosts, check_services, check_version
|
||||
from .constants import (AUDIT_RUN, AUDIT_TARGET, CHECK_DOWNTIMES, CHECK_HOSTS, CHECK_LABELS, CHECK_NAMES,
|
||||
CHECK_SERVICES, CHECK_VERSION, STATUS_DEVIATED, STATUS_FAILED, STATUS_OK)
|
||||
from .model import CheckResult, SelftestRun
|
||||
from .notify import message_for
|
||||
from .repository import SelftestRepository
|
||||
from .runner import SelftestRunner, should_run
|
||||
from .service import SelftestNotConfigured, SelftestService
|
||||
|
||||
__all__ = [
|
||||
"AUDIT_RUN", "AUDIT_TARGET", "CHECK_DOWNTIMES", "CHECK_HOSTS", "CHECK_LABELS", "CHECK_NAMES",
|
||||
"CHECK_SERVICES", "CHECK_VERSION", "CheckResult", "STATUS_DEVIATED", "STATUS_FAILED", "STATUS_OK",
|
||||
"SelftestNotConfigured", "SelftestRepository", "SelftestRun", "SelftestRunner", "SelftestService",
|
||||
"check_downtimes", "check_hosts", "check_services", "check_version", "message_for", "should_run",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import Sequence
|
||||
|
||||
from ..checkmk import (COLUMNS, DOWNTIME_MODE_QUERY, HOST_COLUMNS, HOST_QUERY_GET, CheckmkClient, CheckmkError,
|
||||
CheckmkUnreachable, HostState, ServiceState, downtimes_from_states)
|
||||
from .constants import (CHECK_DOWNTIMES, CHECK_HOSTS, CHECK_SERVICES, CHECK_VERSION, STATUS_DEVIATED,
|
||||
STATUS_FAILED, STATUS_OK)
|
||||
from .model import CheckResult
|
||||
|
||||
SERVICE_REQUIRED = ("host_name", "description", "state")
|
||||
SERVICE_OPTIONAL = tuple(c for c in COLUMNS if c not in SERVICE_REQUIRED)
|
||||
HOST_REQUIRED = ("name", "state")
|
||||
HOST_OPTIONAL = tuple(c for c in HOST_COLUMNS if c not in HOST_REQUIRED)
|
||||
|
||||
|
||||
def _missing_columns(rows: Sequence[dict], required: Sequence[str],
|
||||
optional: Sequence[str]) -> tuple[list[str], list[str]]:
|
||||
missing_required: set[str] = set()
|
||||
missing_optional: set[str] = set()
|
||||
for row in rows:
|
||||
missing_required |= {c for c in required if c not in row}
|
||||
missing_optional |= {c for c in optional if c not in row}
|
||||
return sorted(missing_required), sorted(missing_optional)
|
||||
|
||||
|
||||
def _unreachable_or_unexpected(name: str, exc: CheckmkError) -> CheckResult:
|
||||
if isinstance(exc, CheckmkUnreachable):
|
||||
return CheckResult(name, STATUS_FAILED, f"nicht erreichbar ({exc.brief})")
|
||||
return CheckResult(name, STATUS_FAILED, f"Antwort unerwartet ({exc.brief})")
|
||||
|
||||
|
||||
async def check_version(client: CheckmkClient) -> tuple[CheckResult, str]:
|
||||
try:
|
||||
info = await client.version()
|
||||
except CheckmkError as exc:
|
||||
return _unreachable_or_unexpected(CHECK_VERSION, exc), ""
|
||||
versions = info.get("versions")
|
||||
version = versions.get("checkmk") if isinstance(versions, dict) else None
|
||||
if not version:
|
||||
return CheckResult(CHECK_VERSION, STATUS_DEVIATED, "Feld versions.checkmk fehlt in der Antwort"), ""
|
||||
return CheckResult(CHECK_VERSION, STATUS_OK, f"Checkmk {version}"), str(version)
|
||||
|
||||
|
||||
async def check_services(client: CheckmkClient) -> tuple[CheckResult, list[ServiceState]]:
|
||||
try:
|
||||
rows, services = await client.selftest_services()
|
||||
except CheckmkError as exc:
|
||||
return _unreachable_or_unexpected(CHECK_SERVICES, exc), []
|
||||
missing_required, missing_optional = _missing_columns(rows, SERVICE_REQUIRED, SERVICE_OPTIONAL)
|
||||
if missing_required:
|
||||
return CheckResult(CHECK_SERVICES, STATUS_FAILED,
|
||||
f"Pflichtspalte(n) fehlen: {', '.join(missing_required)}"), services
|
||||
if missing_optional:
|
||||
return CheckResult(CHECK_SERVICES, STATUS_DEVIATED,
|
||||
f"optionale Spalte(n) fehlen, Fallback genutzt: {', '.join(missing_optional)}"), services
|
||||
return CheckResult(CHECK_SERVICES, STATUS_OK,
|
||||
f"{len(services)} Probleme gelesen, alle Spalten vorhanden"), services
|
||||
|
||||
|
||||
async def check_hosts(client: CheckmkClient) -> tuple[CheckResult, list[HostState]]:
|
||||
try:
|
||||
rows, hosts, mode = await client.selftest_hosts()
|
||||
except CheckmkError as exc:
|
||||
return _unreachable_or_unexpected(CHECK_HOSTS, exc), []
|
||||
missing_required, missing_optional = _missing_columns(rows, HOST_REQUIRED, HOST_OPTIONAL)
|
||||
if missing_required:
|
||||
return CheckResult(CHECK_HOSTS, STATUS_FAILED,
|
||||
f"Pflichtspalte(n) fehlen: {', '.join(missing_required)}"), hosts
|
||||
notes = []
|
||||
if mode == HOST_QUERY_GET:
|
||||
notes.append("GET-Fallback verwendet, POST wurde abgelehnt")
|
||||
if missing_optional:
|
||||
notes.append(f"optionale Spalte(n) fehlen, Fallback genutzt: {', '.join(missing_optional)}")
|
||||
if notes:
|
||||
return CheckResult(CHECK_HOSTS, STATUS_DEVIATED, "; ".join(notes)), hosts
|
||||
return CheckResult(CHECK_HOSTS, STATUS_OK, f"{len(hosts)} Probleme gelesen, alle Spalten vorhanden"), hosts
|
||||
|
||||
|
||||
async def check_downtimes(client: CheckmkClient, services: Sequence[ServiceState],
|
||||
hosts: Sequence[HostState]) -> CheckResult:
|
||||
try:
|
||||
downtimes, mode = await client.downtimes_with_fallback()
|
||||
except CheckmkUnreachable as exc:
|
||||
return CheckResult(CHECK_DOWNTIMES, STATUS_FAILED, f"nicht erreichbar ({exc.brief})")
|
||||
except CheckmkError as exc:
|
||||
derived = downtimes_from_states(services, hosts)
|
||||
return CheckResult(CHECK_DOWNTIMES, STATUS_DEVIATED,
|
||||
f"Downtime-Endpunkt abgelehnt ({exc.brief}), Fallback über "
|
||||
f"scheduled_downtime_depth: {len(derived)} Downtime(s) abgeleitet")
|
||||
if mode == DOWNTIME_MODE_QUERY:
|
||||
return CheckResult(CHECK_DOWNTIMES, STATUS_DEVIATED,
|
||||
f"downtime_type abgelehnt, Fallback über query verwendet, {len(downtimes)} gelesen")
|
||||
return CheckResult(CHECK_DOWNTIMES, STATUS_OK, f"{len(downtimes)} Downtime(s) gelesen")
|
||||
@@ -0,0 +1,25 @@
|
||||
STATUS_OK = "ok"
|
||||
STATUS_DEVIATED = "abweichend"
|
||||
STATUS_FAILED = "fehler"
|
||||
STATUSES = (STATUS_OK, STATUS_DEVIATED, STATUS_FAILED)
|
||||
|
||||
CHECK_VERSION = "version"
|
||||
CHECK_SERVICES = "services"
|
||||
CHECK_HOSTS = "hosts"
|
||||
CHECK_DOWNTIMES = "downtimes"
|
||||
CHECK_NAMES = (CHECK_VERSION, CHECK_SERVICES, CHECK_HOSTS, CHECK_DOWNTIMES)
|
||||
CHECK_LABELS = {
|
||||
CHECK_VERSION: "Version",
|
||||
CHECK_SERVICES: "Service-Abfrage",
|
||||
CHECK_HOSTS: "Host-Abfrage",
|
||||
CHECK_DOWNTIMES: "Downtime-Abruf",
|
||||
}
|
||||
|
||||
RUN_HOUR = 4
|
||||
START_DELAY = 20.0
|
||||
CHECK_INTERVAL = 900.0
|
||||
MANUAL_COOLDOWN_SECONDS = 30.0
|
||||
|
||||
AUDIT_RUN = "selftest.run"
|
||||
AUDIT_TARGET = "selftest"
|
||||
SYSTEM_USER = "system"
|
||||
@@ -0,0 +1,31 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .constants import STATUS_DEVIATED, STATUS_FAILED, STATUS_OK
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckResult:
|
||||
name: str
|
||||
status: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelftestRun:
|
||||
ts: int
|
||||
version: str
|
||||
checks: list[CheckResult] = field(default_factory=list)
|
||||
version_changed: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.overall_status == STATUS_OK
|
||||
|
||||
@property
|
||||
def overall_status(self) -> str:
|
||||
statuses = {c.status for c in self.checks}
|
||||
if STATUS_FAILED in statuses:
|
||||
return STATUS_FAILED
|
||||
if STATUS_DEVIATED in statuses:
|
||||
return STATUS_DEVIATED
|
||||
return STATUS_OK
|
||||
@@ -0,0 +1,20 @@
|
||||
from .constants import STATUS_OK
|
||||
from .model import SelftestRun
|
||||
|
||||
|
||||
def _failing_text(run: SelftestRun) -> str:
|
||||
return ", ".join(f"{c.name}: {c.text}" for c in run.checks if c.status != STATUS_OK)
|
||||
|
||||
|
||||
def message_for(previous: SelftestRun | None, current: SelftestRun) -> str | None:
|
||||
if previous is None:
|
||||
return None
|
||||
if current.version_changed:
|
||||
return (f"Checkmk-Version von {previous.version or '?'} auf {current.version or '?'}, "
|
||||
f"Selbsttest: {current.overall_status}")
|
||||
prev_status, cur_status = previous.overall_status, current.overall_status
|
||||
if prev_status == cur_status:
|
||||
return None
|
||||
if cur_status == STATUS_OK:
|
||||
return "Selbsttest wieder ok"
|
||||
return f"Selbsttest {cur_status}: {_failing_text(current)}"
|
||||
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
|
||||
from ..db import Database
|
||||
from .model import CheckResult, SelftestRun
|
||||
|
||||
|
||||
class SelftestRepository:
|
||||
def __init__(self, db: Database):
|
||||
self._db = db
|
||||
|
||||
def last_run(self) -> SelftestRun | None:
|
||||
rows = self._db.query("SELECT ts, version, checks FROM selftest_state WHERE id=1")
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
checks = [CheckResult(**item) for item in json.loads(row["checks"])]
|
||||
return SelftestRun(row["ts"], row["version"] or "", checks)
|
||||
|
||||
def save_run(self, run: SelftestRun) -> None:
|
||||
payload = json.dumps([{"name": c.name, "status": c.status, "text": c.text} for c in run.checks])
|
||||
self._db.execute(
|
||||
"INSERT INTO selftest_state(id, ts, version, checks) VALUES (1,?,?,?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET ts=excluded.ts, version=excluded.version, checks=excluded.checks",
|
||||
(run.ts, run.version, payload),
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from .. import audit
|
||||
from ..alerts import SlidingWindowLimiter, SystemNotifier
|
||||
from ..db import Database
|
||||
from .constants import (AUDIT_RUN, AUDIT_TARGET, CHECK_INTERVAL, MANUAL_COOLDOWN_SECONDS, RUN_HOUR, START_DELAY,
|
||||
SYSTEM_USER)
|
||||
from .model import SelftestRun
|
||||
from .notify import message_for
|
||||
from .service import SelftestNotConfigured, SelftestService
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def should_run(now: datetime, last_run_ts: float | None, startup: bool) -> bool:
|
||||
if last_run_ts is not None and datetime.fromtimestamp(last_run_ts).date() == now.date():
|
||||
return False
|
||||
return startup or now.hour >= RUN_HOUR
|
||||
|
||||
|
||||
class SelftestRunner:
|
||||
def __init__(self, db: Database, service: SelftestService, notifier: SystemNotifier,
|
||||
enabled: Callable[[], bool], notify_enabled: Callable[[], bool],
|
||||
clock: Callable[[], datetime] = datetime.now,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, start_delay: float = START_DELAY,
|
||||
interval: float = CHECK_INTERVAL, cooldown: float = MANUAL_COOLDOWN_SECONDS):
|
||||
self._db = db
|
||||
self._service = service
|
||||
self._notifier = notifier
|
||||
self._enabled = enabled
|
||||
self._notify_enabled = notify_enabled
|
||||
self._clock = clock
|
||||
self._sleep = sleep
|
||||
self._start_delay = start_delay
|
||||
self._interval = interval
|
||||
self._manual_limiter = SlidingWindowLimiter(time.time, cooldown)
|
||||
self._last_attempt: float | None = None
|
||||
|
||||
def try_manual(self) -> bool:
|
||||
return self._manual_limiter.try_acquire(1)
|
||||
|
||||
async def _execute(self, user: str) -> SelftestRun | None:
|
||||
previous = await asyncio.to_thread(self._service.last_run)
|
||||
run = await self._service.run()
|
||||
await asyncio.to_thread(audit.record, self._db, user, AUDIT_RUN, AUDIT_TARGET)
|
||||
if self._notify_enabled():
|
||||
text = message_for(previous, run)
|
||||
if text:
|
||||
await self._notifier.selftest(run.ok, text)
|
||||
return run
|
||||
|
||||
async def run_manual(self, user: str) -> SelftestRun | None:
|
||||
try:
|
||||
return await self._execute(user)
|
||||
except SelftestNotConfigured:
|
||||
return None
|
||||
|
||||
async def tick(self, startup: bool) -> bool:
|
||||
if not self._enabled():
|
||||
return False
|
||||
record = await asyncio.to_thread(self._service.last_run)
|
||||
known = [ts for ts in (record.ts if record else None, self._last_attempt) if ts is not None]
|
||||
now = self._clock()
|
||||
if not should_run(now, max(known) if known else None, startup):
|
||||
return False
|
||||
self._last_attempt = now.timestamp()
|
||||
try:
|
||||
await self._execute(SYSTEM_USER)
|
||||
except SelftestNotConfigured:
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("Selbsttest fehlgeschlagen")
|
||||
return True
|
||||
|
||||
async def run(self) -> None:
|
||||
await self._sleep(self._start_delay)
|
||||
startup = True
|
||||
while True:
|
||||
try:
|
||||
await self.tick(startup)
|
||||
except Exception:
|
||||
log.exception("Selbsttest: Prüfung fehlgeschlagen")
|
||||
startup = False
|
||||
await self._sleep(self._interval)
|
||||
@@ -0,0 +1,43 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from ..checkmk import CheckmkClient
|
||||
from .checks import check_downtimes, check_hosts, check_services, check_version
|
||||
from .model import SelftestRun
|
||||
from .repository import SelftestRepository
|
||||
|
||||
|
||||
class SelftestNotConfigured(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SelftestService:
|
||||
def __init__(self, store, repo: SelftestRepository,
|
||||
client_factory: Callable[[object], CheckmkClient | None] = CheckmkClient.from_settings,
|
||||
clock: Callable[[], float] = time.time):
|
||||
self._store = store
|
||||
self._repo = repo
|
||||
self._client_factory = client_factory
|
||||
self._clock = clock
|
||||
|
||||
def last_run(self) -> SelftestRun | None:
|
||||
return self._repo.last_run()
|
||||
|
||||
async def run(self) -> SelftestRun:
|
||||
client = self._client_factory(self._store)
|
||||
if client is None:
|
||||
raise SelftestNotConfigured
|
||||
try:
|
||||
version_check, version = await check_version(client)
|
||||
services_check, services = await check_services(client)
|
||||
hosts_check, hosts = await check_hosts(client)
|
||||
downtimes_check = await check_downtimes(client, services, hosts)
|
||||
finally:
|
||||
await client.aclose()
|
||||
previous = await asyncio.to_thread(self._repo.last_run)
|
||||
version_changed = bool(previous and previous.version and version and previous.version != version)
|
||||
run = SelftestRun(int(self._clock()), version,
|
||||
[version_check, services_check, hosts_check, downtimes_check], version_changed)
|
||||
await asyncio.to_thread(self._repo.save_run, run)
|
||||
return run
|
||||
+4
-2
@@ -14,7 +14,8 @@ from ..constants import (CHECKMK_CA_FILE, CHECKMK_SECRET, CHECKMK_SITE, CHECKMK_
|
||||
from ..health import build_checks, run_checks
|
||||
from ..ollama import OllamaClient
|
||||
from ..users import MIN_PASSWORD_LEN, ROLES, User, UserError
|
||||
from . import alerting_section, downtime_section, matrix_section, retention_section, telegram_section
|
||||
from . import (alerting_section, downtime_section, matrix_section, retention_section, selftest_section,
|
||||
telegram_section)
|
||||
from .deps import (FLASH_ERROR, FLASH_OK, current_user, flash, redirect, render, require_admin)
|
||||
from .problem_status import problems_with_status
|
||||
from .sections import (URL_RE, Field, Section, check, collect, effective, find_section, form_values,
|
||||
@@ -124,6 +125,7 @@ SECTIONS = {
|
||||
alerting_section.SLUG: alerting_section.SECTION,
|
||||
downtime_section.SLUG: downtime_section.SECTION,
|
||||
retention_section.SLUG: retention_section.SECTION,
|
||||
selftest_section.SLUG: selftest_section.SECTION,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +133,7 @@ SECTIONS = {
|
||||
async def dashboard(request: Request, user: User = Depends(current_user)):
|
||||
store = request.app.state.store
|
||||
runtime = request.app.state.runtime
|
||||
result = await run_checks(build_checks(store, runtime.status))
|
||||
result = await run_checks(build_checks(store, runtime.status, runtime.selftest_repo))
|
||||
setup_open = user.is_admin and not all(
|
||||
section_done(store, s) for s in SECTIONS.values() if not s.optional)
|
||||
return render(request, "dashboard.html", user=user, result=result, setup_open=setup_open,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from ..selftest import CHECK_LABELS
|
||||
from ..users import User
|
||||
from .deps import FLASH_ERROR, FLASH_OK, current_user, flash, redirect, render, require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PATH = "/selftest"
|
||||
RUN_PATH = PATH + "/run"
|
||||
MSG_RATE_LIMITED = "Bitte kurz warten (höchstens 1x alle 30 s)."
|
||||
MSG_NOT_CONFIGURED = "Checkmk ist nicht konfiguriert."
|
||||
|
||||
|
||||
@router.get(PATH)
|
||||
def selftest_page(request: Request, user: User = Depends(current_user)):
|
||||
run = request.app.state.runtime.selftest.last_run()
|
||||
return render(request, "selftest.html", user=user, run=run, check_labels=CHECK_LABELS)
|
||||
|
||||
|
||||
@router.post(RUN_PATH)
|
||||
async def selftest_run(request: Request, admin: User = Depends(require_admin)):
|
||||
runner = request.app.state.runtime.selftest_runner
|
||||
if not runner.try_manual():
|
||||
flash(request, MSG_RATE_LIMITED, FLASH_ERROR)
|
||||
return redirect(PATH)
|
||||
run = await runner.run_manual(admin.username)
|
||||
if run is None:
|
||||
flash(request, MSG_NOT_CONFIGURED, FLASH_ERROR)
|
||||
else:
|
||||
flash(request, f"Selbsttest ausgeführt: {run.overall_status}.", FLASH_OK if run.ok else FLASH_ERROR)
|
||||
return redirect(PATH)
|
||||
@@ -0,0 +1,21 @@
|
||||
from ..constants import SELFTEST_ENABLED, SELFTEST_NOTIFY
|
||||
from .sections import Field, Section
|
||||
|
||||
SLUG = "selftest"
|
||||
|
||||
|
||||
def validate(values: dict) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
SECTION = Section(
|
||||
slug=SLUG,
|
||||
title="Selbsttest",
|
||||
fields=(
|
||||
Field(SELFTEST_ENABLED, "Selbsttest aktiv (täglich 04:00 Uhr und beim Start)", kind="checkbox"),
|
||||
Field(SELFTEST_NOTIFY, "Bei Fehlschlag, Abweichung, Rückkehr zu ok oder Versionswechsel in den Chat "
|
||||
"melden", kind="checkbox"),
|
||||
),
|
||||
validate=validate,
|
||||
optional=True,
|
||||
)
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/routing">Regeln</a>
|
||||
<a href="/mutes">Stummschaltungen</a>
|
||||
<a href="/prompts">Prompts</a>
|
||||
<a href="/selftest">Selbsttest</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="/setup">Setup</a>
|
||||
<a href="/settings/checkmk">Checkmk</a>
|
||||
@@ -28,6 +29,7 @@
|
||||
<a href="/settings/alerting">Alarmierung</a>
|
||||
<a href="/settings/checkmk_dt">Downtime</a>
|
||||
<a href="/settings/retention">Aufbewahrung</a>
|
||||
<a href="/settings/selftest">Selbsttest-Einstellungen</a>
|
||||
<a href="/agent">Agent</a>
|
||||
<a href="/users">Benutzer</a>
|
||||
<a href="/audit">Audit</a>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Selbsttest{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Selbsttest</h1>
|
||||
<section class="card">
|
||||
<p class="muted">Prüft lesend, ob die Checkmk-REST-API wie erwartet antwortet (Version, Service- und Host-Abfrage, Downtime-Abruf). Läuft beim Start, täglich um 04:00 Uhr und bei einem Versionswechsel, ändert nichts in Checkmk. Fallbacks (z. B. GET statt POST) stehen im Text des jeweiligen Teilchecks.</p>
|
||||
{% if run %}
|
||||
<p>Checkmk-Version: <strong>{{ run.version or '–' }}</strong> · letzter Lauf: {{ run.ts | fmt_ts }}
|
||||
<span class="badge {{ 'ok' if run.overall_status == 'ok' else ('warn' if run.overall_status == 'abweichend' else 'error') }}">{{ run.overall_status }}</span>
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Teilcheck</th><th>Status</th><th>Text</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in run.checks %}
|
||||
<tr>
|
||||
<td>{{ check_labels.get(c.name, c.name) }}</td>
|
||||
<td><span class="badge {{ 'ok' if c.status == 'ok' else ('warn' if c.status == 'abweichend' else 'error') }}">{{ c.status }}</span></td>
|
||||
<td>{{ c.text }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Noch kein Lauf.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% if user.is_admin %}
|
||||
<section class="card">
|
||||
<form method="post" action="/selftest/run">
|
||||
{{ csrf_input }}
|
||||
<div class="actions"><button type="submit">Selbsttest jetzt ausführen</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
+5
-3
@@ -167,7 +167,7 @@ install -m 0755 /opt/checkbot/deploy/checkmk/checkbot-local-check /usr/lib/check
|
||||
|
||||
Für ein Intervall von 2 Minuten statt jeder Agent-Abfrage in `/usr/lib/check_mk_agent/local/120/` ablegen. Der Check braucht nur das System-`python3` und ruft `http://127.0.0.1:8080/health` direkt ab (nicht über Caddy). Andere URL: `CHECKBOT_HEALTH_URL`. Timeout 15 s, weil der Bot seine Backends selbst mit bis zu 10 s prüft.
|
||||
|
||||
Services: `Checkbot_api`, `Checkbot_db`, `Checkbot_checkmk`, `Checkbot_ollama`, `Checkbot_poller`, `Checkbot_telegram`, `Checkbot_matrix`.
|
||||
Services: `Checkbot_api`, `Checkbot_db`, `Checkbot_checkmk`, `Checkbot_checkmk_api`, `Checkbot_ollama`, `Checkbot_poller`, `Checkbot_telegram`, `Checkbot_matrix`.
|
||||
|
||||
Bewertung (Entscheidung):
|
||||
|
||||
@@ -175,10 +175,12 @@ Bewertung (Entscheidung):
|
||||
| --- | --- |
|
||||
| Teilcheck ok | OK |
|
||||
| Telegram/Matrix fehlen im JSON (in der UI deaktiviert) | OK, "deaktiviert" |
|
||||
| `nicht konfiguriert` (Checkmk, Ollama, Poller, aktivierter Chat-Adapter) | WARN: der Bot ist ohne Konfiguration nicht voll funktionsfähig, aber nichts ist kaputt. Vor der Einrichtung ist WARN erwartbar |
|
||||
| Fehler, Timeout, Poller-Zyklus überfällig | CRIT |
|
||||
| `nicht konfiguriert` (Checkmk, Ollama, Poller, aktivierter Chat-Adapter, `checkmk_api` ohne Checkmk-Konfiguration oder "noch nicht gelaufen") | WARN: der Bot ist ohne Konfiguration nicht voll funktionsfähig, aber nichts ist kaputt. Vor der Einrichtung ist WARN erwartbar |
|
||||
| Fehler, Timeout, Poller-Zyklus überfällig, Selbsttest `abweichend` oder `fehler` (`checkmk_api`) | CRIT |
|
||||
| Bot nicht erreichbar oder ungültige Antwort | CRIT für alle Services |
|
||||
|
||||
`Checkbot_checkmk_api` liest nur den zuletzt gespeicherten Selbsttest-Lauf (siehe UI `/selftest`, Phase 6d), macht selbst keinen eigenen API-Aufruf. "noch nicht gelaufen" (kurz nach dem Start) zählt wie "nicht konfiguriert" als WARN.
|
||||
|
||||
**Variante B: Active Check "HTTP"** auf `https://<fqdn>/health`, erwarteter Status 200. Der Server braucht Zugriff (Monitoring-IP in `CHECKBOT_MONITOR_IP`) und muss der Caddy-CA vertrauen, sonst Zertifikatsprüfung ausschalten. Achtung: `/health` liefert 503, solange irgendein Teilcheck nicht ok ist, auch "nicht konfiguriert". Ein WARN/CRIT ist vor der vollständigen Einrichtung also normal, und die Detailtexte bekommt man nur im Body.
|
||||
|
||||
## 7. Firewall
|
||||
|
||||
@@ -9,10 +9,12 @@ import urllib.request
|
||||
HEALTH_URL = os.environ.get("CHECKBOT_HEALTH_URL", "http://127.0.0.1:8080/health")
|
||||
# Der Bot prüft seine Backends mit bis zu 10 s Timeout, daher etwas mehr.
|
||||
TIMEOUT_SECONDS = 15
|
||||
CHECK_NAMES = ("db", "checkmk", "ollama", "poller", "telegram", "matrix")
|
||||
CHECK_NAMES = ("db", "checkmk", "checkmk_api", "ollama", "poller", "telegram", "matrix")
|
||||
SERVICE_PREFIX = "Checkbot_"
|
||||
API_SERVICE = "Checkbot_api"
|
||||
NOT_CONFIGURED = "nicht konfiguriert"
|
||||
NOT_YET_RUN = "noch nicht gelaufen"
|
||||
WARN_DETAILS = (NOT_CONFIGURED, NOT_YET_RUN)
|
||||
MAX_TEXT = 200
|
||||
OK, WARN, CRIT = 0, 1, 2
|
||||
|
||||
@@ -45,8 +47,8 @@ def evaluate(name: str, result: dict | None) -> str:
|
||||
detail = result.get("detail", "")
|
||||
if result.get("ok"):
|
||||
return line(OK, service, detail or "ok")
|
||||
if detail == NOT_CONFIGURED:
|
||||
return line(WARN, service, NOT_CONFIGURED)
|
||||
if detail in WARN_DETAILS:
|
||||
return line(WARN, service, detail)
|
||||
return line(CRIT, service, detail or "Fehler")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from app.checkmk import (DOWNTIME_MODE_QUERY, HOST_QUERY_GET, HOST_QUERY_POST, CheckmkClient, CheckmkError,
|
||||
Downtime, HostState, ServiceState, downtimes_from_states, status_hint)
|
||||
|
||||
def make_client(handler):
|
||||
return CheckmkClient("https://cmk.local", "prod", "bot", "s3cret", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def host_row(name="srv1", state=1):
|
||||
return {"extensions": {"name": name, "state": state, "plugin_output": "x", "last_state_change": 1,
|
||||
"scheduled_downtime_depth": 0, "acknowledged": 0}}
|
||||
|
||||
|
||||
async def test_problem_hosts_with_fallback_switches_to_get_and_remembers_mode():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(request.method)
|
||||
if request.method == "POST":
|
||||
return httpx.Response(422, text="Spalten abgelehnt")
|
||||
return httpx.Response(200, json={"value": [host_row(state=0), host_row("srv2", state=2)]})
|
||||
|
||||
client = make_client(handler)
|
||||
hosts, mode = await client.problem_hosts_with_fallback()
|
||||
assert mode == HOST_QUERY_GET
|
||||
assert [h.host for h in hosts] == ["srv2"]
|
||||
assert calls == ["POST", "GET"]
|
||||
|
||||
calls.clear()
|
||||
await client.problem_hosts()
|
||||
assert calls == ["GET"]
|
||||
|
||||
|
||||
async def test_problem_hosts_with_fallback_raises_when_both_fail():
|
||||
def handler(request):
|
||||
return httpx.Response(404, text="weg")
|
||||
|
||||
try:
|
||||
await make_client(handler).problem_hosts_with_fallback()
|
||||
except CheckmkError as exc:
|
||||
assert exc.brief == "HTTP 404"
|
||||
else:
|
||||
raise AssertionError("expected CheckmkError")
|
||||
|
||||
|
||||
async def test_selftest_hosts_reports_raw_rows_and_mode():
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"value": [host_row(state=2)]})
|
||||
|
||||
rows, hosts, mode = await make_client(handler).selftest_hosts()
|
||||
assert mode == HOST_QUERY_POST
|
||||
assert rows[0]["name"] == "srv1"
|
||||
assert [h.state for h in hosts] == [3]
|
||||
|
||||
|
||||
async def test_selftest_services_keeps_raw_rows_when_parse_fails():
|
||||
body = {"value": [{"extensions": {"description": "s", "state": 2}}]} # host_name fehlt (Pflichtspalte)
|
||||
|
||||
rows, services = await make_client(lambda r: httpx.Response(200, json=body)).selftest_services()
|
||||
assert rows == [{"description": "s", "state": 2}]
|
||||
assert services == []
|
||||
|
||||
|
||||
async def test_downtimes_with_fallback_uses_query_when_type_rejected():
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(request)
|
||||
if "downtime_type" in request.url.params:
|
||||
return httpx.Response(422, text="abgelehnt")
|
||||
return httpx.Response(200, json={"value": [{"extensions": {
|
||||
"host_name": "web01", "service_description": "", "start_time": "", "end_time": "",
|
||||
"comment": "", "author": ""}}]})
|
||||
|
||||
downtimes, mode = await make_client(handler).downtimes_with_fallback()
|
||||
assert mode == DOWNTIME_MODE_QUERY
|
||||
assert len(downtimes) == 2
|
||||
queries = [json.loads(r.url.params["query"]) for r in seen if "query" in r.url.params]
|
||||
assert {q["right"] for q in queries} == {"0", "1"}
|
||||
|
||||
|
||||
async def test_downtimes_with_fallback_propagates_when_both_fail():
|
||||
def handler(request):
|
||||
return httpx.Response(404, text="weg")
|
||||
|
||||
try:
|
||||
await make_client(handler).downtimes_with_fallback()
|
||||
except CheckmkError as exc:
|
||||
assert exc.brief == "HTTP 404"
|
||||
else:
|
||||
raise AssertionError("expected CheckmkError")
|
||||
|
||||
|
||||
def test_downtimes_from_states_derives_from_scheduled_downtime_depth():
|
||||
services = [ServiceState("h1", "s1", 2, True, "", 1, True, False), ServiceState("h1", "s2", 0, True, "", 1, False, False)]
|
||||
hosts = [HostState("h2", 2, "", 1, True, False)]
|
||||
derived = downtimes_from_states(services, hosts)
|
||||
assert derived == [Downtime("h2", None, "", "", "", ""), Downtime("h1", "s1", "", "", "", "")]
|
||||
|
||||
|
||||
def test_status_hint_mapping_never_touches_brief():
|
||||
assert status_hint(401) == "Zugangsdaten prüfen"
|
||||
assert status_hint(403) == "Rechte des Automation-Users prüfen"
|
||||
assert status_hint(404) == "Pfad oder Site prüfen"
|
||||
assert status_hint(422) == "Anfrage von Checkmk abgelehnt (Format/Spalten prüfen)"
|
||||
assert status_hint(503) == "Checkmk-Serverfehler"
|
||||
assert status_hint(418) == ""
|
||||
|
||||
|
||||
async def test_request_error_carries_hint_but_brief_is_unchanged():
|
||||
client = make_client(lambda r: httpx.Response(401, text="geheim"))
|
||||
try:
|
||||
await client.problem_services()
|
||||
except CheckmkError as exc:
|
||||
assert exc.brief == "HTTP 401" and exc.hint == "Zugangsdaten prüfen"
|
||||
else:
|
||||
raise AssertionError("expected CheckmkError")
|
||||
@@ -55,6 +55,31 @@ async def test_problems_alias():
|
||||
assert len(await client.problems()) == 1
|
||||
|
||||
|
||||
async def test_service_status_accepts_members_instead_of_value():
|
||||
body = {"members": [item()]}
|
||||
result = await make_client(lambda r: httpx.Response(200, json=body)).service_status("web01", "CPU load")
|
||||
assert result.service == "CPU load" and result.state == 2
|
||||
|
||||
|
||||
async def test_state_type_as_string_hard_and_soft():
|
||||
def hard_soft(state_type_value):
|
||||
extensions = item()["extensions"]
|
||||
extensions["state_type"] = state_type_value
|
||||
return {"extensions": extensions}
|
||||
|
||||
client = make_client(lambda r: httpx.Response(200, json={"value": [hard_soft("hard")]}))
|
||||
(service,) = await client.problem_services()
|
||||
assert service.hard is True
|
||||
|
||||
client = make_client(lambda r: httpx.Response(200, json={"value": [hard_soft("SOFT")]}))
|
||||
(service,) = await client.problem_services()
|
||||
assert service.hard is False
|
||||
|
||||
client = make_client(lambda r: httpx.Response(200, json={"value": [hard_soft(None)]}))
|
||||
(service,) = await client.problem_services()
|
||||
assert service.hard is False
|
||||
|
||||
|
||||
async def test_downtimes_all_and_by_host():
|
||||
seen = []
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
|
||||
from app.constants import CHECKMK_SECRET, CHECKMK_SITE, CHECKMK_URL, CHECKMK_USER
|
||||
from app.crypto import SecretBox
|
||||
from app.db import Database
|
||||
from app.health import build_checks, run_checks
|
||||
from app.selftest import (CHECK_VERSION, STATUS_DEVIATED, STATUS_FAILED, STATUS_OK, CheckResult,
|
||||
SelftestRepository, SelftestRun)
|
||||
from app.settings import SettingsStore
|
||||
|
||||
|
||||
def make_store():
|
||||
db = Database(":memory:")
|
||||
return db, SettingsStore(db, SecretBox(os.urandom(32)))
|
||||
|
||||
|
||||
def configure(store):
|
||||
store.set(CHECKMK_URL, "https://cmk.local", "t")
|
||||
store.set(CHECKMK_SITE, "prod", "t")
|
||||
store.set(CHECKMK_USER, "bot", "t")
|
||||
store.set(CHECKMK_SECRET, "s3cret", "t", secret=True)
|
||||
|
||||
|
||||
def only_checkmk_api(store, selftest_repo=None) -> dict:
|
||||
# Nur den einen Teilcheck ausführen, damit "checkmk" nicht mit (hier nicht gemocktem) echtem Netzwerkzugriff läuft.
|
||||
checks = build_checks(store, selftest_repo=selftest_repo)
|
||||
return {"checkmk_api": checks["checkmk_api"]}
|
||||
|
||||
|
||||
async def test_checkmk_api_missing_repo_is_not_configured():
|
||||
_, store = make_store()
|
||||
configure(store)
|
||||
result = await run_checks(only_checkmk_api(store))
|
||||
assert result["checks"]["checkmk_api"] == {"ok": False, "detail": "nicht konfiguriert"}
|
||||
|
||||
|
||||
async def test_checkmk_api_without_checkmk_config_is_not_configured():
|
||||
db, store = make_store()
|
||||
result = await run_checks(only_checkmk_api(store, SelftestRepository(db)))
|
||||
assert result["checks"]["checkmk_api"] == {"ok": False, "detail": "nicht konfiguriert"}
|
||||
|
||||
|
||||
async def test_checkmk_api_not_yet_run_behaves_like_not_configured():
|
||||
db, store = make_store()
|
||||
configure(store)
|
||||
result = await run_checks(only_checkmk_api(store, SelftestRepository(db)))
|
||||
assert result["checks"]["checkmk_api"] == {"ok": False, "detail": "noch nicht gelaufen"}
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
async def test_checkmk_api_ok():
|
||||
db, store = make_store()
|
||||
configure(store)
|
||||
repo = SelftestRepository(db)
|
||||
repo.save_run(SelftestRun(1, "2.5.0", [CheckResult(CHECK_VERSION, STATUS_OK, "ok")]))
|
||||
result = await run_checks(only_checkmk_api(store, repo))
|
||||
check = result["checks"]["checkmk_api"]
|
||||
assert check["ok"] is True and "2.5.0" in check["detail"]
|
||||
|
||||
|
||||
async def test_checkmk_api_deviated_and_failed_are_not_ok():
|
||||
db, store = make_store()
|
||||
configure(store)
|
||||
repo = SelftestRepository(db)
|
||||
repo.save_run(SelftestRun(1, "2.5.0", [CheckResult(CHECK_VERSION, STATUS_DEVIATED, "Spalte fehlt")]))
|
||||
result = await run_checks(only_checkmk_api(store, repo))
|
||||
assert result["checks"]["checkmk_api"]["ok"] is False and result["ok"] is False
|
||||
|
||||
repo.save_run(SelftestRun(2, "2.5.0", [CheckResult(CHECK_VERSION, STATUS_FAILED, "kaputt")]))
|
||||
result = await run_checks(only_checkmk_api(store, repo))
|
||||
assert result["checks"]["checkmk_api"]["ok"] is False
|
||||
@@ -0,0 +1,179 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app.checkmk import CheckmkClient
|
||||
from app.crypto import SecretBox
|
||||
from app.db import Database
|
||||
from app.selftest import (CHECK_DOWNTIMES, CHECK_HOSTS, CHECK_SERVICES, CHECK_VERSION, STATUS_DEVIATED,
|
||||
STATUS_FAILED, STATUS_OK, SelftestNotConfigured, SelftestRepository, SelftestRun,
|
||||
SelftestService, message_for)
|
||||
from app.selftest.checks import check_downtimes, check_hosts, check_services, check_version
|
||||
from app.selftest.model import CheckResult
|
||||
from app.settings import SettingsStore
|
||||
|
||||
|
||||
def make_client(handler):
|
||||
return CheckmkClient("https://cmk.local", "prod", "bot", "s3cret", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def service_row(host="h1", service="s1", state=2, **extra):
|
||||
data = {"host_name": host, "description": service, "state": state, "state_type": 1, "plugin_output": "x",
|
||||
"last_state_change": 1, "scheduled_downtime_depth": 0, "is_flapping": 0, "acknowledged": 0}
|
||||
data.update(extra)
|
||||
return {"extensions": data}
|
||||
|
||||
|
||||
def host_row(name="h2", state=1, **extra):
|
||||
data = {"name": name, "state": state, "plugin_output": "x", "last_state_change": 1,
|
||||
"scheduled_downtime_depth": 0, "acknowledged": 0}
|
||||
data.update(extra)
|
||||
return {"extensions": data}
|
||||
|
||||
|
||||
def good_handler(request):
|
||||
if request.url.path.endswith("/version"):
|
||||
return httpx.Response(200, json={"versions": {"checkmk": "2.5.0p1"}})
|
||||
if request.url.path.endswith("/service/collections/all"):
|
||||
return httpx.Response(200, json={"value": [service_row()]})
|
||||
if request.url.path.endswith("/host/collections/all"):
|
||||
return httpx.Response(200, json={"value": [host_row()]})
|
||||
if request.url.path.endswith("/downtime/collections/all"):
|
||||
return httpx.Response(200, json={"value": []})
|
||||
raise AssertionError(request.url.path)
|
||||
|
||||
|
||||
async def test_check_version_ok_deviated_and_failed():
|
||||
ok, version = await check_version(make_client(good_handler))
|
||||
assert ok.status == STATUS_OK and ok.name == CHECK_VERSION and version == "2.5.0p1"
|
||||
|
||||
deviated, version = await check_version(make_client(lambda r: httpx.Response(200, json={"versions": {}})))
|
||||
assert deviated.status == STATUS_DEVIATED and version == ""
|
||||
|
||||
failed, version = await check_version(make_client(lambda r: httpx.Response(401, text="x")))
|
||||
assert failed.status == STATUS_FAILED and version == ""
|
||||
|
||||
|
||||
async def test_check_services_reports_missing_columns():
|
||||
client = make_client(lambda r: httpx.Response(200, json={"value": [service_row()]}))
|
||||
result, services = await check_services(client)
|
||||
assert result.status == STATUS_OK and len(services) == 1
|
||||
|
||||
missing_optional = make_client(lambda r: httpx.Response(
|
||||
200, json={"value": [{"extensions": {"host_name": "h", "description": "s", "state": 2}}]}))
|
||||
result, services = await check_services(missing_optional)
|
||||
assert result.status == STATUS_DEVIATED and "last_state_change" in result.text
|
||||
|
||||
missing_required = make_client(lambda r: httpx.Response(
|
||||
200, json={"value": [{"extensions": {"description": "s", "state": 2}}]}))
|
||||
result, services = await check_services(missing_required)
|
||||
assert result.status == STATUS_FAILED and "host_name" in result.text
|
||||
|
||||
|
||||
async def test_check_hosts_reports_get_fallback_as_deviated():
|
||||
def handler(request):
|
||||
if request.method == "POST":
|
||||
return httpx.Response(422, text="x")
|
||||
return httpx.Response(200, json={"value": [host_row(state=2)]})
|
||||
|
||||
result, hosts = await check_hosts(make_client(handler))
|
||||
assert result.status == STATUS_DEVIATED and "GET-Fallback" in result.text and len(hosts) == 1
|
||||
|
||||
|
||||
async def test_check_downtimes_falls_back_to_query_then_to_derived():
|
||||
def query_fallback(request):
|
||||
if "downtime_type" in request.url.params:
|
||||
return httpx.Response(422, text="x")
|
||||
return httpx.Response(200, json={"value": []})
|
||||
|
||||
result = await check_downtimes(make_client(query_fallback), [], [])
|
||||
assert result.status == STATUS_DEVIATED and "query" in result.text
|
||||
|
||||
def all_fail(request):
|
||||
return httpx.Response(404, text="x")
|
||||
|
||||
from app.checkmk import ServiceState
|
||||
services = [ServiceState("h1", "s1", 2, True, "", 1, True, False)]
|
||||
result = await check_downtimes(make_client(all_fail), services, [])
|
||||
assert result.status == STATUS_DEVIATED and "abgeleitet" in result.text and "1 Downtime" in result.text
|
||||
|
||||
|
||||
def make_env():
|
||||
db = Database(":memory:")
|
||||
store = SettingsStore(db, SecretBox(os.urandom(32)))
|
||||
return db, store
|
||||
|
||||
|
||||
def configure(store):
|
||||
from app.constants import CHECKMK_SECRET, CHECKMK_SITE, CHECKMK_URL, CHECKMK_USER
|
||||
store.set(CHECKMK_URL, "https://cmk.local", "t")
|
||||
store.set(CHECKMK_SITE, "prod", "t")
|
||||
store.set(CHECKMK_USER, "bot", "t")
|
||||
store.set(CHECKMK_SECRET, "s3cret", "t", secret=True)
|
||||
|
||||
|
||||
async def test_service_run_not_configured():
|
||||
db, store = make_env()
|
||||
service = SelftestService(store, SelftestRepository(db))
|
||||
try:
|
||||
await service.run()
|
||||
except SelftestNotConfigured:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected SelftestNotConfigured")
|
||||
|
||||
|
||||
async def test_service_run_persists_and_detects_version_change(monkeypatch):
|
||||
db, store = make_env()
|
||||
configure(store)
|
||||
repo = SelftestRepository(db)
|
||||
service = SelftestService(store, repo,
|
||||
client_factory=lambda s: make_client(good_handler), clock=lambda: 1_800_000_000.0)
|
||||
run = await service.run()
|
||||
assert run.version == "2.5.0p1" and run.version_changed is False
|
||||
assert repo.last_run().version == "2.5.0p1"
|
||||
|
||||
def newer(request):
|
||||
if request.url.path.endswith("/version"):
|
||||
return httpx.Response(200, json={"versions": {"checkmk": "2.5.0p2"}})
|
||||
return good_handler(request)
|
||||
|
||||
service2 = SelftestService(store, repo, client_factory=lambda s: make_client(newer),
|
||||
clock=lambda: 1_800_000_100.0)
|
||||
run2 = await service2.run()
|
||||
assert run2.version == "2.5.0p2" and run2.version_changed is True
|
||||
|
||||
|
||||
def test_overall_status_priority():
|
||||
ok = CheckResult("a", STATUS_OK, "")
|
||||
deviated = CheckResult("b", STATUS_DEVIATED, "")
|
||||
failed = CheckResult("c", STATUS_FAILED, "")
|
||||
assert SelftestRun(1, "v", [ok]).overall_status == STATUS_OK
|
||||
assert SelftestRun(1, "v", [ok, deviated]).overall_status == STATUS_DEVIATED
|
||||
assert SelftestRun(1, "v", [ok, deviated, failed]).overall_status == STATUS_FAILED
|
||||
|
||||
|
||||
def run_with(status):
|
||||
return SelftestRun(1, "2.5.0", [CheckResult(CHECK_VERSION, status, "x")])
|
||||
|
||||
|
||||
def test_message_for_no_previous_is_silent():
|
||||
assert message_for(None, run_with(STATUS_OK)) is None
|
||||
|
||||
|
||||
def test_message_for_same_status_is_silent():
|
||||
assert message_for(run_with(STATUS_OK), run_with(STATUS_OK)) is None
|
||||
|
||||
|
||||
def test_message_for_status_change_to_failed_and_back_to_ok():
|
||||
text = message_for(run_with(STATUS_OK), run_with(STATUS_FAILED))
|
||||
assert text is not None and "fehler" in text
|
||||
text = message_for(run_with(STATUS_FAILED), run_with(STATUS_OK))
|
||||
assert text == "Selbsttest wieder ok"
|
||||
|
||||
|
||||
def test_message_for_version_change():
|
||||
previous = SelftestRun(1, "2.4.0", [CheckResult(CHECK_VERSION, STATUS_OK, "x")])
|
||||
current = SelftestRun(2, "2.5.0", [CheckResult(CHECK_VERSION, STATUS_OK, "x")], version_changed=True)
|
||||
text = message_for(previous, current)
|
||||
assert text == "Checkmk-Version von 2.4.0 auf 2.5.0, Selbsttest: ok"
|
||||
@@ -0,0 +1,142 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import Database
|
||||
from app.selftest import CHECK_VERSION, STATUS_FAILED, STATUS_OK, CheckResult, SelftestRun, should_run
|
||||
from app.selftest.runner import SelftestRunner
|
||||
from app.selftest.service import SelftestNotConfigured
|
||||
|
||||
|
||||
def at(day, hour, minute=0):
|
||||
return datetime(2026, 11, day, hour, minute)
|
||||
|
||||
|
||||
def stamp(moment):
|
||||
return moment.timestamp()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("now, last, startup, expected", [
|
||||
(at(6, 3, 59), None, False, False),
|
||||
(at(6, 4, 0), None, False, True),
|
||||
(at(6, 3, 59), None, True, True),
|
||||
(at(6, 10), stamp(at(6, 4, 5)), True, False),
|
||||
(at(6, 10), stamp(at(6, 4, 5)), False, False),
|
||||
(at(6, 1), stamp(at(5, 4, 5)), True, True),
|
||||
(at(6, 1), stamp(at(5, 4, 5)), False, False),
|
||||
])
|
||||
def test_should_run(now, last, startup, expected):
|
||||
assert should_run(now, last, startup) is expected
|
||||
|
||||
|
||||
class Moment:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class FakeNotifier:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def selftest(self, ok, text):
|
||||
self.calls.append((ok, text))
|
||||
|
||||
|
||||
class FakeService:
|
||||
def __init__(self, runs=None, error=None):
|
||||
self.runs = list(runs or [])
|
||||
self.error = error
|
||||
self.record = None
|
||||
self.call_count = 0
|
||||
|
||||
def last_run(self):
|
||||
return self.record
|
||||
|
||||
async def run(self):
|
||||
self.call_count += 1
|
||||
if self.error:
|
||||
raise self.error
|
||||
run = self.runs.pop(0)
|
||||
self.record = run
|
||||
return run
|
||||
|
||||
|
||||
def run_with(ts, status, version_changed=False):
|
||||
return SelftestRun(ts, "2.5.0", [CheckResult(CHECK_VERSION, status, "x")], version_changed)
|
||||
|
||||
|
||||
def make_runner(service, notify_enabled=lambda: True, enabled=lambda: True, clock=None):
|
||||
db = Database(":memory:")
|
||||
return db, SelftestRunner(db, service, FakeNotifier(), enabled, notify_enabled,
|
||||
clock=clock or Moment(at(6, 10)))
|
||||
|
||||
|
||||
async def test_tick_runs_once_per_day_and_respects_enabled():
|
||||
service = FakeService([run_with(int(at(6, 10).timestamp()), STATUS_OK)])
|
||||
clock = Moment(at(6, 10))
|
||||
db, runner = make_runner(service, clock=clock)
|
||||
assert await runner.tick(startup=True) is True
|
||||
clock.value = at(6, 11)
|
||||
assert await runner.tick(startup=False) is False
|
||||
assert service.call_count == 1
|
||||
|
||||
|
||||
async def test_tick_disabled_never_runs():
|
||||
service = FakeService([run_with(1, STATUS_OK)])
|
||||
db, runner = make_runner(service, enabled=lambda: False)
|
||||
assert await runner.tick(startup=True) is False
|
||||
assert service.call_count == 0
|
||||
|
||||
|
||||
async def test_tick_not_configured_is_tolerated():
|
||||
service = FakeService(error=SelftestNotConfigured())
|
||||
db, runner = make_runner(service)
|
||||
assert await runner.tick(startup=True) is True
|
||||
|
||||
|
||||
async def test_manual_rate_limit():
|
||||
service = FakeService([run_with(1, STATUS_OK), run_with(2, STATUS_OK)])
|
||||
db, runner = make_runner(service)
|
||||
assert runner.try_manual() is True
|
||||
assert runner.try_manual() is False
|
||||
|
||||
|
||||
async def test_run_manual_returns_none_when_not_configured():
|
||||
service = FakeService(error=SelftestNotConfigured())
|
||||
db, runner = make_runner(service)
|
||||
assert await runner.run_manual("admin1") is None
|
||||
|
||||
|
||||
async def test_run_manual_writes_audit_entry():
|
||||
service = FakeService([run_with(1, STATUS_OK)])
|
||||
db, runner = make_runner(service)
|
||||
await runner.run_manual("admin1")
|
||||
rows = db.query("SELECT user, action, target FROM audit WHERE action='selftest.run'")
|
||||
assert len(rows) == 1 and rows[0]["user"] == "admin1" and rows[0]["target"] == "selftest"
|
||||
|
||||
|
||||
async def test_notify_fires_on_status_change_but_not_on_repeat():
|
||||
service = FakeService([run_with(1, STATUS_OK), run_with(2, STATUS_FAILED), run_with(3, STATUS_FAILED)])
|
||||
db = Database(":memory:")
|
||||
notifier = FakeNotifier()
|
||||
runner = SelftestRunner(db, service, notifier, lambda: True, lambda: True, clock=Moment(at(6, 10)))
|
||||
|
||||
await runner.run_manual("admin1")
|
||||
assert notifier.calls == [] # erster Lauf, kein Vorlauf zum Vergleich
|
||||
|
||||
await runner.run_manual("admin1")
|
||||
assert len(notifier.calls) == 1 and notifier.calls[0][0] is False
|
||||
|
||||
await runner.run_manual("admin1")
|
||||
assert len(notifier.calls) == 1 # gleicher Status wie zuvor, keine Wiederholung
|
||||
|
||||
|
||||
async def test_notify_disabled_sends_nothing():
|
||||
service = FakeService([run_with(1, STATUS_OK), run_with(2, STATUS_FAILED)])
|
||||
db, runner = make_runner(service, notify_enabled=lambda: False)
|
||||
await runner.run_manual("admin1")
|
||||
await runner.run_manual("admin1")
|
||||
assert runner._notifier.calls == []
|
||||
@@ -0,0 +1,78 @@
|
||||
from app.selftest import CHECK_VERSION, STATUS_OK, CheckResult, SelftestRun
|
||||
from tests.test_web import VIEWER, admin, app, client, login_ok, post, users, viewer # noqa: F401
|
||||
|
||||
PAGE = "/selftest"
|
||||
RUN_PATH = "/selftest/run"
|
||||
|
||||
|
||||
def test_page_requires_login(client):
|
||||
assert client.get(PAGE).status_code == 303
|
||||
|
||||
|
||||
def test_viewer_sees_page_without_run_button(client, viewer):
|
||||
login_ok(client, VIEWER)
|
||||
page = client.get(PAGE)
|
||||
assert page.status_code == 200 and "Noch kein Lauf" in page.text
|
||||
assert 'action="/selftest/run"' not in page.text
|
||||
|
||||
|
||||
def test_admin_sees_run_button(client, admin):
|
||||
login_ok(client)
|
||||
page = client.get(PAGE)
|
||||
assert page.status_code == 200 and 'action="/selftest/run"' in page.text
|
||||
|
||||
|
||||
def test_viewer_cannot_trigger_run(client, viewer):
|
||||
login_ok(client, VIEWER)
|
||||
assert post(client, RUN_PATH, PAGE).status_code == 403
|
||||
|
||||
|
||||
def test_run_requires_csrf(client, admin):
|
||||
login_ok(client)
|
||||
assert client.post(RUN_PATH).status_code == 403
|
||||
|
||||
|
||||
def test_manual_run_not_configured_flashes_error(client, admin):
|
||||
login_ok(client)
|
||||
response = post(client, RUN_PATH, PAGE)
|
||||
assert response.status_code == 303
|
||||
assert "nicht konfiguriert" in client.get(PAGE).text
|
||||
|
||||
|
||||
def test_manual_run_shows_result_and_writes_audit(client, app, admin, monkeypatch):
|
||||
login_ok(client)
|
||||
run = SelftestRun(1_800_000_000, "2.5.0", [CheckResult(CHECK_VERSION, STATUS_OK, "Checkmk 2.5.0")])
|
||||
|
||||
async def fake_run(self):
|
||||
return run
|
||||
|
||||
service = app.state.runtime.selftest
|
||||
monkeypatch.setattr(type(service), "run", fake_run)
|
||||
post(client, RUN_PATH, PAGE)
|
||||
page = client.get(PAGE)
|
||||
assert "Checkmk 2.5.0" in page.text and "2.5.0" in page.text
|
||||
rows = app.state.db.query("SELECT user, action, target FROM audit WHERE action='selftest.run'")
|
||||
assert len(rows) == 1 and rows[0]["user"] == "admin1" and rows[0]["target"] == "selftest"
|
||||
|
||||
|
||||
def test_manual_run_is_rate_limited(client, admin, monkeypatch):
|
||||
login_ok(client)
|
||||
post(client, RUN_PATH, PAGE)
|
||||
response = post(client, RUN_PATH, PAGE)
|
||||
assert "warten" in client.get(PAGE).text
|
||||
|
||||
|
||||
def test_settings_section_toggles_require_admin(client, viewer):
|
||||
login_ok(client, VIEWER)
|
||||
assert client.get("/settings/selftest").status_code == 403
|
||||
|
||||
|
||||
def test_settings_section_save_updates_flags(client, app, admin):
|
||||
login_ok(client)
|
||||
page = client.get("/settings/selftest")
|
||||
assert page.status_code == 200
|
||||
response = post(client, "/settings/selftest", "/settings/selftest",
|
||||
selftest_enabled="", selftest_notify="1")
|
||||
assert response.status_code == 303
|
||||
store = app.state.store
|
||||
assert store.get("selftest.enabled") == "0" and store.get("selftest.notify") == "1"
|
||||
Reference in new issue
Block a user