embedded-lab-control 0.2.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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/core/safe_open.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Open host paths without following symlinks, for check-then-use safety.
|
|
3
|
+
|
|
4
|
+
The MCP host-path allowlist resolves a path and checks it against allowed
|
|
5
|
+
directories. If the file were then re-opened *by path* later (after a
|
|
6
|
+
power-off, a mux switch, a wait for the reader...), anyone able to write to
|
|
7
|
+
a directory on that path could swap in a symlink in between and redirect
|
|
8
|
+
the read or write; raw images are even read by ``sudo dd`` (root). These
|
|
9
|
+
helpers open the already-resolved path one component at a time with
|
|
10
|
+
``O_NOFOLLOW``, so a symlink anywhere on the path makes the open fail
|
|
11
|
+
instead of being followed. Callers then use the returned file descriptor,
|
|
12
|
+
never the path, for the actual I/O.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import errno
|
|
16
|
+
import fcntl
|
|
17
|
+
import os
|
|
18
|
+
import stat
|
|
19
|
+
|
|
20
|
+
# Intermediate directories need only search permission with O_PATH (Linux);
|
|
21
|
+
# fall back to O_RDONLY elsewhere. O_DIRECTORY|O_NOFOLLOW makes a symlinked
|
|
22
|
+
# component fail with ENOTDIR/ELOOP rather than be followed.
|
|
23
|
+
_WALK_FLAGS = getattr(os, "O_PATH", os.O_RDONLY) | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _components(path: str) -> list[str]:
|
|
27
|
+
if not os.path.isabs(path):
|
|
28
|
+
raise ValueError(f"path must be absolute: {path!r}")
|
|
29
|
+
parts = [p for p in path.split("/") if p]
|
|
30
|
+
if any(p in (".", "..") for p in parts):
|
|
31
|
+
raise OSError(errno.EINVAL, "path must be normalized (no '.' or '..')")
|
|
32
|
+
return parts
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _walk(parts: list[str], *, create: bool, mode: int) -> int:
|
|
36
|
+
"""Open the directory reached by ``parts`` from "/", no symlinks."""
|
|
37
|
+
fd = os.open("/", _WALK_FLAGS)
|
|
38
|
+
try:
|
|
39
|
+
for name in parts:
|
|
40
|
+
try:
|
|
41
|
+
nxt = os.open(name, _WALK_FLAGS, dir_fd=fd)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
if not create:
|
|
44
|
+
raise
|
|
45
|
+
try:
|
|
46
|
+
os.mkdir(name, mode, dir_fd=fd)
|
|
47
|
+
except FileExistsError:
|
|
48
|
+
pass # created concurrently; the open below re-checks it
|
|
49
|
+
nxt = os.open(name, _WALK_FLAGS, dir_fd=fd)
|
|
50
|
+
os.close(fd)
|
|
51
|
+
fd = nxt
|
|
52
|
+
except BaseException:
|
|
53
|
+
os.close(fd)
|
|
54
|
+
raise
|
|
55
|
+
return fd
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def open_regular_nonblocking(
|
|
59
|
+
name: str, flags: int, mode: int = 0o644, dir_fd: int | None = None
|
|
60
|
+
) -> int:
|
|
61
|
+
"""Open ``name`` (O_NOFOLLOW), insisting it is a regular file.
|
|
62
|
+
|
|
63
|
+
Opened with O_NONBLOCK so a FIFO planted in a shared directory can't
|
|
64
|
+
block the open (which would hang the caller while it holds locks);
|
|
65
|
+
anything that isn't a regular file is closed and refused with OSError;
|
|
66
|
+
O_NONBLOCK is then cleared for normal I/O.
|
|
67
|
+
"""
|
|
68
|
+
fd = os.open(name, flags | os.O_NOFOLLOW | os.O_NONBLOCK, mode, dir_fd=dir_fd)
|
|
69
|
+
try:
|
|
70
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
71
|
+
raise OSError(errno.EINVAL, f"not a regular file: {name}")
|
|
72
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) & ~os.O_NONBLOCK)
|
|
73
|
+
except BaseException:
|
|
74
|
+
os.close(fd)
|
|
75
|
+
raise
|
|
76
|
+
return fd
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def open_file_nofollow(path: str, flags: int = os.O_RDONLY, mode: int = 0o644) -> int:
|
|
80
|
+
"""Open the regular file at absolute, normalized ``path``, no symlinks.
|
|
81
|
+
|
|
82
|
+
Raises OSError (ELOOP/ENOTDIR) if any component is a symlink, and
|
|
83
|
+
(EINVAL) if the target isn't a regular file (e.g. a FIFO); never blocks
|
|
84
|
+
on opening a FIFO.
|
|
85
|
+
"""
|
|
86
|
+
parts = _components(path)
|
|
87
|
+
if not parts:
|
|
88
|
+
raise IsADirectoryError(errno.EISDIR, "path is the root directory")
|
|
89
|
+
parent = _walk(parts[:-1], create=False, mode=0o755)
|
|
90
|
+
try:
|
|
91
|
+
return open_regular_nonblocking(parts[-1], flags, mode, dir_fd=parent)
|
|
92
|
+
finally:
|
|
93
|
+
os.close(parent)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def open_dir_nofollow(path: str, *, create: bool = False, mode: int = 0o775) -> int:
|
|
97
|
+
"""Open (optionally creating) directory ``path`` with no symlink anywhere.
|
|
98
|
+
|
|
99
|
+
Returns an ``O_RDONLY | O_DIRECTORY`` descriptor usable as ``dir_fd``
|
|
100
|
+
for creating files inside it.
|
|
101
|
+
"""
|
|
102
|
+
parts = _components(path)
|
|
103
|
+
walked = _walk(parts, create=create, mode=mode)
|
|
104
|
+
try:
|
|
105
|
+
# Re-open "." relative to the walked handle as a regular directory
|
|
106
|
+
# fd (an O_PATH fd can't be used for everything on every kernel).
|
|
107
|
+
return os.open(".", os.O_RDONLY | os.O_DIRECTORY, dir_fd=walked)
|
|
108
|
+
finally:
|
|
109
|
+
os.close(walked)
|
labctl/extras.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Install hints for optional dependencies ("extras").
|
|
2
|
+
|
|
3
|
+
A bare ``pip install embedded-lab-control`` has only the core
|
|
4
|
+
dependencies; the web UI, MCP server, Kasa plugs, SDWire muxes and USB
|
|
5
|
+
relays each need an extra. Code that imports one of those packages
|
|
6
|
+
turns the ImportError into a one-line message naming the extra, built
|
|
7
|
+
here so every surface (CLI, REST, MCP) says the same thing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
DIST_NAME = "embedded-lab-control"
|
|
13
|
+
|
|
14
|
+
# extra -> what it enables. Keep in step with
|
|
15
|
+
# [project.optional-dependencies] in pyproject.toml; a test checks the
|
|
16
|
+
# names and extra_packages() against it.
|
|
17
|
+
EXTRAS: dict[str, str] = {
|
|
18
|
+
"web": "Web dashboard and REST API support",
|
|
19
|
+
"mcp": "MCP support",
|
|
20
|
+
"kasa": "Kasa power control",
|
|
21
|
+
"sdwire": "SDWire support",
|
|
22
|
+
"actuators": "USB relay support",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# The extras' requirements, as pyproject.toml declares them, for
|
|
26
|
+
# `pipx inject` (which installs packages, not extras).
|
|
27
|
+
_PACKAGES: dict[str, tuple[str, ...]] = {
|
|
28
|
+
"web": ("flask>=3.0", "flask-socketio>=5.0"),
|
|
29
|
+
"mcp": ("mcp>=2.2,<3",),
|
|
30
|
+
"sdwire": ("sdwire>=0.3",),
|
|
31
|
+
"actuators": ("pyserial>=3.5",),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MissingExtraError(RuntimeError):
|
|
36
|
+
"""An optional dependency is not installed.
|
|
37
|
+
|
|
38
|
+
A RuntimeError so existing ``except RuntimeError`` reporting (CLI
|
|
39
|
+
"Error: ...", REST/MCP error results) shows the hint unchanged.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, extra: str, what: str | None = None):
|
|
43
|
+
self.extra = extra
|
|
44
|
+
super().__init__(missing_extra_message(extra, what))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BrokenExtraError(RuntimeError):
|
|
48
|
+
"""An optional dependency is installed but fails to import (a
|
|
49
|
+
dependency of it is missing, or the version is incompatible).
|
|
50
|
+
|
|
51
|
+
A RuntimeError, like MissingExtraError, so callers report it the same
|
|
52
|
+
way, but with the real ImportError in the message.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, extra: str, error: ImportError, what: str | None = None):
|
|
56
|
+
self.extra = extra
|
|
57
|
+
super().__init__(broken_extra_message(extra, error, what))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def broken_extra_message(
|
|
61
|
+
extra: str, error: ImportError, what: str | None = None
|
|
62
|
+
) -> str:
|
|
63
|
+
"""One line for an extra whose package is installed but won't import.
|
|
64
|
+
|
|
65
|
+
No install command: reinstalling brings back the same newest version
|
|
66
|
+
when that version is the incompatible one, and the right command
|
|
67
|
+
depends on how labctl was installed (pipx, a venv, a git checkout).
|
|
68
|
+
"""
|
|
69
|
+
what = what or EXTRAS[extra]
|
|
70
|
+
packages = ", ".join(extra_packages(extra))
|
|
71
|
+
return (
|
|
72
|
+
f"{what} is installed but failed to import ({error}): a dependency "
|
|
73
|
+
f"is missing or the installed version is incompatible (the [{extra}] "
|
|
74
|
+
f"extra wants {packages})"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extra_packages(extra: str) -> tuple[str, ...]:
|
|
79
|
+
"""The extra's requirements for the running interpreter."""
|
|
80
|
+
if extra == "kasa":
|
|
81
|
+
# python-kasa >= 0.8 needs Python 3.11; 3.10 stays on 0.7.x.
|
|
82
|
+
if sys.version_info >= (3, 11):
|
|
83
|
+
return ("python-kasa>=0.12",)
|
|
84
|
+
return ("python-kasa>=0.7.7,<0.8",)
|
|
85
|
+
return _PACKAGES[extra]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def missing_extra_message(extra: str, what: str | None = None) -> str:
|
|
89
|
+
"""One line: what is missing and how to install the extra.
|
|
90
|
+
|
|
91
|
+
``what`` overrides the extra's description, for a command that
|
|
92
|
+
needs only part of it (e.g. password hashing from [web]).
|
|
93
|
+
"""
|
|
94
|
+
what = what or EXTRAS[extra]
|
|
95
|
+
if extra == "sdwire" and sys.version_info < (3, 12):
|
|
96
|
+
# PyPI sdwire requires Python >= 3.12: on older interpreters the
|
|
97
|
+
# extra installs nothing, so no install command here would work.
|
|
98
|
+
running = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
99
|
+
return (
|
|
100
|
+
f"{what} needs Python 3.12+ (this is Python {running}); the "
|
|
101
|
+
f"[sdwire] extra installs nothing here. Install labctl under "
|
|
102
|
+
f"Python 3.12 or newer to use SDWire"
|
|
103
|
+
)
|
|
104
|
+
pipx = " ".join(f'"{p}"' for p in extra_packages(extra))
|
|
105
|
+
message = (
|
|
106
|
+
f"{what} is not installed. Install the [{extra}] extra: "
|
|
107
|
+
f'pip install "{DIST_NAME}[{extra}]" '
|
|
108
|
+
f"(pipx: pipx inject {DIST_NAME} {pipx})"
|
|
109
|
+
)
|
|
110
|
+
if extra == "sdwire":
|
|
111
|
+
message += "; sdwire needs Python 3.12+"
|
|
112
|
+
return message
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_missing(error: ImportError, *modules: str) -> bool:
|
|
116
|
+
"""True when ``error`` says one of ``modules`` is not installed.
|
|
117
|
+
|
|
118
|
+
Lets callers translate only the optional package's absence. Any
|
|
119
|
+
other ImportError, including ``cannot import name`` from an
|
|
120
|
+
installed but incompatible version (a plain ImportError, not
|
|
121
|
+
ModuleNotFoundError), keeps its real cause, as does a missing
|
|
122
|
+
submodule (``kasa.iot``): the package is there, but not a version
|
|
123
|
+
labctl can use.
|
|
124
|
+
"""
|
|
125
|
+
if not isinstance(error, ModuleNotFoundError):
|
|
126
|
+
return False
|
|
127
|
+
name = error.name or ""
|
|
128
|
+
return name in modules
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health monitoring module for lab controller.
|
|
3
|
+
|
|
4
|
+
Provides health checks, status tracking, and alerting for SBCs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from labctl.health.alerts import (
|
|
8
|
+
Alert,
|
|
9
|
+
AlertHandler,
|
|
10
|
+
AlertLevel,
|
|
11
|
+
AlertManager,
|
|
12
|
+
ConsoleAlertHandler,
|
|
13
|
+
EmailAlertHandler,
|
|
14
|
+
LogAlertHandler,
|
|
15
|
+
SlackAlertHandler,
|
|
16
|
+
)
|
|
17
|
+
from labctl.health.checks import (
|
|
18
|
+
CheckResult,
|
|
19
|
+
CheckType,
|
|
20
|
+
HealthChecker,
|
|
21
|
+
HealthCheckSummary,
|
|
22
|
+
)
|
|
23
|
+
from labctl.health.daemon import MonitorDaemon, format_check_table
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# Checks
|
|
27
|
+
"CheckType",
|
|
28
|
+
"CheckResult",
|
|
29
|
+
"HealthChecker",
|
|
30
|
+
"HealthCheckSummary",
|
|
31
|
+
# Alerts
|
|
32
|
+
"Alert",
|
|
33
|
+
"AlertLevel",
|
|
34
|
+
"AlertHandler",
|
|
35
|
+
"AlertManager",
|
|
36
|
+
"LogAlertHandler",
|
|
37
|
+
"ConsoleAlertHandler",
|
|
38
|
+
"EmailAlertHandler",
|
|
39
|
+
"SlackAlertHandler",
|
|
40
|
+
# Daemon
|
|
41
|
+
"MonitorDaemon",
|
|
42
|
+
"format_check_table",
|
|
43
|
+
]
|
labctl/health/alerts.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Alerting framework for lab controller.
|
|
3
|
+
|
|
4
|
+
Provides alert handling with pluggable handlers for different notification channels.
|
|
5
|
+
Currently implements log file output; email and Slack are stubs for future.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AlertLevel(Enum):
|
|
20
|
+
"""Alert severity levels."""
|
|
21
|
+
|
|
22
|
+
INFO = "info"
|
|
23
|
+
WARNING = "warning"
|
|
24
|
+
CRITICAL = "critical"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Alert:
|
|
29
|
+
"""An alert to be sent to handlers."""
|
|
30
|
+
|
|
31
|
+
level: AlertLevel
|
|
32
|
+
sbc_name: str
|
|
33
|
+
message: str
|
|
34
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
35
|
+
details: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
def format(self) -> str:
|
|
38
|
+
"""Format alert as a string."""
|
|
39
|
+
ts = self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
40
|
+
level = self.level.value.upper()
|
|
41
|
+
msg = f"[{ts}] [{level}] {self.sbc_name}: {self.message}"
|
|
42
|
+
if self.details:
|
|
43
|
+
msg += f" - {self.details}"
|
|
44
|
+
return msg
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AlertHandler(ABC):
|
|
48
|
+
"""Abstract base class for alert handlers."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def send(self, alert: Alert) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Send an alert.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
alert: Alert to send
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True if alert was sent successfully
|
|
60
|
+
"""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
"""Clean up handler resources."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LogAlertHandler(AlertHandler):
|
|
70
|
+
"""Alert handler that writes to a log file."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, log_path: Path):
|
|
73
|
+
"""
|
|
74
|
+
Initialize log alert handler.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
log_path: Path to the alert log file
|
|
78
|
+
"""
|
|
79
|
+
self.log_path = log_path
|
|
80
|
+
self._file_handle: Optional[object] = None
|
|
81
|
+
self._ensure_log_dir()
|
|
82
|
+
|
|
83
|
+
def _ensure_log_dir(self) -> None:
|
|
84
|
+
"""Ensure the log directory exists."""
|
|
85
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
def send(self, alert: Alert) -> bool:
|
|
88
|
+
"""Write alert to log file."""
|
|
89
|
+
try:
|
|
90
|
+
with open(self.log_path, "a") as f:
|
|
91
|
+
f.write(alert.format() + "\n")
|
|
92
|
+
return True
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(f"Failed to write alert to log: {e}")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
"""No cleanup needed for file-based logging."""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ConsoleAlertHandler(AlertHandler):
|
|
103
|
+
"""Alert handler that prints to console."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, min_level: AlertLevel = AlertLevel.INFO):
|
|
106
|
+
"""
|
|
107
|
+
Initialize console alert handler.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
min_level: Minimum alert level to display
|
|
111
|
+
"""
|
|
112
|
+
self.min_level = min_level
|
|
113
|
+
self._level_order = {
|
|
114
|
+
AlertLevel.INFO: 0,
|
|
115
|
+
AlertLevel.WARNING: 1,
|
|
116
|
+
AlertLevel.CRITICAL: 2,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def send(self, alert: Alert) -> bool:
|
|
120
|
+
"""Print alert to console if level meets threshold."""
|
|
121
|
+
if self._level_order[alert.level] >= self._level_order[self.min_level]:
|
|
122
|
+
# Color codes for different levels
|
|
123
|
+
colors = {
|
|
124
|
+
AlertLevel.INFO: "\033[32m", # Green
|
|
125
|
+
AlertLevel.WARNING: "\033[33m", # Yellow
|
|
126
|
+
AlertLevel.CRITICAL: "\033[31m", # Red
|
|
127
|
+
}
|
|
128
|
+
reset = "\033[0m"
|
|
129
|
+
color = colors.get(alert.level, "")
|
|
130
|
+
print(f"{color}{alert.format()}{reset}")
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
"""No cleanup needed for console output."""
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class EmailAlertHandler(AlertHandler):
|
|
139
|
+
"""
|
|
140
|
+
Placeholder for email alert handler.
|
|
141
|
+
|
|
142
|
+
This is a stub for future implementation.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
smtp_host: str = "",
|
|
148
|
+
smtp_port: int = 587,
|
|
149
|
+
sender: str = "",
|
|
150
|
+
recipients: Optional[list[str]] = None,
|
|
151
|
+
):
|
|
152
|
+
"""Initialize email handler (stub)."""
|
|
153
|
+
self.smtp_host = smtp_host
|
|
154
|
+
self.smtp_port = smtp_port
|
|
155
|
+
self.sender = sender
|
|
156
|
+
self.recipients = recipients or []
|
|
157
|
+
logger.warning("EmailAlertHandler is a stub - emails will not be sent")
|
|
158
|
+
|
|
159
|
+
def send(self, alert: Alert) -> bool:
|
|
160
|
+
"""Stub: Log that email would be sent."""
|
|
161
|
+
logger.debug(
|
|
162
|
+
f"Email alert (stub): Would send to {self.recipients}: {alert.format()}"
|
|
163
|
+
)
|
|
164
|
+
return False # Return False since we didn't actually send
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
"""No cleanup needed for stub."""
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SlackAlertHandler(AlertHandler):
|
|
172
|
+
"""
|
|
173
|
+
Placeholder for Slack webhook alert handler.
|
|
174
|
+
|
|
175
|
+
This is a stub for future implementation.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, webhook_url: str = ""):
|
|
179
|
+
"""Initialize Slack handler (stub)."""
|
|
180
|
+
self.webhook_url = webhook_url
|
|
181
|
+
logger.warning("SlackAlertHandler is a stub - Slack messages will not be sent")
|
|
182
|
+
|
|
183
|
+
def send(self, alert: Alert) -> bool:
|
|
184
|
+
"""Stub: Log that Slack message would be sent."""
|
|
185
|
+
logger.debug(f"Slack alert (stub): Would post to webhook: {alert.format()}")
|
|
186
|
+
return False # Return False since we didn't actually send
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
"""No cleanup needed for stub."""
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class AlertManager:
|
|
194
|
+
"""Manages multiple alert handlers and dispatches alerts."""
|
|
195
|
+
|
|
196
|
+
def __init__(self):
|
|
197
|
+
"""Initialize alert manager."""
|
|
198
|
+
self._handlers: list[AlertHandler] = []
|
|
199
|
+
|
|
200
|
+
def add_handler(self, handler: AlertHandler) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Register an alert handler.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
handler: Handler to add
|
|
206
|
+
"""
|
|
207
|
+
self._handlers.append(handler)
|
|
208
|
+
|
|
209
|
+
def remove_handler(self, handler: AlertHandler) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Remove an alert handler.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
handler: Handler to remove
|
|
215
|
+
"""
|
|
216
|
+
if handler in self._handlers:
|
|
217
|
+
self._handlers.remove(handler)
|
|
218
|
+
|
|
219
|
+
def trigger(self, alert: Alert) -> int:
|
|
220
|
+
"""
|
|
221
|
+
Send alert to all registered handlers.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
alert: Alert to send
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Number of handlers that successfully sent the alert
|
|
228
|
+
"""
|
|
229
|
+
success_count = 0
|
|
230
|
+
for handler in self._handlers:
|
|
231
|
+
try:
|
|
232
|
+
if handler.send(alert):
|
|
233
|
+
success_count += 1
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.error(f"Handler {handler.__class__.__name__} failed: {e}")
|
|
236
|
+
return success_count
|
|
237
|
+
|
|
238
|
+
def trigger_info(self, sbc_name: str, message: str, details: str = None) -> int:
|
|
239
|
+
"""Convenience method to trigger an INFO alert."""
|
|
240
|
+
alert = Alert(
|
|
241
|
+
level=AlertLevel.INFO,
|
|
242
|
+
sbc_name=sbc_name,
|
|
243
|
+
message=message,
|
|
244
|
+
details=details,
|
|
245
|
+
)
|
|
246
|
+
return self.trigger(alert)
|
|
247
|
+
|
|
248
|
+
def trigger_warning(self, sbc_name: str, message: str, details: str = None) -> int:
|
|
249
|
+
"""Convenience method to trigger a WARNING alert."""
|
|
250
|
+
alert = Alert(
|
|
251
|
+
level=AlertLevel.WARNING,
|
|
252
|
+
sbc_name=sbc_name,
|
|
253
|
+
message=message,
|
|
254
|
+
details=details,
|
|
255
|
+
)
|
|
256
|
+
return self.trigger(alert)
|
|
257
|
+
|
|
258
|
+
def trigger_critical(self, sbc_name: str, message: str, details: str = None) -> int:
|
|
259
|
+
"""Convenience method to trigger a CRITICAL alert."""
|
|
260
|
+
alert = Alert(
|
|
261
|
+
level=AlertLevel.CRITICAL,
|
|
262
|
+
sbc_name=sbc_name,
|
|
263
|
+
message=message,
|
|
264
|
+
details=details,
|
|
265
|
+
)
|
|
266
|
+
return self.trigger(alert)
|
|
267
|
+
|
|
268
|
+
def close(self) -> None:
|
|
269
|
+
"""Close all handlers."""
|
|
270
|
+
for handler in self._handlers:
|
|
271
|
+
try:
|
|
272
|
+
handler.close()
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.error(f"Error closing handler: {e}")
|
|
275
|
+
self._handlers.clear()
|