Files
patrickandClaude Sonnet 5.5 b937a57602
test / pytest (3.11) (push) Waiting to run
test / pytest (3.13) (push) Waiting to run
feat: Selbsttest bei Versionswechsel und Spaltenprüfung ohne Probleme
Der Runner prüft die Checkmk-Version alle 5 Minuten und startet bei einem
Wechsel sofort einen Selbsttest (Cooldown, Erstlauf ausgenommen). Die
Spaltenprüfung fragt bei leerer Problemliste eine ungefilterte Stichprobe ab;
leeres Checkmk gilt als nicht prüfbar, nicht als Fehler.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 13:00:36 +01:00

363 lines
13 KiB
Python

import json
from dataclasses import dataclass
from typing import Sequence
import httpx
from .constants import (CHECKMK_CA_FILE, CHECKMK_SECRET, CHECKMK_SITE, CHECKMK_URL,
CHECKMK_USER, CHECKMK_VERIFY_TLS, FLAG_OFF, FLAG_ON)
from .models import HOST_SERVICE_NAME
API_PATH = "/check_mk/api/1.0"
SERVICES_PATH = "/domain-types/service/collections/all"
HOSTS_PATH = "/domain-types/host/collections/all"
VERSION_PATH = "/version"
DOWNTIMES_PATH = "/domain-types/downtime/collections/all"
DOWNTIME_TYPE_PARAM = "downtime_type"
DOWNTIME_TYPE_HOST = "host"
DOWNTIME_TYPE_SERVICE = "service"
DOWNTIME_TYPES = (DOWNTIME_TYPE_HOST, DOWNTIME_TYPE_SERVICE)
DEFAULT_TIMEOUT = 15.0
STATE_OK = 0
COLUMNS = [
"host_name",
"description",
"state",
"state_type",
"plugin_output",
"last_state_change",
"scheduled_downtime_depth",
"is_flapping",
"acknowledged",
]
HOST_COLUMNS = [
"name",
"state",
"plugin_output",
"last_state_change",
"scheduled_downtime_depth",
"acknowledged",
]
# Checkmk: 0 UP, 1 DOWN, 2 UNREACH. Intern DOWN=2 und UNREACH=3, damit min_state-Regeln wie bei Services greifen.
HOST_STATE_MAP = {0: 0, 1: 2, 2: 3}
HOST_STATE_FALLBACK = 2
FAILURE_UNREACHABLE = "unreachable"
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 = "", 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):
kind = FAILURE_UNREACHABLE
@dataclass(frozen=True)
class ServiceState:
host: str
service: str
state: int
hard: bool
output: str
last_state_change: int
in_downtime: bool
flapping: bool
acknowledged: bool = False
@dataclass(frozen=True)
class HostState:
host: str
state: int
output: str
last_state_change: int
in_downtime: bool
acknowledged: bool
def as_service(self) -> ServiceState:
return ServiceState(self.host, HOST_SERVICE_NAME, self.state, True, self.output,
self.last_state_change, self.in_downtime, False, self.acknowledged)
@dataclass(frozen=True)
class Downtime:
host: str
service: str | None
start: str
end: str
comment: str
author: str
def _parse_downtime(extensions: dict) -> Downtime:
return Downtime(
host=extensions.get("host_name", ""),
service=extensions.get("service_description") or None,
start=str(extensions.get("start_time", "")),
end=str(extensions.get("end_time", "")),
comment=extensions.get("comment", ""),
author=extensions.get("author", ""),
)
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 HARD_STATE_TYPES
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))),
acknowledged=bool(int(extensions.get("acknowledged", 0))),
)
def _parse_host(extensions: dict) -> HostState:
return HostState(
host=extensions["name"],
state=HOST_STATE_MAP.get(int(extensions["state"]), HOST_STATE_FALLBACK),
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,
acknowledged=bool(int(extensions.get("acknowledged", 0))),
)
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(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))
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,
)
# 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":
if not is_configured(store):
return None
url, site = store.get(CHECKMK_URL), store.get(CHECKMK_SITE)
user, secret = store.get(CHECKMK_USER), store.get(CHECKMK_SECRET)
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 CheckmkUnreachable(f"{type(exc).__name__}: {exc}", brief=type(exc).__name__) from exc
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}",
hint=status_hint(status))
try:
return response.json()
except ValueError as exc:
raise CheckmkError(f"Antwort auf {path} ist kein JSON", brief="Antwort ist kein JSON") from exc
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 _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]:
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 sample_service(self) -> dict | None:
# Die Service-Collection kennt (Stand 2.3) kein Limit/Pagination; um die Antwort klein zu
# halten, wird nur ohne Zustandsfilter abgefragt und davon ausschließlich das erste Element
# ausgewertet, statt die komplette Liste zu laden.
body = await self._request("POST", SERVICES_PATH, json={"columns": COLUMNS})
rows = extract_extensions(body)
return rows[0] if rows else None
async def sample_host(self) -> dict | None:
try:
body = await self._request("POST", HOSTS_PATH, json={"columns": HOST_COLUMNS})
except CheckmkError:
body = await self._request("GET", HOSTS_PATH, params={"columns": ",".join(HOST_COLUMNS)})
rows = extract_extensions(body)
return rows[0] if rows else None
async def problems(self) -> list[ServiceState]:
return await self.problem_services()
async def _services(self, query: dict) -> list[ServiceState]:
body = await self._request("POST", SERVICES_PATH, json={"query": query, "columns": COLUMNS})
return _items(body, _parse_service)
async def service_status(self, host: str, service: str) -> ServiceState | None:
query = {"op": "and", "expr": [_equals("host_name", host), _equals("description", service)]}
services = await self._services(query)
return services[0] if services else None
async def host_services(self, host: str) -> list[ServiceState]:
return await self._services(_equals("host_name", host))
async def downtimes(self, host: str | None = None) -> list[Downtime]:
downtimes: list[Downtime] = []
for downtime_type in DOWNTIME_TYPES:
body = await self._request("GET", DOWNTIMES_PATH, params={DOWNTIME_TYPE_PARAM: downtime_type})
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_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()