stack-guardian 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.
- stack_guardian/__init__.py +13 -0
- stack_guardian/__main__.py +6 -0
- stack_guardian/checks.d/10_core.py +174 -0
- stack_guardian/cli.py +295 -0
- stack_guardian/collect.py +206 -0
- stack_guardian/core.py +264 -0
- stack_guardian/finding.py +491 -0
- stack_guardian/fixes.py +287 -0
- stack_guardian/mcp_server.py +340 -0
- stack_guardian/multinode.py +152 -0
- stack_guardian/parsers.py +658 -0
- stack_guardian/serve.py +198 -0
- stack_guardian/state.py +266 -0
- stack_guardian-0.1.0.dist-info/METADATA +164 -0
- stack_guardian-0.1.0.dist-info/RECORD +19 -0
- stack_guardian-0.1.0.dist-info/WHEEL +5 -0
- stack_guardian-0.1.0.dist-info/entry_points.txt +4 -0
- stack_guardian-0.1.0.dist-info/licenses/LICENSE +21 -0
- stack_guardian-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Multi-node snapshot pull: every node at once, one overall deadline.
|
|
2
|
+
"""Collect health data from several machines at once, without letting one hung
|
|
3
|
+
node stall the whole check.
|
|
4
|
+
|
|
5
|
+
Every node is handed to ``collect_one(name, cfg)`` in its own thread at the same
|
|
6
|
+
instant, and a single overall deadline is applied to all of them. Whatever has
|
|
7
|
+
not answered by the deadline is reported as down instead of being waited on, so
|
|
8
|
+
a run always returns within ``timeout_s`` plus a little slack.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import concurrent.futures
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _down(error):
|
|
19
|
+
return {"reachable": False, "error": error, "ollama": []}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pull(nodes, collect_one, timeout_s=5.0, now=None):
|
|
23
|
+
"""Pull ``nodes`` (name -> cfg, cfg may be None) concurrently.
|
|
24
|
+
|
|
25
|
+
Returns ``{"schema": 1, "taken_at": ..., "nodes": {...}}`` with the node
|
|
26
|
+
results in the same order as the input dict. A node that raised, returned a
|
|
27
|
+
non-dict or did not answer before the deadline is reported as down.
|
|
28
|
+
"""
|
|
29
|
+
items = list((nodes or {}).items())
|
|
30
|
+
result = {}
|
|
31
|
+
if items:
|
|
32
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(items))
|
|
33
|
+
try:
|
|
34
|
+
futures = [
|
|
35
|
+
(name, executor.submit(collect_one, name, cfg if cfg is not None else {}))
|
|
36
|
+
for name, cfg in items
|
|
37
|
+
]
|
|
38
|
+
concurrent.futures.wait([f for _, f in futures], timeout=timeout_s)
|
|
39
|
+
for name, future in futures:
|
|
40
|
+
if not future.done():
|
|
41
|
+
result[name] = _down("timeout after %ss" % timeout_s)
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
value = future.result()
|
|
45
|
+
except Exception as exc: # noqa: BLE001
|
|
46
|
+
result[name] = _down("%s: %s" % (type(exc).__name__, exc))
|
|
47
|
+
continue
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
result[name] = value
|
|
50
|
+
else:
|
|
51
|
+
result[name] = _down("collector returned %s" % type(value).__name__)
|
|
52
|
+
finally:
|
|
53
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
54
|
+
return {
|
|
55
|
+
"schema": 1,
|
|
56
|
+
"taken_at": now or datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
57
|
+
"nodes": result,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def elapsed_ok(started, finished, timeout_s, slack_s=1.0):
|
|
62
|
+
"""True when a run finished within ``timeout_s`` plus ``slack_s``."""
|
|
63
|
+
return (finished - started) <= timeout_s + slack_s
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv=None):
|
|
67
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
68
|
+
if "--selftest" in argv:
|
|
69
|
+
return _selftest()
|
|
70
|
+
print(json.dumps({"usage": "multinode.py --selftest (library: pull(nodes, collect_one, timeout_s))"}))
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _selftest():
|
|
75
|
+
import threading
|
|
76
|
+
release = threading.Event()
|
|
77
|
+
|
|
78
|
+
def fake(name, cfg):
|
|
79
|
+
kind = cfg.get("kind")
|
|
80
|
+
if kind == "hang":
|
|
81
|
+
release.wait(30)
|
|
82
|
+
return {"reachable": True}
|
|
83
|
+
if kind == "slow":
|
|
84
|
+
time.sleep(0.3)
|
|
85
|
+
return {"reachable": True, "who": name}
|
|
86
|
+
if kind == "boom":
|
|
87
|
+
raise ConnectionError("refused")
|
|
88
|
+
if kind == "odd":
|
|
89
|
+
return ["not", "a", "dict"]
|
|
90
|
+
return {"reachable": True, "who": name, "cfg": cfg}
|
|
91
|
+
|
|
92
|
+
def c_order_and_shape():
|
|
93
|
+
r = pull({"b": {}, "a": {}}, fake, timeout_s=2, now="T")
|
|
94
|
+
return r["schema"] == 1 and r["taken_at"] == "T" and list(r["nodes"]) == ["b", "a"] and r["nodes"]["a"]["who"] == "a"
|
|
95
|
+
|
|
96
|
+
def c_cfg_passed_and_none_cfg():
|
|
97
|
+
r = pull({"a": {"x": 1}, "b": None}, fake, timeout_s=2, now="T")
|
|
98
|
+
return r["nodes"]["a"]["cfg"] == {"x": 1} and r["nodes"]["b"]["cfg"] == {}
|
|
99
|
+
|
|
100
|
+
def c_raise_becomes_down():
|
|
101
|
+
r = pull({"d": {"kind": "boom"}}, fake, timeout_s=2, now="T")
|
|
102
|
+
return r["nodes"]["d"] == {"reachable": False, "error": "ConnectionError: refused", "ollama": []}
|
|
103
|
+
|
|
104
|
+
def c_non_dict_becomes_down():
|
|
105
|
+
return pull({"o": {"kind": "odd"}}, fake, timeout_s=2, now="T")["nodes"]["o"]["error"] == "collector returned list"
|
|
106
|
+
|
|
107
|
+
def c_hang_times_out_fast():
|
|
108
|
+
t0 = time.monotonic()
|
|
109
|
+
r = pull({"h": {"kind": "hang"}, "ok": {}}, fake, timeout_s=0.5, now="T")
|
|
110
|
+
took = time.monotonic() - t0
|
|
111
|
+
release.set()
|
|
112
|
+
return (r["nodes"]["h"] == {"reachable": False, "error": "timeout after 0.5s", "ollama": []}
|
|
113
|
+
and r["nodes"]["ok"]["reachable"] is True and took < 1.5)
|
|
114
|
+
|
|
115
|
+
def c_concurrent_not_serial():
|
|
116
|
+
t0 = time.monotonic()
|
|
117
|
+
r = pull({"s1": {"kind": "slow"}, "s2": {"kind": "slow"}, "s3": {"kind": "slow"}}, fake, timeout_s=3, now="T")
|
|
118
|
+
took = time.monotonic() - t0
|
|
119
|
+
return all(n["reachable"] for n in r["nodes"].values()) and took < 0.8
|
|
120
|
+
|
|
121
|
+
def c_empty():
|
|
122
|
+
r = pull({}, fake, now="T")
|
|
123
|
+
return r == {"schema": 1, "taken_at": "T", "nodes": {}} and pull(None, fake, now="T")["nodes"] == {}
|
|
124
|
+
|
|
125
|
+
def c_default_now_is_iso():
|
|
126
|
+
t = pull({}, fake)["taken_at"]
|
|
127
|
+
return isinstance(t, str) and t[4] == "-" and "T" in t
|
|
128
|
+
|
|
129
|
+
def c_elapsed_ok():
|
|
130
|
+
return elapsed_ok(0.0, 5.9, 5) is True and elapsed_ok(0.0, 6.1, 5) is False and elapsed_ok(0.0, 5.4, 5, slack_s=0.2) is False
|
|
131
|
+
|
|
132
|
+
checks = [("order_and_shape", c_order_and_shape), ("cfg_passed_and_none_cfg", c_cfg_passed_and_none_cfg),
|
|
133
|
+
("raise_becomes_down", c_raise_becomes_down), ("non_dict_becomes_down", c_non_dict_becomes_down),
|
|
134
|
+
("hang_times_out_fast", c_hang_times_out_fast), ("concurrent_not_serial", c_concurrent_not_serial),
|
|
135
|
+
("empty", c_empty), ("default_now_is_iso", c_default_now_is_iso), ("elapsed_ok", c_elapsed_ok)]
|
|
136
|
+
passed = failed = 0
|
|
137
|
+
for name, fn in checks:
|
|
138
|
+
try:
|
|
139
|
+
ok = bool(fn())
|
|
140
|
+
except Exception as exc: # noqa: BLE001
|
|
141
|
+
ok = False
|
|
142
|
+
print("FAIL %s -- raised %s: %s" % (name, type(exc).__name__, exc))
|
|
143
|
+
else:
|
|
144
|
+
print(("ok %s" if ok else "FAIL %s") % name)
|
|
145
|
+
passed += ok
|
|
146
|
+
failed += not ok
|
|
147
|
+
print("multinode selftest: %d checks, %d passed, %d failed" % (len(checks), passed, failed))
|
|
148
|
+
return 0 if failed == 0 and checks else 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
sys.exit(main())
|