command_watcher 0.4.1__tar.gz

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,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,99 @@
1
+ Metadata-Version: 2.1
2
+ Name: command_watcher
3
+ Version: 0.4.1
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.10,<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.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Requires-Dist: conf2levels (>=0,<1)
18
+ Requires-Dist: icinga2apic (>=0,<1)
19
+ Requires-Dist: icinga2apic-stubs (>=0,<1)
20
+ Requires-Dist: termcolor (>=1,<2)
21
+ Requires-Dist: types-requests (>=2,<3)
22
+ Requires-Dist: types-termcolor (>=1,<2)
23
+ Project-URL: Repository, https://github.com/Josef-Friedrich/command-watcher
24
+ Description-Content-Type: text/x-rst
25
+
26
+ .. image:: http://img.shields.io/pypi/v/command-watcher.svg
27
+ :target: https://pypi.org/project/command-watcher
28
+ :alt: This package on the Python Package Index
29
+
30
+ .. image:: https://github.com/Josef-Friedrich/command-watcher/actions/workflows/tests.yml/badge.svg
31
+ :target: https://github.com/Josef-Friedrich/command-watcher/actions/workflows/tests.yml
32
+ :alt: Tests
33
+
34
+ command_watcher
35
+ ===============
36
+
37
+ Module to watch the execution of shell scripts. Both streams (`stdout`
38
+ and `stderr`) are captured.
39
+
40
+ .. code:: python
41
+
42
+ watch = Watch()
43
+ watch.log.critical('msg')
44
+ watch.log.error('msg')
45
+ watch.log.warning('msg')
46
+ watch.log.info('msg')
47
+ watch.log.debug('msg')
48
+ watch.run(['rsync', '-av', '/home', '/backup'])
49
+
50
+ .. code-block:: python
51
+
52
+ from command_watcher import Watch
53
+ watch = Watch(
54
+ config_file='/etc/command-watcher.ini',
55
+ service_name='texlive_update'
56
+ )
57
+
58
+ tlmgr = '/usr/local/texlive/bin/x86_64-linux/tlmgr'
59
+
60
+ watch.run('{} update --self'.format(tlmgr))
61
+ watch.run('{} update --all'.format(tlmgr))
62
+ installed_packages = watch.run(
63
+ '{} info --only-installed'.format(tlmgr), log=False
64
+ )
65
+ all_packages = watch.run('{} info'.format(tlmgr), log=False)
66
+
67
+ watch.final_report(
68
+ status=0,
69
+ performance_data={
70
+ 'installed_packages': installed_packages.line_count_stdout,
71
+ 'all_packages': all_packages.line_count_stdout,
72
+ },
73
+ )
74
+
75
+ .. code-block:: ini
76
+
77
+ [email]
78
+ subject_prefix = [cwatcher]
79
+ from_addr =
80
+ to_addr = logs@example.com
81
+ to_addr_critical = critical@example.com
82
+ smtp_login = mailer
83
+ smtp_password = 1234
84
+ smtp_server = mail.example.com:587
85
+
86
+ [nsca]
87
+ remote_host = 1.2.3.4
88
+ password = asdf1234
89
+ encryption_method = 8
90
+ ; port = 5667
91
+
92
+ [icinga]
93
+ url = https://icinga.example.com:5665
94
+ user = user
95
+ password = 1234
96
+
97
+ [beep]
98
+ activated = True
99
+
@@ -0,0 +1,73 @@
1
+ .. image:: http://img.shields.io/pypi/v/command-watcher.svg
2
+ :target: https://pypi.org/project/command-watcher
3
+ :alt: This package on the Python Package Index
4
+
5
+ .. image:: https://github.com/Josef-Friedrich/command-watcher/actions/workflows/tests.yml/badge.svg
6
+ :target: https://github.com/Josef-Friedrich/command-watcher/actions/workflows/tests.yml
7
+ :alt: Tests
8
+
9
+ command_watcher
10
+ ===============
11
+
12
+ Module to watch the execution of shell scripts. Both streams (`stdout`
13
+ and `stderr`) are captured.
14
+
15
+ .. code:: python
16
+
17
+ watch = Watch()
18
+ watch.log.critical('msg')
19
+ watch.log.error('msg')
20
+ watch.log.warning('msg')
21
+ watch.log.info('msg')
22
+ watch.log.debug('msg')
23
+ watch.run(['rsync', '-av', '/home', '/backup'])
24
+
25
+ .. code-block:: python
26
+
27
+ from command_watcher import Watch
28
+ watch = Watch(
29
+ config_file='/etc/command-watcher.ini',
30
+ service_name='texlive_update'
31
+ )
32
+
33
+ tlmgr = '/usr/local/texlive/bin/x86_64-linux/tlmgr'
34
+
35
+ watch.run('{} update --self'.format(tlmgr))
36
+ watch.run('{} update --all'.format(tlmgr))
37
+ installed_packages = watch.run(
38
+ '{} info --only-installed'.format(tlmgr), log=False
39
+ )
40
+ all_packages = watch.run('{} info'.format(tlmgr), log=False)
41
+
42
+ watch.final_report(
43
+ status=0,
44
+ performance_data={
45
+ 'installed_packages': installed_packages.line_count_stdout,
46
+ 'all_packages': all_packages.line_count_stdout,
47
+ },
48
+ )
49
+
50
+ .. code-block:: ini
51
+
52
+ [email]
53
+ subject_prefix = [cwatcher]
54
+ from_addr =
55
+ to_addr = logs@example.com
56
+ to_addr_critical = critical@example.com
57
+ smtp_login = mailer
58
+ smtp_password = 1234
59
+ smtp_server = mail.example.com:587
60
+
61
+ [nsca]
62
+ remote_host = 1.2.3.4
63
+ password = asdf1234
64
+ encryption_method = 8
65
+ ; port = 5667
66
+
67
+ [icinga]
68
+ url = https://icinga.example.com:5665
69
+ user = user
70
+ password = 1234
71
+
72
+ [beep]
73
+ activated = True
@@ -0,0 +1,487 @@
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 importlib import metadata
15
+ from typing import (
16
+ IO,
17
+ Any,
18
+ Dict,
19
+ List,
20
+ Literal,
21
+ Optional,
22
+ Sequence,
23
+ Tuple,
24
+ TypedDict,
25
+ Union,
26
+ )
27
+
28
+ from conf2levels import ClassInterface, ConfigReader, Spec
29
+ from typing_extensions import Unpack
30
+
31
+ from .log import ExtendedLogger, LoggingHandler, setup_logging
32
+ from .report import (
33
+ HOSTNAME,
34
+ BaseChannel,
35
+ BeepChannel,
36
+ EmailChannel,
37
+ IcingaChannel,
38
+ Message,
39
+ MessageParams,
40
+ MinimalMessageParams,
41
+ Status,
42
+ reporter,
43
+ )
44
+
45
+ __version__: str = metadata.version("command_watcher")
46
+
47
+ Stream = Literal["stdout", "stderr"]
48
+
49
+
50
+ class CommandWatcherError(Exception):
51
+ """Exception raised by this module."""
52
+
53
+ def __init__(self, msg: str, **data: Unpack[MessageParams]):
54
+ reporter.report(
55
+ status=2,
56
+ custom_message="{}: {}".format(self.__class__.__name__, msg),
57
+ **data, # type: ignore
58
+ )
59
+
60
+
61
+ class Timer:
62
+ """Measure the execution time of a command run."""
63
+
64
+ stop: float
65
+ """"The time when the timer stops. (UNIX timestamp)"""
66
+
67
+ start: float
68
+ """"The start time. (UNIX timestamp)"""
69
+
70
+ interval: float
71
+ """The time interval between start and stop."""
72
+
73
+ def __init__(self) -> None:
74
+ self.stop = 0
75
+ self.start = time.time()
76
+ self.interval = 0
77
+
78
+ def result(self) -> str:
79
+ """
80
+ Measure the time intervale
81
+
82
+ :return: A formatted string displaying the result."""
83
+ self.stop = time.time()
84
+ self.interval = self.stop - self.start
85
+ return "{:.3f}s".format(self.interval)
86
+
87
+
88
+ # Configuration ###############################################################
89
+
90
+ CONF_DEFAULTS = {
91
+ "email": {
92
+ "subject_prefix": "command_watcher",
93
+ },
94
+ "nsca": {
95
+ "port": 5667,
96
+ },
97
+ }
98
+
99
+
100
+ CONFIG_READER_SPEC: Spec = {
101
+ "email": {
102
+ "from_addr": {
103
+ "description": "The email address of the sender.",
104
+ },
105
+ "to_addr": {
106
+ "description": "The email address of the recipient.",
107
+ "not_empty": True,
108
+ },
109
+ "to_addr_critical": {
110
+ "description": "The email address of the recipient to send "
111
+ "critical messages to.",
112
+ "default": None,
113
+ },
114
+ "smtp_login": {
115
+ "description": "The SMTP login name.",
116
+ "not_empty": True,
117
+ },
118
+ "smtp_password": {
119
+ "description": "The SMTP password.",
120
+ "not_empty": True,
121
+ },
122
+ "smtp_server": {
123
+ "description": "The URL of the SMTP server, for example: "
124
+ "`smtp.example.com:587`.",
125
+ "not_empty": True,
126
+ },
127
+ },
128
+ "nsca": {
129
+ "remote_host": {
130
+ "description": "The IP address of the NSCA remote host.",
131
+ "not_empty": True,
132
+ },
133
+ "password": {
134
+ "description": "The NSCA password.",
135
+ "not_empty": True,
136
+ },
137
+ "encryption_method": {
138
+ "description": "The NSCA encryption method. The supported "
139
+ "encryption methods are: 0 1 2 3 4 8 11 14 15 16",
140
+ "not_empty": True,
141
+ },
142
+ "port": {
143
+ "description": "The NSCA port.",
144
+ "default": 5667,
145
+ },
146
+ },
147
+ "icinga": {
148
+ "url": {
149
+ "description": "The HTTP URL. /v1/actions/process-check-result "
150
+ "is appended.",
151
+ "not_empty": True,
152
+ },
153
+ "user": {
154
+ "description": "The user for the HTTP authentification.",
155
+ "not_empty": True,
156
+ },
157
+ "password": {
158
+ "description": "The password for the HTTP authentification.",
159
+ "not_empty": True,
160
+ },
161
+ },
162
+ "beep": {
163
+ "activated": {
164
+ "description": "Activate the beep channel to report auditive " "messages.",
165
+ "default": False,
166
+ }
167
+ },
168
+ }
169
+
170
+
171
+ # Main code ###################################################################
172
+
173
+ Args = Union[str, List[str], Tuple[str]]
174
+
175
+
176
+ class ProcessArgs(TypedDict, total=False):
177
+ shell: bool
178
+ """If true, the command will be executed through the
179
+ shell.
180
+ """
181
+
182
+ cwd: str
183
+ """Sets the current directory before the child is
184
+ executed."""
185
+
186
+ env: Dict[str, Any]
187
+ """Defines the environment variables for the new process."""
188
+
189
+
190
+ class Process:
191
+ """Run a process.
192
+
193
+ You can use all keyword arguments from
194
+ :py:class:`subprocess.Popen` except `bufsize`, `stderr`, `stdout`.
195
+
196
+ :param args: List, tuple or string. A sequence of
197
+ process arguments, like `subprocess.Popen(args)`.
198
+ """
199
+
200
+ args: Args
201
+ """Process arguments in various types."""
202
+
203
+ _queue: "queue.Queue[Optional[Tuple[bytes, Stream]]]"
204
+
205
+ log: ExtendedLogger
206
+ """A ready to go and configured logger."""
207
+
208
+ log_handler: LoggingHandler
209
+
210
+ subprocess: subprocess.Popen[Any]
211
+
212
+ def __init__(
213
+ self,
214
+ args: Args,
215
+ master_logger: Optional[ExtendedLogger] = None,
216
+ **kwargs: Unpack[ProcessArgs],
217
+ ) -> None:
218
+ # self.args: typing.Union[str, list, tuple] = args
219
+ self.args = args
220
+
221
+ self._queue = queue.Queue()
222
+
223
+ log, log_handler = setup_logging(master_logger=master_logger)
224
+ self.log = log
225
+ self.log_handler = log_handler
226
+
227
+ self.log.info("Run command: {}".format(" ".join(self.args_normalized)))
228
+ timer = Timer()
229
+ self.subprocess = subprocess.Popen(
230
+ self.args_normalized,
231
+ stdout=subprocess.PIPE,
232
+ stderr=subprocess.PIPE,
233
+ # RuntimeWarning: line buffering (buffering=1) isn't
234
+ # supported in binary mode, the default buffer size will be used
235
+ # bufsize=1,
236
+ **kwargs,
237
+ )
238
+
239
+ self._start_thread(self.subprocess.stdout, "stdout")
240
+ self._start_thread(self.subprocess.stderr, "stderr")
241
+
242
+ for _ in range(2):
243
+ for line_bytes, stream in iter(self._queue.get, None):
244
+ line: str = ""
245
+ if line_bytes:
246
+ line = line_bytes.decode("utf-8").strip()
247
+
248
+ if line:
249
+ if stream == "stderr":
250
+ self.log.stderr(line)
251
+ if stream == "stdout":
252
+ self.log.stdout(line)
253
+ self.subprocess.wait()
254
+ self.log.info("Execution time: {}".format(timer.result()))
255
+
256
+ @property
257
+ def args_normalized(self) -> Sequence[str]:
258
+ """Normalized `args`, always a list"""
259
+ if isinstance(self.args, str):
260
+ return shlex.split(self.args)
261
+ else:
262
+ return self.args
263
+
264
+ @property
265
+ def stdout(self) -> str:
266
+ """Alias / shortcut for `self.log_handler.stdout`."""
267
+
268
+ return self.log_handler.stdout
269
+
270
+ @property
271
+ def line_count_stdout(self) -> int:
272
+ """The count of lines of the current `stderr`."""
273
+ return len(self.stdout.splitlines())
274
+
275
+ @property
276
+ def stderr(self) -> str:
277
+ """Alias / shortcut for `self.log_handler.stderr`."""
278
+ return self.log_handler.stderr
279
+
280
+ @property
281
+ def line_count_stderr(self) -> int:
282
+ """The count of lines of the current `stderr`."""
283
+ return len(self.stderr.splitlines())
284
+
285
+ def _stdout_stderr_reader(self, pipe: IO[bytes], stream: Stream) -> None:
286
+ """
287
+ :param object pipe: `process.stdout` or `process.stdout`
288
+ """
289
+ try:
290
+ with pipe:
291
+ for line in iter(pipe.readline, b""):
292
+ self._queue.put((line, stream))
293
+ except Exception:
294
+ pass
295
+ finally:
296
+ self._queue.put(None)
297
+
298
+ def _start_thread(self, pipe: Optional[IO[bytes]], stream: Stream) -> None:
299
+ """
300
+ :param object pipe: `process.stdout` or `process.stdout`
301
+ """
302
+ threading.Thread(target=self._stdout_stderr_reader, args=[pipe, stream]).start()
303
+
304
+
305
+ class Watch:
306
+ """Watch the execution of a command. Capture all output of a command.
307
+ provide and setup a logging facility.
308
+
309
+ :param config_file: The file path of the configuration file in the INI
310
+ format.
311
+ :param service_name: A name of the watched service.
312
+ :param raise_exceptions: Raise exceptions if `watch.run()` exists with a
313
+ non-zero exit code.
314
+ :param config_reader: A custom configuration reader. Specify this
315
+ parameter to not use the build in configuration reader.
316
+ """
317
+
318
+ _hostname: str
319
+ """The hostname of machine the watcher running on."""
320
+
321
+ _service_name: str
322
+ """A name of the watched service."""
323
+
324
+ log: ExtendedLogger
325
+ """A ready to go and configured logger."""
326
+
327
+ _log_handler: LoggingHandler
328
+
329
+ processes: List[Process]
330
+ """A list of completed processes
331
+ :py:class:`Process`. Everytime you use the method
332
+ `run()` the process object is appened in the list."""
333
+
334
+ _conf: Optional[ClassInterface]
335
+
336
+ _raise_exceptions: bool
337
+ """Raise exceptions"""
338
+
339
+ _timer: Timer
340
+
341
+ def __init__(
342
+ self,
343
+ config_file: Optional[str] = None,
344
+ service_name: str = "command_watcher",
345
+ raise_exceptions: bool = True,
346
+ config_reader: Optional[ConfigReader] = None,
347
+ report_channels: Optional[List[BaseChannel]] = None,
348
+ ):
349
+ self._hostname = HOSTNAME
350
+
351
+ self._service_name = service_name
352
+
353
+ log, log_handler = setup_logging()
354
+
355
+ self.log = log
356
+ self.log.info("Hostname: {}".format(self._hostname))
357
+
358
+ self._log_handler = log_handler
359
+
360
+ self._conf = None
361
+
362
+ if not config_reader and config_file:
363
+ config_reader = ConfigReader(
364
+ spec=CONFIG_READER_SPEC,
365
+ ini=config_file,
366
+ dictionary=CONF_DEFAULTS,
367
+ )
368
+
369
+ if not config_reader:
370
+ raise Exception("No config_reader object")
371
+
372
+ self._conf = config_reader.get_class_interface()
373
+
374
+ if report_channels is None:
375
+ try:
376
+ config_reader.check_section("email")
377
+ email_reporter = EmailChannel(
378
+ smtp_server=self._conf.email.smtp_server,
379
+ smtp_login=self._conf.email.smtp_login,
380
+ smtp_password=self._conf.email.smtp_password,
381
+ to_addr=self._conf.email.to_addr,
382
+ from_addr=self._conf.email.from_addr,
383
+ to_addr_critical=self._conf.email.to_addr_critical,
384
+ )
385
+ reporter.add_channel(email_reporter)
386
+ self.log.debug(email_reporter)
387
+ except (ValueError, KeyError):
388
+ pass
389
+
390
+ try:
391
+ config_reader.check_section("icinga")
392
+ icinga_reporter = IcingaChannel(
393
+ url=self._conf.icinga.url,
394
+ user=self._conf.icinga.user,
395
+ password=self._conf.icinga.password,
396
+ service_name=self._service_name,
397
+ )
398
+ reporter.add_channel(icinga_reporter)
399
+ self.log.debug(icinga_reporter)
400
+ except (ValueError, KeyError):
401
+ pass
402
+
403
+ if shutil.which("beep") and self._conf.beep.activated:
404
+ beep_reporter = BeepChannel()
405
+ reporter.add_channel(beep_reporter)
406
+ self.log.debug(beep_reporter)
407
+
408
+ else:
409
+ reporter.channels = []
410
+
411
+ self.processes = []
412
+
413
+ self._raise_exceptions = raise_exceptions
414
+
415
+ self._timer = Timer()
416
+
417
+ @property
418
+ def stdout(self):
419
+ """Alias / shortcut for `self._log_handler.stdout`."""
420
+ return self._log_handler.stdout
421
+
422
+ @property
423
+ def stderr(self):
424
+ """Alias / shortcut for `self._log_handler.stderr`."""
425
+ return self._log_handler.stderr
426
+
427
+ def run(
428
+ self,
429
+ args: Args,
430
+ log: bool = True,
431
+ ignore_exceptions: List[int] = [],
432
+ **kwargs: Unpack[ProcessArgs],
433
+ ) -> Process:
434
+ """
435
+ Run a process.
436
+
437
+ :param args: List, tuple or string. A sequence of
438
+ process arguments, like `subprocess.Popen(args)`.
439
+ :param log: Log the `stderr` and the `stdout` of the
440
+ process. If false the `stdout` and the `stderr` are logged only
441
+ to the local process logger, not to get global master logger.
442
+ :param ignore_exceptions: A list of none-zero exit codes, which is
443
+ ignored by this method.
444
+ """
445
+ if log:
446
+ master_logger = self.log
447
+ else:
448
+ master_logger = None
449
+ process = Process(args, master_logger=master_logger, **kwargs)
450
+ self.processes.append(process)
451
+ rc = process.subprocess.returncode
452
+ if self._raise_exceptions and rc != 0 and rc not in ignore_exceptions:
453
+ raise CommandWatcherError(
454
+ "The command '{}' exists with an non-zero return code ({}).".format(
455
+ " ".join(process.args_normalized), rc
456
+ ),
457
+ service_name=self._service_name,
458
+ log_records=self._log_handler.all_records,
459
+ )
460
+ return process
461
+
462
+ def report(self, status: Status, **data: Unpack[MinimalMessageParams]) -> Message:
463
+ """Report a message using the preconfigured channels."""
464
+ message = reporter.report(
465
+ status=status,
466
+ service_name=self._service_name,
467
+ log_records=self._log_handler.all_records,
468
+ processes=self.processes,
469
+ **data,
470
+ )
471
+ self.log.debug(message)
472
+ return message
473
+
474
+ def final_report(self, **data: Unpack[MessageParams]) -> Message:
475
+ """The same as the `report` method. Adds `execution_time` to the
476
+ `performance_data`.
477
+ """
478
+ timer_result = self._timer.result()
479
+ self.log.info("Overall execution time: {}".format(timer_result))
480
+ status = data.get("status", 0)
481
+ data_dict: Dict[str, Any] = dict(data)
482
+ if "performance_data" not in data_dict:
483
+ data_dict["performance_data"] = {}
484
+ data_dict["performance_data"]["execution_time"] = timer_result
485
+ if "status" in data_dict:
486
+ del data_dict["status"]
487
+ return self.report(status=status, **data_dict)
@@ -0,0 +1,42 @@
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(
8
+ from_addr: str,
9
+ to_addr: str,
10
+ subject: str,
11
+ body: str,
12
+ smtp_login: str,
13
+ smtp_password: str,
14
+ smtp_server: str,
15
+ ):
16
+ """
17
+ Send a email.
18
+
19
+ :param from_addr: The email address of the sender.
20
+ :param to_addr: The email address of the recipient.
21
+ :param subject: The email subject.
22
+ :param body: The email body.
23
+ :param smtp_login: The SMTP login name.
24
+ :param smtp_password: The SMTP password.
25
+ :param smtp_server: The URL of the SMTP server, for
26
+ example: `smtp.example.com:587`.
27
+
28
+ :return: Problems
29
+ """
30
+ message = MIMEText(body, "plain", "utf-8")
31
+
32
+ message["Subject"] = Header(subject, "utf-8")
33
+ message["From"] = from_addr
34
+ message["To"] = to_addr
35
+ message["Date"] = formatdate(localtime=True)
36
+
37
+ server = smtplib.SMTP(smtp_server)
38
+ server.starttls()
39
+ server.login(smtp_login, smtp_password)
40
+ problems = server.sendmail(from_addr, [to_addr], message.as_string())
41
+ server.quit()
42
+ return problems
@@ -0,0 +1,42 @@
1
+ from typing import Literal, Optional
2
+
3
+ import urllib3
4
+ from icinga2apic.client import Client
5
+
6
+ urllib3.disable_warnings() # type: ignore
7
+
8
+
9
+ STATE_OK = 0
10
+ STATE_WARNING = 1
11
+ STATE_CRITICAL = 2
12
+ STATE_UNKNOWN = 3
13
+
14
+ States = {
15
+ STATE_OK: "OK",
16
+ STATE_WARNING: "WARNING",
17
+ STATE_CRITICAL: "CRITICAL",
18
+ STATE_UNKNOWN: "UNKNOWN",
19
+ }
20
+
21
+
22
+ def send_passive_check(
23
+ url: str,
24
+ user: str,
25
+ password: str,
26
+ status: Literal[0, 1, 2, 3],
27
+ host_name: str,
28
+ service_name: str,
29
+ text_output: str,
30
+ performance_data: Optional[str] = None,
31
+ ):
32
+ """
33
+ https://icinga.com/docs/icinga-2/latest/doc/12-icinga2-api/#process-check-result
34
+ """
35
+ client = Client(url=url, username=user, password=password)
36
+ return client.actions.process_check_result(
37
+ object_type="Service",
38
+ name="{}!{}".format(host_name, service_name),
39
+ exit_status=status,
40
+ plugin_output=text_output,
41
+ performance_data=performance_data,
42
+ )
@@ -0,0 +1,183 @@
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
+ _master_logger: Optional[logging.Logger]
33
+
34
+ def __init__(self, master_logger: Optional[logging.Logger] = None):
35
+ BufferingHandler.__init__(self, capacity=1000000)
36
+ self._master_logger = master_logger
37
+
38
+ @staticmethod
39
+ def _print(record: logging.LogRecord) -> None:
40
+ """
41
+ :param logging.LogRecord record: A record object.
42
+ """
43
+ level = record.levelname
44
+ # CRITICAL 50
45
+ # ERROR 40
46
+ # -> STDERR 35
47
+ # WARNING 30
48
+ # INFO 20
49
+ # DEBUG 10
50
+ # --> STDOUT 5
51
+ # NOTSET 0
52
+ attr = None
53
+ if level == "CRITICAL":
54
+ color = "red"
55
+ attr = "bold"
56
+ elif level == "ERROR":
57
+ color = "red"
58
+ elif level == "STDERR":
59
+ color = "red"
60
+ attr = "dark"
61
+ elif level == "WARNING":
62
+ color = "yellow"
63
+ elif level == "INFO":
64
+ color = "green"
65
+ elif level == "DEBUG":
66
+ color = "white"
67
+ elif level == "STDOUT":
68
+ color = "white"
69
+ attr = "dark"
70
+ elif level == "NOTSET":
71
+ color = "grey"
72
+ else:
73
+ color = "grey"
74
+
75
+ if attr:
76
+ reverse = ["reverse", attr]
77
+ normal = [attr]
78
+ else:
79
+ reverse = ["reverse"]
80
+ normal = []
81
+
82
+ if record.levelno >= STDERR:
83
+ stream = sys.stderr
84
+ else:
85
+ stream = sys.stdout
86
+
87
+ created = "{}_{:03d}".format(
88
+ time.strftime(DATEFMT, time.localtime(record.created)),
89
+ int(record.msecs),
90
+ )
91
+
92
+ print(
93
+ "{} {} {}".format(
94
+ created,
95
+ termcolor.colored(" {:<8} ".format(level), color, attrs=reverse),
96
+ termcolor.colored(record.msg, color, attrs=normal),
97
+ ),
98
+ file=stream,
99
+ )
100
+
101
+ def emit(self, record: logging.LogRecord) -> None:
102
+ """
103
+ :param record: A record object.
104
+ """
105
+ self.buffer.append(record)
106
+ if not self._master_logger:
107
+ self._print(record)
108
+ else:
109
+ self._master_logger.log(record.levelno, record.msg)
110
+ if self.shouldFlush(record):
111
+ self.flush()
112
+
113
+ @property
114
+ def stdout(self) -> str:
115
+ messages: List[str] = []
116
+ for record in self.buffer:
117
+ if record.levelname == "STDOUT":
118
+ messages.append(record.msg)
119
+ return "\n".join(messages)
120
+
121
+ @property
122
+ def stderr(self) -> str:
123
+ messages: List[str] = []
124
+ for record in self.buffer:
125
+ if record.levelname == "STDERR":
126
+ messages.append(record.msg)
127
+ return "\n".join(messages)
128
+
129
+ @property
130
+ def all_records(self) -> str:
131
+ """All log messages joined by line breaks."""
132
+ messages: List[str] = []
133
+ for record in self.buffer:
134
+ messages.append(self.format(record))
135
+ return "\n".join(messages)
136
+
137
+
138
+ class ExtendedLogger(logging.Logger):
139
+ def stdout(self, line: object, *args: Any, **kws: Any) -> None:
140
+ ...
141
+
142
+ def stderr(self, line: object, *args: Any, **kws: Any) -> None:
143
+ ...
144
+
145
+
146
+ def _log_stdout(self: ExtendedLogger, message: object, *args: Any, **kws: Any) -> None:
147
+ # Yes, logger takes its '*args' as 'args'.
148
+ self._log(STDOUT, message, args, **kws)
149
+
150
+
151
+ extendedLogger: ExtendedLogger = cast(ExtendedLogger, logging.Logger)
152
+
153
+
154
+ extendedLogger.stdout = _log_stdout # type: ignore
155
+
156
+
157
+ def _log_stderr(self: ExtendedLogger, message: object, *args: Any, **kws: Any) -> None:
158
+ # Yes, logger takes its '*args' as 'args'.
159
+ self._log(STDERR, message, args, **kws)
160
+
161
+
162
+ logging.Logger.stderr = _log_stderr # type: ignore
163
+
164
+
165
+ def setup_logging(
166
+ master_logger: Optional[logging.Logger] = None,
167
+ ) -> Tuple[ExtendedLogger, LoggingHandler]:
168
+ """Setup a fresh logger for each watch action.
169
+
170
+ :param master_logger: Forward all log messages to a master logger."""
171
+ logger = logging.getLogger(name=str(uuid.uuid1()))
172
+ formatter = logging.Formatter(fmt=LOGFMT, datefmt=DATEFMT)
173
+ handler = LoggingHandler(master_logger=master_logger)
174
+ handler.setFormatter(formatter)
175
+ # Show all log messages: use 1 instead of 0: because:
176
+ # From the documentation:
177
+ # When a logger is created, the level is set to NOTSET (which causes all
178
+ # messages to be processed when the logger is the root logger, or
179
+ # delegation to the parent when the logger is a non-root logger). Note that
180
+ # the root logger is created with level WARNING.
181
+ logger.setLevel(1)
182
+ logger.addHandler(handler)
183
+ 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,338 @@
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, Union
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
+ def _obj_to_str(self, attributes: List[str] = []) -> str:
26
+ if not attributes:
27
+ attributes = dir(self)
28
+ output: List[str] = []
29
+ for attribute in attributes:
30
+ if not attribute.startswith("_") and not callable(getattr(self, attribute)):
31
+ value = getattr(self, attribute)
32
+ if value:
33
+ value = textwrap.shorten(str(value), width=64)
34
+ value = value.replace("\n", " ")
35
+ output.append("{}: '{}'".format(attribute, value))
36
+ return "[{}] {}".format(self.__class__.__name__, ", ".join(output))
37
+
38
+
39
+ class MinimalMessageParams(TypedDict, total=False):
40
+ custom_message: str
41
+ """Custom message"""
42
+
43
+ prefix: str
44
+ """ Prefix of the report message."""
45
+
46
+ body: str
47
+ """ A longer report text."""
48
+
49
+ performance_data: Dict[str, Any]
50
+ """ A dictionary like
51
+ `{'perf_1': 1, 'perf_2': 'test'}`"""
52
+
53
+
54
+ class MessageParams(MinimalMessageParams, total=False):
55
+ status: Status
56
+ """ 0 (OK), 1 (WARNING), 2 (CRITICAL), 3 (UNKOWN): see
57
+ Nagios / Icinga monitoring status / state."""
58
+
59
+ service_name: str
60
+ """The name of the service."""
61
+
62
+ log_records: str
63
+ """Log records separated by new lines"""
64
+
65
+ processes: List["Process"]
66
+
67
+
68
+ class Message(BaseClass):
69
+ """
70
+ This message class bundles all available message data into an object. The
71
+ different reporters can choose which data they use.
72
+ """
73
+
74
+ _data: MessageParams
75
+
76
+ def __init__(self, **data: Unpack[MessageParams]):
77
+ self._data = data
78
+
79
+ def __str__(self):
80
+ return self._obj_to_str()
81
+
82
+ @property
83
+ def status(self) -> Literal[0, 1, 2, 3]:
84
+ """0 (OK), 1 (WARNING), 2 (CRITICAL), 3 (UNKOWN): see
85
+ Nagios / Icinga monitoring status / state."""
86
+ return self._data.get("status", 0)
87
+
88
+ @property
89
+ def status_text(self) -> str:
90
+ """The status as a text word like `OK`."""
91
+ return icinga.States[self.status]
92
+
93
+ @property
94
+ def service_name(self) -> str:
95
+ return self._data.get("service_name", "service_not_set")
96
+
97
+ @property
98
+ def performance_data(self) -> str:
99
+ """
100
+ :return: A concatenated string
101
+ """
102
+ performance_data = self._data.get("performance_data")
103
+ if performance_data:
104
+ pairs: List[str] = []
105
+ key: str
106
+ value: Any
107
+ for key, value in performance_data.items():
108
+ pairs.append("{!s}={!s}".format(key, value))
109
+ return " ".join(pairs)
110
+ return ""
111
+
112
+ @property
113
+ def custom_message(self) -> str:
114
+ return self._data.get("custom_message", "")
115
+
116
+ @property
117
+ def prefix(self) -> str:
118
+ return self._data.get("prefix", "[cwatcher]:")
119
+
120
+ @property
121
+ def message(self) -> str:
122
+ output: List[str] = []
123
+ if self.prefix:
124
+ output.append(self.prefix)
125
+
126
+ output.append(self.service_name.upper())
127
+ output.append(self.status_text)
128
+ if self.custom_message:
129
+ output.append("- {}".format(self.custom_message))
130
+ return " ".join(output)
131
+
132
+ @property
133
+ def message_monitoring(self) -> str:
134
+ """message + performance_data"""
135
+ output: List[str] = []
136
+ output.append(self.message)
137
+ if self.performance_data:
138
+ output.append("|")
139
+ output.append(self.performance_data)
140
+ return " ".join(output)
141
+
142
+ @property
143
+ def body(self) -> str:
144
+ """Text body for the e-mail message."""
145
+ output: List[str] = []
146
+ output.append("Host: {}".format(HOSTNAME))
147
+ output.append("User: {}".format(USERNAME))
148
+ output.append("Service name: {}".format(self.service_name))
149
+
150
+ if self.performance_data:
151
+ output.append("Performance data: {}".format(self.performance_data))
152
+
153
+ body: str = self._data.get("body", "")
154
+ if body:
155
+ output.append("")
156
+ output.append(body)
157
+
158
+ log_records = self._data.get("log_records", "")
159
+ if log_records:
160
+ output.append("")
161
+ output.append("Log records:")
162
+ output.append("")
163
+ output.append(log_records)
164
+
165
+ return "\n".join(output)
166
+
167
+ @property
168
+ def processes(self) -> Optional[str]:
169
+ output: List[str] = []
170
+ processes = self._data.get("processes")
171
+ if processes:
172
+ for process in processes:
173
+ output.append(" ".join(process.args_normalized))
174
+ if output:
175
+ return "({})".format("; ".join(output))
176
+ return None
177
+
178
+ @property
179
+ def user(self) -> str:
180
+ return "[user:{}]".format(USERNAME)
181
+
182
+
183
+ class BaseChannel(BaseClass, metaclass=abc.ABCMeta):
184
+ """Base class for all reporters"""
185
+
186
+ @abc.abstractmethod
187
+ def report(self, message: Message) -> None:
188
+ raise NotImplementedError("A reporter class must have a `report` " "method.")
189
+
190
+
191
+ class EmailChannel(BaseChannel):
192
+ """Send reports by e-mail."""
193
+
194
+ smtp_server: str
195
+ smtp_login: str
196
+ smtp_password: str
197
+ to_addr: str
198
+ from_addr: str
199
+ to_addr_critical: str
200
+
201
+ def __init__(
202
+ self,
203
+ smtp_server: str,
204
+ smtp_login: str,
205
+ smtp_password: str,
206
+ to_addr: str,
207
+ from_addr: str = "",
208
+ to_addr_critical: str = "",
209
+ ) -> None:
210
+ self.smtp_server = smtp_server
211
+ self.smtp_login = smtp_login
212
+ self.smtp_password = smtp_password
213
+ self.to_addr = to_addr
214
+ self.from_addr = from_addr
215
+ if not from_addr:
216
+ self.from_addr = "{0} <{1}@{0}>".format(HOSTNAME, USERNAME)
217
+ self.to_addr_critical = to_addr_critical
218
+
219
+ def __str__(self) -> str:
220
+ return self._obj_to_str(
221
+ [
222
+ "smtp_server",
223
+ "smtp_login",
224
+ "to_addr",
225
+ "from_addr",
226
+ ]
227
+ )
228
+
229
+ def report(self, message: Message) -> None:
230
+ """Send an e-mail message.
231
+
232
+ :param message: A message object.
233
+ """
234
+ if message.status == 2 and self.to_addr_critical:
235
+ to_addr = self.to_addr_critical
236
+ else:
237
+ to_addr = self.to_addr
238
+
239
+ send_email(
240
+ from_addr=self.from_addr,
241
+ to_addr=to_addr,
242
+ subject=message.message,
243
+ body=message.body,
244
+ smtp_login=self.smtp_login,
245
+ smtp_password=self.smtp_password,
246
+ smtp_server=self.smtp_server,
247
+ )
248
+
249
+
250
+ class IcingaChannel(BaseChannel):
251
+ url: str
252
+ user: str
253
+ password: str
254
+ service_name: str
255
+
256
+ def __init__(self, url: str, user: str, password: str, service_name: str) -> None:
257
+ self.url = url
258
+ self.user = user
259
+ self.password = password
260
+ self.service_name = service_name
261
+
262
+ def __str__(self) -> str:
263
+ # No password!
264
+ return self._obj_to_str(["url", "user", "service_name"])
265
+
266
+ def report(self, message: Message) -> None:
267
+ icinga.send_passive_check(
268
+ url=self.url,
269
+ user=self.user,
270
+ password=self.password,
271
+ status=message.status,
272
+ host_name=HOSTNAME,
273
+ service_name=message.service_name,
274
+ text_output=message.message,
275
+ performance_data=message.performance_data,
276
+ )
277
+
278
+
279
+ class BeepChannel(BaseChannel):
280
+ """Send beep sounds."""
281
+
282
+ cmd: Union[str, None]
283
+
284
+ def __init__(self) -> None:
285
+ self.cmd = shutil.which("beep")
286
+
287
+ def __str__(self) -> str:
288
+ # No password!
289
+ return self._obj_to_str(["cmd"])
290
+
291
+ def beep(self, frequency: float = 4186.01, length: float = 50) -> None:
292
+ """
293
+ Generate a beep sound using the “beep” command.
294
+
295
+ * A success tone: frequency=4186.01, length=40
296
+ * A failure tone: frequency=65.4064, length=100
297
+
298
+ :param frequency: Frequency in Hz.
299
+ :param length: Length in milliseconds.
300
+ """
301
+ # TODO: Use self.cmd -> Fix tests
302
+ subprocess.run(["beep", "-f", str(float(frequency)), "-l", str(float(length))])
303
+
304
+ def report(self, message: Message) -> None:
305
+ """Send a beep sounds.
306
+
307
+ :param message: A message object. The only attribute that takes an
308
+ effect is the status attribute (0-3).
309
+ """
310
+ if message.status == 0: # OK
311
+ self.beep(frequency=4186.01, length=50) # C8 (highest note)
312
+ elif message.status == 1: # WARNING
313
+ self.beep(frequency=261.626, length=100) # C4 (middle C)
314
+ elif message.status == 2: # CRITICAL
315
+ self.beep(frequency=65.4064, length=150) # C2 (low C)
316
+ elif message.status == 3: # UNKOWN
317
+ self.beep(frequency=32.7032, length=200) # C1
318
+
319
+
320
+ class Reporter:
321
+ """Collect all channels."""
322
+
323
+ channels: List[BaseChannel]
324
+
325
+ def __init__(self) -> None:
326
+ self.channels = []
327
+
328
+ def add_channel(self, channel: BaseChannel) -> None:
329
+ self.channels.append(channel)
330
+
331
+ def report(self, **data: Unpack[MessageParams]) -> Message:
332
+ message = Message(**data)
333
+ for channel in self.channels:
334
+ channel.report(message)
335
+ return message
336
+
337
+
338
+ reporter: Reporter = Reporter()
@@ -0,0 +1,25 @@
1
+ """Small utility functions."""
2
+
3
+ import os
4
+ import shutil
5
+ import stat
6
+ import urllib.request
7
+
8
+
9
+ def download(url: str, dest: str) -> None:
10
+ """Download a file and save it under a destination path.
11
+
12
+ :param url: The URL of the file to download.
13
+ :param dest: The path of the destination file.
14
+ """
15
+ with urllib.request.urlopen(url) as response, open(dest, "wb") as out_file:
16
+ shutil.copyfileobj(response, out_file)
17
+
18
+
19
+ def make_executable(path: str) -> None:
20
+ """Make a file executable.
21
+
22
+ :param path: The path of the file
23
+ """
24
+ st = os.stat(path)
25
+ os.chmod(path, st.st_mode | stat.S_IEXEC)
@@ -0,0 +1,35 @@
1
+ [tool.poetry]
2
+ name = "command_watcher"
3
+ version = "0.4.1"
4
+ description = "Module to watch the execution of shell scripts. Both streams (`stdout` and `stderr`) are captured."
5
+ authors = ["Josef Friedrich <josef@friedrich.rocks>"]
6
+ license = "MIT"
7
+ readme = "README.rst"
8
+ repository = "https://github.com/Josef-Friedrich/command-watcher"
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3 :: Only",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ ]
17
+
18
+ [tool.poetry.dependencies]
19
+ python = "^3.10"
20
+ conf2levels = "^0"
21
+ icinga2apic = "^0"
22
+ termcolor = "^1"
23
+ types-requests = "^2"
24
+ types-termcolor = "^1"
25
+ icinga2apic-stubs = "^0"
26
+
27
+ [tool.poetry.group.dev.dependencies]
28
+ pytest = "^8"
29
+ ruff = "^0"
30
+ stdout-stderr-capturing = "^0"
31
+ tox = "^4"
32
+
33
+ [build-system]
34
+ requires = ["poetry-core"]
35
+ build-backend = "poetry.core.masonry.api"