server-motd 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- server_motd/__init__.py +1 -0
- server_motd/__main__.py +5 -0
- server_motd/cli.py +126 -0
- server_motd/config.py +155 -0
- server_motd/model.py +38 -0
- server_motd/modules/__init__.py +54 -0
- server_motd/modules/_common.py +42 -0
- server_motd/modules/_http.py +78 -0
- server_motd/modules/cpu.py +31 -0
- server_motd/modules/disks.py +32 -0
- server_motd/modules/ips.py +31 -0
- server_motd/modules/load.py +30 -0
- server_motd/modules/logins.py +29 -0
- server_motd/modules/memory.py +38 -0
- server_motd/modules/processes.py +21 -0
- server_motd/modules/qbittorrent.py +82 -0
- server_motd/modules/rtorrent.py +112 -0
- server_motd/modules/sabnzbd.py +59 -0
- server_motd/modules/servarr.py +103 -0
- server_motd/modules/tautulli.py +85 -0
- server_motd/modules/updates.py +56 -0
- server_motd/modules/uptime.py +38 -0
- server_motd/profile.py +167 -0
- server_motd/py.typed +0 -0
- server_motd/render.py +167 -0
- server_motd/runner.py +56 -0
- server_motd/terminal.py +35 -0
- server_motd-0.1.0.dist-info/METADATA +756 -0
- server_motd-0.1.0.dist-info/RECORD +32 -0
- server_motd-0.1.0.dist-info/WHEEL +4 -0
- server_motd-0.1.0.dist-info/entry_points.txt +2 -0
- server_motd-0.1.0.dist-info/licenses/LICENSE +674 -0
server_motd/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""server-motd: login MOTD for media servers."""
|
server_motd/__main__.py
ADDED
server_motd/cli.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Command line entry point. The login invocation always exits 0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from server_motd import config, profile, render, runner, terminal
|
|
13
|
+
from server_motd.config import BadEntry, ConfigError, ModuleSpec
|
|
14
|
+
from server_motd.model import Module, Result, Status
|
|
15
|
+
from server_motd.modules import REGISTRY
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
p = argparse.ArgumentParser(prog="server-motd")
|
|
20
|
+
p.add_argument("--config", type=Path, help="config file (overrides discovery)")
|
|
21
|
+
p.add_argument("--width", type=int, help="output width in columns")
|
|
22
|
+
p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
|
|
23
|
+
p.add_argument("--verbose", action="store_true", help="show exception detail")
|
|
24
|
+
sub = p.add_subparsers(dest="command") # no subcommand = render
|
|
25
|
+
check = sub.add_parser("check", help="validate the config and probe every module")
|
|
26
|
+
# SUPPRESS keeps values given before the subcommand from being overwritten.
|
|
27
|
+
check.add_argument("--config", type=Path, default=argparse.SUPPRESS)
|
|
28
|
+
check.add_argument("--verbose", action="store_true", default=argparse.SUPPRESS)
|
|
29
|
+
sub.add_parser("install-profile", help="add the profile block")
|
|
30
|
+
sub.add_parser("uninstall-profile", help="remove the profile block")
|
|
31
|
+
return p
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
35
|
+
args = build_parser().parse_args(argv)
|
|
36
|
+
try:
|
|
37
|
+
if args.command == "check":
|
|
38
|
+
return _check(args)
|
|
39
|
+
if args.command in ("install-profile", "uninstall-profile"):
|
|
40
|
+
return profile.run(args.command)
|
|
41
|
+
return _render(args)
|
|
42
|
+
except Exception as exc: # never block login
|
|
43
|
+
print(f"server-motd: {exc}", file=sys.stderr)
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bad(title: str, message: str) -> Result:
|
|
48
|
+
return runner.unavailable(title, f"invalid config: {message}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_items(
|
|
52
|
+
entries: list[ModuleSpec | BadEntry],
|
|
53
|
+
) -> list[tuple[str, Module | Result]]:
|
|
54
|
+
# Config order is preserved: each item is a ready Result or a Module to run.
|
|
55
|
+
items: list[tuple[str, Module | Result]] = []
|
|
56
|
+
for entry in entries:
|
|
57
|
+
if isinstance(entry, BadEntry):
|
|
58
|
+
items.append((entry.label, _bad(entry.label, entry.message)))
|
|
59
|
+
continue
|
|
60
|
+
if not entry.enabled:
|
|
61
|
+
continue
|
|
62
|
+
title = entry.name or entry.kind
|
|
63
|
+
factory = REGISTRY.get(entry.kind)
|
|
64
|
+
if factory is None:
|
|
65
|
+
items.append((title, _bad(title, f"unknown kind '{entry.kind}'")))
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
items.append((title, factory(entry)))
|
|
69
|
+
except ConfigError as exc:
|
|
70
|
+
items.append((title, _bad(title, str(exc))))
|
|
71
|
+
return items
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check(args: argparse.Namespace) -> int:
|
|
75
|
+
try:
|
|
76
|
+
entries = config.load(args.config)
|
|
77
|
+
except ConfigError as exc:
|
|
78
|
+
print(f"server-motd: {exc}", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
items = _build_items(entries)
|
|
81
|
+
log: list[str] = []
|
|
82
|
+
to_run = [(t, m) for t, m in items if not isinstance(m, Result)]
|
|
83
|
+
fetched = iter(runner.run(to_run, log))
|
|
84
|
+
failed = False
|
|
85
|
+
for title, item in items:
|
|
86
|
+
result = item if isinstance(item, Result) else next(fetched)
|
|
87
|
+
if result.status is Status.UNAVAILABLE:
|
|
88
|
+
failed = True
|
|
89
|
+
reason = result.rows[0].value.removeprefix("UNAVAILABLE: ")
|
|
90
|
+
print(f"FAIL {title}: {reason}")
|
|
91
|
+
else:
|
|
92
|
+
print(f"ok {title}")
|
|
93
|
+
if args.verbose:
|
|
94
|
+
for entry_log in log:
|
|
95
|
+
print(entry_log, file=sys.stderr)
|
|
96
|
+
return 1 if failed else 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _render(args: argparse.Namespace) -> int:
|
|
100
|
+
try:
|
|
101
|
+
entries = config.load(args.config)
|
|
102
|
+
settings = config.load_settings(args.config)
|
|
103
|
+
except ConfigError as exc:
|
|
104
|
+
print(f"server-motd: {exc}", file=sys.stderr)
|
|
105
|
+
return 0
|
|
106
|
+
items = _build_items(entries)
|
|
107
|
+
|
|
108
|
+
log: list[str] = []
|
|
109
|
+
to_run = [(t, m) for t, m in items if not isinstance(m, Result)]
|
|
110
|
+
fetched = iter(runner.run(to_run, log))
|
|
111
|
+
results = [m if isinstance(m, Result) else next(fetched) for _, m in items]
|
|
112
|
+
|
|
113
|
+
width = args.width or shutil.get_terminal_size((80, 24)).columns
|
|
114
|
+
color = args.color == "always" or (args.color == "auto" and sys.stdout.isatty())
|
|
115
|
+
if color and not terminal.enable_vt() and args.color == "auto":
|
|
116
|
+
color = False # console cannot render escape codes: plain text
|
|
117
|
+
box = settings.box
|
|
118
|
+
if box not in render.BOX_STYLES:
|
|
119
|
+
print(f"server-motd: unknown box style '{box}', using single", file=sys.stderr)
|
|
120
|
+
box = "single"
|
|
121
|
+
title = settings.title or socket.gethostname()
|
|
122
|
+
sys.stdout.write(render.render(results, width, color, title, box))
|
|
123
|
+
if args.verbose:
|
|
124
|
+
for entry_log in log:
|
|
125
|
+
print(entry_log, file=sys.stderr)
|
|
126
|
+
return 0
|
server_motd/config.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""TOML config loading, strict per-module validation and secret resolution.
|
|
2
|
+
|
|
3
|
+
Config shape: an ordered `[[modules]]` array. Each entry has `kind`, optional
|
|
4
|
+
`name`, `enabled` (default true) and any kind-specific keys, including
|
|
5
|
+
`warn`/`crit`. Those are left in `ModuleSpec.options`; a module consumes what
|
|
6
|
+
it supports with the `take_*` helpers and then calls `done()`, which rejects
|
|
7
|
+
any key left over. Errors are raised as `ConfigError` and reported only for
|
|
8
|
+
the module they belong to.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(Exception):
|
|
23
|
+
"""Invalid configuration (file level or for a single module)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ModuleSpec:
|
|
28
|
+
kind: str
|
|
29
|
+
name: str | None
|
|
30
|
+
enabled: bool
|
|
31
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def take(self, key: str, typ: type, default: Any = None) -> Any:
|
|
34
|
+
"""Pop an optional option, checking its type."""
|
|
35
|
+
if key not in self.options:
|
|
36
|
+
return default
|
|
37
|
+
value = self.options.pop(key)
|
|
38
|
+
if typ is float and isinstance(value, int) and not isinstance(value, bool):
|
|
39
|
+
value = float(value)
|
|
40
|
+
if not isinstance(value, typ) or (typ is not bool and isinstance(value, bool)):
|
|
41
|
+
raise ConfigError(f"'{key}' must be {typ.__name__}")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
def take_secret(self, key: str) -> str | None:
|
|
45
|
+
"""Resolve a secret given inline, as `${ENV_VAR}`, or via `<key>_file`."""
|
|
46
|
+
value = self.take(key, str)
|
|
47
|
+
file_key = f"{key}_file"
|
|
48
|
+
path = self.take(file_key, str)
|
|
49
|
+
if value is not None and path is not None:
|
|
50
|
+
raise ConfigError(f"set only one of '{key}' and '{file_key}'")
|
|
51
|
+
if path is not None:
|
|
52
|
+
try:
|
|
53
|
+
return Path(path).read_text(encoding="utf-8").strip()
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise ConfigError(f"cannot read '{file_key}': {exc.strerror}") from exc
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
return _expand_env(value, key)
|
|
59
|
+
|
|
60
|
+
def done(self) -> None:
|
|
61
|
+
if self.options:
|
|
62
|
+
keys = ", ".join(sorted(self.options))
|
|
63
|
+
raise ConfigError(f"unknown option(s): {keys}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_ENV = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _expand_env(value: str, key: str) -> str:
|
|
70
|
+
def sub(m: re.Match[str]) -> str:
|
|
71
|
+
name = m.group(1)
|
|
72
|
+
if name not in os.environ:
|
|
73
|
+
raise ConfigError(f"'{key}' references unset variable {name}")
|
|
74
|
+
return os.environ[name]
|
|
75
|
+
|
|
76
|
+
return _ENV.sub(sub, value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class BadEntry:
|
|
81
|
+
"""A `[[modules]]` entry too malformed to build a ModuleSpec from."""
|
|
82
|
+
|
|
83
|
+
label: str
|
|
84
|
+
message: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def default_config_paths() -> list[Path]:
|
|
88
|
+
if sys.platform == "win32":
|
|
89
|
+
paths = []
|
|
90
|
+
for var in ("APPDATA", "ProgramData"):
|
|
91
|
+
if os.environ.get(var):
|
|
92
|
+
paths.append(Path(os.environ[var]) / "server-motd" / "config.toml")
|
|
93
|
+
return paths
|
|
94
|
+
return [Path("/etc/server-motd/config.toml")]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Settings:
|
|
99
|
+
"""Top-level (non-module) config keys: `title` and `box`."""
|
|
100
|
+
|
|
101
|
+
title: str | None = None
|
|
102
|
+
box: str = "single"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_settings(path: Path | None) -> Settings:
|
|
106
|
+
"""Read the top-level keys; invalid values raise ConfigError."""
|
|
107
|
+
data = _read(path)
|
|
108
|
+
title, box = data.get("title"), data.get("box", "single")
|
|
109
|
+
if title is not None and not isinstance(title, str):
|
|
110
|
+
raise ConfigError("'title' must be a string")
|
|
111
|
+
if not isinstance(box, str):
|
|
112
|
+
raise ConfigError("'box' must be a string")
|
|
113
|
+
return Settings(title, box)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load(path: Path | None) -> list[ModuleSpec | BadEntry]:
|
|
117
|
+
"""Parse the config. Each entry is a ModuleSpec, or a BadEntry."""
|
|
118
|
+
data = _read(path)
|
|
119
|
+
entries = data.get("modules", [])
|
|
120
|
+
if not isinstance(entries, list):
|
|
121
|
+
raise ConfigError("'modules' must be an array of tables")
|
|
122
|
+
return [_parse_entry(e) for e in entries]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read(path: Path | None) -> dict[str, Any]:
|
|
126
|
+
candidates = [path] if path else default_config_paths()
|
|
127
|
+
for candidate in candidates:
|
|
128
|
+
if candidate.is_file():
|
|
129
|
+
chosen = candidate
|
|
130
|
+
break
|
|
131
|
+
else:
|
|
132
|
+
shown = ", ".join(str(c) for c in candidates) or "(none)"
|
|
133
|
+
raise ConfigError(f"no config file found (looked in {shown})")
|
|
134
|
+
try:
|
|
135
|
+
data = tomllib.loads(chosen.read_text(encoding="utf-8"))
|
|
136
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
137
|
+
raise ConfigError(f"{chosen}: {exc}") from exc
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_entry(entry: Any) -> ModuleSpec | BadEntry:
|
|
142
|
+
if not isinstance(entry, dict):
|
|
143
|
+
return BadEntry("?", "module entry must be a table")
|
|
144
|
+
entry = dict(entry)
|
|
145
|
+
kind = entry.pop("kind", None)
|
|
146
|
+
name = entry.pop("name", None)
|
|
147
|
+
enabled = entry.pop("enabled", True)
|
|
148
|
+
label = name if isinstance(name, str) else (kind if isinstance(kind, str) else "?")
|
|
149
|
+
if not isinstance(kind, str):
|
|
150
|
+
return BadEntry(label, "'kind' is required and must be a string")
|
|
151
|
+
if name is not None and not isinstance(name, str):
|
|
152
|
+
return BadEntry(label, "'name' must be a string")
|
|
153
|
+
if not isinstance(enabled, bool):
|
|
154
|
+
return BadEntry(label, "'enabled' must be a boolean")
|
|
155
|
+
return ModuleSpec(kind=kind, name=name, enabled=enabled, options=entry)
|
server_motd/model.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Core schema: Status, Row, Result and the Module protocol.
|
|
2
|
+
|
|
3
|
+
The schema carries no layout. The renderer maps Status to colour.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Status(Enum):
|
|
14
|
+
INFO = "info"
|
|
15
|
+
OK = "ok"
|
|
16
|
+
WARN = "warn"
|
|
17
|
+
CRIT = "crit"
|
|
18
|
+
UNAVAILABLE = "unavailable"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Row:
|
|
23
|
+
label: str
|
|
24
|
+
value: str
|
|
25
|
+
status: Status = Status.INFO
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Result:
|
|
30
|
+
title: str
|
|
31
|
+
rows: list[Row] = field(default_factory=list)
|
|
32
|
+
status: Status = Status.INFO
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Module(Protocol):
|
|
36
|
+
"""A synchronous unit of MOTD output. `fetch` raises on failure."""
|
|
37
|
+
|
|
38
|
+
def fetch(self) -> Result: ...
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Module registry. Extension conventions for adding a module kind:
|
|
2
|
+
|
|
3
|
+
1. Create one file, `modules/<kind>.py`, defining a class with
|
|
4
|
+
`__init__(self, spec: ModuleSpec)` (read options with `spec.take*`, then call
|
|
5
|
+
`spec.done()`; raise `ConfigError` for bad config) and `fetch() -> Result`
|
|
6
|
+
(raise on failure; the runner turns that into UNAVAILABLE).
|
|
7
|
+
2. Add ONE import and ONE line to `REGISTRY` below: `"<kind>": <kind>.Class,`.
|
|
8
|
+
Keep entries alphabetical so parallel tickets merge cleanly.
|
|
9
|
+
3. Test only through the CLI (tests/conftest.py `run_cli`, `write_config`,
|
|
10
|
+
`assert_golden`). Pin anything non-deterministic at the boundary it comes
|
|
11
|
+
from (e.g. monkeypatch `psutil` / `time`), not inside the module.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from server_motd.config import ModuleSpec
|
|
19
|
+
from server_motd.model import Module
|
|
20
|
+
from server_motd.modules import (
|
|
21
|
+
cpu,
|
|
22
|
+
disks,
|
|
23
|
+
ips,
|
|
24
|
+
load,
|
|
25
|
+
logins,
|
|
26
|
+
memory,
|
|
27
|
+
processes,
|
|
28
|
+
qbittorrent,
|
|
29
|
+
rtorrent,
|
|
30
|
+
sabnzbd,
|
|
31
|
+
servarr,
|
|
32
|
+
tautulli,
|
|
33
|
+
updates,
|
|
34
|
+
uptime,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
REGISTRY: dict[str, Callable[[ModuleSpec], Module]] = {
|
|
38
|
+
"cpu": cpu.Cpu,
|
|
39
|
+
"disks": disks.Disks,
|
|
40
|
+
"ips": ips.Ips,
|
|
41
|
+
"lidarr": servarr.Lidarr,
|
|
42
|
+
"load": load.Load,
|
|
43
|
+
"logins": logins.Logins,
|
|
44
|
+
"memory": memory.Memory,
|
|
45
|
+
"processes": processes.Processes,
|
|
46
|
+
"qbittorrent": qbittorrent.QBittorrent,
|
|
47
|
+
"radarr": servarr.Radarr,
|
|
48
|
+
"rtorrent": rtorrent.Rtorrent,
|
|
49
|
+
"sabnzbd": sabnzbd.Sabnzbd,
|
|
50
|
+
"sonarr": servarr.Sonarr,
|
|
51
|
+
"tautulli": tautulli.Tautulli,
|
|
52
|
+
"updates": updates.Updates,
|
|
53
|
+
"uptime": uptime.Uptime,
|
|
54
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Helpers shared by the modules: thresholds, status ranking, size formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from server_motd.config import ConfigError, ModuleSpec
|
|
8
|
+
from server_motd.model import Row, Status
|
|
9
|
+
|
|
10
|
+
_SEVERITY = [Status.INFO, Status.OK, Status.WARN, Status.CRIT, Status.UNAVAILABLE]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Thresholds:
|
|
14
|
+
"""Optional `warn` / `crit` options; a value >= a limit takes that status."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, spec: ModuleSpec) -> None:
|
|
17
|
+
self.warn: float | None = spec.take("warn", float)
|
|
18
|
+
self.crit: float | None = spec.take("crit", float)
|
|
19
|
+
if self.warn is not None and self.crit is not None and self.warn > self.crit:
|
|
20
|
+
raise ConfigError("'warn' must not exceed 'crit'")
|
|
21
|
+
|
|
22
|
+
def status(self, value: float) -> Status:
|
|
23
|
+
if self.warn is None and self.crit is None:
|
|
24
|
+
return Status.INFO
|
|
25
|
+
if self.crit is not None and value >= self.crit:
|
|
26
|
+
return Status.CRIT
|
|
27
|
+
if self.warn is not None and value >= self.warn:
|
|
28
|
+
return Status.WARN
|
|
29
|
+
return Status.OK
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def worst(rows: Iterable[Row]) -> Status:
|
|
33
|
+
"""Overall status: the most severe row status."""
|
|
34
|
+
return max((r.status for r in rows), key=_SEVERITY.index, default=Status.INFO)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def human_bytes(n: float) -> str:
|
|
38
|
+
for unit in "BKMGT":
|
|
39
|
+
if n < 1024 or unit == "T":
|
|
40
|
+
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
41
|
+
n /= 1024
|
|
42
|
+
raise AssertionError("unreachable")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Helpers shared by the HTTP service modules.
|
|
2
|
+
|
|
3
|
+
One request timeout (kept within the runner's per-module limit), the common
|
|
4
|
+
`url` / `api_key` options, and the mapping of transport failures to short
|
|
5
|
+
user-visible messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from server_motd.config import ConfigError, ModuleSpec
|
|
17
|
+
|
|
18
|
+
REQUEST_TIMEOUT = 2.5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def take_url_and_key(spec: ModuleSpec) -> tuple[str, str]:
|
|
22
|
+
"""Take the required `url` (trailing slash stripped) and `api_key` options."""
|
|
23
|
+
url = spec.take("url", str)
|
|
24
|
+
key = spec.take_secret("api_key")
|
|
25
|
+
if not url:
|
|
26
|
+
raise ConfigError("'url' is required")
|
|
27
|
+
if not key:
|
|
28
|
+
raise ConfigError("'api_key' is required")
|
|
29
|
+
return url.rstrip("/"), key
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def request(
|
|
33
|
+
opener: Callable[..., Any],
|
|
34
|
+
*args: Any,
|
|
35
|
+
auth_error: str = "bad API key",
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Call `opener(*args, timeout=REQUEST_TIMEOUT)` and return the response.
|
|
38
|
+
|
|
39
|
+
Failures raise RuntimeError with the message shown in the MOTD. Use as a
|
|
40
|
+
context manager result: the caller closes the response.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return opener(*args, timeout=REQUEST_TIMEOUT)
|
|
44
|
+
except urllib.error.HTTPError as exc:
|
|
45
|
+
if exc.code in (401, 403):
|
|
46
|
+
raise RuntimeError(f"{auth_error} (HTTP {exc.code})") from exc
|
|
47
|
+
raise RuntimeError(f"HTTP {exc.code}") from exc
|
|
48
|
+
except urllib.error.URLError as exc:
|
|
49
|
+
raise RuntimeError(f"unreachable: {exc.reason}") from exc
|
|
50
|
+
except (OSError, ValueError) as exc:
|
|
51
|
+
raise RuntimeError(f"bad response: {exc}") from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read(
|
|
55
|
+
opener: Callable[..., Any],
|
|
56
|
+
*args: Any,
|
|
57
|
+
auth_error: str = "bad API key",
|
|
58
|
+
) -> bytes:
|
|
59
|
+
"""Open, read fully and close; transport failures map as in `request`."""
|
|
60
|
+
try:
|
|
61
|
+
with request(opener, *args, auth_error=auth_error) as resp:
|
|
62
|
+
data: bytes = resp.read()
|
|
63
|
+
return data
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
raise
|
|
66
|
+
except (OSError, ValueError) as exc: # failure while reading the body
|
|
67
|
+
raise RuntimeError(f"bad response: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_json(
|
|
71
|
+
target: str | urllib.request.Request, *, auth_error: str = "bad API key"
|
|
72
|
+
) -> Any:
|
|
73
|
+
"""GET a URL (or Request) and parse the JSON body."""
|
|
74
|
+
body = read(urllib.request.urlopen, target, auth_error=auth_error)
|
|
75
|
+
try:
|
|
76
|
+
return json.loads(body)
|
|
77
|
+
except ValueError as exc:
|
|
78
|
+
raise RuntimeError(f"bad response: {exc}") from exc
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""System module: CPU utilisation percent (works everywhere; replaces load on Windows).
|
|
2
|
+
|
|
3
|
+
Thresholds apply to the utilisation percentage.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
from server_motd.config import ModuleSpec
|
|
11
|
+
from server_motd.model import Result, Row
|
|
12
|
+
from server_motd.modules._common import Thresholds
|
|
13
|
+
|
|
14
|
+
SAMPLE_SECONDS = 0.2 # cpu_percent(None) reports a meaningless 0.0 on first call
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cpu:
|
|
18
|
+
def __init__(self, spec: ModuleSpec) -> None:
|
|
19
|
+
self.title = spec.name or "CPU"
|
|
20
|
+
self.limits = Thresholds(spec)
|
|
21
|
+
spec.done()
|
|
22
|
+
|
|
23
|
+
def fetch(self) -> Result:
|
|
24
|
+
percent = psutil.cpu_percent(interval=SAMPLE_SECONDS)
|
|
25
|
+
cores = psutil.cpu_count() or 1
|
|
26
|
+
status = self.limits.status(percent)
|
|
27
|
+
return Result(
|
|
28
|
+
self.title,
|
|
29
|
+
[Row("Usage", f"{percent:.0f}% ({cores} cores)", status)],
|
|
30
|
+
status,
|
|
31
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""System module: usage per mounted filesystem. Thresholds on percent used."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import psutil
|
|
6
|
+
|
|
7
|
+
from server_motd.config import ModuleSpec
|
|
8
|
+
from server_motd.model import Result, Row
|
|
9
|
+
from server_motd.modules._common import Thresholds, human_bytes, worst
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Disks:
|
|
13
|
+
def __init__(self, spec: ModuleSpec) -> None:
|
|
14
|
+
self.title = spec.name or "Disks"
|
|
15
|
+
self.limits = Thresholds(spec)
|
|
16
|
+
spec.done()
|
|
17
|
+
|
|
18
|
+
def fetch(self) -> Result:
|
|
19
|
+
rows = []
|
|
20
|
+
for part in psutil.disk_partitions(all=False):
|
|
21
|
+
try:
|
|
22
|
+
usage = psutil.disk_usage(part.mountpoint)
|
|
23
|
+
except OSError:
|
|
24
|
+
continue # unreadable mount (e.g. empty drive): not worth a row
|
|
25
|
+
value = (
|
|
26
|
+
f"{human_bytes(usage.used)} / {human_bytes(usage.total)} "
|
|
27
|
+
f"({usage.percent:.0f}%)"
|
|
28
|
+
)
|
|
29
|
+
rows.append(Row(part.mountpoint, value, self.limits.status(usage.percent)))
|
|
30
|
+
if not rows:
|
|
31
|
+
raise RuntimeError("no mounted filesystems found")
|
|
32
|
+
return Result(self.title, rows, worst(rows))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""System module: IP addresses per interface (loopback and link-local skipped)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
from server_motd.config import ModuleSpec
|
|
11
|
+
from server_motd.model import Result, Row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Ips:
|
|
15
|
+
def __init__(self, spec: ModuleSpec) -> None:
|
|
16
|
+
self.title = spec.name or "IPs"
|
|
17
|
+
spec.done()
|
|
18
|
+
|
|
19
|
+
def fetch(self) -> Result:
|
|
20
|
+
rows = []
|
|
21
|
+
for iface, addrs in sorted(psutil.net_if_addrs().items()):
|
|
22
|
+
for addr in addrs:
|
|
23
|
+
if addr.family not in (socket.AF_INET, socket.AF_INET6):
|
|
24
|
+
continue
|
|
25
|
+
ip = ipaddress.ip_address(addr.address.split("%")[0])
|
|
26
|
+
if ip.is_loopback or ip.is_link_local:
|
|
27
|
+
continue
|
|
28
|
+
rows.append(Row(iface, str(ip)))
|
|
29
|
+
if not rows:
|
|
30
|
+
raise RuntimeError("no non-loopback addresses found")
|
|
31
|
+
return Result(self.title, rows)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""System module: load average (Linux only; Windows uses the cpu module).
|
|
2
|
+
|
|
3
|
+
Thresholds apply to the 1-minute load average divided by the CPU count.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import psutil
|
|
11
|
+
|
|
12
|
+
from server_motd.config import ConfigError, ModuleSpec
|
|
13
|
+
from server_motd.model import Result, Row
|
|
14
|
+
from server_motd.modules._common import Thresholds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Load:
|
|
18
|
+
def __init__(self, spec: ModuleSpec) -> None:
|
|
19
|
+
self.title = spec.name or "Load"
|
|
20
|
+
self.limits = Thresholds(spec)
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
raise ConfigError("load is not available on Windows; use the cpu module")
|
|
23
|
+
spec.done()
|
|
24
|
+
|
|
25
|
+
def fetch(self) -> Result:
|
|
26
|
+
one, five, fifteen = psutil.getloadavg()
|
|
27
|
+
per_core = one / (psutil.cpu_count() or 1)
|
|
28
|
+
status = self.limits.status(per_core)
|
|
29
|
+
value = f"{one:.2f} {five:.2f} {fifteen:.2f}"
|
|
30
|
+
return Result(self.title, [Row("Load", value, status)], status)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""System module: logged-in sessions. Thresholds on the session count."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import psutil
|
|
6
|
+
|
|
7
|
+
from server_motd.config import ModuleSpec
|
|
8
|
+
from server_motd.model import Result, Row, Status
|
|
9
|
+
from server_motd.modules._common import Thresholds
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Logins:
|
|
13
|
+
def __init__(self, spec: ModuleSpec) -> None:
|
|
14
|
+
self.title = spec.name or "Logins"
|
|
15
|
+
self.limits = Thresholds(spec)
|
|
16
|
+
spec.done()
|
|
17
|
+
|
|
18
|
+
def fetch(self) -> Result:
|
|
19
|
+
users = psutil.users()
|
|
20
|
+
status = self.limits.status(len(users))
|
|
21
|
+
if not users:
|
|
22
|
+
return Result(self.title, [Row("Sessions", "none", status)], status)
|
|
23
|
+
rows = []
|
|
24
|
+
for u in users:
|
|
25
|
+
where = u.terminal or "-"
|
|
26
|
+
if u.host:
|
|
27
|
+
where += f" ({u.host})"
|
|
28
|
+
rows.append(Row(u.name, where, Status.INFO))
|
|
29
|
+
return Result(self.title, rows, status)
|