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.
@@ -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)
@@ -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)
@@ -0,0 +1,2 @@
1
+ # PEP 561 – Distributing and Packaging Type Information
2
+ # https://peps.python.org/pep-0561/
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.1.0b2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any