Files
checkbot/app/checkmk.py
T
patrickandClaude Sonnet 5.5 347f55cf7c
test / pytest (3.11) (push) Canceled after 0s
test / pytest (3.13) (push) Canceled after 0s
feat: Phase 6c baseline, ack, host alarms, outage notice, send limit, privacy mode, UI link
Includes atomic migrations, per-rule reminder semantics, first-run helper script
and the note on the undecided UI chat idea.

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

241 lines
8.0 KiB
Python

from dataclasses import dataclass
import httpx
from .constants import (CHECKMK_CA_FILE, CHECKMK_SECRET, CHECKMK_SITE, CHECKMK_URL,
CHECKMK_USER, CHECKMK_VERIFY_TLS, FLAG_OFF, FLAG_ON)
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)}
class CheckmkError(Exception):
kind = FAILURE_UNEXPECTED
def __init__(self, message: str, brief: str = ""):
super().__init__(message)
# Kurztext ohne Antwortinhalt, damit er gefahrlos in Chat-Meldungen steht.
self.brief = brief or type(self).__name__
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}
def _is_hard(value) -> bool:
return str(value).lower() in ("1", "hard")
def _parse_service(extensions: dict) -> ServiceState:
return ServiceState(
host=extensions["host_name"],
service=extensions["description"],
state=int(extensions["state"]),
hard=_is_hard(extensions.get("state_type", 1)),
output=extensions.get("plugin_output", ""),
last_state_change=int(extensions.get("last_state_change", 0)),
in_downtime=int(extensions.get("scheduled_downtime_depth", 0)) > 0,
flapping=bool(int(extensions.get("is_flapping", 0))),
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 _items(body, parser) -> list:
try:
return [parser(item["extensions"]) for item in body.get("value", [])]
except (KeyError, TypeError, ValueError, AttributeError) as exc:
raise CheckmkError(f"Unerwartetes Antwortformat ({type(exc).__name__})",
brief="Antwortformat unerwartet") from exc
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,
)
@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}")
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 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)
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 aclose(self) -> None:
await self._client.aclose()