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,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())
|