command_watcher 0.1.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.
- command_watcher/__init__.py +456 -0
- command_watcher/email.py +35 -0
- command_watcher/icinga.py +54 -0
- command_watcher/log.py +180 -0
- command_watcher/py.typed +2 -0
- command_watcher/report.py +324 -0
- command_watcher-0.1.0.dist-info/LICENSE +22 -0
- command_watcher-0.1.0.dist-info/METADATA +90 -0
- command_watcher-0.1.0.dist-info/RECORD +10 -0
- command_watcher-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module to watch the execution of shell scripts. Both streams (`stdout` and
|
|
3
|
+
`stderr`) are captured.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import queue
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import (IO, Any, Dict, List, Optional, Sequence, Tuple, TypedDict,
|
|
15
|
+
Union)
|
|
16
|
+
|
|
17
|
+
from conf2levels import ClassInterface, ConfigReader, Spec
|
|
18
|
+
from jflib import capturing
|
|
19
|
+
from typing_extensions import Unpack
|
|
20
|
+
|
|
21
|
+
from .log import ExtendedLogger, LoggingHandler, setup_logging
|
|
22
|
+
from .report import (HOSTNAME, BaseChannel, BeepChannel, EmailChannel,
|
|
23
|
+
IcingaChannel, Message, MessageParams,
|
|
24
|
+
MinimalMessageParams, Status, reporter)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommandWatcherError(Exception):
|
|
28
|
+
"""Exception raised by this module."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, msg: str, **data: Unpack[MessageParams]):
|
|
31
|
+
reporter.report(
|
|
32
|
+
status=2,
|
|
33
|
+
custom_message='{}: {}'.format(self.__class__.__name__, msg),
|
|
34
|
+
**data, # type: ignore
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Timer:
|
|
39
|
+
"""Measure the execution time of a command run."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self.stop = 0
|
|
43
|
+
""""The time when the timer stops. (UNIX timestamp)"""
|
|
44
|
+
|
|
45
|
+
self.start = time.time()
|
|
46
|
+
""""The start time. (UNIX timestamp)"""
|
|
47
|
+
|
|
48
|
+
self.interval = 0
|
|
49
|
+
"""The time interval between start and stop."""
|
|
50
|
+
|
|
51
|
+
def result(self) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Measure the time intervale
|
|
54
|
+
|
|
55
|
+
:return: A formatted string displaying the result."""
|
|
56
|
+
self.stop = time.time()
|
|
57
|
+
self.interval = self.stop - self.start
|
|
58
|
+
return '{:.3f}s'.format(self.interval)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Configuration ###############################################################
|
|
62
|
+
|
|
63
|
+
CONF_DEFAULTS = {
|
|
64
|
+
'email': {
|
|
65
|
+
'subject_prefix': 'command_watcher',
|
|
66
|
+
},
|
|
67
|
+
'nsca': {
|
|
68
|
+
'port': 5667,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
CONFIG_READER_SPEC: Spec = {
|
|
74
|
+
'email': {
|
|
75
|
+
'from_addr': {
|
|
76
|
+
'description': 'The email address of the sender.',
|
|
77
|
+
},
|
|
78
|
+
'to_addr': {
|
|
79
|
+
'description': 'The email address of the recipient.',
|
|
80
|
+
'not_empty': True,
|
|
81
|
+
},
|
|
82
|
+
'to_addr_critical': {
|
|
83
|
+
'description': 'The email address of the recipient to send '
|
|
84
|
+
'critical messages to.',
|
|
85
|
+
'default': None,
|
|
86
|
+
},
|
|
87
|
+
'smtp_login': {
|
|
88
|
+
'description': 'The SMTP login name.',
|
|
89
|
+
'not_empty': True,
|
|
90
|
+
},
|
|
91
|
+
'smtp_password': {
|
|
92
|
+
'description': 'The SMTP password.',
|
|
93
|
+
'not_empty': True,
|
|
94
|
+
},
|
|
95
|
+
'smtp_server': {
|
|
96
|
+
'description': 'The URL of the SMTP server, for example: '
|
|
97
|
+
'`smtp.example.com:587`.',
|
|
98
|
+
'not_empty': True,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
'nsca': {
|
|
102
|
+
'remote_host': {
|
|
103
|
+
'description': 'The IP address of the NSCA remote host.',
|
|
104
|
+
'not_empty': True,
|
|
105
|
+
},
|
|
106
|
+
'password': {
|
|
107
|
+
'description': 'The NSCA password.',
|
|
108
|
+
'not_empty': True,
|
|
109
|
+
},
|
|
110
|
+
'encryption_method': {
|
|
111
|
+
'description': 'The NSCA encryption method. The supported '
|
|
112
|
+
'encryption methods are: 0 1 2 3 4 8 11 14 15 16',
|
|
113
|
+
'not_empty': True,
|
|
114
|
+
},
|
|
115
|
+
'port': {
|
|
116
|
+
'description': 'The NSCA port.',
|
|
117
|
+
'default': 5667,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
'icinga': {
|
|
121
|
+
'url': {
|
|
122
|
+
'description': 'The HTTP URL. /v1/actions/process-check-result '
|
|
123
|
+
'is appended.',
|
|
124
|
+
'not_empty': True,
|
|
125
|
+
},
|
|
126
|
+
'user': {
|
|
127
|
+
'description': 'The user for the HTTP authentification.',
|
|
128
|
+
'not_empty': True,
|
|
129
|
+
},
|
|
130
|
+
'password': {
|
|
131
|
+
'description': 'The password for the HTTP authentification.',
|
|
132
|
+
'not_empty': True,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
'beep': {
|
|
136
|
+
'activated': {
|
|
137
|
+
'description': 'Activate the beep channel to report auditive '
|
|
138
|
+
'messages.',
|
|
139
|
+
'default': False,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Main code ###################################################################
|
|
146
|
+
|
|
147
|
+
Args = Union[str, List[str], Tuple[str]]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ProcessArgs(TypedDict, total=False):
|
|
151
|
+
shell: bool
|
|
152
|
+
"""If true, the command will be executed through the
|
|
153
|
+
shell.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
cwd: str
|
|
157
|
+
"""Sets the current directory before the child is
|
|
158
|
+
executed."""
|
|
159
|
+
|
|
160
|
+
env: Dict[str, Any]
|
|
161
|
+
"""Defines the environment variables for the new process."""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Process:
|
|
165
|
+
"""Run a process.
|
|
166
|
+
|
|
167
|
+
You can use all keyword arguments from
|
|
168
|
+
:py:class:`subprocess.Popen` except `bufsize`, `stderr`, `stdout`.
|
|
169
|
+
|
|
170
|
+
:param args: List, tuple or string. A sequence of
|
|
171
|
+
process arguments, like `subprocess.Popen(args)`.
|
|
172
|
+
"""
|
|
173
|
+
args: Args
|
|
174
|
+
"""Process arguments in various types."""
|
|
175
|
+
|
|
176
|
+
_queue: 'queue.Queue[Optional[Tuple[bytes, capturing.Stream]]]'
|
|
177
|
+
|
|
178
|
+
log: ExtendedLogger
|
|
179
|
+
"""A ready to go and configured logger."""
|
|
180
|
+
|
|
181
|
+
log_handler: LoggingHandler
|
|
182
|
+
|
|
183
|
+
subprocess: subprocess.Popen[Any]
|
|
184
|
+
|
|
185
|
+
def __init__(self, args: Args,
|
|
186
|
+
master_logger: Optional[ExtendedLogger] = None,
|
|
187
|
+
**kwargs: Unpack[ProcessArgs]):
|
|
188
|
+
# self.args: typing.Union[str, list, tuple] = args
|
|
189
|
+
self.args = args
|
|
190
|
+
|
|
191
|
+
self._queue = queue.Queue()
|
|
192
|
+
|
|
193
|
+
log, log_handler = setup_logging(master_logger=master_logger)
|
|
194
|
+
self.log = log
|
|
195
|
+
self.log_handler = log_handler
|
|
196
|
+
|
|
197
|
+
self.log.info('Run command: {}'.format(' '.join(self.args_normalized)))
|
|
198
|
+
timer = Timer()
|
|
199
|
+
self.subprocess = subprocess.Popen(
|
|
200
|
+
self.args_normalized,
|
|
201
|
+
stdout=subprocess.PIPE,
|
|
202
|
+
stderr=subprocess.PIPE,
|
|
203
|
+
# RuntimeWarning: line buffering (buffering=1) isn't
|
|
204
|
+
# supported in binary mode, the default buffer size will be used
|
|
205
|
+
# bufsize=1,
|
|
206
|
+
**kwargs
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self._start_thread(self.subprocess.stdout, 'stdout')
|
|
210
|
+
self._start_thread(self.subprocess.stderr, 'stderr')
|
|
211
|
+
|
|
212
|
+
for _ in range(2):
|
|
213
|
+
for line, stream in iter(self._queue.get, None):
|
|
214
|
+
if line:
|
|
215
|
+
line = line.decode('utf-8').strip()
|
|
216
|
+
|
|
217
|
+
if line:
|
|
218
|
+
if stream == 'stderr':
|
|
219
|
+
self.log.stderr(line)
|
|
220
|
+
if stream == 'stdout':
|
|
221
|
+
self.log.stdout(line)
|
|
222
|
+
self.subprocess.wait()
|
|
223
|
+
self.log.info('Execution time: {}'.format(timer.result()))
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def args_normalized(self) -> Sequence[str]:
|
|
227
|
+
"""Normalized `args`, always a list"""
|
|
228
|
+
if isinstance(self.args, str):
|
|
229
|
+
return shlex.split(self.args)
|
|
230
|
+
else:
|
|
231
|
+
return self.args
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def stdout(self) -> str:
|
|
235
|
+
"""Alias / shortcut for `self.log_handler.stdout`."""
|
|
236
|
+
return self.log_handler.stdout
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def line_count_stdout(self) -> int:
|
|
240
|
+
"""The count of lines of the current `stderr`."""
|
|
241
|
+
return len(self.stdout.splitlines())
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def stderr(self) -> str:
|
|
245
|
+
"""Alias / shortcut for `self.log_handler.stderr`."""
|
|
246
|
+
return self.log_handler.stderr
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def line_count_stderr(self) -> int:
|
|
250
|
+
"""The count of lines of the current `stderr`."""
|
|
251
|
+
return len(self.stderr.splitlines())
|
|
252
|
+
|
|
253
|
+
def _stdout_stderr_reader(self, pipe: IO[bytes],
|
|
254
|
+
stream: capturing.Stream) -> None:
|
|
255
|
+
"""
|
|
256
|
+
:param object pipe: `process.stdout` or `process.stdout`
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
with pipe:
|
|
260
|
+
for line in iter(pipe.readline, b''):
|
|
261
|
+
self._queue.put((line, stream))
|
|
262
|
+
finally:
|
|
263
|
+
self._queue.put(None)
|
|
264
|
+
|
|
265
|
+
def _start_thread(self, pipe: Optional[IO[str]],
|
|
266
|
+
stream: capturing.Stream) -> None:
|
|
267
|
+
"""
|
|
268
|
+
:param object pipe: `process.stdout` or `process.stdout`
|
|
269
|
+
"""
|
|
270
|
+
threading.Thread(
|
|
271
|
+
target=self._stdout_stderr_reader,
|
|
272
|
+
args=[pipe, stream]
|
|
273
|
+
).start()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Watch:
|
|
277
|
+
"""Watch the execution of a command. Capture all output of a command.
|
|
278
|
+
provide and setup a logging facility.
|
|
279
|
+
|
|
280
|
+
:param config_file: The file path of the configuration file in the INI
|
|
281
|
+
format.
|
|
282
|
+
:param service_name: A name of the watched service.
|
|
283
|
+
:param raise_exceptions: Raise exceptions if `watch.run()` exists with a
|
|
284
|
+
non-zero exit code.
|
|
285
|
+
:param config_reader: A custom configuration reader. Specify this
|
|
286
|
+
parameter to not use the build in configuration reader.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
_hostname: str
|
|
290
|
+
"""The hostname of machine the watcher running on."""
|
|
291
|
+
|
|
292
|
+
_service_name: str
|
|
293
|
+
"""A name of the watched service."""
|
|
294
|
+
|
|
295
|
+
log: ExtendedLogger
|
|
296
|
+
"""A ready to go and configured logger."""
|
|
297
|
+
|
|
298
|
+
_log_handler: LoggingHandler
|
|
299
|
+
|
|
300
|
+
processes: List[Process]
|
|
301
|
+
"""A list of completed processes
|
|
302
|
+
:py:class:`Process`. Everytime you use the method
|
|
303
|
+
`run()` the process object is appened in the list."""
|
|
304
|
+
|
|
305
|
+
_conf: Optional[ClassInterface]
|
|
306
|
+
|
|
307
|
+
_raise_exceptions: bool
|
|
308
|
+
"""Raise exceptions"""
|
|
309
|
+
|
|
310
|
+
_timer: Timer
|
|
311
|
+
|
|
312
|
+
def __init__(self, config_file: Optional[str] = None,
|
|
313
|
+
service_name: str = 'command_watcher',
|
|
314
|
+
raise_exceptions: bool = True,
|
|
315
|
+
config_reader: Optional[ConfigReader] = None,
|
|
316
|
+
report_channels: Optional[List[BaseChannel]] = None):
|
|
317
|
+
self._hostname = HOSTNAME
|
|
318
|
+
|
|
319
|
+
self._service_name = service_name
|
|
320
|
+
|
|
321
|
+
log, log_handler = setup_logging()
|
|
322
|
+
|
|
323
|
+
self.log = log
|
|
324
|
+
self.log.info('Hostname: {}'.format(self._hostname))
|
|
325
|
+
|
|
326
|
+
self._log_handler = log_handler
|
|
327
|
+
|
|
328
|
+
self._conf = None
|
|
329
|
+
|
|
330
|
+
if not config_reader and config_file:
|
|
331
|
+
config_reader = ConfigReader(
|
|
332
|
+
spec=CONFIG_READER_SPEC,
|
|
333
|
+
ini=config_file,
|
|
334
|
+
dictionary=CONF_DEFAULTS,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if not config_reader:
|
|
338
|
+
raise Exception('No config_reader object')
|
|
339
|
+
|
|
340
|
+
self._conf = config_reader.get_class_interface()
|
|
341
|
+
|
|
342
|
+
if report_channels is None:
|
|
343
|
+
try:
|
|
344
|
+
config_reader.check_section('email')
|
|
345
|
+
email_reporter = EmailChannel(
|
|
346
|
+
smtp_server=self._conf.email.smtp_server,
|
|
347
|
+
smtp_login=self._conf.email.smtp_login,
|
|
348
|
+
smtp_password=self._conf.email.smtp_password,
|
|
349
|
+
to_addr=self._conf.email.to_addr,
|
|
350
|
+
from_addr=self._conf.email.from_addr,
|
|
351
|
+
to_addr_critical=self._conf.email.to_addr_critical,
|
|
352
|
+
)
|
|
353
|
+
reporter.add_channel(email_reporter)
|
|
354
|
+
self.log.debug(email_reporter)
|
|
355
|
+
except (ValueError, KeyError):
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
config_reader.check_section('icinga')
|
|
360
|
+
icinga_reporter = IcingaChannel(
|
|
361
|
+
url=self._conf.icinga.url,
|
|
362
|
+
user=self._conf.icinga.user,
|
|
363
|
+
password=self._conf.icinga.password,
|
|
364
|
+
service_name=self._service_name,
|
|
365
|
+
)
|
|
366
|
+
reporter.add_channel(icinga_reporter)
|
|
367
|
+
self.log.debug(icinga_reporter)
|
|
368
|
+
except (ValueError, KeyError):
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
if shutil.which('beep') and self._conf.beep.activated:
|
|
372
|
+
beep_reporter = BeepChannel()
|
|
373
|
+
reporter.add_channel(beep_reporter)
|
|
374
|
+
self.log.debug(beep_reporter)
|
|
375
|
+
|
|
376
|
+
else:
|
|
377
|
+
reporter.channels = []
|
|
378
|
+
|
|
379
|
+
self.processes = []
|
|
380
|
+
|
|
381
|
+
self._raise_exceptions = raise_exceptions
|
|
382
|
+
|
|
383
|
+
self._timer = Timer()
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def stdout(self):
|
|
387
|
+
"""Alias / shortcut for `self._log_handler.stdout`."""
|
|
388
|
+
return self._log_handler.stdout
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def stderr(self):
|
|
392
|
+
"""Alias / shortcut for `self._log_handler.stderr`."""
|
|
393
|
+
return self._log_handler.stderr
|
|
394
|
+
|
|
395
|
+
def run(self,
|
|
396
|
+
args: Args,
|
|
397
|
+
log: bool = True,
|
|
398
|
+
ignore_exceptions: List[int] = [],
|
|
399
|
+
**kwargs: Unpack[ProcessArgs]) -> Process:
|
|
400
|
+
"""
|
|
401
|
+
Run a process.
|
|
402
|
+
|
|
403
|
+
:param args: List, tuple or string. A sequence of
|
|
404
|
+
process arguments, like `subprocess.Popen(args)`.
|
|
405
|
+
:param log: Log the `stderr` and the `stdout` of the
|
|
406
|
+
process. If false the `stdout` and the `stderr` are logged only
|
|
407
|
+
to the local process logger, not to get global master logger.
|
|
408
|
+
:param ignore_exceptions: A list of none-zero exit codes, which is
|
|
409
|
+
ignored by this method.
|
|
410
|
+
"""
|
|
411
|
+
if log:
|
|
412
|
+
master_logger = self.log
|
|
413
|
+
else:
|
|
414
|
+
master_logger = None
|
|
415
|
+
process = Process(args, master_logger=master_logger, **kwargs)
|
|
416
|
+
self.processes.append(process)
|
|
417
|
+
rc = process.subprocess.returncode
|
|
418
|
+
if self._raise_exceptions and rc != 0 and rc not in ignore_exceptions:
|
|
419
|
+
raise CommandWatcherError(
|
|
420
|
+
'The command \'{}\' exists with an non-zero return code ({}).'
|
|
421
|
+
.format(' '.join(process.args_normalized), rc),
|
|
422
|
+
service_name=self._service_name,
|
|
423
|
+
log_records=self._log_handler.all_records,
|
|
424
|
+
)
|
|
425
|
+
return process
|
|
426
|
+
|
|
427
|
+
def report(self, status: Status,
|
|
428
|
+
**data: Unpack[MinimalMessageParams]) -> Message:
|
|
429
|
+
"""Report a message using the preconfigured channels.
|
|
430
|
+
"""
|
|
431
|
+
message = reporter.report(
|
|
432
|
+
status=status,
|
|
433
|
+
service_name=self._service_name,
|
|
434
|
+
log_records=self._log_handler.all_records,
|
|
435
|
+
processes=self.processes,
|
|
436
|
+
**data,
|
|
437
|
+
)
|
|
438
|
+
self.log.debug(message)
|
|
439
|
+
return message
|
|
440
|
+
|
|
441
|
+
def final_report(self, **data: Unpack[MessageParams]) -> Message:
|
|
442
|
+
"""The same as the `report` method. Adds `execution_time` to the
|
|
443
|
+
`performance_data`.
|
|
444
|
+
"""
|
|
445
|
+
timer_result = self._timer.result()
|
|
446
|
+
self.log.info(
|
|
447
|
+
'Overall execution time: {}'.format(timer_result)
|
|
448
|
+
)
|
|
449
|
+
status = data.get('status', 0)
|
|
450
|
+
data_dict: Dict[str, Any] = dict(data)
|
|
451
|
+
if 'performance_data' not in data_dict:
|
|
452
|
+
data_dict['performance_data'] = {}
|
|
453
|
+
data_dict['performance_data']['execution_time'] = timer_result
|
|
454
|
+
if 'status' in data_dict:
|
|
455
|
+
del data_dict['status']
|
|
456
|
+
return self.report(status=status, **data_dict)
|
command_watcher/email.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import smtplib
|
|
2
|
+
from email.header import Header
|
|
3
|
+
from email.mime.text import MIMEText
|
|
4
|
+
from email.utils import formatdate
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def send_email(from_addr: str, to_addr: str, subject: str, body: str,
|
|
8
|
+
smtp_login: str, smtp_password: str, smtp_server: str):
|
|
9
|
+
"""
|
|
10
|
+
Send a email.
|
|
11
|
+
|
|
12
|
+
:param from_addr: The email address of the sender.
|
|
13
|
+
:param to_addr: The email address of the recipient.
|
|
14
|
+
:param subject: The email subject.
|
|
15
|
+
:param body: The email body.
|
|
16
|
+
:param smtp_login: The SMTP login name.
|
|
17
|
+
:param smtp_password: The SMTP password.
|
|
18
|
+
:param smtp_server: The URL of the SMTP server, for
|
|
19
|
+
example: `smtp.example.com:587`.
|
|
20
|
+
|
|
21
|
+
:return: Problems
|
|
22
|
+
"""
|
|
23
|
+
message = MIMEText(body, 'plain', 'utf-8')
|
|
24
|
+
|
|
25
|
+
message['Subject'] = Header(subject, 'utf-8')
|
|
26
|
+
message['From'] = from_addr
|
|
27
|
+
message['To'] = to_addr
|
|
28
|
+
message['Date'] = formatdate(localtime=True)
|
|
29
|
+
|
|
30
|
+
server = smtplib.SMTP(smtp_server)
|
|
31
|
+
server.starttls()
|
|
32
|
+
server.login(smtp_login, smtp_password)
|
|
33
|
+
problems = server.sendmail(from_addr, [to_addr], message.as_string())
|
|
34
|
+
server.quit()
|
|
35
|
+
return problems
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
import urllib3
|
|
6
|
+
|
|
7
|
+
urllib3.disable_warnings() # type: ignore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
STATE_OK = 0
|
|
11
|
+
STATE_WARNING = 1
|
|
12
|
+
STATE_CRITICAL = 2
|
|
13
|
+
STATE_UNKNOWN = 3
|
|
14
|
+
|
|
15
|
+
States = {
|
|
16
|
+
STATE_OK: 'OK',
|
|
17
|
+
STATE_WARNING: 'WARNING',
|
|
18
|
+
STATE_CRITICAL: 'CRITICAL',
|
|
19
|
+
STATE_UNKNOWN: 'UNKNOWN',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def send_passive_check(url: str, user: str, password: str, status: int,
|
|
24
|
+
host_name: str, service_name: str,
|
|
25
|
+
text_output: str,
|
|
26
|
+
performance_data: Optional[str] = None):
|
|
27
|
+
"""
|
|
28
|
+
https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#process-check-result
|
|
29
|
+
|
|
30
|
+
[icinga]
|
|
31
|
+
url
|
|
32
|
+
user
|
|
33
|
+
password
|
|
34
|
+
"""
|
|
35
|
+
request_url = '{}/v1/actions/process-check-result'.format(url)
|
|
36
|
+
headers = {
|
|
37
|
+
'Accept': 'application/json',
|
|
38
|
+
'X-HTTP-Method-Override': 'POST'
|
|
39
|
+
}
|
|
40
|
+
data = {
|
|
41
|
+
'type': 'Service',
|
|
42
|
+
'filter': 'host.name=="{}" && service.name=="{}"'.format(host_name,
|
|
43
|
+
service_name),
|
|
44
|
+
'exit_status': status,
|
|
45
|
+
'plugin_output': text_output,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if performance_data:
|
|
49
|
+
data['performance_data'] = performance_data
|
|
50
|
+
|
|
51
|
+
return requests.post(request_url,
|
|
52
|
+
headers=headers,
|
|
53
|
+
auth=(user, password),
|
|
54
|
+
data=json.dumps(data), verify=False)
|
command_watcher/log.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from logging.handlers import BufferingHandler
|
|
6
|
+
from typing import Any, List, Optional, Tuple, cast
|
|
7
|
+
|
|
8
|
+
import termcolor
|
|
9
|
+
|
|
10
|
+
# Logging #####################################################################
|
|
11
|
+
|
|
12
|
+
# CRITICAL 50
|
|
13
|
+
# ERROR 40
|
|
14
|
+
# -> STDERR 35
|
|
15
|
+
# WARNING 30
|
|
16
|
+
# INFO 20
|
|
17
|
+
# DEBUG 10
|
|
18
|
+
# --> STDOUT 5
|
|
19
|
+
# NOTSET 0
|
|
20
|
+
STDERR = 35
|
|
21
|
+
logging.addLevelName(STDERR, 'STDERR')
|
|
22
|
+
STDOUT = 5
|
|
23
|
+
logging.addLevelName(STDOUT, 'STDOUT')
|
|
24
|
+
|
|
25
|
+
LOGFMT = '%(asctime)s_%(msecs)03d %(levelname)s %(message)s'
|
|
26
|
+
DATEFMT = '%Y%m%d_%H%M%S'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LoggingHandler(BufferingHandler):
|
|
30
|
+
"""Store of all logging records in the memory. Print all records on emit.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_master_logger: Optional[logging.Logger]
|
|
34
|
+
|
|
35
|
+
def __init__(self, master_logger: Optional[logging.Logger] = None):
|
|
36
|
+
BufferingHandler.__init__(self, capacity=1000000)
|
|
37
|
+
self._master_logger = master_logger
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _print(record: logging.LogRecord) -> None:
|
|
41
|
+
"""
|
|
42
|
+
:param logging.LogRecord record: A record object.
|
|
43
|
+
"""
|
|
44
|
+
level = record.levelname
|
|
45
|
+
# CRITICAL 50
|
|
46
|
+
# ERROR 40
|
|
47
|
+
# -> STDERR 35
|
|
48
|
+
# WARNING 30
|
|
49
|
+
# INFO 20
|
|
50
|
+
# DEBUG 10
|
|
51
|
+
# --> STDOUT 5
|
|
52
|
+
# NOTSET 0
|
|
53
|
+
attr = None
|
|
54
|
+
if level == 'CRITICAL':
|
|
55
|
+
color = 'red'
|
|
56
|
+
attr = 'bold'
|
|
57
|
+
elif level == 'ERROR':
|
|
58
|
+
color = 'red'
|
|
59
|
+
elif level == 'STDERR':
|
|
60
|
+
color = 'red'
|
|
61
|
+
attr = 'dark'
|
|
62
|
+
elif level == 'WARNING':
|
|
63
|
+
color = 'yellow'
|
|
64
|
+
elif level == 'INFO':
|
|
65
|
+
color = 'green'
|
|
66
|
+
elif level == 'DEBUG':
|
|
67
|
+
color = 'white'
|
|
68
|
+
elif level == 'STDOUT':
|
|
69
|
+
color = 'white'
|
|
70
|
+
attr = 'dark'
|
|
71
|
+
elif level == 'NOTSET':
|
|
72
|
+
color = 'grey'
|
|
73
|
+
else:
|
|
74
|
+
color = 'grey'
|
|
75
|
+
|
|
76
|
+
if attr:
|
|
77
|
+
reverse = ['reverse', attr]
|
|
78
|
+
normal = [attr]
|
|
79
|
+
else:
|
|
80
|
+
reverse = ['reverse']
|
|
81
|
+
normal = []
|
|
82
|
+
|
|
83
|
+
if record.levelno >= STDERR:
|
|
84
|
+
stream = sys.stderr
|
|
85
|
+
else:
|
|
86
|
+
stream = sys.stdout
|
|
87
|
+
|
|
88
|
+
created = '{}_{:03d}'.format(
|
|
89
|
+
time.strftime(DATEFMT, time.localtime(record.created)),
|
|
90
|
+
int(record.msecs),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
print('{} {} {}'.format(
|
|
94
|
+
created,
|
|
95
|
+
termcolor.colored(' {:<8} '.format(level), color, attrs=reverse),
|
|
96
|
+
termcolor.colored(record.msg, color, attrs=normal),
|
|
97
|
+
), file=stream)
|
|
98
|
+
|
|
99
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
100
|
+
"""
|
|
101
|
+
:param record: A record object.
|
|
102
|
+
"""
|
|
103
|
+
self.buffer.append(record)
|
|
104
|
+
if not self._master_logger:
|
|
105
|
+
self._print(record)
|
|
106
|
+
else:
|
|
107
|
+
self._master_logger.log(record.levelno, record.msg)
|
|
108
|
+
if self.shouldFlush(record):
|
|
109
|
+
self.flush()
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def stdout(self) -> str:
|
|
113
|
+
messages: List[str] = []
|
|
114
|
+
for record in self.buffer:
|
|
115
|
+
if record.levelname == 'STDOUT':
|
|
116
|
+
messages.append(record.msg)
|
|
117
|
+
return '\n'.join(messages)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def stderr(self) -> str:
|
|
121
|
+
messages: List[str] = []
|
|
122
|
+
for record in self.buffer:
|
|
123
|
+
if record.levelname == 'STDERR':
|
|
124
|
+
messages.append(record.msg)
|
|
125
|
+
return '\n'.join(messages)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def all_records(self) -> str:
|
|
129
|
+
"""All log messages joined by line breaks."""
|
|
130
|
+
messages: List[str] = []
|
|
131
|
+
for record in self.buffer:
|
|
132
|
+
messages.append(self.format(record))
|
|
133
|
+
return '\n'.join(messages)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ExtendedLogger(logging.Logger):
|
|
137
|
+
|
|
138
|
+
def stdout(self, line: object, *args: Any, **kws: Any) -> None: ...
|
|
139
|
+
def stderr(self, line: object, *args: Any, **kws: Any) -> None: ...
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _log_stdout(self: ExtendedLogger, message: object, *args: Any,
|
|
143
|
+
**kws: Any) -> None:
|
|
144
|
+
# Yes, logger takes its '*args' as 'args'.
|
|
145
|
+
self._log(STDOUT, message, args, **kws)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
extendedLogger: ExtendedLogger = cast(ExtendedLogger, logging.Logger)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
extendedLogger.stdout = _log_stdout # type: ignore
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _log_stderr(self: ExtendedLogger, message: object, *args: Any,
|
|
155
|
+
**kws: Any) -> None:
|
|
156
|
+
# Yes, logger takes its '*args' as 'args'.
|
|
157
|
+
self._log(STDERR, message, args, **kws)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
logging.Logger.stderr = _log_stderr # type: ignore
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def setup_logging(master_logger: Optional[logging.Logger] = None) -> \
|
|
164
|
+
Tuple[ExtendedLogger, LoggingHandler]:
|
|
165
|
+
"""Setup a fresh logger for each watch action.
|
|
166
|
+
|
|
167
|
+
:param master_logger: Forward all log messages to a master logger."""
|
|
168
|
+
logger = logging.getLogger(name=str(uuid.uuid1()))
|
|
169
|
+
formatter = logging.Formatter(fmt=LOGFMT, datefmt=DATEFMT)
|
|
170
|
+
handler = LoggingHandler(master_logger=master_logger)
|
|
171
|
+
handler.setFormatter(formatter)
|
|
172
|
+
# Show all log messages: use 1 instead of 0: because:
|
|
173
|
+
# From the documentation:
|
|
174
|
+
# When a logger is created, the level is set to NOTSET (which causes all
|
|
175
|
+
# messages to be processed when the logger is the root logger, or
|
|
176
|
+
# delegation to the parent when the logger is a non-root logger). Note that
|
|
177
|
+
# the root logger is created with level WARNING.
|
|
178
|
+
logger.setLevel(1)
|
|
179
|
+
logger.addHandler(handler)
|
|
180
|
+
return (cast(ExtendedLogger, logger), handler)
|
command_watcher/py.typed
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import os
|
|
3
|
+
import pwd
|
|
4
|
+
import shutil
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import textwrap
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypedDict
|
|
9
|
+
|
|
10
|
+
from typing_extensions import Unpack
|
|
11
|
+
|
|
12
|
+
from . import icinga
|
|
13
|
+
from .email import send_email
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from . import Process
|
|
17
|
+
|
|
18
|
+
HOSTNAME = socket.gethostname()
|
|
19
|
+
USERNAME = pwd.getpwuid(os.getuid()).pw_name
|
|
20
|
+
|
|
21
|
+
Status = Literal[0, 1, 2, 3]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseClass:
|
|
25
|
+
|
|
26
|
+
def _obj_to_str(self, attributes: List[str] = []) -> str:
|
|
27
|
+
if not attributes:
|
|
28
|
+
attributes = dir(self)
|
|
29
|
+
output: List[str] = []
|
|
30
|
+
for attribute in attributes:
|
|
31
|
+
if not attribute.startswith('_') and \
|
|
32
|
+
not callable(getattr(self, attribute)):
|
|
33
|
+
value = getattr(self, attribute)
|
|
34
|
+
if value:
|
|
35
|
+
value = textwrap.shorten(str(value), width=64)
|
|
36
|
+
value = value.replace('\n', ' ')
|
|
37
|
+
output.append('{}: \'{}\''.format(attribute, value))
|
|
38
|
+
return '[{}] {}'.format(self.__class__.__name__, ', '.join(output))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MinimalMessageParams(TypedDict, total=False):
|
|
42
|
+
|
|
43
|
+
custom_message: str
|
|
44
|
+
"""Custom message"""
|
|
45
|
+
|
|
46
|
+
prefix: str
|
|
47
|
+
""" Prefix of the report message."""
|
|
48
|
+
|
|
49
|
+
body: str
|
|
50
|
+
""" A longer report text."""
|
|
51
|
+
|
|
52
|
+
preformance_data: Dict[str, Any]
|
|
53
|
+
""" A dictionary like
|
|
54
|
+
`{'perf_1': 1, 'perf_2': 'test'}`"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MessageParams(MinimalMessageParams, total=False):
|
|
58
|
+
|
|
59
|
+
status: Status
|
|
60
|
+
""" 0 (OK), 1 (WARNING), 2 (CRITICAL), 3 (UNKOWN): see
|
|
61
|
+
Nagios / Icinga monitoring status / state."""
|
|
62
|
+
|
|
63
|
+
service_name: str
|
|
64
|
+
"""The name of the service."""
|
|
65
|
+
|
|
66
|
+
log_records: str
|
|
67
|
+
"""Log records separated by new lines"""
|
|
68
|
+
|
|
69
|
+
processes: List['Process']
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Message(BaseClass):
|
|
73
|
+
"""
|
|
74
|
+
This message class bundles all available message data into an object. The
|
|
75
|
+
different reporters can choose which data they use.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_data: MessageParams
|
|
79
|
+
|
|
80
|
+
def __init__(self, **data: Unpack[MessageParams]):
|
|
81
|
+
self._data = data
|
|
82
|
+
|
|
83
|
+
def __str__(self):
|
|
84
|
+
return self._obj_to_str()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def status(self) -> int:
|
|
88
|
+
"""0 (OK), 1 (WARNING), 2 (CRITICAL), 3 (UNKOWN): see
|
|
89
|
+
Nagios / Icinga monitoring status / state."""
|
|
90
|
+
return self._data.get('status', 0)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def status_text(self) -> str:
|
|
94
|
+
"""The status as a text word like `OK`."""
|
|
95
|
+
return icinga.States[self.status]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def service_name(self) -> str:
|
|
99
|
+
return self._data.get('service_name', 'service_not_set')
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def performance_data(self) -> str:
|
|
103
|
+
"""
|
|
104
|
+
:return: A concatenated string
|
|
105
|
+
:rtype: str
|
|
106
|
+
"""
|
|
107
|
+
performance_data = self._data.get('performance_data')
|
|
108
|
+
if performance_data and isinstance(performance_data, dict):
|
|
109
|
+
pairs: List[str] = []
|
|
110
|
+
key: str
|
|
111
|
+
value: Any
|
|
112
|
+
for key, value in performance_data.items():
|
|
113
|
+
pairs.append('{!s}={!s}'.format(key, value))
|
|
114
|
+
return ' '.join(pairs)
|
|
115
|
+
return ''
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def custom_message(self) -> str:
|
|
119
|
+
return self._data.get('custom_message', '')
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def prefix(self) -> str:
|
|
123
|
+
return self._data.get('prefix', '[cwatcher]:')
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def message(self) -> str:
|
|
127
|
+
output: List[str] = []
|
|
128
|
+
if self.prefix:
|
|
129
|
+
output.append(self.prefix)
|
|
130
|
+
|
|
131
|
+
output.append(self.service_name.upper())
|
|
132
|
+
output.append(self.status_text)
|
|
133
|
+
if self.custom_message:
|
|
134
|
+
output.append('- {}'.format(self.custom_message))
|
|
135
|
+
return ' '.join(output)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def message_monitoring(self) -> str:
|
|
139
|
+
"""message + performance_data"""
|
|
140
|
+
output: List[str] = []
|
|
141
|
+
output.append(self.message)
|
|
142
|
+
if self.performance_data:
|
|
143
|
+
output.append('|')
|
|
144
|
+
output.append(self.performance_data)
|
|
145
|
+
return ' '.join(output)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def body(self) -> str:
|
|
149
|
+
"""Text body for the e-mail message."""
|
|
150
|
+
output: List[str] = []
|
|
151
|
+
output.append('Host: {}'.format(HOSTNAME))
|
|
152
|
+
output.append('User: {}'.format(USERNAME))
|
|
153
|
+
output.append('Service name: {}'.format(self.service_name))
|
|
154
|
+
|
|
155
|
+
if self.performance_data:
|
|
156
|
+
output.append('Performance data: {}'.format(self.performance_data))
|
|
157
|
+
|
|
158
|
+
body: str = self._data.get('body', '')
|
|
159
|
+
if body:
|
|
160
|
+
output.append('')
|
|
161
|
+
output.append(body)
|
|
162
|
+
|
|
163
|
+
log_records = self._data.get('log_records', '')
|
|
164
|
+
if log_records:
|
|
165
|
+
output.append('')
|
|
166
|
+
output.append('Log records:')
|
|
167
|
+
output.append('')
|
|
168
|
+
output.append(log_records)
|
|
169
|
+
|
|
170
|
+
return '\n'.join(output)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def processes(self) -> Optional[str]:
|
|
174
|
+
output: List[str] = []
|
|
175
|
+
processes = self._data.get('processes')
|
|
176
|
+
if processes:
|
|
177
|
+
for process in processes:
|
|
178
|
+
output.append(' '.join(process.args_normalized))
|
|
179
|
+
if output:
|
|
180
|
+
return'({})'.format('; '.join(output))
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def user(self) -> str:
|
|
184
|
+
return '[user:{}]'.format(USERNAME)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class BaseChannel(BaseClass, metaclass=abc.ABCMeta):
|
|
188
|
+
"""Base class for all reporters"""
|
|
189
|
+
|
|
190
|
+
@abc.abstractmethod
|
|
191
|
+
def report(self, message: Message) -> None:
|
|
192
|
+
raise NotImplementedError('A reporter class must have a `report` '
|
|
193
|
+
'method.')
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class EmailChannel(BaseChannel):
|
|
197
|
+
"""Send reports by e-mail."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, smtp_server: str, smtp_login: str, smtp_password: str,
|
|
200
|
+
to_addr: str, from_addr: str = '',
|
|
201
|
+
to_addr_critical: str = ''):
|
|
202
|
+
self.smtp_server = smtp_server
|
|
203
|
+
self.smtp_login = smtp_login
|
|
204
|
+
self.smtp_password = smtp_password
|
|
205
|
+
self.to_addr = to_addr
|
|
206
|
+
self.from_addr = from_addr
|
|
207
|
+
if not from_addr:
|
|
208
|
+
self.from_addr = '{0} <{1}@{0}>'.format(HOSTNAME, USERNAME)
|
|
209
|
+
self.to_addr_critical = to_addr_critical
|
|
210
|
+
|
|
211
|
+
def __str__(self) -> str:
|
|
212
|
+
return self._obj_to_str(['smtp_server', 'smtp_login', 'to_addr',
|
|
213
|
+
'from_addr', ])
|
|
214
|
+
|
|
215
|
+
def report(self, message: Message) -> None:
|
|
216
|
+
"""Send an e-mail message.
|
|
217
|
+
|
|
218
|
+
:param message: A message object.
|
|
219
|
+
"""
|
|
220
|
+
if message.status == 2 and self.to_addr_critical:
|
|
221
|
+
to_addr = self.to_addr_critical
|
|
222
|
+
else:
|
|
223
|
+
to_addr = self.to_addr
|
|
224
|
+
|
|
225
|
+
send_email(
|
|
226
|
+
from_addr=self.from_addr,
|
|
227
|
+
to_addr=to_addr,
|
|
228
|
+
subject=message.message,
|
|
229
|
+
body=message.body,
|
|
230
|
+
smtp_login=self.smtp_login,
|
|
231
|
+
smtp_password=self.smtp_password,
|
|
232
|
+
smtp_server=self.smtp_server
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class IcingaChannel(BaseChannel):
|
|
237
|
+
url: str
|
|
238
|
+
user: str
|
|
239
|
+
password: str
|
|
240
|
+
service_name: str
|
|
241
|
+
|
|
242
|
+
def __init__(self, url: str, user: str, password: str,
|
|
243
|
+
service_name: str):
|
|
244
|
+
self.url = url
|
|
245
|
+
self.user = user
|
|
246
|
+
self.password = password
|
|
247
|
+
self.service_name = service_name
|
|
248
|
+
|
|
249
|
+
def __str__(self) -> str:
|
|
250
|
+
# No password!
|
|
251
|
+
return self._obj_to_str(['url', 'user', 'service_name'])
|
|
252
|
+
|
|
253
|
+
def report(self, message: Message) -> None:
|
|
254
|
+
icinga.send_passive_check(
|
|
255
|
+
url=self.url,
|
|
256
|
+
user=self.user,
|
|
257
|
+
password=self.password,
|
|
258
|
+
status=message.status,
|
|
259
|
+
host_name=HOSTNAME,
|
|
260
|
+
service_name=message.service_name,
|
|
261
|
+
text_output=message.message,
|
|
262
|
+
performance_data=message.performance_data
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class BeepChannel(BaseChannel):
|
|
267
|
+
"""Send beep sounds."""
|
|
268
|
+
|
|
269
|
+
def __init__(self):
|
|
270
|
+
self.cmd = shutil.which('beep')
|
|
271
|
+
|
|
272
|
+
def __str__(self) -> str:
|
|
273
|
+
# No password!
|
|
274
|
+
return self._obj_to_str(['cmd'])
|
|
275
|
+
|
|
276
|
+
def beep(self, frequency: float = 4186.01, length: float = 50) -> None:
|
|
277
|
+
"""
|
|
278
|
+
Generate a beep sound using the “beep” command.
|
|
279
|
+
|
|
280
|
+
* A success tone: frequency=4186.01, length=40
|
|
281
|
+
* A failure tone: frequency=65.4064, length=100
|
|
282
|
+
|
|
283
|
+
:param frequency: Frequency in Hz.
|
|
284
|
+
:param length: Length in milliseconds.
|
|
285
|
+
"""
|
|
286
|
+
# TODO: Use self.cmd -> Fix tests
|
|
287
|
+
subprocess.run(['beep', '-f', str(float(frequency)), '-l',
|
|
288
|
+
str(float(length))])
|
|
289
|
+
|
|
290
|
+
def report(self, message: Message) -> None:
|
|
291
|
+
"""Send a beep sounds.
|
|
292
|
+
|
|
293
|
+
:param message: A message object. The only attribute that takes an
|
|
294
|
+
effect is the status attribute (0-3).
|
|
295
|
+
"""
|
|
296
|
+
if message.status == 0: # OK
|
|
297
|
+
self.beep(frequency=4186.01, length=50) # C8 (highest note)
|
|
298
|
+
elif message.status == 1: # WARNING
|
|
299
|
+
self.beep(frequency=261.626, length=100) # C4 (middle C)
|
|
300
|
+
elif message.status == 2: # CRITICAL
|
|
301
|
+
self.beep(frequency=65.4064, length=150) # C2 (low C)
|
|
302
|
+
elif message.status == 3: # UNKOWN
|
|
303
|
+
self.beep(frequency=32.7032, length=200) # C1
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class Reporter:
|
|
307
|
+
"""Collect all channels."""
|
|
308
|
+
|
|
309
|
+
channels: List[BaseChannel]
|
|
310
|
+
|
|
311
|
+
def __init__(self):
|
|
312
|
+
self.channels = []
|
|
313
|
+
|
|
314
|
+
def add_channel(self, channel: BaseChannel) -> None:
|
|
315
|
+
self.channels.append(channel)
|
|
316
|
+
|
|
317
|
+
def report(self, **data: Unpack[MessageParams]) -> Message:
|
|
318
|
+
message = Message(**data)
|
|
319
|
+
for channel in self.channels:
|
|
320
|
+
channel.report(message)
|
|
321
|
+
return message
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
reporter = Reporter()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Josef Friedrich <josef@friedrich.rocks>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
6
|
+
copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included
|
|
14
|
+
in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
17
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
20
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
21
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
22
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: command-watcher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Module to watch the execution of shell scripts. Both streams (`stdout` and `stderr`) are captured.
|
|
5
|
+
Home-page: https://github.com/Josef-Friedrich/command-watcher
|
|
6
|
+
License: MIT
|
|
7
|
+
Author: Josef Friedrich
|
|
8
|
+
Author-email: josef@friedrich.rocks
|
|
9
|
+
Requires-Python: >=3.8,<4.0
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Requires-Dist: conf2levels (>=0.4.0,<0.5.0)
|
|
17
|
+
Requires-Dist: jflib (>=1.0.0,<2.0.0)
|
|
18
|
+
Requires-Dist: requests (>=2.28.1,<3.0.0)
|
|
19
|
+
Requires-Dist: termcolor (>=1.1.0,<2.0.0)
|
|
20
|
+
Requires-Dist: types-requests (>=2.28.1,<3.0.0)
|
|
21
|
+
Requires-Dist: types-termcolor (>=1.1.5,<2.0.0)
|
|
22
|
+
Project-URL: Repository, https://github.com/Josef-Friedrich/command-watcher
|
|
23
|
+
Description-Content-Type: text/x-rst
|
|
24
|
+
|
|
25
|
+
command_watcher
|
|
26
|
+
===============
|
|
27
|
+
|
|
28
|
+
Module to watch the execution of shell scripts. Both streams (`stdout`
|
|
29
|
+
and `stderr`) are captured.
|
|
30
|
+
|
|
31
|
+
.. code:: python
|
|
32
|
+
|
|
33
|
+
watch = Watch()
|
|
34
|
+
watch.log.critical('msg')
|
|
35
|
+
watch.log.error('msg')
|
|
36
|
+
watch.log.warning('msg')
|
|
37
|
+
watch.log.info('msg')
|
|
38
|
+
watch.log.debug('msg')
|
|
39
|
+
watch.run(['rsync', '-av', '/home', '/backup'])
|
|
40
|
+
|
|
41
|
+
.. code-block:: python
|
|
42
|
+
|
|
43
|
+
from command_watcher import Watch
|
|
44
|
+
watch = Watch(
|
|
45
|
+
config_file='/etc/command-watcher.ini',
|
|
46
|
+
service_name='texlive_update'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
tlmgr = '/usr/local/texlive/bin/x86_64-linux/tlmgr'
|
|
50
|
+
|
|
51
|
+
watch.run('{} update --self'.format(tlmgr))
|
|
52
|
+
watch.run('{} update --all'.format(tlmgr))
|
|
53
|
+
installed_packages = watch.run(
|
|
54
|
+
'{} info --only-installed'.format(tlmgr), log=False
|
|
55
|
+
)
|
|
56
|
+
all_packages = watch.run('{} info'.format(tlmgr), log=False)
|
|
57
|
+
|
|
58
|
+
watch.final_report(
|
|
59
|
+
status=0,
|
|
60
|
+
performance_data={
|
|
61
|
+
'installed_packages': installed_packages.line_count_stdout,
|
|
62
|
+
'all_packages': all_packages.line_count_stdout,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
.. code-block:: ini
|
|
67
|
+
|
|
68
|
+
[email]
|
|
69
|
+
subject_prefix = [cwatcher]
|
|
70
|
+
from_addr =
|
|
71
|
+
to_addr = logs@example.com
|
|
72
|
+
to_addr_critical = critical@example.com
|
|
73
|
+
smtp_login = mailer
|
|
74
|
+
smtp_password = 1234
|
|
75
|
+
smtp_server = mail.example.com:587
|
|
76
|
+
|
|
77
|
+
[nsca]
|
|
78
|
+
remote_host = 1.2.3.4
|
|
79
|
+
password = asdf1234
|
|
80
|
+
encryption_method = 8
|
|
81
|
+
; port = 5667
|
|
82
|
+
|
|
83
|
+
[icinga]
|
|
84
|
+
url = https://icinga.example.com:5665
|
|
85
|
+
user = user
|
|
86
|
+
password = 1234
|
|
87
|
+
|
|
88
|
+
[beep]
|
|
89
|
+
activated = True
|
|
90
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
command_watcher/__init__.py,sha256=VFRC70OHN9rRlgRvnh75yf913LoNliSuOe8ZO0cxMdI,14387
|
|
2
|
+
command_watcher/email.py,sha256=FPsDFYDOpWdAHsPtw3SFLpC-C7zArXvUEGtRW6WJa70,1112
|
|
3
|
+
command_watcher/icinga.py,sha256=fPXzClbHys8zQy5RXDbogSDsd_hwQn9gKVGaVh4d5g0,1402
|
|
4
|
+
command_watcher/log.py,sha256=kCo0amJij8e6Q4qdTdm0Eqj30NSRQKz6oAfgnHI59-w,5269
|
|
5
|
+
command_watcher/py.typed,sha256=fTZxIEqDNB02iQ4gJI00cUs5AkemOkUF555psIWfiGA,94
|
|
6
|
+
command_watcher/report.py,sha256=Zr_32bQA3gQkRJCEEt4gCYTy3HBDAT_iry64J9-2ttw,9427
|
|
7
|
+
command_watcher-0.1.0.dist-info/LICENSE,sha256=ejqFH_nPFaJO1iQ8-bQJcoUWk2DlhJmsmTlmcB9bb2s,1096
|
|
8
|
+
command_watcher-0.1.0.dist-info/WHEEL,sha256=FWzWAvOOsDP6aqtZj_z3pIhVEk8S9F03Xwafa0tLqIY,90
|
|
9
|
+
command_watcher-0.1.0.dist-info/METADATA,sha256=JaQbVMzmw19S9Inw6sivRUzrrKWVtX0r9RUaFyEDlkw,2564
|
|
10
|
+
command_watcher-0.1.0.dist-info/RECORD,,
|