ftmon 2.0.0a1__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.
- ftmon/__init__.py +3 -0
- ftmon/__main__.py +9 -0
- ftmon/checks/__init__.py +7 -0
- ftmon/checks/jsoncheck.py +55 -0
- ftmon/checks/model.py +36 -0
- ftmon/checks/nagios.py +98 -0
- ftmon/checks/registry.py +150 -0
- ftmon/checks/runner.py +121 -0
- ftmon/checks/sampler.py +129 -0
- ftmon/checks/text.py +10 -0
- ftmon/cli.py +837 -0
- ftmon/clock.py +130 -0
- ftmon/config.py +324 -0
- ftmon/daemon.py +654 -0
- ftmon/definitions/__init__.py +31 -0
- ftmon/definitions/builtins/disk.toml +126 -0
- ftmon/definitions/builtins/events.toml +38 -0
- ftmon/definitions/builtins/hog.toml +42 -0
- ftmon/definitions/builtins/leak.toml +67 -0
- ftmon/definitions/builtins/load.toml +47 -0
- ftmon/definitions/builtins/net.toml +40 -0
- ftmon/definitions/builtins/self.toml +62 -0
- ftmon/definitions/builtins/service.toml +38 -0
- ftmon/definitions/loader.py +1147 -0
- ftmon/definitions/manage.py +162 -0
- ftmon/definitions/schema.py +160 -0
- ftmon/demo.py +191 -0
- ftmon/deploy/Caddyfile.demo +53 -0
- ftmon/engine/__init__.py +0 -0
- ftmon/engine/actions.py +127 -0
- ftmon/engine/context.py +52 -0
- ftmon/engine/effects.py +107 -0
- ftmon/engine/episodes.py +239 -0
- ftmon/engine/events.py +364 -0
- ftmon/engine/incidents.py +311 -0
- ftmon/engine/pipeline.py +249 -0
- ftmon/engine/render.py +39 -0
- ftmon/engine/rings.py +109 -0
- ftmon/engine/scheduler.py +77 -0
- ftmon/expr/__init__.py +38 -0
- ftmon/expr/eval.py +253 -0
- ftmon/expr/functions.py +133 -0
- ftmon/expr/ir.py +83 -0
- ftmon/expr/parse.py +255 -0
- ftmon/expr/tribool.py +41 -0
- ftmon/mcp_server.py +588 -0
- ftmon/model.py +160 -0
- ftmon/notify/__init__.py +19 -0
- ftmon/notify/base.py +55 -0
- ftmon/notify/desktop.py +51 -0
- ftmon/notify/file.py +44 -0
- ftmon/notify/http.py +68 -0
- ftmon/notify/ntfy.py +64 -0
- ftmon/notify/smtp.py +87 -0
- ftmon/notify/webhook.py +51 -0
- ftmon/paths.py +98 -0
- ftmon/scenarios/__init__.py +1 -0
- ftmon/scenarios/demo-v1.jsonl +16 -0
- ftmon/selfmon.py +83 -0
- ftmon/sources/__init__.py +0 -0
- ftmon/sources/base.py +198 -0
- ftmon/sources/disk.py +94 -0
- ftmon/sources/fixtures.py +363 -0
- ftmon/sources/journald.py +155 -0
- ftmon/sources/net.py +84 -0
- ftmon/sources/process.py +124 -0
- ftmon/sources/system.py +121 -0
- ftmon/sources/unit.py +144 -0
- ftmon/store/__init__.py +8 -0
- ftmon/store/db.py +70 -0
- ftmon/store/doctor.py +67 -0
- ftmon/store/migrations/0001_init.sql +51 -0
- ftmon/store/migrations/0002_action_runs.sql +6 -0
- ftmon/store/migrations/0003_notification_deliveries.sql +44 -0
- ftmon/store/outbox.py +350 -0
- ftmon/store/query.py +500 -0
- ftmon/store/retention.py +337 -0
- ftmon/store/writer.py +435 -0
- ftmon/systemd/ftmon-demo-build.service +27 -0
- ftmon/systemd/ftmon-demo-refresh.service +17 -0
- ftmon/systemd/ftmon-demo-refresh.timer +15 -0
- ftmon/systemd/ftmon-demo-web.service +38 -0
- ftmon/systemd/ftmon-server.service +54 -0
- ftmon/systemd/ftmon.service +18 -0
- ftmon/web/__init__.py +6 -0
- ftmon/web/app.py +756 -0
- ftmon/web/demo_app.py +161 -0
- ftmon/web/static/brand/README.md +16 -0
- ftmon/web/static/brand/apple-touch-icon.png +0 -0
- ftmon/web/static/brand/favicon-64.png +0 -0
- ftmon/web/static/brand/favicon.ico +0 -0
- ftmon/web/static/brand/ftmon-mark.png +0 -0
- ftmon/web/static/ftmon.css +1 -0
- ftmon/web/static/ftmon.js +34 -0
- ftmon/web/static/vendor/README.md +10 -0
- ftmon/web/static/vendor/uPlot.LICENSE.txt +21 -0
- ftmon/web/static/vendor/uPlot.iife.min.js +2 -0
- ftmon/web/static/vendor/uPlot.min.css +1 -0
- ftmon/web/templates/base.html +8 -0
- ftmon/web/templates/dashboard.html +5 -0
- ftmon/web/templates/events.html +1 -0
- ftmon/web/templates/incident.html +4 -0
- ftmon/web/templates/incident_rows.html +2 -0
- ftmon/web/templates/incidents.html +1 -0
- ftmon/web/templates/metrics.html +2 -0
- ftmon/web/templates/monitors.html +2 -0
- ftmon/web/templates/self.html +1 -0
- ftmon/web/templates/trends.html +8 -0
- ftmon-2.0.0a1.dist-info/METADATA +190 -0
- ftmon-2.0.0a1.dist-info/RECORD +113 -0
- ftmon-2.0.0a1.dist-info/WHEEL +4 -0
- ftmon-2.0.0a1.dist-info/entry_points.txt +2 -0
- ftmon-2.0.0a1.dist-info/licenses/LICENSE +21 -0
ftmon/__init__.py
ADDED
ftmon/__main__.py
ADDED
ftmon/checks/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Bounded external-check execution and strict protocol adapters."""
|
|
2
|
+
|
|
3
|
+
from ftmon.checks.model import CheckSpec, RawCheckResult
|
|
4
|
+
from ftmon.checks.runner import CheckRunner
|
|
5
|
+
from ftmon.checks.sampler import ExternalSampler
|
|
6
|
+
|
|
7
|
+
__all__ = ["CheckRunner", "CheckSpec", "ExternalSampler", "RawCheckResult"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Strict FTMON JSON check output adapter (EC-10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from ftmon.checks.model import RawCheckResult, unknown
|
|
9
|
+
from ftmon.checks.text import clean_message
|
|
10
|
+
|
|
11
|
+
_TOP_KEYS = {"schema", "state", "message", "metrics"}
|
|
12
|
+
_ASCII_WS = b" \t\r\n"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _DuplicateKey(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
20
|
+
result: dict[str, object] = {}
|
|
21
|
+
for key, value in pairs:
|
|
22
|
+
if key in result:
|
|
23
|
+
# json.loads normally keeps the last value, which would let a
|
|
24
|
+
# check make parser behavior depend on duplicate-key policy.
|
|
25
|
+
raise _DuplicateKey(key)
|
|
26
|
+
result[key] = value
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse(stdout: bytes, duration_s: float) -> RawCheckResult:
|
|
31
|
+
try:
|
|
32
|
+
text = stdout.strip(_ASCII_WS).decode("utf-8")
|
|
33
|
+
payload = json.loads(text, object_pairs_hook=_object)
|
|
34
|
+
except (UnicodeDecodeError, json.JSONDecodeError, _DuplicateKey):
|
|
35
|
+
return unknown(duration_s, "protocol")
|
|
36
|
+
if type(payload) is not dict or set(payload) != _TOP_KEYS:
|
|
37
|
+
return unknown(duration_s, "protocol")
|
|
38
|
+
state = payload["state"]
|
|
39
|
+
message = payload["message"]
|
|
40
|
+
metrics = payload["metrics"]
|
|
41
|
+
if payload["schema"] != 1 or type(state) is not int or state not in range(4):
|
|
42
|
+
return unknown(duration_s, "protocol")
|
|
43
|
+
if type(message) is not str or type(metrics) is not dict or len(metrics) > 64:
|
|
44
|
+
return unknown(duration_s, "protocol")
|
|
45
|
+
values: dict[str, tuple[float, str]] = {}
|
|
46
|
+
for label, metric in metrics.items():
|
|
47
|
+
if type(label) is not str or not label or type(metric) is not dict:
|
|
48
|
+
return unknown(duration_s, "protocol")
|
|
49
|
+
if set(metric) != {"value", "uom"} or type(metric["uom"]) is not str:
|
|
50
|
+
return unknown(duration_s, "protocol")
|
|
51
|
+
value = metric["value"]
|
|
52
|
+
if type(value) not in (int, float) or not math.isfinite(value):
|
|
53
|
+
return unknown(duration_s, "protocol")
|
|
54
|
+
values[label] = (float(value), metric["uom"])
|
|
55
|
+
return RawCheckResult(state, clean_message(message), duration_s, values)
|
ftmon/checks/model.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Protocol-neutral external check values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CheckSpec:
|
|
13
|
+
alias: str
|
|
14
|
+
argv: tuple[str, ...]
|
|
15
|
+
protocol: Literal["nagios", "ftmon-json"]
|
|
16
|
+
timeout_s: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RawCheckResult:
|
|
21
|
+
state: int
|
|
22
|
+
message: str
|
|
23
|
+
duration_s: float
|
|
24
|
+
values: Mapping[str, tuple[float, str]]
|
|
25
|
+
failure: str | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
# A result is shared by definitions; a caller must not be able to mutate
|
|
29
|
+
# the raw value cache while another definition projects it.
|
|
30
|
+
object.__setattr__(self, "values", MappingProxyType(dict(self.values)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def unknown(
|
|
34
|
+
duration_s: float, failure: str, message: str = "External check failed"
|
|
35
|
+
) -> RawCheckResult:
|
|
36
|
+
return RawCheckResult(3, message, duration_s, {}, failure)
|
ftmon/checks/nagios.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Nagios plugin output adapter (EC-03)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from ftmon.checks.model import RawCheckResult, unknown
|
|
9
|
+
from ftmon.checks.text import clean_message
|
|
10
|
+
|
|
11
|
+
_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
|
|
12
|
+
_VALUE = re.compile(rf"^(?P<number>{_NUMBER})(?P<uom>[^;\s]*)$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _valid_range(value: str) -> bool:
|
|
16
|
+
value = value.removeprefix("@")
|
|
17
|
+
if ":" not in value:
|
|
18
|
+
return re.fullmatch(_NUMBER, value) is not None
|
|
19
|
+
if value.count(":") != 1:
|
|
20
|
+
return False
|
|
21
|
+
start, end = value.split(":")
|
|
22
|
+
return (
|
|
23
|
+
(not start or start == "~" or re.fullmatch(_NUMBER, start) is not None)
|
|
24
|
+
and (not end or re.fullmatch(_NUMBER, end) is not None)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tokens(perfdata: str) -> list[tuple[str, str]] | None:
|
|
29
|
+
result: list[tuple[str, str]] = []
|
|
30
|
+
index = 0
|
|
31
|
+
while index < len(perfdata):
|
|
32
|
+
while index < len(perfdata) and perfdata[index].isspace():
|
|
33
|
+
index += 1
|
|
34
|
+
if index == len(perfdata):
|
|
35
|
+
break
|
|
36
|
+
if perfdata[index] == "'":
|
|
37
|
+
end = perfdata.find("'", index + 1)
|
|
38
|
+
if end < 0:
|
|
39
|
+
return None
|
|
40
|
+
label = perfdata[index + 1 : end]
|
|
41
|
+
index = end + 1
|
|
42
|
+
if index >= len(perfdata) or perfdata[index] != "=":
|
|
43
|
+
return None
|
|
44
|
+
index += 1
|
|
45
|
+
else:
|
|
46
|
+
equal = perfdata.find("=", index)
|
|
47
|
+
if equal < 0 or any(char.isspace() for char in perfdata[index:equal]):
|
|
48
|
+
return None
|
|
49
|
+
label = perfdata[index:equal]
|
|
50
|
+
index = equal + 1
|
|
51
|
+
end = index
|
|
52
|
+
while end < len(perfdata) and not perfdata[end].isspace():
|
|
53
|
+
end += 1
|
|
54
|
+
if not label or end == index:
|
|
55
|
+
return None
|
|
56
|
+
result.append((label, perfdata[index:end]))
|
|
57
|
+
index = end
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse(stdout: bytes, exit_code: int, duration_s: float) -> RawCheckResult:
|
|
62
|
+
if exit_code not in range(4):
|
|
63
|
+
return unknown(duration_s, "exit_status")
|
|
64
|
+
try:
|
|
65
|
+
first_line = stdout.splitlines()[0].decode("utf-8") if stdout else ""
|
|
66
|
+
except UnicodeDecodeError:
|
|
67
|
+
return unknown(duration_s, "protocol")
|
|
68
|
+
summary, separator, perfdata = first_line.partition("|")
|
|
69
|
+
values: dict[str, tuple[float, str]] = {}
|
|
70
|
+
ambiguous: set[str] = set()
|
|
71
|
+
if separator:
|
|
72
|
+
tokens = _tokens(perfdata)
|
|
73
|
+
if tokens is None:
|
|
74
|
+
return unknown(duration_s, "protocol", clean_message(summary))
|
|
75
|
+
for label, raw_value in tokens:
|
|
76
|
+
fields = raw_value.split(";")
|
|
77
|
+
if len(fields) > 5:
|
|
78
|
+
continue
|
|
79
|
+
match = _VALUE.fullmatch(fields[0])
|
|
80
|
+
if match is None:
|
|
81
|
+
continue
|
|
82
|
+
warn_crit = fields[1:3]
|
|
83
|
+
minimum_maximum = fields[3:5]
|
|
84
|
+
if any(value and not _valid_range(value) for value in warn_crit):
|
|
85
|
+
continue
|
|
86
|
+
if any(value and re.fullmatch(_NUMBER, value) is None
|
|
87
|
+
for value in minimum_maximum):
|
|
88
|
+
continue
|
|
89
|
+
value = float(match.group("number"))
|
|
90
|
+
if not math.isfinite(value):
|
|
91
|
+
continue
|
|
92
|
+
if label in values:
|
|
93
|
+
ambiguous.add(label)
|
|
94
|
+
else:
|
|
95
|
+
values[label] = (value, match.group("uom"))
|
|
96
|
+
for label in ambiguous:
|
|
97
|
+
values.pop(label, None)
|
|
98
|
+
return RawCheckResult(exit_code, clean_message(summary), duration_s, values)
|
ftmon/checks/registry.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Load the administrator-owned external-check authority (EC-01/06/07)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import tomllib
|
|
8
|
+
from collections.abc import Iterator, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
|
|
13
|
+
from ftmon.checks.model import CheckSpec
|
|
14
|
+
from ftmon.definitions.schema import valid_name
|
|
15
|
+
from ftmon.expr import ExprSyntaxError, parse_duration
|
|
16
|
+
from ftmon.paths import Paths
|
|
17
|
+
|
|
18
|
+
MAX_CHECKS = 64
|
|
19
|
+
MAX_ARGS = 32
|
|
20
|
+
MAX_ARG_BYTES = 512
|
|
21
|
+
MAX_ARGV_BYTES = 8192
|
|
22
|
+
PROTOCOLS = frozenset({"ftmon-json", "nagios"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RegistryError(ValueError):
|
|
26
|
+
"""A stable, redacted registry failure suitable for self-events."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, category: str) -> None:
|
|
29
|
+
self.category = category
|
|
30
|
+
super().__init__(category)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class CheckRegistry(Mapping[str, CheckSpec]):
|
|
35
|
+
"""Immutable registry published only after every entry validates."""
|
|
36
|
+
|
|
37
|
+
_entries: Mapping[str, CheckSpec]
|
|
38
|
+
|
|
39
|
+
def __getitem__(self, alias: str) -> CheckSpec:
|
|
40
|
+
return self._entries[alias]
|
|
41
|
+
|
|
42
|
+
def __iter__(self) -> Iterator[str]:
|
|
43
|
+
return iter(self._entries)
|
|
44
|
+
|
|
45
|
+
def __len__(self) -> int:
|
|
46
|
+
return len(self._entries)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def empty() -> CheckRegistry:
|
|
50
|
+
"""Return an immutable no-authority registry for missing/invalid setup."""
|
|
51
|
+
return CheckRegistry(MappingProxyType({}))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _regular_protected(path: Path, category: str) -> os.stat_result:
|
|
55
|
+
try:
|
|
56
|
+
info = path.lstat()
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise RegistryError(category) from exc
|
|
59
|
+
if not stat.S_ISREG(info.st_mode) or path.is_symlink():
|
|
60
|
+
raise RegistryError(category)
|
|
61
|
+
if info.st_uid not in {0, os.getuid()} or info.st_mode & 0o022:
|
|
62
|
+
raise RegistryError(category)
|
|
63
|
+
return info
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_registry_file(path: Path) -> None:
|
|
67
|
+
_regular_protected(path, "registry_untrusted")
|
|
68
|
+
# The selected registry's directory is the trust root: checking above it
|
|
69
|
+
# would incorrectly reject safe user registries merely because /tmp or a
|
|
70
|
+
# shared home mount is writable outside FTMON's authority boundary.
|
|
71
|
+
try:
|
|
72
|
+
parent = path.parent.lstat()
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise RegistryError("registry_untrusted") from exc
|
|
75
|
+
if not stat.S_ISDIR(parent.st_mode) or parent.st_mode & 0o022:
|
|
76
|
+
raise RegistryError("registry_untrusted")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _under(path: Path, root: Path) -> bool:
|
|
80
|
+
try:
|
|
81
|
+
path.relative_to(root)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _timeout(value: object) -> float:
|
|
88
|
+
if value is None:
|
|
89
|
+
return 10.0
|
|
90
|
+
if not isinstance(value, str):
|
|
91
|
+
raise RegistryError("invalid_timeout")
|
|
92
|
+
try:
|
|
93
|
+
seconds = parse_duration(value)
|
|
94
|
+
except ExprSyntaxError as exc:
|
|
95
|
+
raise RegistryError("invalid_timeout") from exc
|
|
96
|
+
if not 1.0 <= seconds <= 30.0:
|
|
97
|
+
raise RegistryError("invalid_timeout")
|
|
98
|
+
return seconds
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _entry(alias: object, value: object, paths: Paths | None) -> CheckSpec:
|
|
102
|
+
if not valid_name(alias):
|
|
103
|
+
raise RegistryError("invalid_alias")
|
|
104
|
+
if not isinstance(value, dict) or set(value) - {"argv", "protocol", "timeout"}:
|
|
105
|
+
raise RegistryError("invalid_entry")
|
|
106
|
+
argv = value.get("argv")
|
|
107
|
+
protocol = value.get("protocol")
|
|
108
|
+
if (
|
|
109
|
+
not isinstance(argv, list)
|
|
110
|
+
or not 1 <= len(argv) <= MAX_ARGS
|
|
111
|
+
or not all(isinstance(arg, str) and arg for arg in argv)
|
|
112
|
+
):
|
|
113
|
+
raise RegistryError("invalid_argv")
|
|
114
|
+
encoded = [arg.encode("utf-8") for arg in argv]
|
|
115
|
+
if any(len(arg) > MAX_ARG_BYTES for arg in encoded) or sum(map(len, encoded)) > MAX_ARGV_BYTES:
|
|
116
|
+
raise RegistryError("invalid_argv")
|
|
117
|
+
executable = Path(argv[0])
|
|
118
|
+
if not executable.is_absolute():
|
|
119
|
+
raise RegistryError("invalid_executable")
|
|
120
|
+
if paths is not None:
|
|
121
|
+
resolved = executable.resolve(strict=False)
|
|
122
|
+
forbidden_roots = (paths.data_dir, paths.state_dir, paths.runtime_dir)
|
|
123
|
+
if any(_under(resolved, root.resolve()) for root in forbidden_roots):
|
|
124
|
+
raise RegistryError("invalid_executable")
|
|
125
|
+
info = _regular_protected(executable, "executable_unready")
|
|
126
|
+
if not info.st_mode & 0o111:
|
|
127
|
+
raise RegistryError("executable_unready")
|
|
128
|
+
if protocol not in PROTOCOLS:
|
|
129
|
+
raise RegistryError("invalid_protocol")
|
|
130
|
+
return CheckSpec(alias, tuple(argv), protocol, _timeout(value.get("timeout")))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load(path: Path, *, paths: Paths | None = None) -> CheckRegistry:
|
|
134
|
+
"""Validate and return a complete immutable registry.
|
|
135
|
+
|
|
136
|
+
Callers retain their previous object when this raises, which makes reload
|
|
137
|
+
publication atomic without this loader owning daemon lifecycle state.
|
|
138
|
+
"""
|
|
139
|
+
_validate_registry_file(path)
|
|
140
|
+
try:
|
|
141
|
+
document = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
142
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
143
|
+
raise RegistryError("invalid_toml") from exc
|
|
144
|
+
if set(document) != {"check"} or not isinstance(document["check"], dict):
|
|
145
|
+
raise RegistryError("invalid_schema")
|
|
146
|
+
checks = document["check"]
|
|
147
|
+
if len(checks) > MAX_CHECKS:
|
|
148
|
+
raise RegistryError("too_many_checks")
|
|
149
|
+
entries = {alias: _entry(alias, value, paths) for alias, value in checks.items()}
|
|
150
|
+
return CheckRegistry(MappingProxyType(entries))
|
ftmon/checks/runner.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""No-shell, process-group-bounded external check runner (EC-02)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ftmon.checks import jsoncheck, nagios
|
|
13
|
+
from ftmon.checks.model import CheckSpec, RawCheckResult, unknown
|
|
14
|
+
from ftmon.clock import Clock, SystemClock
|
|
15
|
+
|
|
16
|
+
_STDOUT_LIMIT = 64 * 1024
|
|
17
|
+
_STDERR_LIMIT = 8 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_bounded(stream: object, limit: int, output: bytearray, overflow: list[bool]) -> None:
|
|
21
|
+
while True:
|
|
22
|
+
chunk = stream.read(8192) # type: ignore[attr-defined]
|
|
23
|
+
if not chunk:
|
|
24
|
+
return
|
|
25
|
+
remaining = limit - len(output)
|
|
26
|
+
output.extend(chunk[:remaining])
|
|
27
|
+
if len(chunk) > remaining:
|
|
28
|
+
overflow[0] = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CheckRunner:
|
|
32
|
+
def __init__(self, state_dir: Path, clock: Clock | None = None):
|
|
33
|
+
self._state_dir = state_dir
|
|
34
|
+
self._clock = clock or SystemClock()
|
|
35
|
+
|
|
36
|
+
def _trusted_executable(self, executable: str) -> bool:
|
|
37
|
+
path = Path(executable)
|
|
38
|
+
try:
|
|
39
|
+
info = path.lstat()
|
|
40
|
+
resolved = path.resolve(strict=True)
|
|
41
|
+
resolved_info = resolved.lstat()
|
|
42
|
+
except (OSError, RuntimeError):
|
|
43
|
+
return False
|
|
44
|
+
return (
|
|
45
|
+
path.is_absolute()
|
|
46
|
+
and not stat.S_ISLNK(info.st_mode)
|
|
47
|
+
and stat.S_ISREG(info.st_mode)
|
|
48
|
+
and resolved == path
|
|
49
|
+
and resolved_info.st_uid in {0, os.geteuid()}
|
|
50
|
+
and not resolved_info.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
|
|
51
|
+
and bool(resolved_info.st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def run(self, spec: CheckSpec, deadline_mono: float) -> RawCheckResult:
|
|
55
|
+
started = self._clock.monotonic()
|
|
56
|
+
if not spec.argv or not self._trusted_executable(spec.argv[0]):
|
|
57
|
+
return unknown(0.0, "executable")
|
|
58
|
+
timeout = min(spec.timeout_s, max(0.0, deadline_mono - started))
|
|
59
|
+
if timeout <= 0:
|
|
60
|
+
return unknown(0.0, "timeout")
|
|
61
|
+
env = {
|
|
62
|
+
"PATH": os.defpath,
|
|
63
|
+
"FTMON_CHECK_ALIAS": spec.alias,
|
|
64
|
+
"FTMON_CHECK_TIMEOUT": str(spec.timeout_s),
|
|
65
|
+
}
|
|
66
|
+
try:
|
|
67
|
+
process = subprocess.Popen(
|
|
68
|
+
spec.argv,
|
|
69
|
+
stdin=subprocess.DEVNULL,
|
|
70
|
+
stdout=subprocess.PIPE,
|
|
71
|
+
stderr=subprocess.PIPE,
|
|
72
|
+
cwd=self._state_dir,
|
|
73
|
+
env=env,
|
|
74
|
+
close_fds=True,
|
|
75
|
+
start_new_session=True,
|
|
76
|
+
)
|
|
77
|
+
except OSError:
|
|
78
|
+
return unknown(self._clock.monotonic() - started, "launch")
|
|
79
|
+
stdout, stderr = bytearray(), bytearray()
|
|
80
|
+
stdout_overflow, stderr_overflow = [False], [False]
|
|
81
|
+
readers = [
|
|
82
|
+
threading.Thread(
|
|
83
|
+
target=_read_bounded,
|
|
84
|
+
args=(process.stdout, _STDOUT_LIMIT, stdout, stdout_overflow),
|
|
85
|
+
daemon=True,
|
|
86
|
+
),
|
|
87
|
+
threading.Thread(
|
|
88
|
+
target=_read_bounded,
|
|
89
|
+
args=(process.stderr, _STDERR_LIMIT, stderr, stderr_overflow),
|
|
90
|
+
daemon=True,
|
|
91
|
+
),
|
|
92
|
+
]
|
|
93
|
+
for reader in readers:
|
|
94
|
+
reader.start()
|
|
95
|
+
timed_out = False
|
|
96
|
+
try:
|
|
97
|
+
process.wait(timeout=timeout)
|
|
98
|
+
except subprocess.TimeoutExpired:
|
|
99
|
+
timed_out = True
|
|
100
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
101
|
+
try:
|
|
102
|
+
process.wait(timeout=0.25)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
105
|
+
process.wait()
|
|
106
|
+
for reader in readers:
|
|
107
|
+
reader.join()
|
|
108
|
+
duration = max(0.0, self._clock.monotonic() - started)
|
|
109
|
+
if timed_out:
|
|
110
|
+
return unknown(duration, "timeout")
|
|
111
|
+
if stdout_overflow[0]:
|
|
112
|
+
return unknown(duration, "output_limit")
|
|
113
|
+
if process.returncode is None or process.returncode < 0:
|
|
114
|
+
return unknown(duration, "signal")
|
|
115
|
+
if spec.protocol == "nagios":
|
|
116
|
+
return nagios.parse(bytes(stdout), process.returncode, duration)
|
|
117
|
+
if spec.protocol == "ftmon-json":
|
|
118
|
+
if process.returncode != 0:
|
|
119
|
+
return unknown(duration, "exit_status")
|
|
120
|
+
return jsoncheck.parse(bytes(stdout), duration)
|
|
121
|
+
return unknown(duration, "protocol")
|
ftmon/checks/sampler.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Per-cycle external alias scheduling and definition-specific projection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
7
|
+
from typing import ClassVar, Protocol
|
|
8
|
+
|
|
9
|
+
from ftmon.checks.model import CheckSpec, RawCheckResult
|
|
10
|
+
from ftmon.checks.registry import CheckRegistry
|
|
11
|
+
from ftmon.clock import Clock, SystemClock
|
|
12
|
+
from ftmon.definitions.loader import MonitorDef
|
|
13
|
+
from ftmon.model import EntitySample, Snapshot, SourceDecl
|
|
14
|
+
from ftmon.sources.base import SOURCE_DECLS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Runner(Protocol):
|
|
18
|
+
def run(self, spec: CheckSpec, deadline_mono: float) -> RawCheckResult: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExternalSampler:
|
|
22
|
+
"""Run each due alias once, then project its immutable result per monitor.
|
|
23
|
+
|
|
24
|
+
``prepare`` is deliberately separate from ``sample``: the scheduler knows
|
|
25
|
+
the complete due-monitor set, while the ordinary pipeline asks for one
|
|
26
|
+
monitor at a time. This boundary is what permits both fair alias ordering
|
|
27
|
+
and definition-specific metric declarations.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
decl: ClassVar[SourceDecl] = SOURCE_DECLS["external"]
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
registry: CheckRegistry,
|
|
35
|
+
runner: Runner,
|
|
36
|
+
counter: Callable[[str], None],
|
|
37
|
+
clock: Clock | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._registry = registry
|
|
40
|
+
self._runner = runner
|
|
41
|
+
self._counter = counter
|
|
42
|
+
self._clock = clock or SystemClock()
|
|
43
|
+
self._next_alias: str | None = None
|
|
44
|
+
self._results: dict[str, RawCheckResult] = {}
|
|
45
|
+
|
|
46
|
+
def set_registry(self, registry: CheckRegistry) -> None:
|
|
47
|
+
"""Swap an already validated registry at a cycle boundary."""
|
|
48
|
+
self._registry = registry
|
|
49
|
+
if self._next_alias not in registry:
|
|
50
|
+
self._next_alias = None
|
|
51
|
+
|
|
52
|
+
def prepare(self, monitors: Iterable[MonitorDef], deadline_mono: float) -> None:
|
|
53
|
+
"""Execute unique aliases for one cycle within the shared deadline."""
|
|
54
|
+
aliases = list(
|
|
55
|
+
dict.fromkeys(
|
|
56
|
+
monitor.source_options["check"]
|
|
57
|
+
for monitor in monitors
|
|
58
|
+
if monitor.source == "external"
|
|
59
|
+
and monitor.source_options.get("check") in self._registry
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
self._results = {}
|
|
63
|
+
if not aliases:
|
|
64
|
+
self._next_alias = None
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
start = aliases.index(self._next_alias) if self._next_alias in aliases else 0
|
|
68
|
+
ordered = aliases[start:] + aliases[:start]
|
|
69
|
+
for index, alias in enumerate(ordered):
|
|
70
|
+
if self._clock.monotonic() >= deadline_mono:
|
|
71
|
+
# Leave the first unstarted alias at the head next cycle; slow
|
|
72
|
+
# aliases therefore cannot permanently starve later entries.
|
|
73
|
+
self._next_alias = alias
|
|
74
|
+
for _ in ordered[index:]:
|
|
75
|
+
self._counter("external_checks_skipped")
|
|
76
|
+
return
|
|
77
|
+
result = self._runner.run(self._registry[alias], deadline_mono)
|
|
78
|
+
self._results[alias] = result
|
|
79
|
+
if result.failure is not None:
|
|
80
|
+
self._counter(f"external_check_failures:{result.failure}")
|
|
81
|
+
|
|
82
|
+
self._next_alias = ordered[0]
|
|
83
|
+
|
|
84
|
+
def project(self, monitor: MonitorDef, now: float) -> Snapshot:
|
|
85
|
+
"""Project a cached raw result through one monitor's declared mappings."""
|
|
86
|
+
options = monitor.source_options
|
|
87
|
+
alias = options["check"]
|
|
88
|
+
raw = self._results.get(alias)
|
|
89
|
+
if raw is None:
|
|
90
|
+
# A budget skip is absence, not synthetic UNKNOWN evidence: an
|
|
91
|
+
# absent entity cannot falsely clear or alter an incident.
|
|
92
|
+
return Snapshot(source="external", ts=now, entities=())
|
|
93
|
+
|
|
94
|
+
return self._project_options(options, raw, now)
|
|
95
|
+
|
|
96
|
+
def sample(self, now: float, deadline_mono: float, options: Mapping) -> Snapshot:
|
|
97
|
+
"""Sampler-compatible projection after ``prepare`` has run."""
|
|
98
|
+
# Kept mapping-only so future pipeline wiring does not need to retain a
|
|
99
|
+
# MonitorDef just to consume the cycle cache.
|
|
100
|
+
raw = self._results.get(options["check"])
|
|
101
|
+
if raw is None:
|
|
102
|
+
return Snapshot(source="external", ts=now, entities=())
|
|
103
|
+
return self._project_options(options, raw, now)
|
|
104
|
+
|
|
105
|
+
def _project_options(self, options: Mapping, raw: RawCheckResult, now: float) -> Snapshot:
|
|
106
|
+
metrics = {
|
|
107
|
+
"plugin_state": float(raw.state),
|
|
108
|
+
"plugin_ok": float(raw.state == 0),
|
|
109
|
+
"duration_s": raw.duration_s,
|
|
110
|
+
}
|
|
111
|
+
for mapping in options.get("perfdata", ()):
|
|
112
|
+
source_value = raw.values.get(mapping["label"])
|
|
113
|
+
if source_value is None:
|
|
114
|
+
continue
|
|
115
|
+
value, uom = source_value
|
|
116
|
+
if uom != mapping["plugin_uom"]:
|
|
117
|
+
self._counter("external_perfdata_rejected:uom")
|
|
118
|
+
continue
|
|
119
|
+
scaled = value * mapping.get("scale", 1.0)
|
|
120
|
+
if not math.isfinite(value) or not math.isfinite(scaled):
|
|
121
|
+
self._counter("external_perfdata_rejected:non_finite")
|
|
122
|
+
continue
|
|
123
|
+
metrics[mapping["metric"]] = scaled
|
|
124
|
+
entity = EntitySample(
|
|
125
|
+
entity_id=options["entity"],
|
|
126
|
+
attrs={"plugin_message": raw.message},
|
|
127
|
+
metrics=metrics,
|
|
128
|
+
)
|
|
129
|
+
return Snapshot(source="external", ts=now, entities=(entity,))
|
ftmon/checks/text.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Untrusted plugin text normalization."""
|
|
2
|
+
|
|
3
|
+
MESSAGE_LIMIT = 2048
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def clean_message(value: str) -> str:
|
|
7
|
+
# Preserve useful non-ASCII text, but remove ASCII controls that could forge
|
|
8
|
+
# terminal/log structure. Newlines never reach here from the protocol adapters.
|
|
9
|
+
cleaned = "".join(char for char in value if ord(char) >= 32 and ord(char) != 127)
|
|
10
|
+
return cleaned.encode("utf-8")[:MESSAGE_LIMIT].decode("utf-8", errors="ignore")
|