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.
Files changed (113) hide show
  1. ftmon/__init__.py +3 -0
  2. ftmon/__main__.py +9 -0
  3. ftmon/checks/__init__.py +7 -0
  4. ftmon/checks/jsoncheck.py +55 -0
  5. ftmon/checks/model.py +36 -0
  6. ftmon/checks/nagios.py +98 -0
  7. ftmon/checks/registry.py +150 -0
  8. ftmon/checks/runner.py +121 -0
  9. ftmon/checks/sampler.py +129 -0
  10. ftmon/checks/text.py +10 -0
  11. ftmon/cli.py +837 -0
  12. ftmon/clock.py +130 -0
  13. ftmon/config.py +324 -0
  14. ftmon/daemon.py +654 -0
  15. ftmon/definitions/__init__.py +31 -0
  16. ftmon/definitions/builtins/disk.toml +126 -0
  17. ftmon/definitions/builtins/events.toml +38 -0
  18. ftmon/definitions/builtins/hog.toml +42 -0
  19. ftmon/definitions/builtins/leak.toml +67 -0
  20. ftmon/definitions/builtins/load.toml +47 -0
  21. ftmon/definitions/builtins/net.toml +40 -0
  22. ftmon/definitions/builtins/self.toml +62 -0
  23. ftmon/definitions/builtins/service.toml +38 -0
  24. ftmon/definitions/loader.py +1147 -0
  25. ftmon/definitions/manage.py +162 -0
  26. ftmon/definitions/schema.py +160 -0
  27. ftmon/demo.py +191 -0
  28. ftmon/deploy/Caddyfile.demo +53 -0
  29. ftmon/engine/__init__.py +0 -0
  30. ftmon/engine/actions.py +127 -0
  31. ftmon/engine/context.py +52 -0
  32. ftmon/engine/effects.py +107 -0
  33. ftmon/engine/episodes.py +239 -0
  34. ftmon/engine/events.py +364 -0
  35. ftmon/engine/incidents.py +311 -0
  36. ftmon/engine/pipeline.py +249 -0
  37. ftmon/engine/render.py +39 -0
  38. ftmon/engine/rings.py +109 -0
  39. ftmon/engine/scheduler.py +77 -0
  40. ftmon/expr/__init__.py +38 -0
  41. ftmon/expr/eval.py +253 -0
  42. ftmon/expr/functions.py +133 -0
  43. ftmon/expr/ir.py +83 -0
  44. ftmon/expr/parse.py +255 -0
  45. ftmon/expr/tribool.py +41 -0
  46. ftmon/mcp_server.py +588 -0
  47. ftmon/model.py +160 -0
  48. ftmon/notify/__init__.py +19 -0
  49. ftmon/notify/base.py +55 -0
  50. ftmon/notify/desktop.py +51 -0
  51. ftmon/notify/file.py +44 -0
  52. ftmon/notify/http.py +68 -0
  53. ftmon/notify/ntfy.py +64 -0
  54. ftmon/notify/smtp.py +87 -0
  55. ftmon/notify/webhook.py +51 -0
  56. ftmon/paths.py +98 -0
  57. ftmon/scenarios/__init__.py +1 -0
  58. ftmon/scenarios/demo-v1.jsonl +16 -0
  59. ftmon/selfmon.py +83 -0
  60. ftmon/sources/__init__.py +0 -0
  61. ftmon/sources/base.py +198 -0
  62. ftmon/sources/disk.py +94 -0
  63. ftmon/sources/fixtures.py +363 -0
  64. ftmon/sources/journald.py +155 -0
  65. ftmon/sources/net.py +84 -0
  66. ftmon/sources/process.py +124 -0
  67. ftmon/sources/system.py +121 -0
  68. ftmon/sources/unit.py +144 -0
  69. ftmon/store/__init__.py +8 -0
  70. ftmon/store/db.py +70 -0
  71. ftmon/store/doctor.py +67 -0
  72. ftmon/store/migrations/0001_init.sql +51 -0
  73. ftmon/store/migrations/0002_action_runs.sql +6 -0
  74. ftmon/store/migrations/0003_notification_deliveries.sql +44 -0
  75. ftmon/store/outbox.py +350 -0
  76. ftmon/store/query.py +500 -0
  77. ftmon/store/retention.py +337 -0
  78. ftmon/store/writer.py +435 -0
  79. ftmon/systemd/ftmon-demo-build.service +27 -0
  80. ftmon/systemd/ftmon-demo-refresh.service +17 -0
  81. ftmon/systemd/ftmon-demo-refresh.timer +15 -0
  82. ftmon/systemd/ftmon-demo-web.service +38 -0
  83. ftmon/systemd/ftmon-server.service +54 -0
  84. ftmon/systemd/ftmon.service +18 -0
  85. ftmon/web/__init__.py +6 -0
  86. ftmon/web/app.py +756 -0
  87. ftmon/web/demo_app.py +161 -0
  88. ftmon/web/static/brand/README.md +16 -0
  89. ftmon/web/static/brand/apple-touch-icon.png +0 -0
  90. ftmon/web/static/brand/favicon-64.png +0 -0
  91. ftmon/web/static/brand/favicon.ico +0 -0
  92. ftmon/web/static/brand/ftmon-mark.png +0 -0
  93. ftmon/web/static/ftmon.css +1 -0
  94. ftmon/web/static/ftmon.js +34 -0
  95. ftmon/web/static/vendor/README.md +10 -0
  96. ftmon/web/static/vendor/uPlot.LICENSE.txt +21 -0
  97. ftmon/web/static/vendor/uPlot.iife.min.js +2 -0
  98. ftmon/web/static/vendor/uPlot.min.css +1 -0
  99. ftmon/web/templates/base.html +8 -0
  100. ftmon/web/templates/dashboard.html +5 -0
  101. ftmon/web/templates/events.html +1 -0
  102. ftmon/web/templates/incident.html +4 -0
  103. ftmon/web/templates/incident_rows.html +2 -0
  104. ftmon/web/templates/incidents.html +1 -0
  105. ftmon/web/templates/metrics.html +2 -0
  106. ftmon/web/templates/monitors.html +2 -0
  107. ftmon/web/templates/self.html +1 -0
  108. ftmon/web/templates/trends.html +8 -0
  109. ftmon-2.0.0a1.dist-info/METADATA +190 -0
  110. ftmon-2.0.0a1.dist-info/RECORD +113 -0
  111. ftmon-2.0.0a1.dist-info/WHEEL +4 -0
  112. ftmon-2.0.0a1.dist-info/entry_points.txt +2 -0
  113. ftmon-2.0.0a1.dist-info/licenses/LICENSE +21 -0
ftmon/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FTMON v2 - lightweight local systems monitor. See SPEC.md and DESIGN.md."""
2
+
3
+ __version__ = "2.0.0a1"
ftmon/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """`python -m ftmon` — same entry as the `ftmon` console script. Exists so
2
+ the e2e harness (TS-05) can spawn the daemon with sys.executable and no
3
+ dependence on installed script shims."""
4
+
5
+ import sys
6
+
7
+ from ftmon.cli import main
8
+
9
+ sys.exit(main())
@@ -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)
@@ -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")
@@ -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")