netbox-checkmk 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_checkmk/__init__.py +41 -0
- netbox_checkmk/checkmk.py +272 -0
- netbox_checkmk/locale/de/LC_MESSAGES/django.mo +0 -0
- netbox_checkmk/locale/de/LC_MESSAGES/django.po +100 -0
- netbox_checkmk/signals.py +34 -0
- netbox_checkmk/status.py +123 -0
- netbox_checkmk/tables.py +34 -0
- netbox_checkmk/templates/netbox_checkmk/inc/panel_content.html +129 -0
- netbox_checkmk/templates/netbox_checkmk/inc/service_counts.html +1 -0
- netbox_checkmk/templates/netbox_checkmk/inc/tab_badges.html +1 -0
- netbox_checkmk/templates/netbox_checkmk/tab.html +27 -0
- netbox_checkmk/templatetags/__init__.py +0 -0
- netbox_checkmk/templatetags/checkmk_tags.py +72 -0
- netbox_checkmk/urls.py +8 -0
- netbox_checkmk/views.py +182 -0
- netbox_checkmk-0.3.2.dist-info/METADATA +285 -0
- netbox_checkmk-0.3.2.dist-info/RECORD +21 -0
- netbox_checkmk-0.3.2.dist-info/WHEEL +5 -0
- netbox_checkmk-0.3.2.dist-info/licenses/LICENSE +202 -0
- netbox_checkmk-0.3.2.dist-info/licenses/NOTICE +10 -0
- netbox_checkmk-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.2"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetBoxCheckmkConfig(PluginConfig):
|
|
7
|
+
name = "netbox_checkmk"
|
|
8
|
+
verbose_name = "Checkmk Status"
|
|
9
|
+
description = "Shows live Checkmk host and service status on devices, VMs and clusters"
|
|
10
|
+
version = __version__
|
|
11
|
+
author = "Christian Wirtz"
|
|
12
|
+
author_email = "doc@snowheaven.de"
|
|
13
|
+
base_url = "checkmk"
|
|
14
|
+
min_version = "4.3.0"
|
|
15
|
+
default_settings = {
|
|
16
|
+
# List of Checkmk sites, queried in order; the first site knowing a host wins:
|
|
17
|
+
# {"name": "main", "url": "https://cmk.example.com/mysite",
|
|
18
|
+
# "username": "automation", "secret": "...", "verify_ssl": True, "timeout": 5}
|
|
19
|
+
"sites": [],
|
|
20
|
+
# How often (seconds) NetBox fetches fresh state from Checkmk: lookups (also
|
|
21
|
+
# "host not found") are cached this long, and an open Checkmk tab reloads itself
|
|
22
|
+
# at this interval.
|
|
23
|
+
"check_interval": 300,
|
|
24
|
+
# Custom field (on devices/VMs/clusters) overriding the Checkmk host name.
|
|
25
|
+
"hostname_custom_field": "checkmk_hostname",
|
|
26
|
+
# Create that custom field (text, on devices/VMs/clusters) on `manage.py migrate`
|
|
27
|
+
# if it does not exist yet. An existing field is never modified.
|
|
28
|
+
"create_custom_field": True,
|
|
29
|
+
# Max. number of services listed on a device/VM tab (worst state first).
|
|
30
|
+
"max_services": 500,
|
|
31
|
+
# Max. number of member devices/VMs evaluated on a cluster tab.
|
|
32
|
+
"max_cluster_members": 100,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def ready(self):
|
|
36
|
+
super().ready()
|
|
37
|
+
# Registers the "Checkmk" tab views; must happen before NetBox builds its URLconf.
|
|
38
|
+
from . import signals, tables, views # noqa: F401
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
config = NetBoxCheckmkConfig
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Minimal Checkmk REST API client (monitoring status only).
|
|
2
|
+
|
|
3
|
+
Deliberately free of Django imports so it can be unit-tested standalone.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from urllib.parse import quote, urlencode
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
HOST_COLUMNS = (
|
|
14
|
+
"name",
|
|
15
|
+
"state",
|
|
16
|
+
"has_been_checked",
|
|
17
|
+
"plugin_output",
|
|
18
|
+
"acknowledged",
|
|
19
|
+
"scheduled_downtime_depth",
|
|
20
|
+
"last_check",
|
|
21
|
+
"num_services_ok",
|
|
22
|
+
"num_services_warn",
|
|
23
|
+
"num_services_crit",
|
|
24
|
+
"num_services_unknown",
|
|
25
|
+
"num_services_pending",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
SERVICE_COLUMNS = (
|
|
29
|
+
"description",
|
|
30
|
+
"state",
|
|
31
|
+
"has_been_checked",
|
|
32
|
+
"plugin_output",
|
|
33
|
+
"acknowledged",
|
|
34
|
+
"scheduled_downtime_depth",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Chunk size for multi-host queries, keeps single Livestatus queries reasonably small.
|
|
38
|
+
QUERY_CHUNK = 50
|
|
39
|
+
|
|
40
|
+
_REGEX_SPECIAL = set(".^$*+?()[]{}|\\")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _regex_literal(value: str) -> str:
|
|
44
|
+
return "".join("\\" + c if c in _REGEX_SPECIAL else c for c in value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CheckmkError(Exception):
|
|
48
|
+
"""Raised when a Checkmk site cannot be queried."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Unified severity, used to pick the "worst" state across hosts and services.
|
|
52
|
+
SEVERITY_OK = 0
|
|
53
|
+
SEVERITY_PENDING = 1
|
|
54
|
+
SEVERITY_WARN = 2
|
|
55
|
+
SEVERITY_UNKNOWN = 3
|
|
56
|
+
SEVERITY_CRIT = 4
|
|
57
|
+
|
|
58
|
+
HOST_STATES = {0: ("UP", SEVERITY_OK), 1: ("DOWN", SEVERITY_CRIT), 2: ("UNREACH", SEVERITY_UNKNOWN)}
|
|
59
|
+
SERVICE_STATES = {
|
|
60
|
+
0: ("OK", SEVERITY_OK),
|
|
61
|
+
1: ("WARN", SEVERITY_WARN),
|
|
62
|
+
2: ("CRIT", SEVERITY_CRIT),
|
|
63
|
+
3: ("UNKNOWN", SEVERITY_UNKNOWN),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ServiceStatus:
|
|
69
|
+
description: str
|
|
70
|
+
state: int
|
|
71
|
+
has_been_checked: bool
|
|
72
|
+
plugin_output: str = ""
|
|
73
|
+
acknowledged: bool = False
|
|
74
|
+
in_downtime: bool = False
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def label(self) -> str:
|
|
78
|
+
if not self.has_been_checked:
|
|
79
|
+
return "PEND"
|
|
80
|
+
return SERVICE_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[0]
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def severity(self) -> int:
|
|
84
|
+
if not self.has_been_checked:
|
|
85
|
+
return SEVERITY_PENDING
|
|
86
|
+
return SERVICE_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[1]
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_api(cls, ext: dict) -> ServiceStatus:
|
|
90
|
+
return cls(
|
|
91
|
+
description=ext.get("description", ""),
|
|
92
|
+
state=int(ext.get("state", 3)),
|
|
93
|
+
has_been_checked=bool(ext.get("has_been_checked", 1)),
|
|
94
|
+
plugin_output=ext.get("plugin_output", ""),
|
|
95
|
+
acknowledged=bool(ext.get("acknowledged", 0)),
|
|
96
|
+
in_downtime=bool(ext.get("scheduled_downtime_depth", 0)),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class HostStatus:
|
|
102
|
+
name: str
|
|
103
|
+
state: int
|
|
104
|
+
has_been_checked: bool
|
|
105
|
+
plugin_output: str = ""
|
|
106
|
+
acknowledged: bool = False
|
|
107
|
+
in_downtime: bool = False
|
|
108
|
+
last_check: int = 0
|
|
109
|
+
services: dict = field(default_factory=dict) # label -> count
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def label(self) -> str:
|
|
113
|
+
if not self.has_been_checked:
|
|
114
|
+
return "PEND"
|
|
115
|
+
return HOST_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[0]
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def host_severity(self) -> int:
|
|
119
|
+
if not self.has_been_checked:
|
|
120
|
+
return SEVERITY_PENDING
|
|
121
|
+
return HOST_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[1]
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def worst_service_severity(self) -> int:
|
|
125
|
+
for label, severity in (
|
|
126
|
+
("CRIT", SEVERITY_CRIT),
|
|
127
|
+
("UNKNOWN", SEVERITY_UNKNOWN),
|
|
128
|
+
("WARN", SEVERITY_WARN),
|
|
129
|
+
("PEND", SEVERITY_PENDING),
|
|
130
|
+
):
|
|
131
|
+
if self.services.get(label):
|
|
132
|
+
return severity
|
|
133
|
+
return SEVERITY_OK
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def severity(self) -> int:
|
|
137
|
+
"""Overall severity: worst of host state and service states."""
|
|
138
|
+
return max(self.host_severity, self.worst_service_severity)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_api(cls, ext: dict) -> HostStatus:
|
|
142
|
+
return cls(
|
|
143
|
+
name=ext["name"],
|
|
144
|
+
state=int(ext.get("state", 0)),
|
|
145
|
+
has_been_checked=bool(ext.get("has_been_checked", 1)),
|
|
146
|
+
plugin_output=ext.get("plugin_output", ""),
|
|
147
|
+
acknowledged=bool(ext.get("acknowledged", 0)),
|
|
148
|
+
in_downtime=bool(ext.get("scheduled_downtime_depth", 0)),
|
|
149
|
+
last_check=int(ext.get("last_check", 0) or 0),
|
|
150
|
+
services={
|
|
151
|
+
"OK": int(ext.get("num_services_ok", 0)),
|
|
152
|
+
"WARN": int(ext.get("num_services_warn", 0)),
|
|
153
|
+
"CRIT": int(ext.get("num_services_crit", 0)),
|
|
154
|
+
"UNKNOWN": int(ext.get("num_services_unknown", 0)),
|
|
155
|
+
"PEND": int(ext.get("num_services_pending", 0)),
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def to_dict(self) -> dict:
|
|
160
|
+
return {
|
|
161
|
+
"name": self.name,
|
|
162
|
+
"state": self.state,
|
|
163
|
+
"has_been_checked": self.has_been_checked,
|
|
164
|
+
"plugin_output": self.plugin_output,
|
|
165
|
+
"acknowledged": self.acknowledged,
|
|
166
|
+
"in_downtime": self.in_downtime,
|
|
167
|
+
"last_check": self.last_check,
|
|
168
|
+
"services": dict(self.services),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_dict(cls, data: dict) -> HostStatus:
|
|
173
|
+
return cls(**data)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CheckmkSite:
|
|
177
|
+
"""One Checkmk site, addressed by its site URL, e.g. https://cmk.example.com/mysite."""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
name: str,
|
|
182
|
+
url: str,
|
|
183
|
+
username: str,
|
|
184
|
+
secret: str,
|
|
185
|
+
verify_ssl: bool = True,
|
|
186
|
+
timeout: float = 5,
|
|
187
|
+
session: requests.Session | None = None,
|
|
188
|
+
):
|
|
189
|
+
self.name = name
|
|
190
|
+
self.url = url.rstrip("/")
|
|
191
|
+
self.api_url = f"{self.url}/check_mk/api/1.0"
|
|
192
|
+
self.verify_ssl = verify_ssl
|
|
193
|
+
self.timeout = timeout
|
|
194
|
+
self.session = session or requests.Session()
|
|
195
|
+
self.session.headers.update(
|
|
196
|
+
{"Authorization": f"Bearer {username} {secret}", "Accept": "application/json"}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_config(cls, config: dict) -> CheckmkSite:
|
|
201
|
+
try:
|
|
202
|
+
return cls(
|
|
203
|
+
name=config.get("name") or config["url"],
|
|
204
|
+
url=config["url"],
|
|
205
|
+
username=config["username"],
|
|
206
|
+
secret=config["secret"],
|
|
207
|
+
verify_ssl=config.get("verify_ssl", True),
|
|
208
|
+
timeout=config.get("timeout", 5),
|
|
209
|
+
)
|
|
210
|
+
except KeyError as e:
|
|
211
|
+
raise CheckmkError(f"Checkmk site config is missing {e}") from e
|
|
212
|
+
|
|
213
|
+
def _query(self, path: str, columns: tuple[str, ...], query: dict) -> dict:
|
|
214
|
+
# POST variant of the monitoring collection endpoints: works on 2.5 and is the
|
|
215
|
+
# only one left on 3.0 (the GET variant returns 405 there).
|
|
216
|
+
try:
|
|
217
|
+
resp = self.session.post(
|
|
218
|
+
f"{self.api_url}{path}",
|
|
219
|
+
json={"columns": list(columns), "query": query},
|
|
220
|
+
verify=self.verify_ssl,
|
|
221
|
+
timeout=self.timeout,
|
|
222
|
+
)
|
|
223
|
+
except requests.RequestException as e:
|
|
224
|
+
raise CheckmkError(f"{self.name}: {e.__class__.__name__}: {e}") from e
|
|
225
|
+
if resp.status_code != 200:
|
|
226
|
+
try:
|
|
227
|
+
detail = resp.json().get("detail") or resp.json().get("title")
|
|
228
|
+
except ValueError:
|
|
229
|
+
detail = resp.text[:200]
|
|
230
|
+
raise CheckmkError(f"{self.name}: HTTP {resp.status_code}: {detail}")
|
|
231
|
+
try:
|
|
232
|
+
return resp.json()
|
|
233
|
+
except ValueError as e:
|
|
234
|
+
raise CheckmkError(f"{self.name}: invalid JSON response") from e
|
|
235
|
+
|
|
236
|
+
def get_hosts(self, names: list[str]) -> dict[str, HostStatus]:
|
|
237
|
+
"""Look up hosts by name (case-insensitive).
|
|
238
|
+
|
|
239
|
+
Returns a dict keyed by the *requested* name (lowercased) for every host found.
|
|
240
|
+
"""
|
|
241
|
+
found: dict[str, HostStatus] = {}
|
|
242
|
+
wanted = sorted({n.lower() for n in names if n})
|
|
243
|
+
for i in range(0, len(wanted), QUERY_CHUNK):
|
|
244
|
+
chunk = wanted[i : i + QUERY_CHUNK]
|
|
245
|
+
query = {
|
|
246
|
+
"op": "or",
|
|
247
|
+
"expr": [{"op": "~~", "left": "name", "right": f"^{_regex_literal(n)}$"} for n in chunk],
|
|
248
|
+
}
|
|
249
|
+
data = self._query("/domain-types/host/collections/all", HOST_COLUMNS, query)
|
|
250
|
+
for item in data.get("value", []):
|
|
251
|
+
host = HostStatus.from_api(item.get("extensions", {}))
|
|
252
|
+
found.setdefault(host.name.lower(), host)
|
|
253
|
+
return found
|
|
254
|
+
|
|
255
|
+
def get_services(self, host_name: str, limit: int = 500) -> list[ServiceStatus]:
|
|
256
|
+
"""All services of a host, worst state first."""
|
|
257
|
+
query = {"op": "=", "left": "host_name", "right": host_name}
|
|
258
|
+
data = self._query("/domain-types/service/collections/all", SERVICE_COLUMNS, query)
|
|
259
|
+
services = [ServiceStatus.from_api(i.get("extensions", {})) for i in data.get("value", [])]
|
|
260
|
+
services.sort(key=lambda s: (-s.severity, s.description.lower()))
|
|
261
|
+
return services[:limit]
|
|
262
|
+
|
|
263
|
+
def _view_url(self, **params: str) -> str:
|
|
264
|
+
return f"{self.url}/check_mk/index.py?" + urlencode(
|
|
265
|
+
{"start_url": "view.py?" + urlencode(params)}, quote_via=quote
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def host_url(self, host_name: str) -> str:
|
|
269
|
+
return self._view_url(view_name="host", host=host_name)
|
|
270
|
+
|
|
271
|
+
def service_url(self, host_name: str, service: str) -> str:
|
|
272
|
+
return self._view_url(view_name="service", host=host_name, service=service)
|
|
Binary file
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# German translation of the netbox-checkmk NetBox plugin.
|
|
2
|
+
# Compile with: python scripts/compile_translations.py
|
|
3
|
+
msgid ""
|
|
4
|
+
msgstr ""
|
|
5
|
+
"Project-Id-Version: netbox-checkmk\n"
|
|
6
|
+
"Language: de\n"
|
|
7
|
+
"MIME-Version: 1.0\n"
|
|
8
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
9
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
10
|
+
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
11
|
+
|
|
12
|
+
msgid "Checkmk"
|
|
13
|
+
msgstr "Checkmk"
|
|
14
|
+
|
|
15
|
+
msgid "Checkmk Monitoring"
|
|
16
|
+
msgstr "Checkmk-Monitoring"
|
|
17
|
+
|
|
18
|
+
msgid "Refresh"
|
|
19
|
+
msgstr "Aktualisieren"
|
|
20
|
+
|
|
21
|
+
msgid "Reload from Checkmk (bypass cache)"
|
|
22
|
+
msgstr "Neu aus Checkmk laden (Cache umgehen)"
|
|
23
|
+
|
|
24
|
+
msgid "Loading Checkmk status…"
|
|
25
|
+
msgstr "Checkmk-Status wird geladen …"
|
|
26
|
+
|
|
27
|
+
msgid "Checkmk query failed"
|
|
28
|
+
msgstr "Checkmk-Abfrage fehlgeschlagen"
|
|
29
|
+
|
|
30
|
+
msgid "No Checkmk sites configured. Set PLUGINS_CONFIG['netbox_checkmk']['sites']."
|
|
31
|
+
msgstr "Keine Checkmk-Sites konfiguriert. Bitte PLUGINS_CONFIG['netbox_checkmk']['sites'] setzen."
|
|
32
|
+
|
|
33
|
+
msgid "Overall state"
|
|
34
|
+
msgstr "Gesamtzustand"
|
|
35
|
+
|
|
36
|
+
msgid "%(found)s of %(total)s members found in Checkmk"
|
|
37
|
+
msgstr "%(found)s von %(total)s Mitgliedern in Checkmk gefunden"
|
|
38
|
+
|
|
39
|
+
msgid "Cluster host"
|
|
40
|
+
msgstr "Cluster-Host"
|
|
41
|
+
|
|
42
|
+
msgid "No Checkmk host named \"%(name)s\""
|
|
43
|
+
msgstr "Kein Checkmk-Host namens \"%(name)s\""
|
|
44
|
+
|
|
45
|
+
msgid "Member"
|
|
46
|
+
msgstr "Mitglied"
|
|
47
|
+
|
|
48
|
+
msgid "Checkmk host"
|
|
49
|
+
msgstr "Checkmk-Host"
|
|
50
|
+
|
|
51
|
+
msgid "Host"
|
|
52
|
+
msgstr "Host"
|
|
53
|
+
|
|
54
|
+
msgid "Services"
|
|
55
|
+
msgstr "Services"
|
|
56
|
+
|
|
57
|
+
msgid "Site"
|
|
58
|
+
msgstr "Site"
|
|
59
|
+
|
|
60
|
+
msgid "Not found in Checkmk"
|
|
61
|
+
msgstr "In Checkmk nicht gefunden"
|
|
62
|
+
|
|
63
|
+
msgid "This cluster has no member devices or virtual machines."
|
|
64
|
+
msgstr "Dieser Cluster enthält keine Geräte oder virtuellen Maschinen."
|
|
65
|
+
|
|
66
|
+
msgid "Member list truncated (max_cluster_members)."
|
|
67
|
+
msgstr "Mitgliederliste gekürzt (max_cluster_members)."
|
|
68
|
+
|
|
69
|
+
msgid "ACK"
|
|
70
|
+
msgstr "ACK"
|
|
71
|
+
|
|
72
|
+
msgid "Downtime"
|
|
73
|
+
msgstr "Wartungszeit"
|
|
74
|
+
|
|
75
|
+
msgid "Output"
|
|
76
|
+
msgstr "Ausgabe"
|
|
77
|
+
|
|
78
|
+
msgid "Last check"
|
|
79
|
+
msgstr "Letzter Check"
|
|
80
|
+
|
|
81
|
+
msgid "State"
|
|
82
|
+
msgstr "Zustand"
|
|
83
|
+
|
|
84
|
+
msgid "Service"
|
|
85
|
+
msgstr "Service"
|
|
86
|
+
|
|
87
|
+
msgid "Acknowledged"
|
|
88
|
+
msgstr "Quittiert"
|
|
89
|
+
|
|
90
|
+
msgid "Scheduled downtime"
|
|
91
|
+
msgstr "Geplante Wartungszeit"
|
|
92
|
+
|
|
93
|
+
msgid "Host \"%(name)s\" could not be determined."
|
|
94
|
+
msgstr "Host \"%(name)s\" konnte nicht ermittelt werden."
|
|
95
|
+
|
|
96
|
+
msgid "No Checkmk host named \"%(name)s\" found on any configured site."
|
|
97
|
+
msgstr "Auf keiner konfigurierten Site wurde ein Checkmk-Host namens \"%(name)s\" gefunden."
|
|
98
|
+
|
|
99
|
+
msgid "This object has no name to look up in Checkmk."
|
|
100
|
+
msgstr "Dieses Objekt hat keinen Namen, nach dem in Checkmk gesucht werden kann."
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Creates the host name override custom field on `manage.py migrate` (if enabled)."""
|
|
2
|
+
|
|
3
|
+
from django.apps import apps
|
|
4
|
+
from django.db.models.signals import post_migrate
|
|
5
|
+
|
|
6
|
+
from .status import config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_custom_field(**kwargs):
|
|
10
|
+
from core.models import ObjectType
|
|
11
|
+
from dcim.models import Device
|
|
12
|
+
from extras.choices import CustomFieldTypeChoices
|
|
13
|
+
from extras.models import CustomField
|
|
14
|
+
from virtualization.models import Cluster, VirtualMachine
|
|
15
|
+
|
|
16
|
+
name = config("hostname_custom_field")
|
|
17
|
+
if not name or not config("create_custom_field") or CustomField.objects.filter(name=name).exists():
|
|
18
|
+
return
|
|
19
|
+
field = CustomField.objects.create(
|
|
20
|
+
name=name,
|
|
21
|
+
type=CustomFieldTypeChoices.TYPE_TEXT,
|
|
22
|
+
label="Checkmk host name",
|
|
23
|
+
description="Checkmk host name, if it differs from this object's name (netbox-checkmk plugin)",
|
|
24
|
+
required=False,
|
|
25
|
+
)
|
|
26
|
+
field.object_types.set([ObjectType.objects.get_for_model(m) for m in (Device, VirtualMachine, Cluster)])
|
|
27
|
+
print(f"netbox_checkmk: created custom field '{name}'")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# post_migrate is only sent for apps with models; hook onto NetBox's "extras" app, which
|
|
31
|
+
# owns CustomField. It fires after the whole migrate run, so all tables exist by then.
|
|
32
|
+
post_migrate.connect(
|
|
33
|
+
create_custom_field, sender=apps.get_app_config("extras"), dispatch_uid="netbox_checkmk_custom_field"
|
|
34
|
+
)
|
netbox_checkmk/status.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Hostname resolution, multi-site lookup and caching on top of the Checkmk client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from django.core.cache import cache
|
|
8
|
+
from netbox.plugins import get_plugin_config
|
|
9
|
+
|
|
10
|
+
from .checkmk import CheckmkError, CheckmkSite, HostStatus, ServiceStatus
|
|
11
|
+
|
|
12
|
+
PLUGIN_NAME = "netbox_checkmk"
|
|
13
|
+
CACHE_PREFIX = "netbox_checkmk:v1"
|
|
14
|
+
_NOT_FOUND = "__not_found__"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config(key: str):
|
|
18
|
+
return get_plugin_config(PLUGIN_NAME, key)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_sites() -> list[CheckmkSite]:
|
|
22
|
+
return [CheckmkSite.from_config(c) for c in config("sites") or []]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def checkmk_hostname(obj) -> str:
|
|
26
|
+
"""Checkmk host name for a NetBox object: custom field override, else the object name."""
|
|
27
|
+
cf_name = config("hostname_custom_field")
|
|
28
|
+
if cf_name:
|
|
29
|
+
override = (getattr(obj, "custom_field_data", None) or {}).get(cf_name)
|
|
30
|
+
if override:
|
|
31
|
+
return str(override).strip()
|
|
32
|
+
return (getattr(obj, "name", None) or "").strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class HostResult:
|
|
37
|
+
hostname: str
|
|
38
|
+
site: CheckmkSite | None = None
|
|
39
|
+
status: HostStatus | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def found(self) -> bool:
|
|
43
|
+
return self.status is not None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def url(self) -> str | None:
|
|
47
|
+
return self.site.host_url(self.status.name) if self.found else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class LookupResult:
|
|
52
|
+
hosts: dict[str, HostResult] = field(default_factory=dict) # keyed by lowercased hostname
|
|
53
|
+
errors: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
def get(self, hostname: str) -> HostResult:
|
|
56
|
+
return self.hosts.get(hostname.lower()) or HostResult(hostname=hostname)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _cache_key(kind: str, *parts: str) -> str:
|
|
60
|
+
# Hex-encode: memcached-style backends reject spaces/control characters in keys.
|
|
61
|
+
return ":".join([CACHE_PREFIX, kind] + [p.lower().encode("utf-8").hex() for p in parts])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lookup_hosts(hostnames: list[str], refresh: bool = False) -> LookupResult:
|
|
65
|
+
"""Find hosts across all configured sites (first site that knows a host wins).
|
|
66
|
+
|
|
67
|
+
Results (including "not found") are cached for ``check_interval`` seconds. If any
|
|
68
|
+
site errors, hosts that could not be found are reported as unknown and not cached,
|
|
69
|
+
since they may well live on the failing site.
|
|
70
|
+
"""
|
|
71
|
+
result = LookupResult()
|
|
72
|
+
sites = {s.name: s for s in get_sites()}
|
|
73
|
+
timeout = config("check_interval")
|
|
74
|
+
pending = []
|
|
75
|
+
for name in dict.fromkeys(h for h in hostnames if h):
|
|
76
|
+
key = name.lower()
|
|
77
|
+
cached = None if refresh else cache.get(_cache_key("host", name))
|
|
78
|
+
if cached == _NOT_FOUND:
|
|
79
|
+
result.hosts[key] = HostResult(hostname=name)
|
|
80
|
+
elif cached and cached["site"] in sites:
|
|
81
|
+
result.hosts[key] = HostResult(
|
|
82
|
+
hostname=name, site=sites[cached["site"]], status=HostStatus.from_dict(cached["status"])
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
pending.append(name)
|
|
86
|
+
|
|
87
|
+
for site in sites.values():
|
|
88
|
+
if not pending:
|
|
89
|
+
break
|
|
90
|
+
try:
|
|
91
|
+
found = site.get_hosts(pending)
|
|
92
|
+
except CheckmkError as e:
|
|
93
|
+
result.errors.append(str(e))
|
|
94
|
+
continue
|
|
95
|
+
still_pending = []
|
|
96
|
+
for name in pending:
|
|
97
|
+
status = found.get(name.lower())
|
|
98
|
+
if status is None:
|
|
99
|
+
still_pending.append(name)
|
|
100
|
+
continue
|
|
101
|
+
result.hosts[name.lower()] = HostResult(hostname=name, site=site, status=status)
|
|
102
|
+
cache.set(_cache_key("host", name), {"site": site.name, "status": status.to_dict()}, timeout)
|
|
103
|
+
pending = still_pending
|
|
104
|
+
|
|
105
|
+
for name in pending:
|
|
106
|
+
result.hosts[name.lower()] = HostResult(hostname=name)
|
|
107
|
+
if not result.errors:
|
|
108
|
+
cache.set(_cache_key("host", name), _NOT_FOUND, timeout)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def host_services(host: HostResult, refresh: bool = False):
|
|
113
|
+
"""All services of a found host (cached). Returns (services, error)."""
|
|
114
|
+
key = _cache_key("svc", host.site.name, host.status.name)
|
|
115
|
+
cached = None if refresh else cache.get(key)
|
|
116
|
+
if cached is not None:
|
|
117
|
+
return [ServiceStatus(**s) for s in cached], None
|
|
118
|
+
try:
|
|
119
|
+
services = host.site.get_services(host.status.name, config("max_services"))
|
|
120
|
+
except CheckmkError as e:
|
|
121
|
+
return [], str(e)
|
|
122
|
+
cache.set(key, [s.__dict__ for s in services], config("check_interval"))
|
|
123
|
+
return services, None
|
netbox_checkmk/tables.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Optional "Checkmk" column for the core device, VM and cluster lists.
|
|
2
|
+
|
|
3
|
+
Registered via NetBox's documented plugin API (`register_table_column`). Hidden by default;
|
|
4
|
+
users enable it with "Configure Table". Each cell lazy-loads its badges via htmx, so a list
|
|
5
|
+
page never waits for Checkmk.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import django_tables2 as tables
|
|
9
|
+
from dcim.tables import DeviceTable
|
|
10
|
+
from django.urls import reverse
|
|
11
|
+
from django.utils.html import format_html
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
13
|
+
from utilities.tables import register_table_column
|
|
14
|
+
from virtualization.tables import ClusterTable, VirtualMachineTable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CheckmkStatusColumn(tables.Column):
|
|
18
|
+
def __init__(self):
|
|
19
|
+
super().__init__(verbose_name=_("Checkmk"), accessor=tables.A("pk"), orderable=False, empty_values=())
|
|
20
|
+
|
|
21
|
+
def render(self, record):
|
|
22
|
+
url = reverse(
|
|
23
|
+
"plugins:netbox_checkmk:tab_badges",
|
|
24
|
+
kwargs={"model": record._meta.model_name, "pk": record.pk},
|
|
25
|
+
)
|
|
26
|
+
return format_html('<span hx-get="{}?list=1" hx-trigger="load"></span>', url)
|
|
27
|
+
|
|
28
|
+
def value(self, record):
|
|
29
|
+
# CSV/export: live state isn't part of the exported data.
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
for _table in (DeviceTable, VirtualMachineTable, ClusterTable):
|
|
34
|
+
register_table_column(CheckmkStatusColumn(), "checkmk_status", _table)
|