disermo 0.0.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.
- disermo-0.0.1/LICENSE +28 -0
- disermo-0.0.1/PKG-INFO +72 -0
- disermo-0.0.1/README.rst +50 -0
- disermo-0.0.1/disermo/__init__.py +7 -0
- disermo-0.0.1/disermo/checks/__init__.py +5 -0
- disermo-0.0.1/disermo/checks/base.py +83 -0
- disermo-0.0.1/disermo/checks/remote.py +123 -0
- disermo-0.0.1/disermo/checks/sensors.py +5 -0
- disermo-0.0.1/disermo/checks/storage.py +49 -0
- disermo-0.0.1/disermo/checks/system.py +5 -0
- disermo-0.0.1/disermo/constants.py +11 -0
- disermo-0.0.1/disermo/node.py +83 -0
- disermo-0.0.1/disermo/notifiers/__init__.py +3 -0
- disermo-0.0.1/disermo/notifiers/base.py +107 -0
- disermo-0.0.1/disermo/notifiers/email.py +64 -0
- disermo-0.0.1/disermo/notifiers/stream.py +25 -0
- disermo-0.0.1/disermo/storage/__init__.py +2 -0
- disermo-0.0.1/disermo/storage/base.py +72 -0
- disermo-0.0.1/disermo/storage/csv.py +47 -0
- disermo-0.0.1/disermo/utils.py +61 -0
- disermo-0.0.1/disermo.egg-info/PKG-INFO +72 -0
- disermo-0.0.1/disermo.egg-info/SOURCES.txt +27 -0
- disermo-0.0.1/disermo.egg-info/dependency_links.txt +1 -0
- disermo-0.0.1/disermo.egg-info/requires.txt +1 -0
- disermo-0.0.1/disermo.egg-info/top_level.txt +1 -0
- disermo-0.0.1/pyproject.toml +37 -0
- disermo-0.0.1/setup.cfg +4 -0
- disermo-0.0.1/tests/test_node.py +138 -0
- disermo-0.0.1/tests/test_utils.py +42 -0
disermo-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019-2026, Richard Terry
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
disermo-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: disermo
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Self hosted server monitoring
|
|
5
|
+
Author-email: Richard Terry <code@radiac.net>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/radiac/disermo
|
|
8
|
+
Project-URL: Documentation, https://disermo.readthedocs.io/en/latest/
|
|
9
|
+
Project-URL: Repository, https://github.com/radiac/disermo
|
|
10
|
+
Project-URL: Issues, https://github.com/radiac/disermo/issues
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/x-rst
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: typing_extensions
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
=======
|
|
24
|
+
Disermo
|
|
25
|
+
=======
|
|
26
|
+
|
|
27
|
+
Self hosted server monitoring.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Installation
|
|
31
|
+
============
|
|
32
|
+
|
|
33
|
+
Install from PyPI::
|
|
34
|
+
|
|
35
|
+
pip install disermo
|
|
36
|
+
|
|
37
|
+
This requires Python 3.8 or later.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Development
|
|
41
|
+
===========
|
|
42
|
+
|
|
43
|
+
During development, install in a virtual environment::
|
|
44
|
+
|
|
45
|
+
mkdir disermo
|
|
46
|
+
cd disermo
|
|
47
|
+
git clone <path-to-repo> repo
|
|
48
|
+
python3 -m venv venv
|
|
49
|
+
. venv/bin/activate
|
|
50
|
+
pip install pip-tools
|
|
51
|
+
cd repo
|
|
52
|
+
pip-sync
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Quickstart
|
|
56
|
+
==========
|
|
57
|
+
|
|
58
|
+
Create a Disermo script based on ``example.py``.
|
|
59
|
+
|
|
60
|
+
To run it::
|
|
61
|
+
|
|
62
|
+
/path/to/venv/bin/python /path/to/repo/example.py
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Tests
|
|
66
|
+
=====
|
|
67
|
+
|
|
68
|
+
To run tests::
|
|
69
|
+
|
|
70
|
+
cd disermo/repo
|
|
71
|
+
. ../venv/bin/activate
|
|
72
|
+
pytest --flake8 --mypy --cov=disermo --cov-report=term --cov-report=html
|
disermo-0.0.1/README.rst
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
=======
|
|
2
|
+
Disermo
|
|
3
|
+
=======
|
|
4
|
+
|
|
5
|
+
Self hosted server monitoring.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Installation
|
|
9
|
+
============
|
|
10
|
+
|
|
11
|
+
Install from PyPI::
|
|
12
|
+
|
|
13
|
+
pip install disermo
|
|
14
|
+
|
|
15
|
+
This requires Python 3.8 or later.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Development
|
|
19
|
+
===========
|
|
20
|
+
|
|
21
|
+
During development, install in a virtual environment::
|
|
22
|
+
|
|
23
|
+
mkdir disermo
|
|
24
|
+
cd disermo
|
|
25
|
+
git clone <path-to-repo> repo
|
|
26
|
+
python3 -m venv venv
|
|
27
|
+
. venv/bin/activate
|
|
28
|
+
pip install pip-tools
|
|
29
|
+
cd repo
|
|
30
|
+
pip-sync
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
Quickstart
|
|
34
|
+
==========
|
|
35
|
+
|
|
36
|
+
Create a Disermo script based on ``example.py``.
|
|
37
|
+
|
|
38
|
+
To run it::
|
|
39
|
+
|
|
40
|
+
/path/to/venv/bin/python /path/to/repo/example.py
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Tests
|
|
44
|
+
=====
|
|
45
|
+
|
|
46
|
+
To run tests::
|
|
47
|
+
|
|
48
|
+
cd disermo/repo
|
|
49
|
+
. ../venv/bin/activate
|
|
50
|
+
pytest --flake8 --mypy --cov=disermo --cov-report=term --cov-report=html
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checks
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from typing import List, Dict, Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from ..constants import Status
|
|
8
|
+
from ..notifiers import Notifier
|
|
9
|
+
from ..utils import camel_to_sentence
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
T = TypeVar('T', bound='Check')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Check:
|
|
16
|
+
uid: str
|
|
17
|
+
label: str
|
|
18
|
+
subchecks: List[Check]
|
|
19
|
+
notifiers: List[Notifier]
|
|
20
|
+
status: Status
|
|
21
|
+
data: Dict[str, Any]
|
|
22
|
+
|
|
23
|
+
def __init__(self, label: str = None) -> None:
|
|
24
|
+
if label is None:
|
|
25
|
+
label = self.default_label
|
|
26
|
+
self.label = label
|
|
27
|
+
self.uid = self.get_uid()
|
|
28
|
+
self.subchecks = []
|
|
29
|
+
self.notifiers = []
|
|
30
|
+
|
|
31
|
+
self.status = Status.DISABLED
|
|
32
|
+
self.data = {}
|
|
33
|
+
|
|
34
|
+
def __repr__(self):
|
|
35
|
+
return f'<{type(self).__name__}: {self.uid}>'
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def class_id(self) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Default the class ID to the class name, but expect subclasses to
|
|
41
|
+
override with a string
|
|
42
|
+
"""
|
|
43
|
+
return type(self).__name__.lower()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def default_label(self) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Default the label to the class name, but expect subclasses to override
|
|
49
|
+
with a string
|
|
50
|
+
"""
|
|
51
|
+
return camel_to_sentence(type(self).__name__)
|
|
52
|
+
|
|
53
|
+
def get_uid(self) -> str:
|
|
54
|
+
return f'{self.class_id}'
|
|
55
|
+
|
|
56
|
+
def add(self: T, *subchecks) -> T:
|
|
57
|
+
self.subchecks.extend(subchecks)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def run(self) -> Status:
|
|
61
|
+
"""
|
|
62
|
+
Run the check of this node, and of all its subchecks
|
|
63
|
+
|
|
64
|
+
The resulting status is the worst status for this node and all subnodes
|
|
65
|
+
"""
|
|
66
|
+
self.update()
|
|
67
|
+
worst: Status = self.status
|
|
68
|
+
for check in self.subchecks:
|
|
69
|
+
result: Status = check.run()
|
|
70
|
+
if result > worst:
|
|
71
|
+
worst = result
|
|
72
|
+
|
|
73
|
+
# The status of this node is the worst found
|
|
74
|
+
self.status = worst
|
|
75
|
+
return worst
|
|
76
|
+
|
|
77
|
+
def update(self) -> None:
|
|
78
|
+
self.status = Status.DISABLED
|
|
79
|
+
self.data = {}
|
|
80
|
+
|
|
81
|
+
def notify(self: T, *notifiers: Notifier) -> T:
|
|
82
|
+
self.notifiers.extend(notifiers)
|
|
83
|
+
return self
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checks which perform remote requests
|
|
3
|
+
"""
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from http.client import HTTPResponse
|
|
6
|
+
import socket
|
|
7
|
+
from typing import cast, Union
|
|
8
|
+
from urllib.request import urlopen
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
|
|
11
|
+
from ..constants import Status
|
|
12
|
+
from ..utils import Timer
|
|
13
|
+
from .base import Check
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Default timeout, in seconds
|
|
17
|
+
DEFAULT_TIMEOUT = 10
|
|
18
|
+
|
|
19
|
+
# Error code for failed connection
|
|
20
|
+
# This should be outside the standard HTTP error codes, so it is differentiated
|
|
21
|
+
STATUS_FAILED = 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Response:
|
|
26
|
+
"""
|
|
27
|
+
Wrapper for an urllib Response
|
|
28
|
+
"""
|
|
29
|
+
raw: Union[HTTPResponse, HTTPError]
|
|
30
|
+
_content: str = field(init=False, repr=False)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def code(self):
|
|
34
|
+
return self.raw.code
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def content(self):
|
|
38
|
+
if self._content is None:
|
|
39
|
+
self._content = self.raw.read().decode('utf-8')
|
|
40
|
+
return self._content
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Web(Check):
|
|
44
|
+
url: str
|
|
45
|
+
timeout: float
|
|
46
|
+
status_code: int = 200
|
|
47
|
+
content_contains: Union[str, None]
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
url: str,
|
|
52
|
+
label: str = None,
|
|
53
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
54
|
+
status_code: int = 200,
|
|
55
|
+
content_contains: str = None,
|
|
56
|
+
):
|
|
57
|
+
super().__init__(label)
|
|
58
|
+
self.url = url
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
self.status_code = status_code
|
|
61
|
+
self.content_contains = content_contains
|
|
62
|
+
|
|
63
|
+
def update(self) -> None:
|
|
64
|
+
# Clean data, assuming we're going to fail
|
|
65
|
+
self.data = {
|
|
66
|
+
'status': STATUS_FAILED,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Make the request
|
|
70
|
+
timer = Timer()
|
|
71
|
+
try:
|
|
72
|
+
raw_response: HTTPResponse = cast(
|
|
73
|
+
HTTPResponse,
|
|
74
|
+
urlopen(
|
|
75
|
+
self.url,
|
|
76
|
+
timeout=self.timeout,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
except HTTPError as e_response:
|
|
81
|
+
# HTTP error - should be expected, analyse below
|
|
82
|
+
response = Response(raw=e_response)
|
|
83
|
+
|
|
84
|
+
except URLError as e:
|
|
85
|
+
# Unable to connect - not expected, raise immediately
|
|
86
|
+
self.status = Status.ERROR
|
|
87
|
+
self.data['error'] = f'Could not reach server: {e.reason}'
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
# Most likely here is socket.timeout for HTTPS
|
|
92
|
+
self.status = Status.ERROR
|
|
93
|
+
self.data['error'] = f'Could not reach server: {e}'
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
else:
|
|
97
|
+
# Successful request
|
|
98
|
+
response = Response(raw=raw_response)
|
|
99
|
+
|
|
100
|
+
finally:
|
|
101
|
+
# All checks log their elapsed time
|
|
102
|
+
self.data['elapsed'] = timer.elapsed()
|
|
103
|
+
|
|
104
|
+
# Update status code to that returned by server
|
|
105
|
+
self.data['status'] = response.code
|
|
106
|
+
|
|
107
|
+
if response.code != self.status_code:
|
|
108
|
+
self.status = Status.ERROR
|
|
109
|
+
self.data['error'] = (
|
|
110
|
+
f'Expected status {self.status_code}, '
|
|
111
|
+
f'instead found {response.code}.'
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
self.content_contains and
|
|
117
|
+
self.content_contains not in response.content
|
|
118
|
+
):
|
|
119
|
+
self.status = Status.ERROR
|
|
120
|
+
self.data['error'] = 'Expected content not found'
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
self.status = Status.OK
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage checks
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from ..constants import Status
|
|
8
|
+
from .base import Check
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_WARNING = 0.1
|
|
12
|
+
DEFAULT_ERROR = 0.05
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FreeSpace(Check):
|
|
16
|
+
drive: str
|
|
17
|
+
warning: float
|
|
18
|
+
error: float
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
drive: str,
|
|
23
|
+
label: str = None,
|
|
24
|
+
warning: float = DEFAULT_WARNING,
|
|
25
|
+
error: float = DEFAULT_ERROR,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(label)
|
|
28
|
+
self.drive = drive
|
|
29
|
+
self.warning = warning
|
|
30
|
+
self.error = error
|
|
31
|
+
|
|
32
|
+
def update(self) -> None:
|
|
33
|
+
data = os.statvfs(self.drive)
|
|
34
|
+
total_bytes = data.f_frsize * data.f_blocks
|
|
35
|
+
# free_bytes = data.f_frsize * data.f_bfree
|
|
36
|
+
allowed_bytes = data.f_frsize * data.f_bavail
|
|
37
|
+
percent_free = allowed_bytes / total_bytes
|
|
38
|
+
|
|
39
|
+
self.data = {
|
|
40
|
+
'total': total_bytes,
|
|
41
|
+
'free': allowed_bytes,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if percent_free < self.error:
|
|
45
|
+
self.status = Status.ERROR
|
|
46
|
+
elif percent_free < self.warning:
|
|
47
|
+
self.status = Status.WARN
|
|
48
|
+
else:
|
|
49
|
+
self.status = Status.OK
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Node object
|
|
3
|
+
"""
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import List, DefaultDict, Iterator, Tuple
|
|
6
|
+
|
|
7
|
+
from .checks.base import Check
|
|
8
|
+
from .constants import Status
|
|
9
|
+
from .notifiers import Notifier
|
|
10
|
+
from .storage import Storage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Node(Check):
|
|
14
|
+
def check(self, storage: Storage) -> Status:
|
|
15
|
+
"""
|
|
16
|
+
Run the checks and notify
|
|
17
|
+
"""
|
|
18
|
+
status: Status = super().run()
|
|
19
|
+
|
|
20
|
+
# Collect flat list of all checks
|
|
21
|
+
checks: List[Check] = list(self.iter_flat_checks())
|
|
22
|
+
|
|
23
|
+
# Collect notifiers
|
|
24
|
+
notifiers: DefaultDict[Notifier, List[Check]] = defaultdict(list)
|
|
25
|
+
for check in checks:
|
|
26
|
+
for notifier in check.notifiers:
|
|
27
|
+
notifiers[notifier].append(check)
|
|
28
|
+
|
|
29
|
+
# Load storage so notifiers understand status context
|
|
30
|
+
storage.load()
|
|
31
|
+
|
|
32
|
+
# Call notifiers
|
|
33
|
+
for notifier, notifier_checks in notifiers.items():
|
|
34
|
+
notifier.process(self, storage, notifier_checks)
|
|
35
|
+
|
|
36
|
+
# Store statuses for trend spotting
|
|
37
|
+
for check in checks:
|
|
38
|
+
storage.set(check.uid, check.status)
|
|
39
|
+
storage.save()
|
|
40
|
+
|
|
41
|
+
return status
|
|
42
|
+
|
|
43
|
+
def iter_flat_checks(self) -> Iterator[Check]:
|
|
44
|
+
"""
|
|
45
|
+
Generator to return a flat list of the node tree, starting with self
|
|
46
|
+
"""
|
|
47
|
+
checks: List[Check] = [self]
|
|
48
|
+
while checks:
|
|
49
|
+
check = checks.pop(0)
|
|
50
|
+
checks = check.subchecks + checks
|
|
51
|
+
yield check
|
|
52
|
+
|
|
53
|
+
def iter_flat_depth_checks(self) -> Iterator[Tuple[Check, int]]:
|
|
54
|
+
"""
|
|
55
|
+
Generator to return a flat list of (check, depth) pairs of the node
|
|
56
|
+
tree, starting with self, where depth is a 0-indexed depth (0 is self)
|
|
57
|
+
"""
|
|
58
|
+
checks: List[Tuple[Check, int]] = [(self, 0)]
|
|
59
|
+
while checks:
|
|
60
|
+
check, depth = checks.pop(0)
|
|
61
|
+
checks = [
|
|
62
|
+
(subcheck, depth + 1)
|
|
63
|
+
for subcheck in check.subchecks
|
|
64
|
+
] + checks
|
|
65
|
+
yield (check, depth)
|
|
66
|
+
|
|
67
|
+
def iter_flat_labelled_checks(self) -> Iterator[Tuple[Check, List[str]]]:
|
|
68
|
+
"""
|
|
69
|
+
Generator to return a flat list of (check, labels) pairs of the node
|
|
70
|
+
tree, starting with self, where ``labels`` is a list of all parent
|
|
71
|
+
labels, in order oldest first
|
|
72
|
+
"""
|
|
73
|
+
checks: List[Tuple[Check, List[str]]] = [(self, [self.label])]
|
|
74
|
+
while checks:
|
|
75
|
+
# Look at next and search its children
|
|
76
|
+
check, label = checks.pop(0)
|
|
77
|
+
checks = [
|
|
78
|
+
(subcheck, label + [subcheck.label])
|
|
79
|
+
for subcheck in check.subchecks
|
|
80
|
+
] + checks
|
|
81
|
+
|
|
82
|
+
# See if we've found one
|
|
83
|
+
yield (check, label)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for all notifiers
|
|
3
|
+
"""
|
|
4
|
+
from typing import TYPE_CHECKING, List
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
7
|
+
from ..checks import Check
|
|
8
|
+
from ..constants import Status
|
|
9
|
+
from ..node import Node
|
|
10
|
+
from ..storage import Storage, GroupedStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_TREND_LIMIT = 5
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Notifier:
|
|
17
|
+
def process(
|
|
18
|
+
self,
|
|
19
|
+
node: 'Node',
|
|
20
|
+
storage: 'Storage',
|
|
21
|
+
checks: 'List[Check]',
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Process a set of checks
|
|
25
|
+
|
|
26
|
+
Filters the checks and passes notifiable ones on to send()
|
|
27
|
+
"""
|
|
28
|
+
# Get list of checks with something worth notifying
|
|
29
|
+
notifiable = [
|
|
30
|
+
check for check in checks
|
|
31
|
+
if self.test(storage, check)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Send the notification(s) out
|
|
35
|
+
self.send(node, notifiable)
|
|
36
|
+
|
|
37
|
+
def test(self, storage: 'Storage', check: 'Check') -> bool:
|
|
38
|
+
"""
|
|
39
|
+
Subclasses will perform tests to see if notification is required.
|
|
40
|
+
|
|
41
|
+
This base class will just pass all notifications through
|
|
42
|
+
"""
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def send(self, node: 'Node', checks: 'List[Check]') -> None:
|
|
46
|
+
raise NotImplementedError() # pragma: no cover
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TrendNotifier(Notifier):
|
|
50
|
+
after: int
|
|
51
|
+
|
|
52
|
+
def __init__(self, after: int = DEFAULT_TREND_LIMIT):
|
|
53
|
+
self.after = after
|
|
54
|
+
|
|
55
|
+
def test(self, storage: 'Storage', check: 'Check') -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Performs any tests to see if the notification is required.
|
|
58
|
+
|
|
59
|
+
Looking for a change which occurred {self.after} ago
|
|
60
|
+
"""
|
|
61
|
+
uid: str = check.uid
|
|
62
|
+
status: Status = check.status
|
|
63
|
+
generator: GroupedStatus = storage.get(key=uid, grouped=True)
|
|
64
|
+
|
|
65
|
+
# Get first (newest) status
|
|
66
|
+
first_status: Status
|
|
67
|
+
first_count: int
|
|
68
|
+
try:
|
|
69
|
+
first_status, first_count = next(generator)
|
|
70
|
+
except StopIteration:
|
|
71
|
+
# Nothing on the stack
|
|
72
|
+
if self.after <= 1:
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# See if this is starting or continuing a trend
|
|
77
|
+
if first_status == status:
|
|
78
|
+
if first_count >= self.after:
|
|
79
|
+
# Continuing trend - no change
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
elif first_count + 1 == self.after:
|
|
83
|
+
# This next one will start a new trend
|
|
84
|
+
# But what about the previous trend; is this actually a change?
|
|
85
|
+
past_status: Status
|
|
86
|
+
past_count: int
|
|
87
|
+
for past_status, past_count in generator:
|
|
88
|
+
if past_count >= self.after:
|
|
89
|
+
# Found the previous trend
|
|
90
|
+
if past_status == status:
|
|
91
|
+
# This is returning to an old trend - no change
|
|
92
|
+
return False
|
|
93
|
+
else:
|
|
94
|
+
# This is starting a new trend
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
# Haven't found a previous trend - this is starting a new one
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
else:
|
|
101
|
+
# Status doesn't match, it can't be a trend...
|
|
102
|
+
if self.after <= 1:
|
|
103
|
+
# ... unless we start a new trend immediately
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
# Have not started a new trend yet - either a new status, or not enough
|
|
107
|
+
return False
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email notifier
|
|
3
|
+
"""
|
|
4
|
+
from email.mime.text import MIMEText
|
|
5
|
+
import smtplib
|
|
6
|
+
from typing import TYPE_CHECKING, List, Tuple
|
|
7
|
+
|
|
8
|
+
from .base import TrendNotifier
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..checks import Check
|
|
12
|
+
from ..node import Node
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Email(TrendNotifier):
|
|
16
|
+
to_addr: str
|
|
17
|
+
from_addr: str
|
|
18
|
+
|
|
19
|
+
template_subject = '''[Disermo] {name} => {status}'''
|
|
20
|
+
template_body = '''{summary}'''
|
|
21
|
+
template_body_summary = '''{check}: {status}'''
|
|
22
|
+
|
|
23
|
+
def __init__(self, to_addr: str, from_addr: str, **kwargs) -> None:
|
|
24
|
+
super().__init__(**kwargs)
|
|
25
|
+
self.to_addr = to_addr
|
|
26
|
+
self.from_addr = from_addr
|
|
27
|
+
|
|
28
|
+
def send(self, node: 'Node', checks: 'List[Check]') -> None:
|
|
29
|
+
if not checks:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
subject = self.template_subject.format(
|
|
33
|
+
name=node.label,
|
|
34
|
+
status=node.status,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Generate full labels
|
|
38
|
+
found: List[Tuple[Check, List[str]]] = [
|
|
39
|
+
(check, label)
|
|
40
|
+
for check, label in node.iter_flat_labelled_checks()
|
|
41
|
+
if check in checks
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Render summary
|
|
45
|
+
summary = '\n'.join([
|
|
46
|
+
self.template_body_summary.format(
|
|
47
|
+
check=' > '.join(label),
|
|
48
|
+
status=check.status.name.title(),
|
|
49
|
+
)
|
|
50
|
+
for check, label in found
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
body = self.template_body.format(
|
|
54
|
+
summary=summary,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
msg = MIMEText(body)
|
|
58
|
+
msg['Subject'] = subject
|
|
59
|
+
msg['To'] = self.to_addr
|
|
60
|
+
msg['From'] = self.from_addr
|
|
61
|
+
|
|
62
|
+
s = smtplib.SMTP('localhost')
|
|
63
|
+
s.send_message(msg)
|
|
64
|
+
s.quit()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stream notifier
|
|
3
|
+
"""
|
|
4
|
+
from typing import TYPE_CHECKING, List, TextIO
|
|
5
|
+
|
|
6
|
+
from .base import Notifier
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
9
|
+
from ..checks import Check
|
|
10
|
+
from ..node import Node
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Stream(Notifier):
|
|
14
|
+
stream: TextIO
|
|
15
|
+
|
|
16
|
+
def __init__(self, stream: TextIO):
|
|
17
|
+
self.stream = stream
|
|
18
|
+
|
|
19
|
+
def send(self, node: 'Node', checks: 'List[Check]') -> None:
|
|
20
|
+
check: Check
|
|
21
|
+
depth: int
|
|
22
|
+
for check, depth in node.iter_flat_depth_checks():
|
|
23
|
+
self.stream.write(
|
|
24
|
+
f'{" " * depth}{check.label}: {check.status.name.title()}\n'
|
|
25
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage base
|
|
3
|
+
"""
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from typing import DefaultDict, List, Tuple, Iterator, overload
|
|
6
|
+
from typing_extensions import Literal
|
|
7
|
+
|
|
8
|
+
from ..constants import Status
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
GroupedStatus = Iterator[Tuple[Status, int]]
|
|
12
|
+
FlatStatus = Iterator[Status]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Storage:
|
|
16
|
+
"""
|
|
17
|
+
Store recent statuses
|
|
18
|
+
"""
|
|
19
|
+
path: str
|
|
20
|
+
data: DefaultDict[str, List[Tuple[Status, int]]]
|
|
21
|
+
max_memory: int
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_memory: int = 10) -> None:
|
|
24
|
+
self.data = defaultdict(list)
|
|
25
|
+
self.max_memory = max_memory
|
|
26
|
+
|
|
27
|
+
def load(self) -> None:
|
|
28
|
+
raise NotImplementedError() # pragma: no cover
|
|
29
|
+
|
|
30
|
+
def save(self) -> None:
|
|
31
|
+
raise NotImplementedError() # pragma: no cover
|
|
32
|
+
|
|
33
|
+
def set(self, key: str, status: Status) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Add a status to the memory
|
|
36
|
+
"""
|
|
37
|
+
count = 1
|
|
38
|
+
if self.data[key]:
|
|
39
|
+
last_status: Status
|
|
40
|
+
last_count: int
|
|
41
|
+
last_status, last_count = self.data[key][-1]
|
|
42
|
+
if last_status == status:
|
|
43
|
+
# Increment the last status rather than creating a new one
|
|
44
|
+
self.data[key].pop()
|
|
45
|
+
count = last_count + 1
|
|
46
|
+
|
|
47
|
+
self.data[key].append((status, count))
|
|
48
|
+
|
|
49
|
+
# Assert max memory
|
|
50
|
+
if len(self.data[key]) > self.max_memory:
|
|
51
|
+
self.data[key] = self.data[key][-self.max_memory:]
|
|
52
|
+
|
|
53
|
+
@overload
|
|
54
|
+
def get(self, key: str, grouped: Literal[False]) -> FlatStatus:
|
|
55
|
+
pass # pragma: no cover
|
|
56
|
+
|
|
57
|
+
@overload # noqa: F811 until flake8 supports PEP 484
|
|
58
|
+
def get(self, key: str, grouped: Literal[True]) -> GroupedStatus:
|
|
59
|
+
pass # pragma: no cover
|
|
60
|
+
|
|
61
|
+
def get(self, key, grouped=False): # noqa: E501,F811 until flake8 supports PEP 484
|
|
62
|
+
"""
|
|
63
|
+
Generator to return stored statuses, with most recent first
|
|
64
|
+
"""
|
|
65
|
+
status: Status
|
|
66
|
+
count: int
|
|
67
|
+
for status, count in reversed(self.data[key]):
|
|
68
|
+
if grouped:
|
|
69
|
+
yield status, count
|
|
70
|
+
else:
|
|
71
|
+
for i in range(count):
|
|
72
|
+
yield status
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CSV storage
|
|
3
|
+
"""
|
|
4
|
+
import csv
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from ..constants import Status
|
|
8
|
+
from .base import Storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CSV(Storage):
|
|
12
|
+
"""
|
|
13
|
+
Store records in CSV format:
|
|
14
|
+
|
|
15
|
+
key,status|count,status|count,...
|
|
16
|
+
|
|
17
|
+
with oldest status first
|
|
18
|
+
"""
|
|
19
|
+
SEPARATOR = '|'
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: str, max_memory: int = 10) -> None:
|
|
22
|
+
super().__init__(max_memory=max_memory)
|
|
23
|
+
self.path = path
|
|
24
|
+
|
|
25
|
+
def load(self) -> None:
|
|
26
|
+
if not os.path.exists(self.path):
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
with open(self.path, 'r') as file:
|
|
30
|
+
reader = csv.reader(file)
|
|
31
|
+
for row in reader:
|
|
32
|
+
self.data[row[0]] = [
|
|
33
|
+
(Status(int(status)), int(count))
|
|
34
|
+
for (status, count) in [
|
|
35
|
+
cell.split(self.SEPARATOR, 1)
|
|
36
|
+
for cell in row[1:]
|
|
37
|
+
]
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
def save(self) -> None:
|
|
41
|
+
with open(self.path, 'w') as file:
|
|
42
|
+
writer = csv.writer(file)
|
|
43
|
+
for key, values in self.data.items():
|
|
44
|
+
writer.writerow([key] + [
|
|
45
|
+
f'{status.value}{self.SEPARATOR}{count}'
|
|
46
|
+
for status, count in values
|
|
47
|
+
])
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic utility functions
|
|
3
|
+
"""
|
|
4
|
+
from enum import Enum
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_re_camel_to_sentence = re.compile(r'([A-Z]+(?=[A-Z][a-z]|$)|[A-Z][a-z])')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OrderedEnum(Enum): # pragma: no cover - comes from python docs
|
|
13
|
+
"""
|
|
14
|
+
Ordered enum to allow value comparison, from python docs
|
|
15
|
+
"""
|
|
16
|
+
def __ge__(self, other):
|
|
17
|
+
if self.__class__ is other.__class__:
|
|
18
|
+
return self.value >= other.value
|
|
19
|
+
return NotImplemented
|
|
20
|
+
|
|
21
|
+
def __gt__(self, other):
|
|
22
|
+
if self.__class__ is other.__class__:
|
|
23
|
+
return self.value > other.value
|
|
24
|
+
return NotImplemented
|
|
25
|
+
|
|
26
|
+
def __le__(self, other):
|
|
27
|
+
if self.__class__ is other.__class__:
|
|
28
|
+
return self.value <= other.value
|
|
29
|
+
return NotImplemented
|
|
30
|
+
|
|
31
|
+
def __lt__(self, other):
|
|
32
|
+
if self.__class__ is other.__class__:
|
|
33
|
+
return self.value < other.value
|
|
34
|
+
return NotImplemented
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def camel_to_sentence(val: str) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Given a camelCase, convert it into Sentence case
|
|
40
|
+
|
|
41
|
+
It is acroynm-aware
|
|
42
|
+
"""
|
|
43
|
+
if not val:
|
|
44
|
+
return ''
|
|
45
|
+
|
|
46
|
+
words = [
|
|
47
|
+
word if all([char.isupper() for char in word]) else word.lower()
|
|
48
|
+
for word in _re_camel_to_sentence.sub(r' \1', val).split(' ')
|
|
49
|
+
if word
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
sentence = ' '.join(words)
|
|
53
|
+
return f'{sentence[0].upper()}{sentence[1:]}'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Timer:
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self.start = time.time()
|
|
59
|
+
|
|
60
|
+
def elapsed(self):
|
|
61
|
+
return time.time() - self.start
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: disermo
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Self hosted server monitoring
|
|
5
|
+
Author-email: Richard Terry <code@radiac.net>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/radiac/disermo
|
|
8
|
+
Project-URL: Documentation, https://disermo.readthedocs.io/en/latest/
|
|
9
|
+
Project-URL: Repository, https://github.com/radiac/disermo
|
|
10
|
+
Project-URL: Issues, https://github.com/radiac/disermo/issues
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/x-rst
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: typing_extensions
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
=======
|
|
24
|
+
Disermo
|
|
25
|
+
=======
|
|
26
|
+
|
|
27
|
+
Self hosted server monitoring.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Installation
|
|
31
|
+
============
|
|
32
|
+
|
|
33
|
+
Install from PyPI::
|
|
34
|
+
|
|
35
|
+
pip install disermo
|
|
36
|
+
|
|
37
|
+
This requires Python 3.8 or later.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Development
|
|
41
|
+
===========
|
|
42
|
+
|
|
43
|
+
During development, install in a virtual environment::
|
|
44
|
+
|
|
45
|
+
mkdir disermo
|
|
46
|
+
cd disermo
|
|
47
|
+
git clone <path-to-repo> repo
|
|
48
|
+
python3 -m venv venv
|
|
49
|
+
. venv/bin/activate
|
|
50
|
+
pip install pip-tools
|
|
51
|
+
cd repo
|
|
52
|
+
pip-sync
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Quickstart
|
|
56
|
+
==========
|
|
57
|
+
|
|
58
|
+
Create a Disermo script based on ``example.py``.
|
|
59
|
+
|
|
60
|
+
To run it::
|
|
61
|
+
|
|
62
|
+
/path/to/venv/bin/python /path/to/repo/example.py
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Tests
|
|
66
|
+
=====
|
|
67
|
+
|
|
68
|
+
To run tests::
|
|
69
|
+
|
|
70
|
+
cd disermo/repo
|
|
71
|
+
. ../venv/bin/activate
|
|
72
|
+
pytest --flake8 --mypy --cov=disermo --cov-report=term --cov-report=html
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.rst
|
|
3
|
+
pyproject.toml
|
|
4
|
+
disermo/__init__.py
|
|
5
|
+
disermo/constants.py
|
|
6
|
+
disermo/node.py
|
|
7
|
+
disermo/utils.py
|
|
8
|
+
disermo.egg-info/PKG-INFO
|
|
9
|
+
disermo.egg-info/SOURCES.txt
|
|
10
|
+
disermo.egg-info/dependency_links.txt
|
|
11
|
+
disermo.egg-info/requires.txt
|
|
12
|
+
disermo.egg-info/top_level.txt
|
|
13
|
+
disermo/checks/__init__.py
|
|
14
|
+
disermo/checks/base.py
|
|
15
|
+
disermo/checks/remote.py
|
|
16
|
+
disermo/checks/sensors.py
|
|
17
|
+
disermo/checks/storage.py
|
|
18
|
+
disermo/checks/system.py
|
|
19
|
+
disermo/notifiers/__init__.py
|
|
20
|
+
disermo/notifiers/base.py
|
|
21
|
+
disermo/notifiers/email.py
|
|
22
|
+
disermo/notifiers/stream.py
|
|
23
|
+
disermo/storage/__init__.py
|
|
24
|
+
disermo/storage/base.py
|
|
25
|
+
disermo/storage/csv.py
|
|
26
|
+
tests/test_node.py
|
|
27
|
+
tests/test_utils.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
typing_extensions
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
disermo
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "disermo"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Self hosted server monitoring"
|
|
9
|
+
readme = "README.rst"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"typing_extensions",
|
|
13
|
+
]
|
|
14
|
+
license = "BSD-3-Clause"
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "Richard Terry", email = "code@radiac.net" },
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: System Administrators",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: System :: Monitoring",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/radiac/disermo"
|
|
29
|
+
Documentation = "https://disermo.readthedocs.io/en/latest/"
|
|
30
|
+
Repository = "https://github.com/radiac/disermo"
|
|
31
|
+
Issues = "https://github.com/radiac/disermo/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.dynamic]
|
|
34
|
+
version = { attr = "disermo.__version__" }
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
include = ["disermo*"]
|
disermo-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test disermo/node.py
|
|
3
|
+
"""
|
|
4
|
+
from io import StringIO
|
|
5
|
+
|
|
6
|
+
from disermo.checks.base import Check
|
|
7
|
+
from disermo.constants import Status
|
|
8
|
+
from disermo.node import Node
|
|
9
|
+
from disermo.notifiers.stream import Stream
|
|
10
|
+
from disermo.storage.base import Storage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MockCheck(Check):
|
|
14
|
+
mock_status = Status.DISABLED
|
|
15
|
+
|
|
16
|
+
def get_uid(self):
|
|
17
|
+
return self.label
|
|
18
|
+
|
|
19
|
+
def update(self):
|
|
20
|
+
self.status = self.mock_status
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MockStorage(Storage):
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
self.loaded = False
|
|
27
|
+
self.saved = False
|
|
28
|
+
|
|
29
|
+
def load(self) -> None:
|
|
30
|
+
self.loaded = True
|
|
31
|
+
|
|
32
|
+
def save(self) -> None:
|
|
33
|
+
self.saved = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MockNotifier(Stream):
|
|
37
|
+
def __init__(self):
|
|
38
|
+
super().__init__(StringIO())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def gen_tree():
|
|
42
|
+
"""
|
|
43
|
+
Generate following check tree:
|
|
44
|
+
|
|
45
|
+
node
|
|
46
|
+
check 0
|
|
47
|
+
check 1
|
|
48
|
+
check 2
|
|
49
|
+
check 3
|
|
50
|
+
check 4
|
|
51
|
+
check 5
|
|
52
|
+
"""
|
|
53
|
+
check0 = MockCheck('check 0')
|
|
54
|
+
check1 = MockCheck('check 1')
|
|
55
|
+
check2 = MockCheck('check 2')
|
|
56
|
+
check3 = MockCheck('check 3')
|
|
57
|
+
check4 = MockCheck('check 4')
|
|
58
|
+
check5 = MockCheck('check 5')
|
|
59
|
+
node = Node('node')
|
|
60
|
+
node.add(check0)
|
|
61
|
+
node.add(check1)
|
|
62
|
+
check1.add(check2)
|
|
63
|
+
node.add(check3)
|
|
64
|
+
check3.add(check4)
|
|
65
|
+
check4.add(check5)
|
|
66
|
+
return node, [check0, check1, check2, check3, check4, check5]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_node_children():
|
|
70
|
+
node, checks = gen_tree()
|
|
71
|
+
assert node.subchecks == [checks[0], checks[1], checks[3]]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_iter_flat_checks__tree_is_flattened():
|
|
75
|
+
node, checks = gen_tree()
|
|
76
|
+
assert list(node.iter_flat_checks()) == [node] + checks
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_iter_flat_depth_checks__tree_is_flattened_with_depths():
|
|
80
|
+
node, checks = gen_tree()
|
|
81
|
+
assert list(node.iter_flat_depth_checks()) == [
|
|
82
|
+
(node, 0),
|
|
83
|
+
(checks[0], 1),
|
|
84
|
+
(checks[1], 1),
|
|
85
|
+
(checks[2], 2),
|
|
86
|
+
(checks[3], 1),
|
|
87
|
+
(checks[4], 2),
|
|
88
|
+
(checks[5], 3),
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_iter_flat_labelled_checks__tree_is_flattened_with_labels():
|
|
93
|
+
node, checks = gen_tree()
|
|
94
|
+
assert list(node.iter_flat_labelled_checks()) == [
|
|
95
|
+
(node, ['node']),
|
|
96
|
+
(checks[0], ['node', 'check 0']),
|
|
97
|
+
(checks[1], ['node', 'check 1']),
|
|
98
|
+
(checks[2], ['node', 'check 1', 'check 2']),
|
|
99
|
+
(checks[3], ['node', 'check 3']),
|
|
100
|
+
(checks[4], ['node', 'check 3', 'check 4']),
|
|
101
|
+
(checks[5], ['node', 'check 3', 'check 4', 'check 5']),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_check():
|
|
106
|
+
# Set up tree with notifiers on each node
|
|
107
|
+
node, checks = gen_tree()
|
|
108
|
+
notifierNode = MockNotifier()
|
|
109
|
+
notifiers = [MockNotifier()] * 6
|
|
110
|
+
node.notify(notifierNode)
|
|
111
|
+
for i in range(6):
|
|
112
|
+
checks[i].notify(notifiers[i])
|
|
113
|
+
storage = MockStorage()
|
|
114
|
+
|
|
115
|
+
node.check(storage)
|
|
116
|
+
|
|
117
|
+
assert storage.data == {
|
|
118
|
+
'node': [(Status.DISABLED, 1)],
|
|
119
|
+
'check 0': [(Status.DISABLED, 1)],
|
|
120
|
+
'check 1': [(Status.DISABLED, 1)],
|
|
121
|
+
'check 2': [(Status.DISABLED, 1)],
|
|
122
|
+
'check 3': [(Status.DISABLED, 1)],
|
|
123
|
+
'check 4': [(Status.DISABLED, 1)],
|
|
124
|
+
'check 5': [(Status.DISABLED, 1)],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
notification = (
|
|
128
|
+
'node: Disabled\n'
|
|
129
|
+
' check 0: Disabled\n'
|
|
130
|
+
' check 1: Disabled\n'
|
|
131
|
+
' check 2: Disabled\n'
|
|
132
|
+
' check 3: Disabled\n'
|
|
133
|
+
' check 4: Disabled\n'
|
|
134
|
+
' check 5: Disabled\n'
|
|
135
|
+
)
|
|
136
|
+
assert notifierNode.stream.getvalue() == notification
|
|
137
|
+
for i in range(6):
|
|
138
|
+
assert notifiers[i].stream.getvalue() == notification
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test disermo/util.py
|
|
3
|
+
"""
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
from disermo.utils import camel_to_sentence, Timer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestCamelToSentence:
|
|
10
|
+
def test_none(self):
|
|
11
|
+
# Invalid when type checking, but test it anyway
|
|
12
|
+
assert camel_to_sentence(None) == ''
|
|
13
|
+
|
|
14
|
+
def test_empty(self):
|
|
15
|
+
assert camel_to_sentence('') == ''
|
|
16
|
+
|
|
17
|
+
def test_word(self):
|
|
18
|
+
assert camel_to_sentence('word') == 'Word'
|
|
19
|
+
|
|
20
|
+
def test_two_words(self):
|
|
21
|
+
assert camel_to_sentence('twoWords') == 'Two words'
|
|
22
|
+
|
|
23
|
+
def test_first_capitalised(self):
|
|
24
|
+
assert camel_to_sentence('TwoWords') == 'Two words'
|
|
25
|
+
|
|
26
|
+
def test_many_words(self):
|
|
27
|
+
assert camel_to_sentence('thisHasManyWords') == 'This has many words'
|
|
28
|
+
|
|
29
|
+
def test_acroynm_start(self):
|
|
30
|
+
assert camel_to_sentence('HTTPServer') == 'HTTP server'
|
|
31
|
+
|
|
32
|
+
def test_acroynm_middle(self):
|
|
33
|
+
assert camel_to_sentence('theHTTPServer') == 'The HTTP server'
|
|
34
|
+
|
|
35
|
+
def test_acroynm_end(self):
|
|
36
|
+
assert camel_to_sentence('servesHTTP') == 'Serves HTTP'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mock.patch('time.time', mock.MagicMock(side_effect=[10.0, 20.0]))
|
|
40
|
+
def test_timer():
|
|
41
|
+
timer = Timer()
|
|
42
|
+
assert timer.elapsed() == 10.0
|