mplugin 2.0.0__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.
- mplugin/__init__.py +2315 -0
- mplugin/py.typed +2 -0
- mplugin/testing.py +96 -0
- mplugin-2.0.0.dist-info/METADATA +108 -0
- mplugin-2.0.0.dist-info/RECORD +6 -0
- mplugin-2.0.0.dist-info/WHEEL +4 -0
mplugin/__init__.py
ADDED
|
@@ -0,0 +1,2315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import collections
|
|
5
|
+
import functools
|
|
6
|
+
import importlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import numbers
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import traceback
|
|
15
|
+
import typing
|
|
16
|
+
from collections import UserDict
|
|
17
|
+
from importlib import metadata
|
|
18
|
+
from logging import StreamHandler
|
|
19
|
+
from tempfile import TemporaryFile
|
|
20
|
+
from types import TracebackType
|
|
21
|
+
|
|
22
|
+
import typing_extensions
|
|
23
|
+
|
|
24
|
+
__version__: str = metadata.version("mplugin")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# error.py
|
|
28
|
+
|
|
29
|
+
"""Exceptions with special meanings for mplugin."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CheckError(RuntimeError):
|
|
33
|
+
"""Abort check execution.
|
|
34
|
+
|
|
35
|
+
This exception should be raised if it becomes clear for a plugin
|
|
36
|
+
that it is not able to determine the system status. Raising this
|
|
37
|
+
exception will make the plugin display the exception's argument and
|
|
38
|
+
exit with an UNKNOWN (3) status.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Timeout(RuntimeError):
|
|
45
|
+
"""Maximum check run time exceeded.
|
|
46
|
+
|
|
47
|
+
This exception is raised internally by mplugin if the check's
|
|
48
|
+
run time takes longer than allowed. Check execution is aborted and
|
|
49
|
+
the plugin exits with an UNKNOWN (3) status.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# state.py
|
|
56
|
+
|
|
57
|
+
"""Classes to represent check outcomes.
|
|
58
|
+
|
|
59
|
+
This module defines :class:`ServiceState` which is the abstract base class
|
|
60
|
+
for check outcomes. The four states defined by the :term:`Monitoring plugin API`
|
|
61
|
+
are represented as singleton subclasses.
|
|
62
|
+
|
|
63
|
+
Note that the *warning* state is defined by the :class:`Warn` class. The
|
|
64
|
+
class has not been named `Warning` to avoid being confused with the
|
|
65
|
+
built-in Python exception of the same name.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def worst(states: list["ServiceState"]) -> "ServiceState":
|
|
70
|
+
"""Reduce list of *states* to the most significant state."""
|
|
71
|
+
return functools.reduce(lambda a, b: a if a > b else b, states, ok)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ServiceState:
|
|
75
|
+
"""Abstract base class for all states.
|
|
76
|
+
|
|
77
|
+
Each state has two constant attributes: :attr:`text` is the short
|
|
78
|
+
text representation which is printed for example at the beginning of
|
|
79
|
+
the summary line. :attr:`code` is the corresponding exit code.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
code: int
|
|
83
|
+
"""The Plugin API compliant exit code."""
|
|
84
|
+
|
|
85
|
+
text: str
|
|
86
|
+
"""The short text representation which is printed for example at the
|
|
87
|
+
beginning of the summary line."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, code: int, text: str) -> None:
|
|
90
|
+
self.code = code
|
|
91
|
+
self.text = text
|
|
92
|
+
|
|
93
|
+
def __str__(self) -> str:
|
|
94
|
+
"""Plugin-API compliant text representation."""
|
|
95
|
+
return self.text
|
|
96
|
+
|
|
97
|
+
def __int__(self) -> int:
|
|
98
|
+
"""The Plugin API compliant exit code."""
|
|
99
|
+
return self.code
|
|
100
|
+
|
|
101
|
+
def __gt__(self, other: typing.Any) -> bool:
|
|
102
|
+
return (
|
|
103
|
+
hasattr(other, "code")
|
|
104
|
+
and isinstance(other.code, int)
|
|
105
|
+
and self.code > other.code
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def __eq__(self, other: typing.Any) -> bool:
|
|
109
|
+
return (
|
|
110
|
+
hasattr(other, "code")
|
|
111
|
+
and isinstance(other.code, int)
|
|
112
|
+
and self.code == other.code
|
|
113
|
+
and hasattr(other, "text")
|
|
114
|
+
and isinstance(other.text, str)
|
|
115
|
+
and self.text == other.text
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def __hash__(self) -> int:
|
|
119
|
+
return hash((self.code, self.text))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class __Ok(ServiceState):
|
|
123
|
+
def __init__(self) -> None:
|
|
124
|
+
super().__init__(0, "ok")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
ok: ServiceState = __Ok()
|
|
128
|
+
"""The plugin was able to check the service and it appeared to be functioning
|
|
129
|
+
properly."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class __Warning(ServiceState):
|
|
133
|
+
def __init__(self) -> None:
|
|
134
|
+
super().__init__(1, "warning")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
warning: ServiceState = __Warning()
|
|
138
|
+
"""
|
|
139
|
+
The plugin was able to check the service, but it appeared to be above some
|
|
140
|
+
``warning`` threshold or did not appear to be working properly."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class __Critical(ServiceState):
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
super().__init__(2, "critical")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
critical: ServiceState = __Critical()
|
|
149
|
+
"""The plugin detected that either the service was not running or it was above
|
|
150
|
+
some ``critical`` threshold."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class __Unknown(ServiceState):
|
|
154
|
+
def __init__(self) -> None:
|
|
155
|
+
super().__init__(3, "unknown")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
unknown: ServiceState = __Unknown()
|
|
159
|
+
"""Invalid command line arguments were supplied to the plugin or low-level
|
|
160
|
+
failures internal to the plugin (such as unable to fork, or open a tcp socket)
|
|
161
|
+
that prevent it from performing the specified operation. Higher-level errors
|
|
162
|
+
(such as name resolution errors, socket timeouts, etc) are outside of the control
|
|
163
|
+
of plugins and should generally NOT be reported as ``unknown`` states.
|
|
164
|
+
|
|
165
|
+
The --help or --version output should also result in ``unknown`` state."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def state(exit_code: int) -> ServiceState:
|
|
169
|
+
"""
|
|
170
|
+
Convert an exit code to a ServiceState.
|
|
171
|
+
|
|
172
|
+
:param exit_code: The exit code to convert. Must be 0, 1, 2, or 3.
|
|
173
|
+
|
|
174
|
+
:return: The corresponding ServiceState (ok, warn, critical, or unknown).
|
|
175
|
+
|
|
176
|
+
:raises CheckError: If exit_code is greater than 3.
|
|
177
|
+
"""
|
|
178
|
+
if exit_code == 0:
|
|
179
|
+
return ok
|
|
180
|
+
elif exit_code == 1:
|
|
181
|
+
return warning
|
|
182
|
+
elif exit_code == 2:
|
|
183
|
+
return critical
|
|
184
|
+
elif exit_code == 3:
|
|
185
|
+
return unknown
|
|
186
|
+
raise CheckError(f"Exit code {exit_code} is > 3")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# range.py
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
RangeSpec = typing.Union[str, int, float, "Range"]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Range:
|
|
196
|
+
"""Represents a threshold range.
|
|
197
|
+
|
|
198
|
+
The general format is ``[@][start:][end]``. ``start:`` may be omitted if
|
|
199
|
+
``start==0``. ``~:`` means that start is negative infinity. If ``end`` is
|
|
200
|
+
omitted, infinity is assumed. To invert the match condition, prefix
|
|
201
|
+
the range expression with ``@``.
|
|
202
|
+
|
|
203
|
+
See the
|
|
204
|
+
`Monitoring plugin guidelines <https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/definitions/01.range_expressions.md>`__
|
|
205
|
+
for details.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
invert: bool
|
|
209
|
+
|
|
210
|
+
start: float
|
|
211
|
+
|
|
212
|
+
end: float
|
|
213
|
+
|
|
214
|
+
def __init__(self, spec: typing.Optional[RangeSpec] = None) -> None:
|
|
215
|
+
"""Creates a Range object according to `spec`.
|
|
216
|
+
|
|
217
|
+
:param spec: may be either a string, a float, or another
|
|
218
|
+
Range object.
|
|
219
|
+
"""
|
|
220
|
+
spec = spec or ""
|
|
221
|
+
if isinstance(spec, Range):
|
|
222
|
+
self.invert = spec.invert
|
|
223
|
+
self.start = spec.start
|
|
224
|
+
self.end = spec.end
|
|
225
|
+
elif isinstance(spec, int) or isinstance(spec, float):
|
|
226
|
+
self.invert = False
|
|
227
|
+
self.start = 0
|
|
228
|
+
self.end = spec
|
|
229
|
+
else:
|
|
230
|
+
self.start, self.end, self.invert = Range._parse(str(spec))
|
|
231
|
+
Range._verify(self.start, self.end)
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def _parse(cls, spec: str) -> tuple[float, float, bool]:
|
|
235
|
+
invert = False
|
|
236
|
+
start: float
|
|
237
|
+
start_str: str
|
|
238
|
+
end: float
|
|
239
|
+
end_str: str
|
|
240
|
+
if spec.startswith("@"):
|
|
241
|
+
invert = True
|
|
242
|
+
spec = spec[1:]
|
|
243
|
+
if ":" in spec:
|
|
244
|
+
start_str, end_str = spec.split(":")
|
|
245
|
+
else:
|
|
246
|
+
start_str, end_str = "", spec
|
|
247
|
+
if start_str == "~":
|
|
248
|
+
start = float("-inf")
|
|
249
|
+
else:
|
|
250
|
+
start = cls._parse_atom(start_str, 0)
|
|
251
|
+
end = cls._parse_atom(end_str, float("inf"))
|
|
252
|
+
return start, end, invert
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _parse_atom(atom: str, default: float) -> float:
|
|
256
|
+
if atom == "":
|
|
257
|
+
return default
|
|
258
|
+
if "." in atom:
|
|
259
|
+
return float(atom)
|
|
260
|
+
return int(atom)
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _verify(start: float, end: float) -> None:
|
|
264
|
+
"""Throws ValueError if the range is not consistent."""
|
|
265
|
+
if start > end:
|
|
266
|
+
raise ValueError("start %s must not be greater than end %s" % (start, end))
|
|
267
|
+
|
|
268
|
+
def match(self, value: float) -> bool:
|
|
269
|
+
"""Decides if `value` is inside/outside the threshold.
|
|
270
|
+
|
|
271
|
+
:returns: `True` if value is inside the bounds for non-inverted
|
|
272
|
+
Ranges.
|
|
273
|
+
|
|
274
|
+
Also available as `in` operator.
|
|
275
|
+
"""
|
|
276
|
+
if value < self.start:
|
|
277
|
+
return False ^ self.invert
|
|
278
|
+
if value > self.end:
|
|
279
|
+
return False ^ self.invert
|
|
280
|
+
return True ^ self.invert
|
|
281
|
+
|
|
282
|
+
def __contains__(self, value: float) -> bool:
|
|
283
|
+
return self.match(value)
|
|
284
|
+
|
|
285
|
+
def _format(self, omit_zero_start: bool = True) -> str:
|
|
286
|
+
result: list[str] = []
|
|
287
|
+
if self.invert:
|
|
288
|
+
result.append("@")
|
|
289
|
+
if self.start == float("-inf"):
|
|
290
|
+
result.append("~:")
|
|
291
|
+
elif not omit_zero_start or self.start != 0:
|
|
292
|
+
result.append(("%s:" % self.start))
|
|
293
|
+
if self.end != float("inf"):
|
|
294
|
+
result.append(("%s" % self.end))
|
|
295
|
+
return "".join(result)
|
|
296
|
+
|
|
297
|
+
def __str__(self) -> str:
|
|
298
|
+
"""Human-readable range specification."""
|
|
299
|
+
return self._format()
|
|
300
|
+
|
|
301
|
+
def __repr__(self) -> str:
|
|
302
|
+
"""Parseable range specification."""
|
|
303
|
+
return "Range(%r)" % str(self)
|
|
304
|
+
|
|
305
|
+
def __eq__(self, value: object) -> bool:
|
|
306
|
+
if not isinstance(value, Range):
|
|
307
|
+
return False
|
|
308
|
+
return (
|
|
309
|
+
self.invert == value.invert
|
|
310
|
+
and self.start == self.start
|
|
311
|
+
and self.end == self.end
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def violation(self) -> str:
|
|
316
|
+
"""Human-readable description why a value does not match."""
|
|
317
|
+
return "outside range {0}".format(self._format(False))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# multiarg.py
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class MultiArg:
|
|
324
|
+
"""
|
|
325
|
+
A container class for handling multiple arguments that can be indexed and iterated.
|
|
326
|
+
|
|
327
|
+
This class is designed to be used as a type converter in argparse for arguments
|
|
328
|
+
that accept comma-separated or otherwise delimited values. It provides convenient
|
|
329
|
+
access to individual arguments with optional fill values for missing indices.
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
.. code-block:: python
|
|
333
|
+
|
|
334
|
+
argp.add_argument(
|
|
335
|
+
"--tw",
|
|
336
|
+
"--ttot-warning",
|
|
337
|
+
metavar="RANGE[,RANGE,...]",
|
|
338
|
+
type=mplugin.MultiArg,
|
|
339
|
+
default="",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
:param args: The list of parsed argument strings.
|
|
343
|
+
:param fill: An optional default value to return for indices
|
|
344
|
+
beyond the length of the args list. If not provided, the last argument
|
|
345
|
+
is returned instead, or None if the list is empty.
|
|
346
|
+
:param splitchar:
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
args: list[str]
|
|
350
|
+
"""The list of parsed argument strings."""
|
|
351
|
+
|
|
352
|
+
fill: typing.Optional[str]
|
|
353
|
+
"""An optional default value to return for indices
|
|
354
|
+
beyond the length of the args list. If not provided, the last argument
|
|
355
|
+
is returned instead, or None if the list is empty."""
|
|
356
|
+
|
|
357
|
+
def __init__(
|
|
358
|
+
self,
|
|
359
|
+
args: typing.Union[list[str], str],
|
|
360
|
+
fill: typing.Optional[str] = None,
|
|
361
|
+
splitchar: str = ",",
|
|
362
|
+
) -> None:
|
|
363
|
+
if isinstance(args, list):
|
|
364
|
+
self.args = args
|
|
365
|
+
else:
|
|
366
|
+
self.args = args.split(splitchar)
|
|
367
|
+
self.fill = fill
|
|
368
|
+
|
|
369
|
+
def __len__(self) -> int:
|
|
370
|
+
return self.args.__len__()
|
|
371
|
+
|
|
372
|
+
def __iter__(self) -> typing.Iterator[str]:
|
|
373
|
+
return self.args.__iter__()
|
|
374
|
+
|
|
375
|
+
def __getitem__(self, key: int) -> typing.Optional[str]:
|
|
376
|
+
try:
|
|
377
|
+
return self.args.__getitem__(key)
|
|
378
|
+
except IndexError:
|
|
379
|
+
pass
|
|
380
|
+
if self.fill is not None:
|
|
381
|
+
return self.fill
|
|
382
|
+
try:
|
|
383
|
+
return self.args.__getitem__(-1)
|
|
384
|
+
except IndexError:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# platform.py
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _with_timeout(
|
|
392
|
+
time: int, func: typing.Callable[P, R], *args: typing.Any, **kwargs: typing.Any
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Call `func` but terminate after `t` seconds."""
|
|
395
|
+
|
|
396
|
+
if os.name == "posix":
|
|
397
|
+
signal = importlib.import_module("signal")
|
|
398
|
+
|
|
399
|
+
def timeout_handler(signum: int, frame: typing.Any) -> typing.NoReturn:
|
|
400
|
+
raise Timeout("{0}s".format(time))
|
|
401
|
+
|
|
402
|
+
signal.signal(signal.SIGALRM, timeout_handler)
|
|
403
|
+
signal.alarm(time)
|
|
404
|
+
try:
|
|
405
|
+
func(*args, **kwargs)
|
|
406
|
+
finally:
|
|
407
|
+
signal.alarm(0)
|
|
408
|
+
|
|
409
|
+
if os.name == "nt":
|
|
410
|
+
# We use a thread here since NT systems don't have POSIX signals.
|
|
411
|
+
threading = importlib.import_module("threading")
|
|
412
|
+
|
|
413
|
+
func_thread = threading.Thread(target=func, args=args, kwargs=kwargs)
|
|
414
|
+
func_thread.daemon = True # quit interpreter even if still running
|
|
415
|
+
func_thread.start()
|
|
416
|
+
func_thread.join(time)
|
|
417
|
+
if func_thread.is_alive():
|
|
418
|
+
raise Timeout("{0}s".format(time))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _flock_exclusive(fileobj: io.TextIOWrapper) -> None:
|
|
422
|
+
"""Acquire exclusive lock for open file `fileobj`."""
|
|
423
|
+
|
|
424
|
+
if os.name == "posix":
|
|
425
|
+
fcntl = importlib.import_module("fcntl")
|
|
426
|
+
fcntl.flock(fileobj, fcntl.LOCK_EX)
|
|
427
|
+
|
|
428
|
+
if os.name == "nt":
|
|
429
|
+
msvcrt = importlib.import_module("msvcrt")
|
|
430
|
+
msvcrt.locking(fileobj.fileno(), msvcrt.LK_LOCK, 2147483647)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# cookie.py
|
|
434
|
+
|
|
435
|
+
"""Persistent dict to remember state between invocations.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class Cookie(UserDict[str, typing.Any]):
|
|
440
|
+
"""Creates a persistent dict to keep state.
|
|
441
|
+
|
|
442
|
+
Cookies are used to remember file positions, counters and the like
|
|
443
|
+
between plugin invocations. It is not intended for substantial amounts
|
|
444
|
+
of data. Cookies are serialized into JSON and saved to a state file. We
|
|
445
|
+
prefer a plain text format to allow administrators to inspect and edit
|
|
446
|
+
its content. See :class:`~mplugin.logtail.LogTail` for an
|
|
447
|
+
application of cookies to get only new lines of a continuously growing
|
|
448
|
+
file.
|
|
449
|
+
|
|
450
|
+
Cookies are locked exclusively so that at most one process at a time has
|
|
451
|
+
access to it. Changes to the dict are not reflected in the file until
|
|
452
|
+
:meth:`Cookie.commit` is called. It is recommended to use Cookie as
|
|
453
|
+
context manager to get it opened and committed automatically.
|
|
454
|
+
|
|
455
|
+
After creation, a cookie behaves like a normal dict.
|
|
456
|
+
|
|
457
|
+
:param statefile: file name to save the dict's contents
|
|
458
|
+
|
|
459
|
+
.. note:: If `statefile` is empty or None, the Cookie will be
|
|
460
|
+
oblivous, i.e., it will forget its contents on garbage
|
|
461
|
+
collection. This makes it possible to explicitely throw away
|
|
462
|
+
state between plugin runs (for example by a command line
|
|
463
|
+
argument).
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
path: typing.Optional[str]
|
|
467
|
+
|
|
468
|
+
fobj: typing.Optional[io.TextIOWrapper]
|
|
469
|
+
|
|
470
|
+
def __init__(self, statefile: typing.Optional[str] = None) -> None:
|
|
471
|
+
|
|
472
|
+
super(Cookie, self).__init__()
|
|
473
|
+
self.path = statefile
|
|
474
|
+
self.fobj = None
|
|
475
|
+
|
|
476
|
+
def __enter__(self) -> typing_extensions.Self:
|
|
477
|
+
"""Allows Cookie to be used as context manager.
|
|
478
|
+
|
|
479
|
+
Opens the file and passes a dict-like object into the
|
|
480
|
+
subordinate context. See :meth:`open` for details about opening
|
|
481
|
+
semantics. When the context is left in the regular way (no
|
|
482
|
+
exception raised), the cookie is committed to disk.
|
|
483
|
+
|
|
484
|
+
:yields: open cookie
|
|
485
|
+
"""
|
|
486
|
+
self.open()
|
|
487
|
+
return self
|
|
488
|
+
|
|
489
|
+
def __exit__(
|
|
490
|
+
self,
|
|
491
|
+
exc_type: typing.Optional[type[BaseException]],
|
|
492
|
+
exc_value: typing.Optional[BaseException],
|
|
493
|
+
traceback: typing.Optional[TracebackType],
|
|
494
|
+
) -> None:
|
|
495
|
+
if not exc_type:
|
|
496
|
+
self.commit()
|
|
497
|
+
self.close()
|
|
498
|
+
|
|
499
|
+
def open(self) -> typing_extensions.Self:
|
|
500
|
+
"""Reads/creates the state file and initializes the dict.
|
|
501
|
+
|
|
502
|
+
If the state file does not exist, it is touched into existence.
|
|
503
|
+
An exclusive lock is acquired to ensure serialized access. If
|
|
504
|
+
:meth:`open` fails to parse file contents, it truncates
|
|
505
|
+
the file before raising an exception. This guarantees that
|
|
506
|
+
plugins will not fail repeatedly when their state files get
|
|
507
|
+
damaged.
|
|
508
|
+
|
|
509
|
+
:returns: Cookie object (self)
|
|
510
|
+
:raises ValueError: if the state file is corrupted or does not
|
|
511
|
+
deserialize into a dict
|
|
512
|
+
"""
|
|
513
|
+
self.fobj = self._create_fobj()
|
|
514
|
+
_flock_exclusive(self.fobj)
|
|
515
|
+
if os.fstat(self.fobj.fileno()).st_size:
|
|
516
|
+
try:
|
|
517
|
+
self.data = self._load()
|
|
518
|
+
except ValueError:
|
|
519
|
+
self.fobj.truncate(0)
|
|
520
|
+
raise
|
|
521
|
+
return self
|
|
522
|
+
|
|
523
|
+
def _create_fobj(self) -> io.TextIOWrapper:
|
|
524
|
+
if not self.path:
|
|
525
|
+
return TemporaryFile(
|
|
526
|
+
"w+", encoding="ascii", prefix="oblivious_cookie_", dir=None
|
|
527
|
+
)
|
|
528
|
+
# mode='a+' has problems with mixed R/W operation on Mac OS X
|
|
529
|
+
try:
|
|
530
|
+
return open(self.path, "r+", encoding="ascii")
|
|
531
|
+
except IOError:
|
|
532
|
+
return open(self.path, "w+", encoding="ascii")
|
|
533
|
+
|
|
534
|
+
def _load(self) -> dict[str, typing.Any]:
|
|
535
|
+
if not self.fobj:
|
|
536
|
+
raise RuntimeError("file object is none")
|
|
537
|
+
self.fobj.seek(0)
|
|
538
|
+
data = json.load(self.fobj)
|
|
539
|
+
if not isinstance(data, dict):
|
|
540
|
+
raise ValueError(
|
|
541
|
+
"format error: cookie does not contain dict", self.path, data
|
|
542
|
+
)
|
|
543
|
+
return typing.cast(dict[str, typing.Any], data)
|
|
544
|
+
|
|
545
|
+
def close(self) -> None:
|
|
546
|
+
"""Closes a cookie and its underlying state file.
|
|
547
|
+
|
|
548
|
+
This method has no effect if the cookie is already closed.
|
|
549
|
+
Once the cookie is closed, any operation (like :meth:`commit`)
|
|
550
|
+
will raise an exception.
|
|
551
|
+
"""
|
|
552
|
+
if not self.fobj:
|
|
553
|
+
return
|
|
554
|
+
self.fobj.close()
|
|
555
|
+
self.fobj = None
|
|
556
|
+
|
|
557
|
+
def commit(self) -> None:
|
|
558
|
+
"""Persists the cookie's dict items in the state file.
|
|
559
|
+
|
|
560
|
+
The cookies content is serialized as JSON string and saved to
|
|
561
|
+
the state file. The buffers are flushed to ensure that the new
|
|
562
|
+
content is saved in a durable way.
|
|
563
|
+
"""
|
|
564
|
+
if not self.fobj:
|
|
565
|
+
raise IOError("cannot commit closed cookie", self.path)
|
|
566
|
+
self.fobj.seek(0)
|
|
567
|
+
self.fobj.truncate()
|
|
568
|
+
json.dump(self.data, self.fobj)
|
|
569
|
+
self.fobj.write("\n")
|
|
570
|
+
self.fobj.flush()
|
|
571
|
+
os.fsync(self.fobj)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# logtail.py
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class LogTail:
|
|
578
|
+
"""Access previously unseen parts of a growing file.
|
|
579
|
+
|
|
580
|
+
LogTail builds on :class:`~.cookie.Cookie` to access new lines of a
|
|
581
|
+
continuosly growing log file. It should be used as context manager that
|
|
582
|
+
provides an iterator over new lines to the subordinate context. LogTail
|
|
583
|
+
saves the last file position into the provided cookie object.
|
|
584
|
+
As the path to the log file is saved in the cookie, several LogTail
|
|
585
|
+
instances may share the same cookie.
|
|
586
|
+
"""
|
|
587
|
+
|
|
588
|
+
path: str
|
|
589
|
+
cookie: "Cookie"
|
|
590
|
+
logfile: typing.Optional[io.BufferedIOBase] = None
|
|
591
|
+
stat: typing.Optional[os.stat_result]
|
|
592
|
+
|
|
593
|
+
def __init__(self, path: str, cookie: "Cookie") -> None:
|
|
594
|
+
"""Creates new LogTail context.
|
|
595
|
+
|
|
596
|
+
:param path: path to the log file that is to be observed
|
|
597
|
+
:param cookie: :class:`~.cookie.Cookie` object to save the last
|
|
598
|
+
file position
|
|
599
|
+
"""
|
|
600
|
+
self.path = os.path.abspath(path)
|
|
601
|
+
self.cookie = cookie
|
|
602
|
+
self.logfile = None
|
|
603
|
+
self.stat = None
|
|
604
|
+
|
|
605
|
+
def _seek_if_applicable(self, fileinfo: dict[str, typing.Any]) -> None:
|
|
606
|
+
self.stat = os.stat(self.path)
|
|
607
|
+
if (
|
|
608
|
+
self.stat.st_ino == fileinfo.get("inode", -1)
|
|
609
|
+
and self.stat.st_size >= fileinfo.get("pos", 0)
|
|
610
|
+
and self.logfile is not None
|
|
611
|
+
):
|
|
612
|
+
self.logfile.seek(fileinfo["pos"])
|
|
613
|
+
|
|
614
|
+
def __enter__(self) -> typing.Generator[bytes, typing.Any, None]:
|
|
615
|
+
"""Seeks to the last seen position and reads new lines.
|
|
616
|
+
|
|
617
|
+
The last file position is read from the cookie. If the log file
|
|
618
|
+
has not been changed since the last invocation, LogTail seeks to
|
|
619
|
+
that position and reads new lines. Otherwise, the position saved
|
|
620
|
+
in the cookie is reset and LogTail reads from the beginning.
|
|
621
|
+
After leaving the subordinate context, the new position is saved
|
|
622
|
+
in the cookie and the cookie is closed.
|
|
623
|
+
|
|
624
|
+
:yields: new lines as bytes strings
|
|
625
|
+
"""
|
|
626
|
+
self.logfile = open(self.path, "rb")
|
|
627
|
+
self.cookie.open()
|
|
628
|
+
self._seek_if_applicable(self.cookie.get(self.path, {}))
|
|
629
|
+
line = self.logfile.readline()
|
|
630
|
+
while len(line):
|
|
631
|
+
yield line
|
|
632
|
+
line = self.logfile.readline()
|
|
633
|
+
|
|
634
|
+
def __exit__(
|
|
635
|
+
self,
|
|
636
|
+
exc_type: typing.Optional[type[BaseException]],
|
|
637
|
+
exc_value: typing.Optional[BaseException],
|
|
638
|
+
traceback: typing.Optional[TracebackType],
|
|
639
|
+
) -> None:
|
|
640
|
+
if not exc_type and self.stat is not None and self.logfile is not None:
|
|
641
|
+
self.cookie[self.path] = dict(
|
|
642
|
+
inode=self.stat.st_ino, pos=self.logfile.tell()
|
|
643
|
+
)
|
|
644
|
+
self.cookie.commit()
|
|
645
|
+
self.cookie.close()
|
|
646
|
+
if self.logfile is not None:
|
|
647
|
+
self.logfile.close()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# output.py
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _filter_output(output: str, filtered: str) -> str:
|
|
654
|
+
"""Filters out characters from output"""
|
|
655
|
+
for char in filtered:
|
|
656
|
+
output = output.replace(char, "")
|
|
657
|
+
return output
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class _Output:
|
|
661
|
+
ILLEGAL = "|"
|
|
662
|
+
|
|
663
|
+
logchan: StreamHandler[io.StringIO]
|
|
664
|
+
verbose: int
|
|
665
|
+
status: str
|
|
666
|
+
out: list[str]
|
|
667
|
+
warnings: list[str]
|
|
668
|
+
longperfdata: list[str]
|
|
669
|
+
|
|
670
|
+
def __init__(self, logchan: StreamHandler[io.StringIO], verbose: int = 0) -> None:
|
|
671
|
+
self.logchan = logchan
|
|
672
|
+
self.verbose = verbose
|
|
673
|
+
self.status = ""
|
|
674
|
+
self.out = []
|
|
675
|
+
self.warnings = []
|
|
676
|
+
self.longperfdata = []
|
|
677
|
+
|
|
678
|
+
def add(self, check: "Check") -> None:
|
|
679
|
+
self.status = self.format_status(check)
|
|
680
|
+
if self.verbose == 0:
|
|
681
|
+
perfdata = self.format_perfdata(check)
|
|
682
|
+
if perfdata:
|
|
683
|
+
self.status += " " + perfdata
|
|
684
|
+
else:
|
|
685
|
+
self.add_longoutput(check.verbose_str)
|
|
686
|
+
self.longperfdata.append(self.format_perfdata(check, 79))
|
|
687
|
+
|
|
688
|
+
def format_status(self, check: "Check"):
|
|
689
|
+
if check.name:
|
|
690
|
+
name_prefix = check.name.upper() + " "
|
|
691
|
+
else:
|
|
692
|
+
name_prefix = ""
|
|
693
|
+
summary_str = check.summary_str.strip()
|
|
694
|
+
return self._screen_chars(
|
|
695
|
+
"{0}{1}{2}".format(
|
|
696
|
+
name_prefix,
|
|
697
|
+
str(check.state).upper(),
|
|
698
|
+
" - " + summary_str if summary_str else "",
|
|
699
|
+
),
|
|
700
|
+
"status line",
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Needs refactoring, but won't remove now because it's probably API-breaking
|
|
704
|
+
# pylint: disable-next=unused-argument
|
|
705
|
+
def format_perfdata(self, check: "Check", linebreak: typing.Any = None) -> str:
|
|
706
|
+
if not check.perfdata:
|
|
707
|
+
return ""
|
|
708
|
+
out = " ".join(check.perfdata)
|
|
709
|
+
return "| " + self._screen_chars(out, "perfdata")
|
|
710
|
+
|
|
711
|
+
def add_longoutput(self, text: str | list[str] | tuple[str, ...]) -> None:
|
|
712
|
+
if isinstance(text, (list, tuple)):
|
|
713
|
+
for line in text:
|
|
714
|
+
self.add_longoutput(line)
|
|
715
|
+
else:
|
|
716
|
+
self.out.append(self._screen_chars(text, "long output"))
|
|
717
|
+
|
|
718
|
+
def __str__(self):
|
|
719
|
+
output = [
|
|
720
|
+
elem
|
|
721
|
+
for elem in [self.status]
|
|
722
|
+
+ self.out
|
|
723
|
+
+ [self._screen_chars(self.logchan.stream.getvalue(), "logging output")]
|
|
724
|
+
+ self.warnings
|
|
725
|
+
+ self.longperfdata
|
|
726
|
+
if elem
|
|
727
|
+
]
|
|
728
|
+
return "\n".join(output) + "\n"
|
|
729
|
+
|
|
730
|
+
def _screen_chars(self, text: str, where: str) -> str:
|
|
731
|
+
text = text.rstrip("\n")
|
|
732
|
+
screened = _filter_output(text, self.ILLEGAL)
|
|
733
|
+
if screened != text:
|
|
734
|
+
self.warnings.append(
|
|
735
|
+
self._illegal_chars_warning(where, set(text) - set(screened))
|
|
736
|
+
)
|
|
737
|
+
return screened
|
|
738
|
+
|
|
739
|
+
@staticmethod
|
|
740
|
+
def _illegal_chars_warning(where: str, removed_chars: set[str]) -> str:
|
|
741
|
+
hex_chars = ", ".join("0x{0:x}".format(ord(c)) for c in removed_chars)
|
|
742
|
+
return "warning: removed illegal characters ({0}) from {1}".format(
|
|
743
|
+
hex_chars, where
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# performance.py
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _quote(label: str) -> str:
|
|
751
|
+
if re.match(r"^\w+$", label):
|
|
752
|
+
return label
|
|
753
|
+
return f"'{label}'"
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
class Performance:
|
|
757
|
+
"""
|
|
758
|
+
Performance data (perfdata) representation.
|
|
759
|
+
|
|
760
|
+
:term:`Performance data` are created during metric evaluation in a context
|
|
761
|
+
and are written into the *perfdata* section of the plugin's output.
|
|
762
|
+
:class:`Performance` allows the creation of value objects that are passed
|
|
763
|
+
between other mplugin objects.
|
|
764
|
+
|
|
765
|
+
For sake of consistency, performance data should represent their values in
|
|
766
|
+
their respective base unit, so ``Performance('size', 10000, 'B')`` is better
|
|
767
|
+
than ``Performance('size', 10, 'kB')``.
|
|
768
|
+
|
|
769
|
+
See the
|
|
770
|
+
`Monitoring plugin guidelines <https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/03.Output.md#performance-data>`__
|
|
771
|
+
for details.
|
|
772
|
+
"""
|
|
773
|
+
|
|
774
|
+
label: str
|
|
775
|
+
"""short identifier, results in graph titles for example (20 chars or less recommended)"""
|
|
776
|
+
|
|
777
|
+
value: typing.Any
|
|
778
|
+
"""measured value (usually an int, float, or bool)"""
|
|
779
|
+
|
|
780
|
+
uom: typing.Optional[str]
|
|
781
|
+
"""unit of measure -- use base units whereever possible"""
|
|
782
|
+
|
|
783
|
+
warn: typing.Optional["RangeSpec"]
|
|
784
|
+
"""warning range"""
|
|
785
|
+
|
|
786
|
+
crit: typing.Optional["RangeSpec"]
|
|
787
|
+
"""critical range"""
|
|
788
|
+
|
|
789
|
+
min: typing.Optional[float]
|
|
790
|
+
"""known value minimum (None for no minimum)"""
|
|
791
|
+
|
|
792
|
+
max: typing.Optional[float]
|
|
793
|
+
"""known value maximum (None for no maximum)"""
|
|
794
|
+
|
|
795
|
+
# Changing these now would be API-breaking, so we'll ignore these
|
|
796
|
+
# shadowed built-ins and the long list of arguments
|
|
797
|
+
# pylint: disable-next=redefined-builtin,too-many-arguments
|
|
798
|
+
def __init__(
|
|
799
|
+
self,
|
|
800
|
+
label: str,
|
|
801
|
+
value: typing.Any,
|
|
802
|
+
uom: typing.Optional[str] = None,
|
|
803
|
+
warn: typing.Optional["RangeSpec"] = None,
|
|
804
|
+
crit: typing.Optional["RangeSpec"] = None,
|
|
805
|
+
min: typing.Optional[float] = None,
|
|
806
|
+
max: typing.Optional[float] = None,
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Create new performance data object.
|
|
809
|
+
|
|
810
|
+
:param label: short identifier, results in graph
|
|
811
|
+
titles for example (20 chars or less recommended)
|
|
812
|
+
:param value: measured value (usually an int, float, or bool)
|
|
813
|
+
:param uom: unit of measure -- use base units whereever possible
|
|
814
|
+
:param warn: warning range
|
|
815
|
+
:param crit: critical range
|
|
816
|
+
:param min: known value minimum (None for no minimum)
|
|
817
|
+
:param max: known value maximum (None for no maximum)
|
|
818
|
+
"""
|
|
819
|
+
if "'" in label or "=" in label:
|
|
820
|
+
raise RuntimeError("label contains illegal characters", label)
|
|
821
|
+
self.label = label
|
|
822
|
+
self.value = value
|
|
823
|
+
self.uom = uom
|
|
824
|
+
self.warn = warn
|
|
825
|
+
self.crit = crit
|
|
826
|
+
self.min = min
|
|
827
|
+
self.max = max
|
|
828
|
+
|
|
829
|
+
def __str__(self) -> str:
|
|
830
|
+
"""String representation conforming to the plugin API.
|
|
831
|
+
|
|
832
|
+
Labels containing spaces or special characters will be quoted.
|
|
833
|
+
"""
|
|
834
|
+
|
|
835
|
+
performance: str = f"{_quote(self.label)}={self.value}"
|
|
836
|
+
|
|
837
|
+
if self.uom is not None:
|
|
838
|
+
performance += self.uom
|
|
839
|
+
|
|
840
|
+
out: list[str] = [performance]
|
|
841
|
+
|
|
842
|
+
if self.warn is not None and self.warn != "" and self.warn != Range(""):
|
|
843
|
+
out.append(str(self.warn))
|
|
844
|
+
|
|
845
|
+
if self.crit is not None and self.crit != "" and self.crit != Range(""):
|
|
846
|
+
out.append(str(self.crit))
|
|
847
|
+
|
|
848
|
+
if self.min is not None:
|
|
849
|
+
out.append(str(self.min))
|
|
850
|
+
|
|
851
|
+
if self.max is not None:
|
|
852
|
+
out.append(str(self.max))
|
|
853
|
+
|
|
854
|
+
return ";".join(out)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
# runtime.py
|
|
858
|
+
|
|
859
|
+
"""Functions and classes to interface with the system.
|
|
860
|
+
|
|
861
|
+
This module contains the :class:`Runtime` class that handles exceptions,
|
|
862
|
+
timeouts and logging. Plugin authors should not use Runtime directly,
|
|
863
|
+
but decorate the plugin's main function with :func:`~.runtime.guarded`.
|
|
864
|
+
"""
|
|
865
|
+
|
|
866
|
+
P = typing.ParamSpec("P")
|
|
867
|
+
R = typing.TypeVar("R")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def guarded(
|
|
871
|
+
original_function: typing.Any = None, verbose: typing.Any = None
|
|
872
|
+
) -> typing.Any:
|
|
873
|
+
"""Runs a function mplugin's Runtime environment.
|
|
874
|
+
|
|
875
|
+
`guarded` makes the decorated function behave correctly with respect
|
|
876
|
+
to the monitoring plugin API if it aborts with an uncaught exception or
|
|
877
|
+
a timeout. It exits with an *unknown* exit code and prints a
|
|
878
|
+
traceback in a format acceptable by monitoring solution.
|
|
879
|
+
|
|
880
|
+
This function should be used as a decorator for the script's `main`
|
|
881
|
+
function.
|
|
882
|
+
|
|
883
|
+
:param verbose: Optional keyword parameter to control verbosity
|
|
884
|
+
level during early execution (before
|
|
885
|
+
:meth:`~mplugin.Check.main` has been called). For example,
|
|
886
|
+
use `@guarded(verbose=0)` to turn tracebacks in that phase off.
|
|
887
|
+
"""
|
|
888
|
+
|
|
889
|
+
def _decorate(func: typing.Callable[P, R]):
|
|
890
|
+
@functools.wraps(func)
|
|
891
|
+
# This inconsistent-return-statements error can be fixed by adding a
|
|
892
|
+
# typing.NoReturn type hint to Runtime._handle_exception(), but we can't do
|
|
893
|
+
# that as long as we're maintaining py27 compatability.
|
|
894
|
+
# pylint: disable-next=inconsistent-return-statements
|
|
895
|
+
def wrapper(*args: typing.Any, **kwds: typing.Any):
|
|
896
|
+
runtime = _Runtime()
|
|
897
|
+
if verbose is not None:
|
|
898
|
+
runtime.verbose = verbose
|
|
899
|
+
try:
|
|
900
|
+
return func(*args, **kwds)
|
|
901
|
+
except Timeout as exc:
|
|
902
|
+
runtime._handle_exception( # type: ignore
|
|
903
|
+
"Timeout: check execution aborted after {0}".format(exc)
|
|
904
|
+
)
|
|
905
|
+
except Exception:
|
|
906
|
+
runtime._handle_exception() # type: ignore
|
|
907
|
+
|
|
908
|
+
return wrapper
|
|
909
|
+
|
|
910
|
+
if original_function is not None:
|
|
911
|
+
assert callable(original_function), (
|
|
912
|
+
'Function {!r} not callable. Forgot to add "verbose=" keyword?'.format(
|
|
913
|
+
original_function
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
return _decorate(original_function)
|
|
917
|
+
return _decorate # type: ignore
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
class _AnsiColorFormatter(logging.Formatter):
|
|
921
|
+
"""https://medium.com/@kamilmatejuk/inside-python-colorful-logging-ad3a74442cc6"""
|
|
922
|
+
|
|
923
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
924
|
+
no_style = "\033[0m"
|
|
925
|
+
bold = "\033[91m"
|
|
926
|
+
grey = "\033[90m"
|
|
927
|
+
yellow = "\033[93m"
|
|
928
|
+
red = "\033[31m"
|
|
929
|
+
red_light = "\033[91m"
|
|
930
|
+
blue = "\033[34m"
|
|
931
|
+
start_style = {
|
|
932
|
+
"DEBUG": grey,
|
|
933
|
+
"INFO": blue,
|
|
934
|
+
"WARNING": yellow,
|
|
935
|
+
"ERROR": red,
|
|
936
|
+
"CRITICAL": red_light + bold,
|
|
937
|
+
}.get(record.levelname, no_style)
|
|
938
|
+
end_style = no_style
|
|
939
|
+
return f"{start_style}{super().format(record)}{end_style}"
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
class _Runtime:
|
|
943
|
+
instance: typing.Optional[typing_extensions.Self] = None # type: ignore
|
|
944
|
+
check: typing.Optional["Check"] = None
|
|
945
|
+
_verbose = 1
|
|
946
|
+
_colorize: bool = False
|
|
947
|
+
"""Use ANSI colors to colorize the logging output"""
|
|
948
|
+
timeout: typing.Optional[int] = None
|
|
949
|
+
logchan: logging.StreamHandler[io.StringIO]
|
|
950
|
+
output: _Output
|
|
951
|
+
stdout: typing.Optional[io.StringIO] = None
|
|
952
|
+
exitcode: int = 70 # EX_SOFTWARE
|
|
953
|
+
|
|
954
|
+
def __new__(cls) -> typing_extensions.Self:
|
|
955
|
+
if not cls.instance:
|
|
956
|
+
cls.instance: typing_extensions.Self = super(_Runtime, cls).__new__(cls)
|
|
957
|
+
return cls.instance
|
|
958
|
+
|
|
959
|
+
def __init__(self) -> None:
|
|
960
|
+
rootlogger = logging.getLogger("mplugin")
|
|
961
|
+
rootlogger.setLevel(logging.DEBUG)
|
|
962
|
+
self.logchan = logging.StreamHandler(io.StringIO())
|
|
963
|
+
self.logchan.setFormatter(logging.Formatter("%(message)s"))
|
|
964
|
+
rootlogger.addHandler(self.logchan)
|
|
965
|
+
self.output = _Output(self.logchan)
|
|
966
|
+
|
|
967
|
+
def _handle_exception(
|
|
968
|
+
self, statusline: typing.Optional[str] = None
|
|
969
|
+
) -> typing.NoReturn:
|
|
970
|
+
exc_type, value = sys.exc_info()[0:2]
|
|
971
|
+
name = self.check.name.upper() + " " if self.check else ""
|
|
972
|
+
self.output.status = "{0}UNKNOWN: {1}".format(
|
|
973
|
+
name,
|
|
974
|
+
statusline or traceback.format_exception_only(exc_type, value)[0].strip(),
|
|
975
|
+
)
|
|
976
|
+
if self.verbose > 0:
|
|
977
|
+
self.output.add_longoutput(traceback.format_exc())
|
|
978
|
+
print("{0}".format(self.output), end="", file=self.stdout)
|
|
979
|
+
self.exitcode = 3
|
|
980
|
+
self.sysexit()
|
|
981
|
+
|
|
982
|
+
@property
|
|
983
|
+
def verbose(self) -> int:
|
|
984
|
+
return self._verbose
|
|
985
|
+
|
|
986
|
+
@verbose.setter
|
|
987
|
+
def verbose(self, verbose: typing.Any) -> None:
|
|
988
|
+
if isinstance(verbose, int):
|
|
989
|
+
self._verbose = verbose
|
|
990
|
+
elif isinstance(verbose, float):
|
|
991
|
+
self._verbose = int(verbose)
|
|
992
|
+
else:
|
|
993
|
+
self._verbose = len(verbose or [])
|
|
994
|
+
if self._verbose >= 3:
|
|
995
|
+
self.logchan.setLevel(logging.DEBUG)
|
|
996
|
+
self._verbose = 3
|
|
997
|
+
elif self._verbose == 2:
|
|
998
|
+
self.logchan.setLevel(logging.INFO)
|
|
999
|
+
else:
|
|
1000
|
+
self.logchan.setLevel(logging.WARNING)
|
|
1001
|
+
self.output.verbose = self._verbose
|
|
1002
|
+
|
|
1003
|
+
@property
|
|
1004
|
+
def colorize(self) -> int:
|
|
1005
|
+
return self._colorize
|
|
1006
|
+
|
|
1007
|
+
@colorize.setter
|
|
1008
|
+
def colorize(self, colorize: bool) -> None:
|
|
1009
|
+
self._colorize = colorize
|
|
1010
|
+
if colorize:
|
|
1011
|
+
self.logchan.setFormatter(_AnsiColorFormatter("%(message)s"))
|
|
1012
|
+
else:
|
|
1013
|
+
self.logchan.setFormatter(logging.Formatter("%(message)s"))
|
|
1014
|
+
|
|
1015
|
+
def run(self, check: "Check") -> None:
|
|
1016
|
+
check()
|
|
1017
|
+
self.output.add(check)
|
|
1018
|
+
self.exitcode = check.exitcode
|
|
1019
|
+
|
|
1020
|
+
def execute(
|
|
1021
|
+
self,
|
|
1022
|
+
check: "Check",
|
|
1023
|
+
verbose: typing.Any = None,
|
|
1024
|
+
timeout: typing.Any = None,
|
|
1025
|
+
colorize: bool = False,
|
|
1026
|
+
) -> typing.NoReturn:
|
|
1027
|
+
self.check = check
|
|
1028
|
+
if verbose is not None:
|
|
1029
|
+
self.verbose = verbose
|
|
1030
|
+
if timeout is not None:
|
|
1031
|
+
self.timeout = int(timeout)
|
|
1032
|
+
if colorize:
|
|
1033
|
+
self.colorize = True
|
|
1034
|
+
if self.timeout:
|
|
1035
|
+
_with_timeout(self.timeout, self.run, check)
|
|
1036
|
+
else:
|
|
1037
|
+
self.run(check)
|
|
1038
|
+
print("{0}".format(self.output), end="", file=self.stdout)
|
|
1039
|
+
self.sysexit()
|
|
1040
|
+
|
|
1041
|
+
def sysexit(self) -> typing.NoReturn:
|
|
1042
|
+
sys.exit(self.exitcode)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
# metric.py
|
|
1046
|
+
|
|
1047
|
+
"""Structured representation for data points.
|
|
1048
|
+
"""
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
class _MetricKwargs(typing.TypedDict, total=False):
|
|
1052
|
+
name: str
|
|
1053
|
+
value: typing.Any
|
|
1054
|
+
uom: str
|
|
1055
|
+
min: float
|
|
1056
|
+
max: float
|
|
1057
|
+
context: str
|
|
1058
|
+
contextobj: "Context"
|
|
1059
|
+
resource: "Resource"
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
class Metric:
|
|
1063
|
+
"""Single measured value.
|
|
1064
|
+
|
|
1065
|
+
This module contains the :class:`Metric` class whose instances are
|
|
1066
|
+
passed as value objects between most of mplugin's core classes.
|
|
1067
|
+
Typically, :class:`~.resource.Resource` objects emit a list of metrics
|
|
1068
|
+
as result of their :meth:`~.resource.Resource.probe` methods.
|
|
1069
|
+
|
|
1070
|
+
The value should be expressed in terms of base units, so
|
|
1071
|
+
Metric('swap', 10240, 'B') is better than Metric('swap', 10, 'kiB').
|
|
1072
|
+
"""
|
|
1073
|
+
|
|
1074
|
+
name: str
|
|
1075
|
+
value: typing.Any
|
|
1076
|
+
uom: typing.Optional[str] = None
|
|
1077
|
+
min: typing.Optional[float] = None
|
|
1078
|
+
max: typing.Optional[float] = None
|
|
1079
|
+
context: str
|
|
1080
|
+
contextobj: typing.Optional["Context"] = None
|
|
1081
|
+
resource: typing.Optional["Resource"] = None
|
|
1082
|
+
|
|
1083
|
+
# Changing these now would be API-breaking, so we'll ignore these
|
|
1084
|
+
# shadowed built-ins
|
|
1085
|
+
# pylint: disable-next=redefined-builtin
|
|
1086
|
+
def __init__(
|
|
1087
|
+
self,
|
|
1088
|
+
name: str,
|
|
1089
|
+
value: typing.Any,
|
|
1090
|
+
uom: typing.Optional[str] = None,
|
|
1091
|
+
min: typing.Optional[float] = None,
|
|
1092
|
+
max: typing.Optional[float] = None,
|
|
1093
|
+
context: typing.Optional[str] = None,
|
|
1094
|
+
contextobj: typing.Optional["Context"] = None,
|
|
1095
|
+
resource: typing.Optional["Resource"] = None,
|
|
1096
|
+
) -> None:
|
|
1097
|
+
"""Creates new Metric instance.
|
|
1098
|
+
|
|
1099
|
+
:param name: short internal identifier for the value -- appears
|
|
1100
|
+
also in the performance data
|
|
1101
|
+
:param value: data point, usually has a boolen or numeric type,
|
|
1102
|
+
but other types are also possible
|
|
1103
|
+
:param uom: :term:`unit of measure`, preferrably as ISO
|
|
1104
|
+
abbreviation like "s"
|
|
1105
|
+
:param min: minimum value or None if there is no known minimum
|
|
1106
|
+
:param max: maximum value or None if there is no known maximum
|
|
1107
|
+
:param context: name of the associated context (defaults to the
|
|
1108
|
+
metric's name if left out)
|
|
1109
|
+
:param contextobj: reference to the associated context object
|
|
1110
|
+
(set automatically by :class:`~mplugin.check.Check`)
|
|
1111
|
+
:param resource: reference to the originating
|
|
1112
|
+
:class:`~mplugin.Resource` (set automatically
|
|
1113
|
+
by :class:`~mplugin.check.Check`)
|
|
1114
|
+
"""
|
|
1115
|
+
self.name = name
|
|
1116
|
+
self.value = value
|
|
1117
|
+
self.uom = uom
|
|
1118
|
+
self.min = min
|
|
1119
|
+
self.max = max
|
|
1120
|
+
if context is not None:
|
|
1121
|
+
self.context = context
|
|
1122
|
+
else:
|
|
1123
|
+
self.context = name
|
|
1124
|
+
self.contextobj = contextobj
|
|
1125
|
+
self.resource = resource
|
|
1126
|
+
|
|
1127
|
+
def __str__(self) -> str:
|
|
1128
|
+
"""Same as :attr:`valueunit`."""
|
|
1129
|
+
return self.valueunit
|
|
1130
|
+
|
|
1131
|
+
def replace(
|
|
1132
|
+
self, **attr: typing_extensions.Unpack[_MetricKwargs]
|
|
1133
|
+
) -> typing_extensions.Self:
|
|
1134
|
+
"""Creates new instance with updated attributes."""
|
|
1135
|
+
for key, value in attr.items():
|
|
1136
|
+
setattr(self, key, value)
|
|
1137
|
+
return self
|
|
1138
|
+
|
|
1139
|
+
@property
|
|
1140
|
+
def description(self) -> typing.Optional[str]:
|
|
1141
|
+
"""Human-readable, detailed string representation.
|
|
1142
|
+
|
|
1143
|
+
Delegates to the :class:`~.context.Context` to format the value.
|
|
1144
|
+
|
|
1145
|
+
:returns: :meth:`~.context.Context.describe` output or
|
|
1146
|
+
:attr:`valueunit` if no context has been associated yet
|
|
1147
|
+
"""
|
|
1148
|
+
if self.contextobj:
|
|
1149
|
+
return self.contextobj.describe(self)
|
|
1150
|
+
return str(self)
|
|
1151
|
+
|
|
1152
|
+
@property
|
|
1153
|
+
def valueunit(self) -> str:
|
|
1154
|
+
"""Compact string representation.
|
|
1155
|
+
|
|
1156
|
+
This is just the value and the unit. If the value is a real
|
|
1157
|
+
number, express the value with a limited number of digits to
|
|
1158
|
+
improve readability.
|
|
1159
|
+
"""
|
|
1160
|
+
return "%s%s" % (self._human_readable_value, self.uom or "")
|
|
1161
|
+
|
|
1162
|
+
@property
|
|
1163
|
+
def _human_readable_value(self) -> str:
|
|
1164
|
+
"""Limit number of digits for floats."""
|
|
1165
|
+
if isinstance(self.value, numbers.Real) and not isinstance(
|
|
1166
|
+
self.value, numbers.Integral
|
|
1167
|
+
):
|
|
1168
|
+
return "%.4g" % self.value
|
|
1169
|
+
return str(self.value)
|
|
1170
|
+
|
|
1171
|
+
def evaluate(self) -> typing.Union["Result", "ServiceState"]:
|
|
1172
|
+
"""Evaluates this instance according to the context.
|
|
1173
|
+
|
|
1174
|
+
:return: :class:`~mplugin.Result` object
|
|
1175
|
+
:raise RuntimeError: if no context has been associated yet
|
|
1176
|
+
"""
|
|
1177
|
+
if not self.contextobj:
|
|
1178
|
+
raise RuntimeError("no context set for metric", self.name)
|
|
1179
|
+
if not self.resource:
|
|
1180
|
+
raise RuntimeError("no resource set for metric", self.name)
|
|
1181
|
+
return self.contextobj.evaluate(self, self.resource)
|
|
1182
|
+
|
|
1183
|
+
def performance(self) -> typing.Optional[Performance]:
|
|
1184
|
+
"""Generates performance data according to the context.
|
|
1185
|
+
|
|
1186
|
+
:return: :class:`~mplugin.performance.Performance` object
|
|
1187
|
+
:raise RuntimeError: if no context has been associated yet
|
|
1188
|
+
"""
|
|
1189
|
+
if not self.contextobj:
|
|
1190
|
+
raise RuntimeError("no context set for metric", self.name)
|
|
1191
|
+
if not self.resource:
|
|
1192
|
+
raise RuntimeError("no resource set for metric", self.name)
|
|
1193
|
+
return self.contextobj.performance(self, self.resource)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
# resource.py
|
|
1197
|
+
|
|
1198
|
+
"""Domain model for data :term:`acquisition`.
|
|
1199
|
+
"""
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
class Resource:
|
|
1203
|
+
"""Abstract base class for custom domain models.
|
|
1204
|
+
|
|
1205
|
+
:class:`Resource` is the base class for the plugin's :term:`domain
|
|
1206
|
+
model`. It shoul model the relevant details of reality that a plugin is
|
|
1207
|
+
supposed to check. The :class:`~.check.Check` controller calls
|
|
1208
|
+
:meth:`Resource.probe` on all passed resource objects to acquire data.
|
|
1209
|
+
|
|
1210
|
+
Plugin authors should subclass :class:`Resource` and write
|
|
1211
|
+
whatever methods are needed to get the interesting bits of information.
|
|
1212
|
+
The most important resource subclass should be named after the plugin
|
|
1213
|
+
itself.
|
|
1214
|
+
|
|
1215
|
+
Subclasses may add arguments to the constructor to parametrize
|
|
1216
|
+
information retrieval.
|
|
1217
|
+
"""
|
|
1218
|
+
|
|
1219
|
+
@property
|
|
1220
|
+
def name(self) -> str:
|
|
1221
|
+
return self.__class__.__name__
|
|
1222
|
+
|
|
1223
|
+
# This could be corrected by re-implementing this class as a proper ABC.
|
|
1224
|
+
# See issue #42
|
|
1225
|
+
# pylint: disable=no-self-use
|
|
1226
|
+
def probe(
|
|
1227
|
+
self,
|
|
1228
|
+
) -> typing.Union[list["Metric"], "Metric", typing.Generator["Metric", None, None]]:
|
|
1229
|
+
"""Query system state and return metrics.
|
|
1230
|
+
|
|
1231
|
+
This is the only method called by the check controller.
|
|
1232
|
+
It should trigger all necessary actions and create metrics.
|
|
1233
|
+
|
|
1234
|
+
A plugin can perform several measurements at once.
|
|
1235
|
+
|
|
1236
|
+
.. code-block:: Python
|
|
1237
|
+
|
|
1238
|
+
def probe(self):
|
|
1239
|
+
self.users = self.list_users()
|
|
1240
|
+
self.unique_users = set(self.users)
|
|
1241
|
+
return [
|
|
1242
|
+
Metric("total", len(self.users), min=0, context="users"),
|
|
1243
|
+
Metric("unique", len(self.unique_users), min=0, context="users"),
|
|
1244
|
+
]
|
|
1245
|
+
|
|
1246
|
+
Alternatively, the probe() method can act as generator and yield metrics:
|
|
1247
|
+
|
|
1248
|
+
.. code-block:: Python
|
|
1249
|
+
|
|
1250
|
+
def probe(self):
|
|
1251
|
+
self.users = self.list_users()
|
|
1252
|
+
self.unique_users = set(self.users)
|
|
1253
|
+
yield Metric('total', len(self.users), min=0,
|
|
1254
|
+
context='users')
|
|
1255
|
+
yield Metric('unique', len(self.unique_users), min=0,
|
|
1256
|
+
context='users')]
|
|
1257
|
+
|
|
1258
|
+
:return: list of :class:`~mplugin.Metric` objects,
|
|
1259
|
+
or generator that emits :class:`~mplugin.Metric`
|
|
1260
|
+
objects, or single :class:`~mplugin.Metric`
|
|
1261
|
+
object
|
|
1262
|
+
"""
|
|
1263
|
+
return []
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
# result.py
|
|
1267
|
+
|
|
1268
|
+
"""Outcomes from evaluating metrics in contexts.
|
|
1269
|
+
|
|
1270
|
+
The :class:`Result` class is the base class for all evaluation results.
|
|
1271
|
+
The :class:`Results` class (plural form) provides a result container with
|
|
1272
|
+
access functions and iterators.
|
|
1273
|
+
|
|
1274
|
+
Plugin authors may create their own :class:`Result` subclass to
|
|
1275
|
+
accomodate for special needs.
|
|
1276
|
+
"""
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
class Result:
|
|
1280
|
+
"""Evaluation outcome consisting of state and explanation.
|
|
1281
|
+
|
|
1282
|
+
A Result object is typically emitted by a
|
|
1283
|
+
:class:`~mplugin.Context` object and represents the
|
|
1284
|
+
outcome of an evaluation. It contains a
|
|
1285
|
+
:class:`~mplugin.state.ServiceState` as well as an explanation.
|
|
1286
|
+
Plugin authors may subclass Result to implement specific features.
|
|
1287
|
+
"""
|
|
1288
|
+
|
|
1289
|
+
state: "ServiceState"
|
|
1290
|
+
|
|
1291
|
+
hint: typing.Optional[str]
|
|
1292
|
+
|
|
1293
|
+
metric: typing.Optional["Metric"]
|
|
1294
|
+
|
|
1295
|
+
def __init__(
|
|
1296
|
+
self,
|
|
1297
|
+
state: "ServiceState",
|
|
1298
|
+
hint: typing.Optional[str] = None,
|
|
1299
|
+
metric: typing.Optional["Metric"] = None,
|
|
1300
|
+
) -> None:
|
|
1301
|
+
self.state = state
|
|
1302
|
+
self.hint = hint
|
|
1303
|
+
self.metric = metric
|
|
1304
|
+
|
|
1305
|
+
def __str__(self) -> str:
|
|
1306
|
+
"""Textual result explanation.
|
|
1307
|
+
|
|
1308
|
+
The result explanation is taken from :attr:`metric.description`
|
|
1309
|
+
(if a metric has been passed to the constructur), followed
|
|
1310
|
+
optionally by the value of :attr:`hint`. This method's output
|
|
1311
|
+
should consist only of a text for the reason but not for the
|
|
1312
|
+
result's state. The latter is rendered independently.
|
|
1313
|
+
|
|
1314
|
+
:returns: result explanation or empty string
|
|
1315
|
+
"""
|
|
1316
|
+
if self.metric and self.metric.description:
|
|
1317
|
+
desc = self.metric.description
|
|
1318
|
+
else:
|
|
1319
|
+
desc = None
|
|
1320
|
+
|
|
1321
|
+
if self.hint and desc:
|
|
1322
|
+
return "{0} ({1})".format(desc, self.hint)
|
|
1323
|
+
if self.hint:
|
|
1324
|
+
return self.hint
|
|
1325
|
+
if desc:
|
|
1326
|
+
return desc
|
|
1327
|
+
return ""
|
|
1328
|
+
|
|
1329
|
+
@property
|
|
1330
|
+
def resource(self) -> typing.Optional["Resource"]:
|
|
1331
|
+
"""Reference to the resource used to generate this result."""
|
|
1332
|
+
if not self.metric:
|
|
1333
|
+
return None
|
|
1334
|
+
return self.metric.resource
|
|
1335
|
+
|
|
1336
|
+
@property
|
|
1337
|
+
def context(self) -> typing.Optional["Context"]:
|
|
1338
|
+
"""Reference to the metric used to generate this result."""
|
|
1339
|
+
if not self.metric:
|
|
1340
|
+
return None
|
|
1341
|
+
return self.metric.contextobj
|
|
1342
|
+
|
|
1343
|
+
def __eq__(self, value: object) -> bool:
|
|
1344
|
+
if not isinstance(value, Result):
|
|
1345
|
+
return False
|
|
1346
|
+
return (
|
|
1347
|
+
self.state == value.state
|
|
1348
|
+
and self.hint == value.hint
|
|
1349
|
+
and self.metric == value.metric
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
class Results:
|
|
1354
|
+
"""Container for result sets.
|
|
1355
|
+
|
|
1356
|
+
Basically, this class manages a set of results and provides
|
|
1357
|
+
convenient access methods by index, name, or result state. It is
|
|
1358
|
+
meant to make queries in :class:`~.summary.Summary`
|
|
1359
|
+
implementations compact and readable.
|
|
1360
|
+
|
|
1361
|
+
The constructor accepts an arbitrary number of result objects and
|
|
1362
|
+
adds them to the container.
|
|
1363
|
+
"""
|
|
1364
|
+
|
|
1365
|
+
results: list[Result]
|
|
1366
|
+
by_state: dict["ServiceState", list[Result]]
|
|
1367
|
+
by_name: dict[str, Result]
|
|
1368
|
+
|
|
1369
|
+
def __init__(self, *results: Result) -> None:
|
|
1370
|
+
self.results = []
|
|
1371
|
+
self.by_state = collections.defaultdict(list)
|
|
1372
|
+
self.by_name = {}
|
|
1373
|
+
if results:
|
|
1374
|
+
self.add(*results)
|
|
1375
|
+
|
|
1376
|
+
def add(self, *results: Result) -> typing_extensions.Self:
|
|
1377
|
+
"""Adds more results to the container.
|
|
1378
|
+
|
|
1379
|
+
Besides passing :class:`Result` objects in the constructor,
|
|
1380
|
+
additional results may be added after creating the container.
|
|
1381
|
+
|
|
1382
|
+
:raises ValueError: if `result` is not a :class:`Result` object
|
|
1383
|
+
"""
|
|
1384
|
+
for result in results:
|
|
1385
|
+
if not isinstance(result, Result): # type: ignore
|
|
1386
|
+
raise ValueError(
|
|
1387
|
+
"trying to add non-Result to Results container", result
|
|
1388
|
+
)
|
|
1389
|
+
self.results.append(result)
|
|
1390
|
+
self.by_state[result.state].append(result)
|
|
1391
|
+
try:
|
|
1392
|
+
self.by_name[result.metric.name] = result # type: ignore
|
|
1393
|
+
except AttributeError:
|
|
1394
|
+
pass
|
|
1395
|
+
return self
|
|
1396
|
+
|
|
1397
|
+
def __iter__(self) -> typing.Generator[Result, typing.Any, None]:
|
|
1398
|
+
"""Iterates over all results.
|
|
1399
|
+
|
|
1400
|
+
The iterator is sorted in order of decreasing state
|
|
1401
|
+
significance (unknown > critical > warning > ok).
|
|
1402
|
+
|
|
1403
|
+
:returns: result object iterator
|
|
1404
|
+
"""
|
|
1405
|
+
for state in reversed(sorted(self.by_state)):
|
|
1406
|
+
for result in self.by_state[state]:
|
|
1407
|
+
yield result
|
|
1408
|
+
|
|
1409
|
+
def __len__(self) -> int:
|
|
1410
|
+
"""Number of results in this container."""
|
|
1411
|
+
return len(self.results)
|
|
1412
|
+
|
|
1413
|
+
def __getitem__(self, item: typing.Union[int, str]) -> Result:
|
|
1414
|
+
"""Access result by index or name.
|
|
1415
|
+
|
|
1416
|
+
If *item* is an integer, the itemth element in the
|
|
1417
|
+
container is returned. If *item* is a string, it is used to
|
|
1418
|
+
look up a result with the given name.
|
|
1419
|
+
|
|
1420
|
+
:returns: :class:`Result` object
|
|
1421
|
+
:raises KeyError: if no matching result is found
|
|
1422
|
+
"""
|
|
1423
|
+
if isinstance(item, int):
|
|
1424
|
+
return self.results[item]
|
|
1425
|
+
return self.by_name[item]
|
|
1426
|
+
|
|
1427
|
+
def __contains__(self, name: str) -> bool:
|
|
1428
|
+
"""Tests if a result with given name is present.
|
|
1429
|
+
|
|
1430
|
+
:returns: boolean
|
|
1431
|
+
"""
|
|
1432
|
+
return name in self.by_name
|
|
1433
|
+
|
|
1434
|
+
@property
|
|
1435
|
+
def most_significant_state(self) -> "ServiceState":
|
|
1436
|
+
"""The "worst" state found in all results.
|
|
1437
|
+
|
|
1438
|
+
:returns: :obj:`~mplugin.state.ServiceState` object
|
|
1439
|
+
:raises ValueError: if no results are present
|
|
1440
|
+
"""
|
|
1441
|
+
return max(self.by_state.keys())
|
|
1442
|
+
|
|
1443
|
+
@property
|
|
1444
|
+
def most_significant(self) -> list[Result]:
|
|
1445
|
+
"""Returns list of results with most significant state.
|
|
1446
|
+
|
|
1447
|
+
From all results present, a subset with the "worst" state is
|
|
1448
|
+
selected.
|
|
1449
|
+
|
|
1450
|
+
:returns: list of :class:`Result` objects or empty list if no
|
|
1451
|
+
results are present
|
|
1452
|
+
"""
|
|
1453
|
+
try:
|
|
1454
|
+
return self.by_state[self.most_significant_state]
|
|
1455
|
+
except ValueError:
|
|
1456
|
+
return []
|
|
1457
|
+
|
|
1458
|
+
@property
|
|
1459
|
+
def first_significant(self) -> Result:
|
|
1460
|
+
"""Selects one of the results with most significant state.
|
|
1461
|
+
|
|
1462
|
+
:returns: :class:`Result` object
|
|
1463
|
+
:raises IndexError: if no results are present
|
|
1464
|
+
"""
|
|
1465
|
+
return self.most_significant[0]
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
# summary.py
|
|
1469
|
+
|
|
1470
|
+
"""Create status line from results.
|
|
1471
|
+
|
|
1472
|
+
This module contains the :class:`Summary` class which serves as base
|
|
1473
|
+
class to get a status line from the check's :class:`~.result.Results`. A
|
|
1474
|
+
Summary object is used by :class:`~.check.Check` to obtain a suitable data
|
|
1475
|
+
:term:`presentation` depending on the check's overall state.
|
|
1476
|
+
|
|
1477
|
+
Plugin authors may either stick to the default implementation or subclass it
|
|
1478
|
+
to adapt it to the check's domain. The status line is probably the most
|
|
1479
|
+
important piece of text returned from a check: It must lead directly to the
|
|
1480
|
+
problem in the most concise way. So while the default implementation is quite
|
|
1481
|
+
usable, plugin authors should consider subclassing to provide a specific
|
|
1482
|
+
implementation that gets the output to the point.
|
|
1483
|
+
"""
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
class Summary:
|
|
1487
|
+
"""Creates a summary formatter object.
|
|
1488
|
+
|
|
1489
|
+
This base class takes no parameters in its constructor, but subclasses may
|
|
1490
|
+
provide more elaborate constructors that accept parameters to influence
|
|
1491
|
+
output creation.
|
|
1492
|
+
"""
|
|
1493
|
+
|
|
1494
|
+
# It might be possible to re-implement this as a @staticmethod,
|
|
1495
|
+
# but this might be an API-breaking change, so it should probably stay in
|
|
1496
|
+
# place until a 2.x rewrite. If this can't be a @staticmethod, then it
|
|
1497
|
+
# should probably be an @abstractmethod.
|
|
1498
|
+
# See issue #44
|
|
1499
|
+
# pylint: disable-next=no-self-use
|
|
1500
|
+
def ok(self, results: "Results") -> str:
|
|
1501
|
+
"""Formats status line when overall state is ok.
|
|
1502
|
+
|
|
1503
|
+
The default implementation returns a string representation of
|
|
1504
|
+
the first result.
|
|
1505
|
+
|
|
1506
|
+
:param results: :class:`~mplugin.Results` container
|
|
1507
|
+
:returns: status line
|
|
1508
|
+
"""
|
|
1509
|
+
return "{0}".format(results[0])
|
|
1510
|
+
|
|
1511
|
+
# It might be possible to re-implement this as a @staticmethod,
|
|
1512
|
+
# but this might be an API-breaking change, so it should probably stay in
|
|
1513
|
+
# place until a 2.x rewrite. If this can't be a @staticmethod, then it
|
|
1514
|
+
# should probably be an @abstractmethod.
|
|
1515
|
+
# See issue #44
|
|
1516
|
+
# pylint: disable-next=no-self-use
|
|
1517
|
+
def problem(self, results: "Results") -> str:
|
|
1518
|
+
"""Formats status line when overall state is not ok.
|
|
1519
|
+
|
|
1520
|
+
The default implementation returns a string representation of te
|
|
1521
|
+
first significant result, i.e. the result with the "worst"
|
|
1522
|
+
state.
|
|
1523
|
+
|
|
1524
|
+
:param results: :class:`~.result.Results` container
|
|
1525
|
+
:returns: status line
|
|
1526
|
+
"""
|
|
1527
|
+
return "{0}".format(results.first_significant)
|
|
1528
|
+
|
|
1529
|
+
# It might be possible to re-implement this as a @staticmethod,
|
|
1530
|
+
# but this might be an API-breaking change, so it should probably stay in
|
|
1531
|
+
# place until a 2.x rewrite. If this can't be a @staticmethod, then it
|
|
1532
|
+
# should probably be an @abstractmethod.
|
|
1533
|
+
# See issue #44
|
|
1534
|
+
# pylint: disable-next=no-self-use
|
|
1535
|
+
def verbose(self, results: "Results") -> list[str]:
|
|
1536
|
+
"""Provides extra lines if verbose plugin execution is requested.
|
|
1537
|
+
|
|
1538
|
+
The default implementation returns a list of all resources that are in
|
|
1539
|
+
a non-ok state.
|
|
1540
|
+
|
|
1541
|
+
:param results: :class:`~.result.Results` container
|
|
1542
|
+
:returns: list of strings
|
|
1543
|
+
"""
|
|
1544
|
+
msgs: list[str] = []
|
|
1545
|
+
for result in results:
|
|
1546
|
+
if result.state == ok:
|
|
1547
|
+
continue
|
|
1548
|
+
msgs.append("{0}: {1}".format(result.state, result))
|
|
1549
|
+
return msgs
|
|
1550
|
+
|
|
1551
|
+
# It might be possible to re-implement this as a @staticmethod,
|
|
1552
|
+
# but this might be an API-breaking change, so it should probably stay in
|
|
1553
|
+
# place until a 2.x rewrite. If this can't be a @staticmethod, then it
|
|
1554
|
+
# should probably be an @abstractmethod.
|
|
1555
|
+
# See issue #44
|
|
1556
|
+
# pylint: disable-next=no-self-use
|
|
1557
|
+
def empty(self) -> typing.Literal["no check results"]:
|
|
1558
|
+
"""Formats status line when the result set is empty.
|
|
1559
|
+
|
|
1560
|
+
:returns: status line
|
|
1561
|
+
"""
|
|
1562
|
+
return "no check results"
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
# context.py
|
|
1566
|
+
|
|
1567
|
+
"""Metadata about metrics to perform data :term:`evaluation`.
|
|
1568
|
+
|
|
1569
|
+
This module contains the :class:`Context` class, which is the base for
|
|
1570
|
+
all contexts. :class:`ScalarContext` is an important specialization to
|
|
1571
|
+
cover numeric contexts with warning and critical thresholds. The
|
|
1572
|
+
:class:`~.check.Check` controller selects a context for each
|
|
1573
|
+
:class:`~.metric.Metric` by matching the metric's `context` attribute with the
|
|
1574
|
+
context's `name`. The same context may be used for several metrics.
|
|
1575
|
+
|
|
1576
|
+
Plugin authors may just use to :class:`ScalarContext` in the majority of cases.
|
|
1577
|
+
Sometimes is better to subclass :class:`Context` instead to implement custom
|
|
1578
|
+
evaluation or performance data logic.
|
|
1579
|
+
"""
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
FmtMetric = str | typing.Callable[["Metric", "Context"], str]
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
class Context:
|
|
1586
|
+
"""Creates generic context identified by `name`.
|
|
1587
|
+
|
|
1588
|
+
Generic contexts just format associated metrics and evaluate
|
|
1589
|
+
always to :obj:`~mplugin.ok`. Metric formatting is
|
|
1590
|
+
controlled with the :attr:`fmt_metric` attribute. It can either
|
|
1591
|
+
be a string or a callable. See the :meth:`describe` method for
|
|
1592
|
+
how formatting is done.
|
|
1593
|
+
|
|
1594
|
+
:param name: A context name that is matched by the context
|
|
1595
|
+
attribute of :class:`~mplugin.Metric`
|
|
1596
|
+
:param fmt_metric: string or callable to convert
|
|
1597
|
+
context and associated metric to a human readable string
|
|
1598
|
+
"""
|
|
1599
|
+
|
|
1600
|
+
name: str
|
|
1601
|
+
fmt_metric: typing.Optional[FmtMetric]
|
|
1602
|
+
|
|
1603
|
+
def __init__(
|
|
1604
|
+
self,
|
|
1605
|
+
name: str,
|
|
1606
|
+
fmt_metric: typing.Optional[FmtMetric] = None,
|
|
1607
|
+
) -> None:
|
|
1608
|
+
|
|
1609
|
+
self.name = name
|
|
1610
|
+
self.fmt_metric = fmt_metric
|
|
1611
|
+
|
|
1612
|
+
def evaluate(
|
|
1613
|
+
self, metric: "Metric", resource: "Resource"
|
|
1614
|
+
) -> typing.Union[Result, ServiceState]:
|
|
1615
|
+
"""Determines state of a given metric.
|
|
1616
|
+
|
|
1617
|
+
This base implementation returns :class:`~mplugin.ok`
|
|
1618
|
+
in all cases. Plugin authors may override this method in
|
|
1619
|
+
subclasses to specialize behaviour.
|
|
1620
|
+
|
|
1621
|
+
:param metric: associated metric that is to be evaluated
|
|
1622
|
+
:param resource: resource that produced the associated metric
|
|
1623
|
+
(may optionally be consulted)
|
|
1624
|
+
:returns: :class:`~.result.Result` or
|
|
1625
|
+
:class:`~.state.ServiceState` object
|
|
1626
|
+
"""
|
|
1627
|
+
return self.result(ok, metric=metric)
|
|
1628
|
+
|
|
1629
|
+
def result(
|
|
1630
|
+
self,
|
|
1631
|
+
state: ServiceState,
|
|
1632
|
+
hint: typing.Optional[str] = None,
|
|
1633
|
+
metric: typing.Optional["Metric"] = None,
|
|
1634
|
+
) -> Result:
|
|
1635
|
+
"""
|
|
1636
|
+
Create a Result object with the given state, hint, and metric.
|
|
1637
|
+
|
|
1638
|
+
:param state: The service state for the result.
|
|
1639
|
+
:param hint: An optional hint message providing additional context.
|
|
1640
|
+
:param metric: An optional Metric object associated with the result.
|
|
1641
|
+
|
|
1642
|
+
:return: A Result object containing the provided state, hint, and metric.
|
|
1643
|
+
"""
|
|
1644
|
+
return Result(state=state, hint=hint, metric=metric)
|
|
1645
|
+
|
|
1646
|
+
def ok(
|
|
1647
|
+
self,
|
|
1648
|
+
hint: typing.Optional[str] = None,
|
|
1649
|
+
metric: typing.Optional["Metric"] = None,
|
|
1650
|
+
) -> Result:
|
|
1651
|
+
"""
|
|
1652
|
+
Create a successful Result.
|
|
1653
|
+
|
|
1654
|
+
:param hint: Optional hint message providing additional context about the successful operation.
|
|
1655
|
+
:param metric: Optional Metric object associated with this result.
|
|
1656
|
+
|
|
1657
|
+
:return: A Result object representing a successful operation.
|
|
1658
|
+
"""
|
|
1659
|
+
return self.result(ok, hint=hint, metric=metric)
|
|
1660
|
+
|
|
1661
|
+
def warning(
|
|
1662
|
+
self,
|
|
1663
|
+
hint: typing.Optional[str] = None,
|
|
1664
|
+
metric: typing.Optional["Metric"] = None,
|
|
1665
|
+
) -> Result:
|
|
1666
|
+
"""
|
|
1667
|
+
Create a warning result.
|
|
1668
|
+
|
|
1669
|
+
:param hint: Optional hint message to provide additional context for the warning.
|
|
1670
|
+
:param metric: Optional metric associated with the warning.
|
|
1671
|
+
|
|
1672
|
+
:return: A Result object representing a warning.
|
|
1673
|
+
"""
|
|
1674
|
+
return self.result(warning, hint=hint, metric=metric)
|
|
1675
|
+
|
|
1676
|
+
def critical(
|
|
1677
|
+
self,
|
|
1678
|
+
hint: typing.Optional[str] = None,
|
|
1679
|
+
metric: typing.Optional["Metric"] = None,
|
|
1680
|
+
) -> Result:
|
|
1681
|
+
"""
|
|
1682
|
+
Create a critical result.
|
|
1683
|
+
|
|
1684
|
+
:param hint: Optional hint message providing additional context about the critical result.
|
|
1685
|
+
:param metric: Optional metric object associated with this critical result.
|
|
1686
|
+
|
|
1687
|
+
:return: A Result object representing a critical state.
|
|
1688
|
+
"""
|
|
1689
|
+
return self.result(critical, hint=hint, metric=metric)
|
|
1690
|
+
|
|
1691
|
+
def unknown(
|
|
1692
|
+
self,
|
|
1693
|
+
hint: typing.Optional[str] = None,
|
|
1694
|
+
metric: typing.Optional["Metric"] = None,
|
|
1695
|
+
) -> Result:
|
|
1696
|
+
"""
|
|
1697
|
+
Create a Result object with an unknown status.
|
|
1698
|
+
|
|
1699
|
+
:param hint: Optional hint message providing additional context about why the result is unknown
|
|
1700
|
+
:param metric: Optional Metric object associated with this result
|
|
1701
|
+
|
|
1702
|
+
:return: A Result object with unknown status
|
|
1703
|
+
"""
|
|
1704
|
+
return self.result(unknown, hint=hint, metric=metric)
|
|
1705
|
+
|
|
1706
|
+
# This could be corrected by re-implementing this class as a proper ABC.
|
|
1707
|
+
# See issue #43
|
|
1708
|
+
# pylint: disable-next=no-self-use
|
|
1709
|
+
def performance(
|
|
1710
|
+
self, metric: "Metric", resource: "Resource"
|
|
1711
|
+
) -> typing.Optional[Performance]:
|
|
1712
|
+
"""Derives performance data from a given metric.
|
|
1713
|
+
|
|
1714
|
+
This base implementation just returns none. Plugin authors may
|
|
1715
|
+
override this method in subclass to specialize behaviour.
|
|
1716
|
+
|
|
1717
|
+
.. code-block:: python
|
|
1718
|
+
|
|
1719
|
+
def performance(self, metric: Metric, resource: Resource) -> Performance:
|
|
1720
|
+
return Performance(label=metric.name, value=metric.value)
|
|
1721
|
+
|
|
1722
|
+
.. code-block:: python
|
|
1723
|
+
|
|
1724
|
+
def performance(
|
|
1725
|
+
self, metric: Metric, resource: Resource
|
|
1726
|
+
) -> Performance | None:
|
|
1727
|
+
if not opts.performance_data:
|
|
1728
|
+
return None
|
|
1729
|
+
return Performance(
|
|
1730
|
+
metric.name,
|
|
1731
|
+
metric.value,
|
|
1732
|
+
metric.uom,
|
|
1733
|
+
self.warning,
|
|
1734
|
+
self.critical,
|
|
1735
|
+
metric.min,
|
|
1736
|
+
metric.max,
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
:param metric: associated metric from which performance data are
|
|
1740
|
+
derived
|
|
1741
|
+
:param resource: resource that produced the associated metric
|
|
1742
|
+
(may optionally be consulted)
|
|
1743
|
+
:returns: :class:`~.performance.Performance` object or `None`
|
|
1744
|
+
"""
|
|
1745
|
+
return None
|
|
1746
|
+
|
|
1747
|
+
def describe(self, metric: "Metric") -> typing.Optional[str]:
|
|
1748
|
+
"""Provides human-readable metric description.
|
|
1749
|
+
|
|
1750
|
+
Formats the metric according to the :attr:`fmt_metric`
|
|
1751
|
+
attribute. If :attr:`fmt_metric` is a string, it is evaluated as
|
|
1752
|
+
format string with all metric attributes in the root namespace.
|
|
1753
|
+
If :attr:`fmt_metric` is callable, it is called with the metric
|
|
1754
|
+
and this context as arguments. If :attr:`fmt_metric` is not set,
|
|
1755
|
+
this default implementation does not return a description.
|
|
1756
|
+
|
|
1757
|
+
Plugin authors may override this method in subclasses to control
|
|
1758
|
+
text output more tightly.
|
|
1759
|
+
|
|
1760
|
+
:param metric: associated metric
|
|
1761
|
+
:returns: description string or None
|
|
1762
|
+
"""
|
|
1763
|
+
if not self.fmt_metric:
|
|
1764
|
+
return None
|
|
1765
|
+
|
|
1766
|
+
if isinstance(self.fmt_metric, str):
|
|
1767
|
+
return self.fmt_metric.format(
|
|
1768
|
+
name=metric.name,
|
|
1769
|
+
value=metric.value,
|
|
1770
|
+
uom=metric.uom,
|
|
1771
|
+
valueunit=metric.valueunit,
|
|
1772
|
+
min=metric.min,
|
|
1773
|
+
max=metric.max,
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
return self.fmt_metric(metric, self)
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
class ScalarContext(Context):
|
|
1780
|
+
warn_range: Range
|
|
1781
|
+
|
|
1782
|
+
critical_range: Range
|
|
1783
|
+
|
|
1784
|
+
def __init__(
|
|
1785
|
+
self,
|
|
1786
|
+
name: str,
|
|
1787
|
+
warning: typing.Optional[RangeSpec] = None,
|
|
1788
|
+
critical: typing.Optional[RangeSpec] = None,
|
|
1789
|
+
fmt_metric: FmtMetric = "{name} is {valueunit}",
|
|
1790
|
+
) -> None:
|
|
1791
|
+
"""Ready-to-use :class:`Context` subclass for scalar values.
|
|
1792
|
+
|
|
1793
|
+
ScalarContext models the common case where a single scalar is to
|
|
1794
|
+
be evaluated against a pair of warning and critical thresholds.
|
|
1795
|
+
|
|
1796
|
+
:attr:`name` and :attr:`fmt_metric`,
|
|
1797
|
+
are described in the :class:`Context` base class.
|
|
1798
|
+
|
|
1799
|
+
:param warning: Warning threshold as
|
|
1800
|
+
:class:`~mplugin.Range` object or range string.
|
|
1801
|
+
:param critical: Critical threshold as
|
|
1802
|
+
:class:`~mplugin.Range` object or range string.
|
|
1803
|
+
"""
|
|
1804
|
+
super(ScalarContext, self).__init__(name, fmt_metric)
|
|
1805
|
+
self.warn_range = Range(warning)
|
|
1806
|
+
self.critical_range = Range(critical)
|
|
1807
|
+
|
|
1808
|
+
def evaluate(self, metric: "Metric", resource: "Resource") -> Result:
|
|
1809
|
+
"""Compares metric with ranges and determines result state.
|
|
1810
|
+
|
|
1811
|
+
The metric's value is compared to the instance's :attr:`warning`
|
|
1812
|
+
and :attr:`critical` ranges, yielding an appropropiate state
|
|
1813
|
+
depending on how the metric fits in the ranges. Plugin authors
|
|
1814
|
+
may override this method in subclasses to provide custom
|
|
1815
|
+
evaluation logic.
|
|
1816
|
+
|
|
1817
|
+
:param metric: metric that is to be evaluated
|
|
1818
|
+
:param resource: not used
|
|
1819
|
+
:returns: :class:`~mplugin.Result` object
|
|
1820
|
+
"""
|
|
1821
|
+
if not self.critical_range.match(metric.value):
|
|
1822
|
+
return self.critical(self.critical_range.violation, metric)
|
|
1823
|
+
if not self.warn_range.match(metric.value):
|
|
1824
|
+
return self.warning(self.warn_range.violation, metric)
|
|
1825
|
+
return self.ok(None, metric)
|
|
1826
|
+
|
|
1827
|
+
def performance(self, metric: "Metric", resource: "Resource") -> Performance:
|
|
1828
|
+
"""Derives performance data.
|
|
1829
|
+
|
|
1830
|
+
The metric's attributes are combined with the local
|
|
1831
|
+
:attr:`warning` and :attr:`critical` ranges to get a
|
|
1832
|
+
fully populated :class:`~mplugin.performance.Performance`
|
|
1833
|
+
object.
|
|
1834
|
+
|
|
1835
|
+
:param metric: metric from which performance data are derived
|
|
1836
|
+
:param resource: not used
|
|
1837
|
+
:returns: :class:`~mplugin.performance.Performance` object
|
|
1838
|
+
"""
|
|
1839
|
+
return Performance(
|
|
1840
|
+
metric.name,
|
|
1841
|
+
metric.value,
|
|
1842
|
+
metric.uom,
|
|
1843
|
+
self.warn_range,
|
|
1844
|
+
self.critical_range,
|
|
1845
|
+
metric.min,
|
|
1846
|
+
metric.max,
|
|
1847
|
+
)
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
class _Contexts:
|
|
1851
|
+
"""Container for collecting all generated contexts."""
|
|
1852
|
+
|
|
1853
|
+
by_name: dict[str, Context]
|
|
1854
|
+
|
|
1855
|
+
def __init__(self) -> None:
|
|
1856
|
+
self.by_name = dict(
|
|
1857
|
+
default=ScalarContext("default", "", ""), null=Context("null")
|
|
1858
|
+
)
|
|
1859
|
+
|
|
1860
|
+
def add(self, context: Context) -> None:
|
|
1861
|
+
self.by_name[context.name] = context
|
|
1862
|
+
|
|
1863
|
+
def __getitem__(self, context_name: str) -> Context:
|
|
1864
|
+
try:
|
|
1865
|
+
return self.by_name[context_name]
|
|
1866
|
+
except KeyError:
|
|
1867
|
+
raise KeyError(
|
|
1868
|
+
"cannot find context",
|
|
1869
|
+
context_name,
|
|
1870
|
+
"known contexts: {0}".format(", ".join(self.by_name.keys())),
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1873
|
+
def __contains__(self, context_name: str) -> bool:
|
|
1874
|
+
return context_name in self.by_name
|
|
1875
|
+
|
|
1876
|
+
def __iter__(self) -> typing.Iterator[str]:
|
|
1877
|
+
return iter(self.by_name)
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
# check.py
|
|
1881
|
+
|
|
1882
|
+
"""Controller logic for check execution.
|
|
1883
|
+
"""
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
log: logging.Logger = logging.getLogger("mplugin")
|
|
1887
|
+
"""
|
|
1888
|
+
**mplugin** integrates with the logging module from Python's standard
|
|
1889
|
+
library. If the main function is decorated with :meth:`guarded` (which is heavily
|
|
1890
|
+
recommended), the logging module gets automatically configured before the
|
|
1891
|
+
execution of the `main()` function starts. Messages logged to the *mplugin*
|
|
1892
|
+
logger (or any sublogger) are processed with mplugin's integrated logging.
|
|
1893
|
+
|
|
1894
|
+
The verbosity level is set in the :meth:`check.main()` invocation depending on
|
|
1895
|
+
the number of ``-v`` flags.
|
|
1896
|
+
|
|
1897
|
+
When called with *verbose=0,* both the summary and the performance data are
|
|
1898
|
+
printed on one line and the warning message is displayed. Messages logged with
|
|
1899
|
+
*warning* or *error* level are always printed.
|
|
1900
|
+
Setting *verbose* to 1 does not change the logging level but enable multi-line
|
|
1901
|
+
output. Additionally, full tracebacks would be printed in the case of an
|
|
1902
|
+
uncaught exception.
|
|
1903
|
+
Verbosity levels of ``2`` and ``3`` enable logging with *info* or *debug* levels.
|
|
1904
|
+
"""
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
class Check:
|
|
1908
|
+
"""Controller logic for check execution.
|
|
1909
|
+
|
|
1910
|
+
The class :class:`Check` orchestrates the
|
|
1911
|
+
the various stages of check execution. Interfacing with the
|
|
1912
|
+
outside system is done via a separate :class:`Runtime` object.
|
|
1913
|
+
|
|
1914
|
+
When a check is called (using :meth:`Check.main` or
|
|
1915
|
+
:meth:`Check.__call__`), it probes all resources and evaluates the
|
|
1916
|
+
returned metrics to results and performance data. A typical usage
|
|
1917
|
+
pattern would be to populate a check with domain objects and then
|
|
1918
|
+
delegate control to it.
|
|
1919
|
+
"""
|
|
1920
|
+
|
|
1921
|
+
resources: list[Resource]
|
|
1922
|
+
contexts: _Contexts
|
|
1923
|
+
summary: Summary
|
|
1924
|
+
results: Results
|
|
1925
|
+
perfdata: list[str]
|
|
1926
|
+
name: str
|
|
1927
|
+
|
|
1928
|
+
def __init__(
|
|
1929
|
+
self,
|
|
1930
|
+
*objects: Resource | Context | Summary | Results,
|
|
1931
|
+
name: typing.Optional[str] = None,
|
|
1932
|
+
) -> None:
|
|
1933
|
+
"""Creates and configures a check.
|
|
1934
|
+
|
|
1935
|
+
Specialized *objects* representing resources, contexts,
|
|
1936
|
+
summary, or results are passed to the the :meth:`add` method.
|
|
1937
|
+
Alternatively, objects can be added later manually.
|
|
1938
|
+
If no *name* is given, the output prefix is set to the first
|
|
1939
|
+
resource's name. If *name* is None, no prefix is set at all.
|
|
1940
|
+
"""
|
|
1941
|
+
self.resources = []
|
|
1942
|
+
self.contexts = _Contexts()
|
|
1943
|
+
self.summary = Summary()
|
|
1944
|
+
self.results = Results()
|
|
1945
|
+
self.perfdata = []
|
|
1946
|
+
if name is not None:
|
|
1947
|
+
self.name = name
|
|
1948
|
+
else:
|
|
1949
|
+
self.name = ""
|
|
1950
|
+
self.add(*objects)
|
|
1951
|
+
|
|
1952
|
+
def add(self, *objects: Resource | Context | Summary | Results):
|
|
1953
|
+
"""Adds domain objects to a check.
|
|
1954
|
+
|
|
1955
|
+
:param objects: one or more objects that are descendants from
|
|
1956
|
+
:class:`~mplugin.Resource`,
|
|
1957
|
+
:class:`~mplugin.Context`,
|
|
1958
|
+
:class:`~mplugin.Summary`, or
|
|
1959
|
+
:class:`~mplugin.Results`.
|
|
1960
|
+
"""
|
|
1961
|
+
for obj in objects:
|
|
1962
|
+
if isinstance(obj, Resource):
|
|
1963
|
+
self.resources.append(obj)
|
|
1964
|
+
if self.name is None: # type: ignore
|
|
1965
|
+
self.name = ""
|
|
1966
|
+
elif self.name == "":
|
|
1967
|
+
self.name = self.resources[0].name
|
|
1968
|
+
elif isinstance(obj, Context):
|
|
1969
|
+
self.contexts.add(obj)
|
|
1970
|
+
elif isinstance(obj, Summary):
|
|
1971
|
+
self.summary = obj
|
|
1972
|
+
elif isinstance(obj, Results): # type: ignore
|
|
1973
|
+
self.results = obj
|
|
1974
|
+
else:
|
|
1975
|
+
raise TypeError("cannot add type {0} to check".format(type(obj)), obj)
|
|
1976
|
+
return self
|
|
1977
|
+
|
|
1978
|
+
def _evaluate_resource(self, resource: Resource) -> None:
|
|
1979
|
+
metric = None
|
|
1980
|
+
try:
|
|
1981
|
+
metrics = resource.probe()
|
|
1982
|
+
if not metrics:
|
|
1983
|
+
log.warning("resource %s did not produce any metric", resource.name)
|
|
1984
|
+
if isinstance(metrics, Metric):
|
|
1985
|
+
# resource returned a bare metric instead of list/generator
|
|
1986
|
+
metrics = [metrics]
|
|
1987
|
+
for metric in metrics:
|
|
1988
|
+
context = self.contexts[metric.context]
|
|
1989
|
+
metric = metric.replace(contextobj=context, resource=resource)
|
|
1990
|
+
result = metric.evaluate()
|
|
1991
|
+
if isinstance(result, Result):
|
|
1992
|
+
self.results.add(result)
|
|
1993
|
+
elif isinstance(result, ServiceState): # type: ignore
|
|
1994
|
+
self.results.add(Result(result, metric=metric))
|
|
1995
|
+
else:
|
|
1996
|
+
raise ValueError(
|
|
1997
|
+
"evaluate() returned neither Result nor ServiceState object",
|
|
1998
|
+
metric.name,
|
|
1999
|
+
result,
|
|
2000
|
+
)
|
|
2001
|
+
self.perfdata.append(str(metric.performance() or ""))
|
|
2002
|
+
except CheckError as e:
|
|
2003
|
+
self.results.add(Result(unknown, str(e), metric))
|
|
2004
|
+
|
|
2005
|
+
def __call__(self) -> None:
|
|
2006
|
+
"""Actually run the check.
|
|
2007
|
+
|
|
2008
|
+
After a check has been called, the :attr:`results` and
|
|
2009
|
+
:attr:`perfdata` attributes are populated with the outcomes. In
|
|
2010
|
+
most cases, you should not use __call__ directly but invoke
|
|
2011
|
+
:meth:`main`, which delegates check execution to the
|
|
2012
|
+
:class:`Runtime` environment.
|
|
2013
|
+
"""
|
|
2014
|
+
for resource in self.resources:
|
|
2015
|
+
self._evaluate_resource(resource)
|
|
2016
|
+
self.perfdata = sorted([p for p in self.perfdata if p])
|
|
2017
|
+
|
|
2018
|
+
def main(
|
|
2019
|
+
self,
|
|
2020
|
+
verbose: typing.Any = None,
|
|
2021
|
+
timeout: typing.Any = None,
|
|
2022
|
+
colorize: bool = False,
|
|
2023
|
+
) -> typing.NoReturn:
|
|
2024
|
+
"""All-in-one control delegation to the runtime environment.
|
|
2025
|
+
|
|
2026
|
+
Get a :class:`~mplugin.runtime.Runtime` instance and
|
|
2027
|
+
perform all phases: run the check (via :meth:`__call__`), print
|
|
2028
|
+
results and exit the program with an appropriate status code.
|
|
2029
|
+
|
|
2030
|
+
:param verbose: output verbosity level between 0 and 3
|
|
2031
|
+
:param timeout: abort check execution with a :exc:`Timeout`
|
|
2032
|
+
exception after so many seconds (use 0 for no timeout)
|
|
2033
|
+
:param colorize: Use ANSI colors to colorize the logging output
|
|
2034
|
+
"""
|
|
2035
|
+
runtime = _Runtime()
|
|
2036
|
+
runtime.execute(self, verbose=verbose, timeout=timeout, colorize=colorize)
|
|
2037
|
+
|
|
2038
|
+
@property
|
|
2039
|
+
def state(self) -> ServiceState:
|
|
2040
|
+
"""Overall check state.
|
|
2041
|
+
|
|
2042
|
+
The most significant (=worst) state seen in :attr:`results` to
|
|
2043
|
+
far. :obj:`~mplugin.unknown` if no results have been
|
|
2044
|
+
collected yet. Corresponds with :attr:`exitcode`. Read-only
|
|
2045
|
+
property.
|
|
2046
|
+
"""
|
|
2047
|
+
try:
|
|
2048
|
+
return self.results.most_significant_state
|
|
2049
|
+
except ValueError:
|
|
2050
|
+
return unknown
|
|
2051
|
+
|
|
2052
|
+
@property
|
|
2053
|
+
def summary_str(self) -> str:
|
|
2054
|
+
"""Status line summary string.
|
|
2055
|
+
|
|
2056
|
+
The first line of output that summarizes that situation as
|
|
2057
|
+
perceived by the check. The string is usually queried from a
|
|
2058
|
+
:class:`Summary` object. Read-only property.
|
|
2059
|
+
"""
|
|
2060
|
+
if not self.results:
|
|
2061
|
+
return self.summary.empty() or ""
|
|
2062
|
+
|
|
2063
|
+
if self.state == ok:
|
|
2064
|
+
return self.summary.ok(self.results) or ""
|
|
2065
|
+
|
|
2066
|
+
return self.summary.problem(self.results) or ""
|
|
2067
|
+
|
|
2068
|
+
@property
|
|
2069
|
+
def verbose_str(self):
|
|
2070
|
+
"""Additional lines of output.
|
|
2071
|
+
|
|
2072
|
+
Long text output if check runs in verbose mode. Also queried
|
|
2073
|
+
from :class:`~mplugin.Summary`. Read-only property.
|
|
2074
|
+
"""
|
|
2075
|
+
return self.summary.verbose(self.results) or ""
|
|
2076
|
+
|
|
2077
|
+
@property
|
|
2078
|
+
def exitcode(self) -> int:
|
|
2079
|
+
"""Overall check exit code according to the monitoring API.
|
|
2080
|
+
|
|
2081
|
+
Corresponds with :attr:`state`. Read-only property.
|
|
2082
|
+
"""
|
|
2083
|
+
try:
|
|
2084
|
+
return int(self.results.most_significant_state)
|
|
2085
|
+
except ValueError:
|
|
2086
|
+
return 3
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
# cli.py (argparse)
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
class __CustomArgumentParser(argparse.ArgumentParser):
|
|
2093
|
+
"""
|
|
2094
|
+
Override the exit method for the options ``--help``, ``-h`` and ``--version``,
|
|
2095
|
+
``-V`` with ``Unknown`` (exit code 3), according to the
|
|
2096
|
+
`Monitoring Plugin Guidelines
|
|
2097
|
+
<https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/02.Input.md>`__.
|
|
2098
|
+
"""
|
|
2099
|
+
|
|
2100
|
+
def exit(
|
|
2101
|
+
self, status: int = 3, message: typing.Optional[str] = None
|
|
2102
|
+
) -> typing.NoReturn:
|
|
2103
|
+
if message:
|
|
2104
|
+
self._print_message(message, sys.stderr)
|
|
2105
|
+
sys.exit(status)
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
def setup_argparser(
|
|
2109
|
+
name: typing.Optional[str],
|
|
2110
|
+
version: typing.Optional[str] = None,
|
|
2111
|
+
license: typing.Optional[str] = None,
|
|
2112
|
+
repository: typing.Optional[str] = None,
|
|
2113
|
+
copyright: typing.Optional[str] = None,
|
|
2114
|
+
description: typing.Optional[str] = None,
|
|
2115
|
+
epilog: typing.Optional[str] = None,
|
|
2116
|
+
verbose: bool = False,
|
|
2117
|
+
) -> argparse.ArgumentParser:
|
|
2118
|
+
"""
|
|
2119
|
+
Set up and configure an argument parser for a monitoring plugin
|
|
2120
|
+
according the
|
|
2121
|
+
`Monitoring Plugin Guidelines
|
|
2122
|
+
<https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/02.Input.md>`__.
|
|
2123
|
+
|
|
2124
|
+
This function creates a customized ArgumentParser instance with metadata
|
|
2125
|
+
and formatting suitable for monitoring plugins. It automatically prefixes
|
|
2126
|
+
the plugin name with ``check_`` if not already present.
|
|
2127
|
+
|
|
2128
|
+
:param name: The name of the plugin. If provided and doesn't start with
|
|
2129
|
+
``check``, it will be prefixed with ``check_``.
|
|
2130
|
+
:param version: The version number of the plugin. If provided, it will be
|
|
2131
|
+
included in the parser description. In addition, an option ``-V``,
|
|
2132
|
+
``--version`` is provided, which outputs the version number.
|
|
2133
|
+
:param license: The license type of the plugin. If provided, it will be
|
|
2134
|
+
included in the parser description.
|
|
2135
|
+
:param repository: The repository URL of the plugin. If provided, it will
|
|
2136
|
+
be included in the parser description.
|
|
2137
|
+
:param copyright: The copyright information for the plugin. If provided,
|
|
2138
|
+
it will be included in the parser description.
|
|
2139
|
+
:param description: A detailed description of the plugin's functionality.
|
|
2140
|
+
If provided, it will be appended to the parser description after a
|
|
2141
|
+
blank line.
|
|
2142
|
+
:param epilog: Additional information to display after the help message.
|
|
2143
|
+
:param verbose: Provide a ``-v``, ``--verbose`` option. The option can be
|
|
2144
|
+
specified multiple times, e. g. ``-vvv``
|
|
2145
|
+
|
|
2146
|
+
:returns: A configured ArgumentParser instance with RawDescriptionHelpFormatter,
|
|
2147
|
+
80 character width, and metadata assembled from the provided parameters.
|
|
2148
|
+
"""
|
|
2149
|
+
description_lines: list[str] = []
|
|
2150
|
+
|
|
2151
|
+
if name is not None and not name.startswith("check"):
|
|
2152
|
+
name = f"check_{name}"
|
|
2153
|
+
|
|
2154
|
+
if version is not None:
|
|
2155
|
+
description_lines.append(f"version {version}")
|
|
2156
|
+
|
|
2157
|
+
if license is not None:
|
|
2158
|
+
description_lines.append(f"Licensed under the {license}.")
|
|
2159
|
+
|
|
2160
|
+
if repository is not None:
|
|
2161
|
+
description_lines.append(f"Repository: {repository}.")
|
|
2162
|
+
|
|
2163
|
+
if copyright is not None:
|
|
2164
|
+
description_lines.append(copyright)
|
|
2165
|
+
|
|
2166
|
+
if description is not None:
|
|
2167
|
+
description_lines.append("")
|
|
2168
|
+
description_lines.append(description)
|
|
2169
|
+
|
|
2170
|
+
parser: argparse.ArgumentParser = __CustomArgumentParser(
|
|
2171
|
+
prog=name,
|
|
2172
|
+
formatter_class=lambda prog: argparse.RawDescriptionHelpFormatter(
|
|
2173
|
+
prog, width=80
|
|
2174
|
+
),
|
|
2175
|
+
description="\n".join(description_lines),
|
|
2176
|
+
epilog=epilog,
|
|
2177
|
+
)
|
|
2178
|
+
|
|
2179
|
+
if version is not None:
|
|
2180
|
+
parser.add_argument(
|
|
2181
|
+
"-V",
|
|
2182
|
+
"--version",
|
|
2183
|
+
action="version",
|
|
2184
|
+
version=f"%(prog)s {version}",
|
|
2185
|
+
)
|
|
2186
|
+
|
|
2187
|
+
if verbose:
|
|
2188
|
+
# https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/02.Input.md
|
|
2189
|
+
parser.add_argument(
|
|
2190
|
+
"-v",
|
|
2191
|
+
"--verbose",
|
|
2192
|
+
action="count",
|
|
2193
|
+
default=0,
|
|
2194
|
+
help="Increase the output verbosity.",
|
|
2195
|
+
)
|
|
2196
|
+
|
|
2197
|
+
return parser
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def timespan(spec: typing.Union[str, int, float]) -> float:
|
|
2201
|
+
"""Convert a timespan format string to seconds.
|
|
2202
|
+
If no time unit is specified, generally seconds are assumed.
|
|
2203
|
+
|
|
2204
|
+
The following time units are understood:
|
|
2205
|
+
|
|
2206
|
+
- ``years``, ``year``, ``y`` (defined as ``365.25`` days)
|
|
2207
|
+
- ``months``, ``month``, ``M`` (defined as ``30.44`` days)
|
|
2208
|
+
- ``weeks``, ``week``, ``w``
|
|
2209
|
+
- ``days``, ``day``, ``d``
|
|
2210
|
+
- ``hours``, ``hour``, ``hr``, ``h``
|
|
2211
|
+
- ``minutes``, ``minute``, ``min``, ``m``
|
|
2212
|
+
- ``seconds``, ``second``, ``sec``, ``s``
|
|
2213
|
+
- ``milliseconds``, ``millisecond``, ``msec``, ``ms``
|
|
2214
|
+
- ``microseconds``, ``microsecond``, ``usec``, ``μs``, ``μ``, ``us``
|
|
2215
|
+
|
|
2216
|
+
This function can be used as type in the
|
|
2217
|
+
:py:meth:`argparse.ArgumentParser.add_argument` method.
|
|
2218
|
+
|
|
2219
|
+
.. code-block:: python
|
|
2220
|
+
|
|
2221
|
+
parser.add_argument(
|
|
2222
|
+
"-c",
|
|
2223
|
+
"--critical",
|
|
2224
|
+
default=5356800,
|
|
2225
|
+
help="Interval in seconds for critical state.",
|
|
2226
|
+
type=timespan,
|
|
2227
|
+
)
|
|
2228
|
+
|
|
2229
|
+
:param timespan: The specification of the timespan as a string, for example
|
|
2230
|
+
``2.345s``, ``3min 45.234s``, ``34min``, ``2 months 8 days`` or as a
|
|
2231
|
+
number.
|
|
2232
|
+
|
|
2233
|
+
:return: The timespan in seconds
|
|
2234
|
+
"""
|
|
2235
|
+
|
|
2236
|
+
# A int or a float encoded as string without an extension
|
|
2237
|
+
try:
|
|
2238
|
+
spec = float(spec)
|
|
2239
|
+
except ValueError:
|
|
2240
|
+
pass
|
|
2241
|
+
|
|
2242
|
+
if isinstance(spec, int) or isinstance(spec, float):
|
|
2243
|
+
return spec
|
|
2244
|
+
|
|
2245
|
+
# Remove all whitespaces
|
|
2246
|
+
spec = re.sub(r"\s+", "", spec)
|
|
2247
|
+
|
|
2248
|
+
replacements: list[tuple[list[str], str]] = [
|
|
2249
|
+
(["years", "year"], "y"),
|
|
2250
|
+
(["months", "month"], "M"),
|
|
2251
|
+
(["weeks", "week"], "w"),
|
|
2252
|
+
(["days", "day"], "d"),
|
|
2253
|
+
(["hours", "hour", "hr"], "h"),
|
|
2254
|
+
(["minutes", "minute", "min"], "m"),
|
|
2255
|
+
(["seconds", "second", "sec"], "s"),
|
|
2256
|
+
(["milliseconds", "millisecond", "msec"], "ms"),
|
|
2257
|
+
(["microseconds", "microsecond", "usec", "μs", "μ"], "us"),
|
|
2258
|
+
]
|
|
2259
|
+
|
|
2260
|
+
for replacement in replacements:
|
|
2261
|
+
for r in replacement[0]:
|
|
2262
|
+
spec = spec.replace(r, replacement[1])
|
|
2263
|
+
|
|
2264
|
+
# Add a whitespace after the units
|
|
2265
|
+
spec = re.sub(r"([a-zA-Z]+)", r"\1 ", spec)
|
|
2266
|
+
|
|
2267
|
+
seconds: dict[str, float] = {
|
|
2268
|
+
"y": 31557600, # 365.25 days
|
|
2269
|
+
"M": 2630016, # 30.44 days
|
|
2270
|
+
"w": 604800, # 7 * 24 * 60 * 60
|
|
2271
|
+
"d": 86400, # 24 * 60 * 60
|
|
2272
|
+
"h": 3600, # 60 * 60
|
|
2273
|
+
"m": 60,
|
|
2274
|
+
"s": 1,
|
|
2275
|
+
"ms": 0.001,
|
|
2276
|
+
"us": 0.000001,
|
|
2277
|
+
}
|
|
2278
|
+
result: float = 0
|
|
2279
|
+
# Split on the whitespaces
|
|
2280
|
+
for span in spec.split():
|
|
2281
|
+
match = re.search(r"([\d\.]+)([a-zA-Z]+)", span)
|
|
2282
|
+
if match:
|
|
2283
|
+
value = match.group(1)
|
|
2284
|
+
unit = match.group(2)
|
|
2285
|
+
result += float(value) * seconds[unit]
|
|
2286
|
+
return result
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
TIMESPAN_FORMAT_HELP = """
|
|
2290
|
+
Timespan format
|
|
2291
|
+
---------------
|
|
2292
|
+
|
|
2293
|
+
If no time unit is specified, generally seconds are assumed. The following time
|
|
2294
|
+
units are understood:
|
|
2295
|
+
|
|
2296
|
+
- years, year, y (defined as 365.25 days)
|
|
2297
|
+
- months, month, M (defined as 30.44 days)
|
|
2298
|
+
- weeks, week, w
|
|
2299
|
+
- days, day, d
|
|
2300
|
+
- hours, hour, hr, h
|
|
2301
|
+
- minutes, minute, min, m
|
|
2302
|
+
- seconds, second, sec, s
|
|
2303
|
+
- milliseconds, millisecond, msec, ms
|
|
2304
|
+
- microseconds, microsecond, usec, μs, μ, us
|
|
2305
|
+
|
|
2306
|
+
The following are valid examples of timespan specifications:
|
|
2307
|
+
|
|
2308
|
+
- `1`
|
|
2309
|
+
- `1.23`
|
|
2310
|
+
- `2.345s`
|
|
2311
|
+
- `3min 45.234s`
|
|
2312
|
+
- `34min`
|
|
2313
|
+
- `2 months 8 days`
|
|
2314
|
+
- `1h30m`
|
|
2315
|
+
"""
|