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.
@@ -0,0 +1,206 @@
1
+ """sg_collect -- snapshot collectors for the stack-guardian health doctor.
2
+
3
+ Each collector probes one surface of the home AI stack: an Ollama endpoint over
4
+ HTTP, the local NVIDIA GPUs through nvidia-smi, and the tailnet through
5
+ `tailscale status --json`. Every command runner and HTTP getter is injectable so
6
+ tests never touch the network or the GPU. A dead endpoint is data, never a crash.
7
+ """
8
+
9
+ import json
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ import urllib.request
14
+
15
+ try:
16
+ from . import parsers as sg_parsers
17
+ except ImportError: # run as a plain file next to sg_parsers.py
18
+ import sg_parsers
19
+
20
+ GPU_QUERY = ["nvidia-smi", "--query-gpu=index,uuid,name,memory.total,memory.used", "--format=csv,noheader,nounits"]
21
+ APPS_QUERY = ["nvidia-smi", "--query-compute-apps=gpu_uuid,pid,process_name,used_memory", "--format=csv,noheader,nounits"]
22
+ TAILSCALE_STATUS = ["tailscale", "status", "--json"]
23
+
24
+
25
+ def default_run(argv, timeout=15):
26
+ """Run a command and return its stdout, or None if it failed or is missing."""
27
+ try:
28
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False)
29
+ except (OSError, subprocess.SubprocessError):
30
+ return None
31
+ return proc.stdout if proc.returncode == 0 else None
32
+
33
+
34
+ def default_http_get(url, timeout=5):
35
+ """GET a URL and return (status, body text)."""
36
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
37
+ return resp.status, resp.read().decode("utf-8", errors="replace")
38
+
39
+
40
+ def collect_ollama(url, http_get=None, clock=None):
41
+ """Probe one Ollama endpoint: version, installed models, loaded models."""
42
+ http_get = http_get or default_http_get
43
+ clock = clock or time.monotonic
44
+ base = url.rstrip("/")
45
+ out = {"url": base, "kind": "ollama", "version": None, "models": [], "running": [], "latency_ms": None, "error": None}
46
+ try:
47
+ t0 = clock()
48
+ status, body = http_get(base + "/api/version")
49
+ out["latency_ms"] = int(round((clock() - t0) * 1000))
50
+ if status != 200:
51
+ raise ValueError("HTTP %s on /api/version" % status)
52
+ out["version"] = json.loads(body).get("version")
53
+ status, body = http_get(base + "/api/tags")
54
+ if status == 200:
55
+ out["models"] = [m["name"] for m in (json.loads(body).get("models") or []) if isinstance(m, dict) and m.get("name")]
56
+ status, body = http_get(base + "/api/ps")
57
+ if status == 200:
58
+ out["running"] = sg_parsers.parse_ollama_ps(body)
59
+ except Exception as exc:
60
+ out["error"] = ("%s: %s" % (type(exc).__name__, exc))[:200]
61
+ return out
62
+
63
+
64
+ def collect_gpus(run=None):
65
+ """Describe the local GPUs and their attributed compute processes."""
66
+ return collect_gpus_status(run)[0]
67
+
68
+
69
+ def collect_gpus_status(run=None):
70
+ """(gpus, error): error is set when nvidia-smi is missing or fails, so "no GPUs" and "cannot tell" differ."""
71
+ run = run or default_run
72
+ gpu = run(GPU_QUERY)
73
+ if gpu is None:
74
+ return [], "nvidia-smi unavailable (not installed, or it failed)"
75
+ if not gpu.strip():
76
+ return [], None
77
+ return sg_parsers.parse_nvsmi(gpu, run(APPS_QUERY) or ""), None
78
+
79
+
80
+ def collect_tailnet(peer_host, run=None):
81
+ """Report whether peer_host is visible on the tailnet and how it is reached; peer None when tailscale cannot say."""
82
+ run = run or default_run
83
+ out = run(TAILSCALE_STATUS)
84
+ if out is None:
85
+ return dict(sg_parsers.UNAVAILABLE, error="tailscale status failed (tailscale missing or tailscaled down)")
86
+ return sg_parsers.parse_tailscale(out, peer_host)
87
+
88
+
89
+ def snapshot(nodes, run=None, http_get=None, now=None, clock=None, timeout_s=10.0):
90
+ """Build the one snapshot dict the checks read, in node input order. Every node is collected at
91
+ the same time and a node that has not answered within timeout_s is reported down (multinode.pull)."""
92
+ try:
93
+ from . import multinode as sg_multinode
94
+ except ImportError: # run as a plain file next to the sg_* modules, like cli.py
95
+ import multinode as sg_multinode
96
+
97
+ def one(name, cfg):
98
+ ollama = [collect_ollama(u, http_get=http_get, clock=clock) for u in (cfg.get("ollama") or [])]
99
+ ok = [o for o in ollama if o["error"] is None]
100
+ node = {"reachable": bool(ok), "latency_ms": ok[0]["latency_ms"] if ok else None, "ollama": ollama}
101
+ if cfg.get("local"):
102
+ node["gpus"], gpu_error = collect_gpus_status(run=run)
103
+ if gpu_error:
104
+ node["gpu_error"] = gpu_error
105
+ if cfg.get("tailnet_host"):
106
+ node["tailnet"] = collect_tailnet(cfg["tailnet_host"], run=run)
107
+ return node
108
+ return sg_multinode.pull(nodes, one, timeout_s=timeout_s, now=now)
109
+
110
+
111
+ def _selftest():
112
+ TAGS = '{"models": [{"name": "qwen3:30b"}, {"name": "nomic-embed-text"}]}'
113
+ PS = '{"models": [{"name": "qwen3:30b", "size_vram": 19000000000, "expires_at": "2026-10-04T00:00:00Z"}]}'
114
+
115
+ def http_ok(url, timeout=5):
116
+ if url.endswith("/api/version"):
117
+ return 200, '{"version": "0.34.2"}'
118
+ if url.endswith("/api/tags"):
119
+ return 200, TAGS
120
+ return 200, PS
121
+
122
+ def http_dead(url, timeout=5):
123
+ raise OSError("connection refused")
124
+
125
+ ticks = iter([10.0, 10.042, 20.0, 20.5])
126
+
127
+ def clock():
128
+ return next(ticks)
129
+
130
+ def run_ok(argv, timeout=15):
131
+ if argv[0] == "tailscale":
132
+ return '{"Peer": {"k": {"HostName": "gpu-box", "Online": true, "CurAddr": "1.2.3.4:41641", "TailscaleIPs": ["100.64.0.10"]}}}'
133
+ if "--query-gpu=index,uuid,name,memory.total,memory.used" in argv:
134
+ return "0, GPU-a, RTX 3060, 12288, 9000\n1, GPU-b, RTX 4070 Ti SUPER, 16376, 300\n"
135
+ return "GPU-a, 4242, C:\\ollama\\ollama.exe, 8800\n"
136
+
137
+ def run_none(argv, timeout=15):
138
+ return None
139
+
140
+ def t_ollama_ok():
141
+ o = collect_ollama("http://gpu-box:11434/", http_get=http_ok, clock=clock)
142
+ return (o["url"] == "http://gpu-box:11434" and o["version"] == "0.34.2" and o["models"] == ["qwen3:30b", "nomic-embed-text"]
143
+ and o["running"][0]["name"] == "qwen3:30b" and o["latency_ms"] == 42 and o["error"] is None)
144
+
145
+ def t_ollama_dead():
146
+ o = collect_ollama("http://x:11434", http_get=http_dead)
147
+ return o["version"] is None and o["models"] == [] and o["error"].startswith("OSError: connection refused")
148
+
149
+ def t_gpus():
150
+ g = collect_gpus(run=run_ok)
151
+ return len(g) == 2 and g[0]["attributed_mib"] == 8800 and g[0]["processes"][0]["name"] == "ollama.exe"
152
+
153
+ def t_no_nvidia_smi():
154
+ return collect_gpus(run=run_none) == []
155
+
156
+ def t_tailnet():
157
+ t = collect_tailnet("GPU-BOX", run=run_ok)
158
+ return t["peer"] is True and t["direct"] is True and t["ips"] == ["100.64.0.10"]
159
+
160
+ def t_tailnet_missing():
161
+ return collect_tailnet("gpu-box", run=run_none)["peer"] is None
162
+
163
+ def t_snapshot_shape():
164
+ s = snapshot({"gpu-box": {"ollama": ["http://gpu-box:11434"], "tailnet_host": "gpu-box"},
165
+ "gpu-box-2": {"ollama": ["http://n1:11434"], "local": True}},
166
+ run=run_ok, http_get=lambda u, timeout=5: http_dead(u) if "n1" in u else http_ok(u), now="T")
167
+ a, n = s["nodes"]["gpu-box"], s["nodes"]["gpu-box-2"]
168
+ return (s["schema"] == 1 and s["taken_at"] == "T" and a["reachable"] is True and "gpus" not in a
169
+ and a["tailnet"]["peer"] is True and n["reachable"] is False and n["latency_ms"] is None and len(n["gpus"]) == 2)
170
+
171
+ def t_empty():
172
+ return snapshot({}, now="T") == {"schema": 1, "taken_at": "T", "nodes": {}}
173
+
174
+ checks = [
175
+ ("ollama_ok", t_ollama_ok),
176
+ ("ollama_dead", t_ollama_dead),
177
+ ("gpus", t_gpus),
178
+ ("no_nvidia_smi", t_no_nvidia_smi),
179
+ ("tailnet", t_tailnet),
180
+ ("tailnet_missing", t_tailnet_missing),
181
+ ("snapshot_shape", t_snapshot_shape),
182
+ ("empty", t_empty),
183
+ ]
184
+ passed = 0
185
+ for name, fn in checks:
186
+ try:
187
+ ok = bool(fn())
188
+ except Exception as exc: # noqa: BLE001 - a crashed check is a failed check
189
+ ok = False
190
+ name = "%s (raised %s: %s)" % (name, type(exc).__name__, exc)
191
+ passed += ok
192
+ print(("ok " if ok else "FAIL ") + name)
193
+ failed = len(checks) - passed
194
+ print("sg_collect selftest: %d checks, %d passed, %d failed" % (len(checks), passed, failed))
195
+ return 0 if failed == 0 and checks else 1
196
+
197
+
198
+ if __name__ == "__main__":
199
+ if "--selftest" in sys.argv:
200
+ sys.exit(_selftest())
201
+ if "--nodes" in sys.argv:
202
+ with open(sys.argv[sys.argv.index("--nodes") + 1], encoding="utf-8") as fh:
203
+ print(json.dumps(snapshot(json.load(fh)), indent=1))
204
+ sys.exit(0)
205
+ print("usage: sg_collect.py --nodes NODES.json | --selftest")
206
+ sys.exit(2)
stack_guardian/core.py ADDED
@@ -0,0 +1,264 @@
1
+ """stack-guardian core runner.
2
+
3
+ Loads the Python checks of a stack-guardian folder, runs them concurrently
4
+ against a snapshot dict and reports the findings it collects.
5
+ """
6
+
7
+ import argparse
8
+ import contextlib
9
+ import importlib.util
10
+ import json
11
+ import os
12
+ import shutil
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ from concurrent.futures import ThreadPoolExecutor
17
+
18
+ try:
19
+ from . import finding as sg_finding
20
+ except ImportError: # run as a plain file next to sg_finding.py
21
+ import sg_finding
22
+
23
+ _LEVEL_RANK = {"FAIL": 0, "WARN": 1, "INFO": 2, "OK": 3}
24
+
25
+
26
+ def _broken_check(message):
27
+ """Build a check function that raises RuntimeError(message) when run."""
28
+
29
+ def _check(*_args, **_kwargs):
30
+ raise RuntimeError(message)
31
+
32
+ _check.__name__ = "broken_check"
33
+ return _check
34
+
35
+
36
+ def load_checks(checks_dir):
37
+ """Return the [(check_id, fn), ...] list published by checks_dir.
38
+
39
+ Files are visited in file-name order; names starting with "_" are
40
+ skipped. A file that cannot be imported, or that publishes no CHECKS
41
+ list, contributes a single ("load.<stem>", fn) entry whose fn raises,
42
+ so the doctor reports it as a crashed check instead of dying itself.
43
+ """
44
+ if not checks_dir or not os.path.isdir(checks_dir):
45
+ return []
46
+ try:
47
+ names = sorted(os.listdir(checks_dir))
48
+ except OSError:
49
+ return []
50
+
51
+ checks = []
52
+ for position, name in enumerate(names):
53
+ if name.startswith("_") or not name.endswith(".py"):
54
+ continue
55
+ path = os.path.join(checks_dir, name)
56
+ if not os.path.isfile(path):
57
+ continue
58
+ try:
59
+ mod_name = "sg_check_%d_%s" % (position, name[:-3])
60
+ spec = importlib.util.spec_from_file_location(mod_name, path)
61
+ module = importlib.util.module_from_spec(spec)
62
+ spec.loader.exec_module(module)
63
+ entries = getattr(module, "CHECKS", None)
64
+ if not isinstance(entries, list):
65
+ raise RuntimeError("no CHECKS list")
66
+ for check_id, fn in entries:
67
+ checks.append((check_id, fn))
68
+ except Exception as exc:
69
+ checks.append(("load." + name[:-3],
70
+ _broken_check("%s: %s" % (name, exc))))
71
+ return checks
72
+
73
+
74
+ def run_all(checks, snapshot, ctx=None, max_workers=8):
75
+ """Run every check through sg_finding.run_check, concurrently.
76
+
77
+ Findings come back in the order of `checks`; a check may return one finding
78
+ or a list of them (one per node); None results are dropped.
79
+ """
80
+ checks = list(checks)
81
+ if not checks:
82
+ return []
83
+ ctx = ctx or {}
84
+ results = [None] * len(checks)
85
+
86
+ def _work(index, check_id, fn):
87
+ results[index] = sg_finding.run_check(check_id, fn, snapshot, ctx)
88
+
89
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
90
+ futures = [pool.submit(_work, index, check_id, fn)
91
+ for index, (check_id, fn) in enumerate(checks)]
92
+ for future in futures:
93
+ future.result()
94
+ flat = []
95
+ for result in results:
96
+ flat.extend(result if isinstance(result, list) else [result])
97
+ return [finding for finding in flat if finding is not None]
98
+
99
+
100
+ def render(findings):
101
+ """Render findings one per line, worst level first, plus a verdict."""
102
+ findings = list(findings)
103
+ if not findings:
104
+ return "worst: OK"
105
+ ordered = sorted(findings,
106
+ key=lambda f: _LEVEL_RANK.get(f.get("level", "OK"), 3))
107
+ lines = []
108
+ for finding in ordered:
109
+ node = finding.get("node") or ""
110
+ lines.append("%-4s %s%s: %s" % (finding.get("level", "OK"),
111
+ finding.get("code", ""),
112
+ " [" + node + "]" if node else "",
113
+ finding.get("summary", "")))
114
+ lines.append("worst: " + sg_finding.worst(findings))
115
+ return "\n".join(lines)
116
+
117
+
118
+ @contextlib.contextmanager
119
+ def _scratch_dir():
120
+ """A temporary directory that is always removed again."""
121
+ path = tempfile.mkdtemp()
122
+ try:
123
+ yield path
124
+ finally:
125
+ shutil.rmtree(path, ignore_errors=True)
126
+
127
+
128
+ def _write_check_file(directory, name, body):
129
+ with open(os.path.join(directory, name), "w", encoding="utf-8") as handle:
130
+ handle.write(body)
131
+
132
+
133
+ def _check_file(check_id):
134
+ """Source of a check file that publishes a single no-op check."""
135
+ return ('def _noop(*_args, **_kwargs):\n return None\n\n'
136
+ 'CHECKS = [("%s", _noop)]\n' % check_id)
137
+
138
+
139
+ def _check_loads_in_file_order():
140
+ with _scratch_dir() as scratch:
141
+ _write_check_file(scratch, "b.py", _check_file("b1"))
142
+ _write_check_file(scratch, "a.py", _check_file("a1"))
143
+ return [check_id for check_id, _fn in load_checks(scratch)] == ["a1", "b1"]
144
+
145
+
146
+ def _check_underscore_skipped():
147
+ with _scratch_dir() as scratch:
148
+ _write_check_file(scratch, "_helper.py", _check_file("helper"))
149
+ return load_checks(scratch) == []
150
+
151
+
152
+ def _check_broken_file_is_a_check():
153
+ with _scratch_dir() as scratch:
154
+ _write_check_file(scratch, "bad.py", "this is not python\n")
155
+ checks = load_checks(scratch)
156
+ if [check_id for check_id, _fn in checks] != ["load.bad"]:
157
+ return False
158
+ findings = run_all(checks, {})
159
+ if len(findings) != 1:
160
+ return False
161
+ return (findings[0].get("code") == "check.crashed"
162
+ and findings[0].get("level") == "WARN")
163
+
164
+
165
+ def _check_none_dropped():
166
+ with _scratch_dir() as scratch:
167
+ _write_check_file(scratch, "quiet.py", _check_file("quiet"))
168
+ return run_all(load_checks(scratch), {}) == []
169
+
170
+
171
+ def _check_order_kept():
172
+ def _ok(code):
173
+ def _check(*_args, **_kwargs):
174
+ return sg_finding.make_finding(code, "ok", "OK", "", code)
175
+ return _check
176
+
177
+ def _slow(*_args, **_kwargs):
178
+ time.sleep(0.2)
179
+ return sg_finding.make_finding("x", "ok", "OK", "", "x")
180
+
181
+ checks = [("x", _slow), ("y", _ok("y")), ("z", _ok("z"))]
182
+ findings = run_all(checks, {})
183
+ return [finding.get("code") for finding in findings] == ["x", "y", "z"]
184
+
185
+
186
+ def _check_render_sorted():
187
+ findings = [sg_finding.make_finding("ok_code", "ok", "OK", "", "ok summary"),
188
+ sg_finding.make_finding("bad_code", "missing", "FAIL", "web",
189
+ "fail summary")]
190
+ lines = render(findings).split("\n")
191
+ return lines[0].startswith("FAIL")
192
+
193
+
194
+ def _check_render_empty():
195
+ return render([]) == "worst: OK"
196
+
197
+
198
+ def _check_missing_dir():
199
+ with _scratch_dir() as scratch:
200
+ return load_checks(os.path.join(scratch, "absent")) == []
201
+
202
+
203
+ _SELFTESTS = [
204
+ ("loads_in_file_order", _check_loads_in_file_order),
205
+ ("underscore_skipped", _check_underscore_skipped),
206
+ ("broken_file_is_a_check", _check_broken_file_is_a_check),
207
+ ("none_dropped", _check_none_dropped),
208
+ ("order_kept", _check_order_kept),
209
+ ("render_sorted", _check_render_sorted),
210
+ ("render_empty", _check_render_empty),
211
+ ("missing_dir", _check_missing_dir),
212
+ ]
213
+
214
+
215
+ def run_selftest():
216
+ """Run the built-in checks, print the tally and return an exit code."""
217
+ total = len(_SELFTESTS)
218
+ passed = 0
219
+ failed = 0
220
+ for name, check in _SELFTESTS:
221
+ detail = ""
222
+ try:
223
+ ok = bool(check())
224
+ except Exception as exc:
225
+ ok = False
226
+ detail = "%s: %s" % (type(exc).__name__, exc)
227
+ if ok:
228
+ passed += 1
229
+ else:
230
+ failed += 1
231
+ print("FAIL %s%s" % (name, ": " + detail if detail else ""))
232
+ print("sg_core selftest: %d checks, %d passed, %d failed"
233
+ % (total, passed, failed))
234
+ return 0 if failed == 0 and total > 0 else 1
235
+
236
+
237
+ def main(argv=None):
238
+ parser = argparse.ArgumentParser(prog="sg_core",
239
+ description="stack-guardian health doctor")
240
+ parser.add_argument("--checks-dir", help="folder holding the check modules")
241
+ parser.add_argument("--snapshot", help="JSON file holding the snapshot")
242
+ parser.add_argument("--json", action="store_true",
243
+ help="print the findings as one JSON line")
244
+ parser.add_argument("--selftest", action="store_true",
245
+ help="run the built-in checks and exit")
246
+ args = parser.parse_args(argv)
247
+
248
+ if args.selftest:
249
+ return run_selftest()
250
+ if not args.checks_dir or not args.snapshot:
251
+ parser.error("--checks-dir and --snapshot are required")
252
+
253
+ with open(args.snapshot, "r", encoding="utf-8") as handle:
254
+ snapshot = json.load(handle)
255
+ findings = run_all(load_checks(args.checks_dir), snapshot)
256
+ if args.json:
257
+ print(json.dumps(findings))
258
+ else:
259
+ print(render(findings))
260
+ return sg_finding.exit_code(findings)
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())