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,13 @@
1
+ """stack-guardian: a health doctor for a home AI stack (Ollama nodes, GPUs, tailnet).
2
+
3
+ Check files (checks.d/*.py) export ``CHECKS = [(check_id, fn(snapshot, ctx) -> finding | None)]`` and build findings with
4
+ ``stack_guardian.finding.make_finding``. ``import sg_finding`` keeps working inside a check file: the package registers
5
+ that name for checks written against the flat module.
6
+ """
7
+ import sys
8
+
9
+ from . import finding
10
+
11
+ __version__ = "0.1.0.dev0"
12
+
13
+ sys.modules.setdefault("sg_finding", finding)
@@ -0,0 +1,6 @@
1
+ """python -m stack_guardian doctor ..."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,174 @@
1
+ """Core 0.1 rules for stack-guardian, a health doctor for a home AI stack.
2
+
3
+ Each rule inspects a whole snapshot and returns a list with ONE finding per
4
+ failing node (empty when the stack is healthy along that axis), so every node
5
+ keeps its own streak, matches a node= filter and can be named as code@node.
6
+ """
7
+
8
+ import sys
9
+
10
+ import sg_finding as F
11
+
12
+
13
+ def _nodes(snapshot):
14
+ """Return the snapshot's nodes as a list of (name, node) pairs, node None -> {}."""
15
+ nodes = (snapshot or {}).get("nodes") or {}
16
+ return [(name, node or {}) for name, node in nodes.items()]
17
+
18
+
19
+ def node_unreachable(snapshot, ctx):
20
+ return [F.make_finding("node.unreachable", "missing", "FAIL", name,
21
+ "node did not answer the collector: %s" % node["error"],
22
+ evidence=["%s: %s" % (name, node["error"])],
23
+ try_=["check the machine is on and reachable", "raise timeout_s if the node is just slow"])
24
+ for name, node in _nodes(snapshot) if node.get("error")]
25
+
26
+
27
+ def ollama_unreachable(snapshot, ctx):
28
+ out = []
29
+ for name, node in _nodes(snapshot):
30
+ bad = [(ep.get("url"), ep.get("error")) for ep in (node.get("ollama") or []) if (ep or {}).get("error")]
31
+ if bad:
32
+ out.append(F.make_finding(
33
+ "ollama.unreachable", "missing", "FAIL", name,
34
+ "%d Ollama endpoint(s) do not answer: %s" % (len(bad), ", ".join(url for url, _ in bad)),
35
+ evidence=["%s %s: %s" % (name, url, error) for url, error in bad],
36
+ try_=["check the Ollama service on that node"]))
37
+ return out
38
+
39
+
40
+ def model_not_loaded(snapshot, ctx):
41
+ expect = (ctx or {}).get("expect") or {}
42
+ out = []
43
+ for name, node in _nodes(snapshot):
44
+ want = (expect.get(name) or {}).get("model")
45
+ if not want or not node.get("reachable"):
46
+ continue
47
+ running = [(m or {}).get("name") for ep in (node.get("ollama") or []) for m in ((ep or {}).get("running") or [])]
48
+ if want not in running:
49
+ out.append(F.make_finding("model.not_loaded", "partial", "WARN", name,
50
+ "expected model not loaded: %s wants %s" % (name, want),
51
+ evidence=["%s running: %s" % (name, ", ".join(running) or "nothing")]))
52
+ return out
53
+
54
+
55
+ def vram_unattributed(snapshot, ctx):
56
+ limit = int((ctx or {}).get("vram_unattributed_mib", 2048))
57
+ out = []
58
+ for name, node in _nodes(snapshot):
59
+ bad = [((gpu or {}).get("name"), int((gpu or {}).get("unattributed_mib") or 0)) for gpu in (node.get("gpus") or [])]
60
+ bad = [(gpu, mib) for gpu, mib in bad if mib >= limit]
61
+ if bad:
62
+ out.append(F.make_finding("vram.unattributed", "partial", "WARN", name,
63
+ "%d GPU(s) hold VRAM no listed process explains" % len(bad),
64
+ evidence=["%s %s: %d MiB unattributed" % (name, gpu, mib) for gpu, mib in bad]))
65
+ return out
66
+
67
+
68
+ def gpu_unavailable(snapshot, ctx):
69
+ return [F.make_finding("gpu.unavailable", "partial", "INFO", name, "GPU status unavailable",
70
+ evidence=["%s: %s" % (name, node["gpu_error"])], try_=["check nvidia-smi runs on that node"])
71
+ for name, node in _nodes(snapshot) if node.get("gpu_error")]
72
+
73
+
74
+ def _tailnet(node):
75
+ return (node.get("tailnet") or {}) if "tailnet" in node else None
76
+
77
+
78
+ def tailnet_unavailable(snapshot, ctx):
79
+ return [F.make_finding("tailnet.unavailable", "partial", "INFO", name, "tailnet status unavailable",
80
+ evidence=["%s: %s" % (name, _tailnet(node).get("error") or "tailscale gave no status")],
81
+ try_=["check tailscale is installed and tailscaled is running"])
82
+ for name, node in _nodes(snapshot) if _tailnet(node) is not None and _tailnet(node).get("peer") is None]
83
+
84
+
85
+ def tailnet_peer_offline(snapshot, ctx):
86
+ return [F.make_finding("tailnet.peer_offline", "missing", "FAIL", name, "tailnet peer offline: " + name)
87
+ for name, node in _nodes(snapshot) if _tailnet(node) is not None and _tailnet(node).get("peer") is False]
88
+
89
+
90
+ def tailnet_relayed(snapshot, ctx):
91
+ out = []
92
+ for name, node in _nodes(snapshot):
93
+ tailnet = node.get("tailnet") or {}
94
+ if tailnet.get("peer") and tailnet.get("direct") is False:
95
+ out.append(F.make_finding("tailnet.relayed", "partial", "INFO", name,
96
+ "tailnet traffic is relayed, not direct: %s via %s" % (name, tailnet.get("derp"))))
97
+ return out
98
+
99
+
100
+ CHECKS = [
101
+ ("node.unreachable", node_unreachable),
102
+ ("ollama.unreachable", ollama_unreachable),
103
+ ("model.not_loaded", model_not_loaded),
104
+ ("vram.unattributed", vram_unattributed),
105
+ ("gpu.unavailable", gpu_unavailable),
106
+ ("tailnet.unavailable", tailnet_unavailable),
107
+ ("tailnet.peer_offline", tailnet_peer_offline),
108
+ ("tailnet.relayed", tailnet_relayed),
109
+ ]
110
+
111
+
112
+ def _selftest():
113
+ def snap(**over):
114
+ box = {"reachable": True, "latency_ms": 5, "ollama": [{"url": "http://a:11434", "error": None, "running": [{"name": "qwen3:30b"}]}],
115
+ "tailnet": {"peer": True, "direct": True, "derp": None, "ips": []}}
116
+ box2 = {"reachable": True, "latency_ms": 1, "ollama": [{"url": "http://n:11435", "error": None, "running": []}],
117
+ "gpus": [{"name": "RTX 3060", "used_mib": 300, "unattributed_mib": 300}]}
118
+ box.update(over.get("gpu-box", {}))
119
+ box2.update(over.get("gpu-box-2", {}))
120
+ return {"schema": 1, "taken_at": "T", "nodes": {"gpu-box": box, "gpu-box-2": box2}}
121
+
122
+ ctx = {"expect": {"gpu-box": {"model": "qwen3:30b"}}, "vram_unattributed_mib": 2048}
123
+
124
+ def t_clean_snapshot_no_findings():
125
+ return all(fn(snap(), ctx) == [] for _, fn in CHECKS)
126
+
127
+ def t_dead_endpoint_fail():
128
+ [f] = ollama_unreachable(snap(**{"gpu-box-2": {"ollama": [{"url": "http://n:11435", "error": "OSError: refused", "running": []}]}}), ctx)
129
+ return f["code"] == "ollama.unreachable" and f["level"] == "FAIL" and f["node"] == "gpu-box-2" and "refused" in f["evidence"][0]
130
+
131
+ def t_model_not_loaded_warn():
132
+ [f] = model_not_loaded(snap(**{"gpu-box": {"ollama": [{"url": "http://a:11434", "error": None, "running": [{"name": "qwen3-coder:30b"}]}]}}), ctx)
133
+ return f["level"] == "WARN" and f["node"] == "gpu-box" and "qwen3-coder:30b" in f["evidence"][0]
134
+
135
+ def t_unreachable_node_not_blamed_for_model():
136
+ return model_not_loaded(snap(**{"gpu-box": {"reachable": False}}), ctx) == []
137
+
138
+ def t_vram_threshold():
139
+ [f] = vram_unattributed(snap(**{"gpu-box-2": {"gpus": [{"name": "RTX 4070", "used_mib": 9000, "unattributed_mib": 8000}]}}), ctx)
140
+ return f["level"] == "WARN" and "8000 MiB" in f["evidence"][0]
141
+
142
+ def t_tailnet_offline_and_relayed():
143
+ [off] = tailnet_peer_offline(snap(**{"gpu-box": {"tailnet": {"peer": False, "direct": None, "derp": None, "ips": []}}}), ctx)
144
+ [rel] = tailnet_relayed(snap(**{"gpu-box": {"tailnet": {"peer": True, "direct": False, "derp": "ord", "ips": []}}}), ctx)
145
+ return off["level"] == "FAIL" and rel["level"] == "INFO" and "ord" in rel["summary"]
146
+
147
+ def t_empty_snapshot():
148
+ return all(fn({}, {}) == [] for _, fn in CHECKS) and all(fn(None, None) == [] for _, fn in CHECKS)
149
+
150
+ checks = [
151
+ ("clean_snapshot_no_findings", t_clean_snapshot_no_findings),
152
+ ("dead_endpoint_fail", t_dead_endpoint_fail),
153
+ ("model_not_loaded_warn", t_model_not_loaded_warn),
154
+ ("unreachable_node_not_blamed_for_model", t_unreachable_node_not_blamed_for_model),
155
+ ("vram_threshold", t_vram_threshold),
156
+ ("tailnet_offline_and_relayed", t_tailnet_offline_and_relayed),
157
+ ("empty_snapshot", t_empty_snapshot),
158
+ ]
159
+ passed = 0
160
+ for name, fn in checks:
161
+ try:
162
+ ok = bool(fn())
163
+ except Exception as exc: # noqa: BLE001 - a crashed check is a failed check
164
+ ok = False
165
+ name = "%s (raised %s: %s)" % (name, type(exc).__name__, exc)
166
+ passed += ok
167
+ print(("ok " if ok else "FAIL ") + name)
168
+ failed = len(checks) - passed
169
+ print("sg_checks_core selftest: %d checks, %d passed, %d failed" % (len(checks), passed, failed))
170
+ return 0 if failed == 0 and checks else 1
171
+
172
+
173
+ if __name__ == "__main__":
174
+ sys.exit(_selftest() if "--selftest" in sys.argv else 2)
stack_guardian/cli.py ADDED
@@ -0,0 +1,295 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import urllib.error
9
+ import urllib.request
10
+ from datetime import datetime, timezone
11
+
12
+ try:
13
+ from . import collect as sg_collect
14
+ from . import core as sg_core
15
+ from . import finding as sg_finding
16
+ from . import fixes as sg_fixes
17
+ from . import state as sg_state
18
+ except ImportError: # run as a plain file next to the sg_* modules
19
+ import sg_collect
20
+ import sg_core
21
+ import sg_finding
22
+ import sg_fixes
23
+ import sg_state
24
+
25
+ # The rules shipped with the package; --checks-dir points elsewhere.
26
+ DEFAULT_CHECKS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "checks.d")
27
+
28
+
29
+ USAGE = 3
30
+ CRASH = 4
31
+ NODES_ENV = "STACK_GUARDIAN_NODES"
32
+
33
+
34
+ def doctor(checks_dir, snapshot, state_path=None, at=None, ctx=None, record=True):
35
+ """
36
+ Run health checks against a snapshot and return graded findings and exit code.
37
+
38
+ Args:
39
+ checks_dir: Directory containing check modules
40
+ snapshot: Dictionary with snapshot data
41
+ state_path: Path to state file (optional)
42
+ at: Timestamp for the run (optional)
43
+ record: False grades this run against the saved history without saving it
44
+
45
+ Returns:
46
+ tuple: (graded_findings, exit_code)
47
+ """
48
+ at = at or datetime.now(timezone.utc).isoformat(timespec="seconds")
49
+ checks = sg_core.load_checks(checks_dir)
50
+ findings = sg_core.run_all(checks, snapshot, ctx or {})
51
+
52
+ state = sg_state.load(state_path)
53
+ nodes = set((snapshot.get("nodes") or {}).keys()) | {f.get("node", "") for f in findings}
54
+
55
+ for node in sorted(nodes):
56
+ sg_state.record_run(state, node, findings, at)
57
+
58
+ graded = [sg_state.grade(state, f.get("node", ""), f) for f in findings]
59
+ if record:
60
+ sg_state.save(state, state_path)
61
+
62
+ return graded, sg_finding.exit_code(graded)
63
+
64
+
65
+ def _state_dir(state_path):
66
+ """Folder holding snapshots/, fixes.jsonl and env next to the state file."""
67
+ return os.path.dirname(os.path.abspath(state_path)) if state_path else os.path.dirname(os.path.abspath(sg_state.state_path()))
68
+
69
+
70
+ def real_deps(snapshot, state_dir, audit_path=None, http_timeout=10, run_timeout=120):
71
+ """The real dependencies fixes.apply_fix needs. Everything that touches the world lives here, in the CLI layer."""
72
+ state_dir = os.fspath(state_dir)
73
+ audit_path = audit_path or os.path.join(state_dir, "fixes.jsonl")
74
+
75
+ def save_snapshot(obj):
76
+ folder = os.path.join(state_dir, "snapshots")
77
+ os.makedirs(folder, exist_ok=True)
78
+ name = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + ".json"
79
+ path = os.path.join(folder, name)
80
+ with open(path, "w", encoding="utf-8") as fh:
81
+ json.dump(obj, fh, indent=1)
82
+ return path
83
+
84
+ def http(method, url, body):
85
+ data = json.dumps(body).encode("utf-8") if body is not None else None
86
+ req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
87
+ try:
88
+ with urllib.request.urlopen(req, timeout=http_timeout) as resp: # noqa: S310 - fixes.validate_fix only allows http(s)
89
+ return resp.status
90
+ except urllib.error.HTTPError as exc:
91
+ return exc.code
92
+ except (urllib.error.URLError, OSError):
93
+ return 0
94
+
95
+ def run(argv):
96
+ """(returncode, last 200 chars of stderr): 124 on a timeout, 127 when the program cannot be started."""
97
+ try: # argv is a list from a declared lane: never a shell string
98
+ proc = subprocess.run(list(argv), capture_output=True, timeout=run_timeout, shell=False) # noqa: S603
99
+ except subprocess.TimeoutExpired as exc:
100
+ return 124, _tail(exc.stderr)
101
+ except (OSError, subprocess.SubprocessError) as exc:
102
+ return 127, _tail(str(exc))
103
+ return proc.returncode, _tail(proc.stderr)
104
+
105
+ def set_env(name, value):
106
+ """Persist NAME=value in <state dir>/env (replacing any earlier line) and set it for this process."""
107
+ path = os.path.join(state_dir, "env")
108
+ try:
109
+ os.makedirs(state_dir, exist_ok=True)
110
+ kept = []
111
+ if os.path.isfile(path):
112
+ with open(path, encoding="utf-8") as fh:
113
+ kept = [ln for ln in fh.read().splitlines() if not ln.startswith(name + "=")]
114
+ kept.append("%s=%s" % (name, value))
115
+ with open(path, "w", encoding="utf-8", newline="\n") as fh:
116
+ fh.write("\n".join(kept) + "\n")
117
+ except OSError:
118
+ return False
119
+ os.environ[name] = value
120
+ return True
121
+
122
+ def audit(line):
123
+ os.makedirs(os.path.dirname(os.path.abspath(audit_path)), exist_ok=True)
124
+ with open(audit_path, "a", encoding="utf-8", newline="\n") as fh:
125
+ fh.write(line + "\n")
126
+
127
+ return {"snapshot": lambda: snapshot, "save_snapshot": save_snapshot, "http": http, "run": run, "set_env": set_env,
128
+ "audit": audit, "now": lambda: datetime.now(timezone.utc).isoformat(timespec="seconds")}
129
+
130
+
131
+ def _tail(err, keep=200):
132
+ if isinstance(err, bytes):
133
+ err = err.decode("utf-8", errors="replace")
134
+ return (err or "").strip()[-keep:]
135
+
136
+
137
+ def pick_fix_finding(findings, ident):
138
+ """(finding, error): ident is a finding code, or code@node when several nodes raise the same code."""
139
+ code, _, node = ident.partition("@")
140
+ hits = [f for f in findings if f.get("code") == code and (not node or f.get("node") == node)]
141
+ if not hits:
142
+ return None, "no finding %s in this run" % ident
143
+ with_fix = [f for f in hits if f.get("fix")]
144
+ if not with_fix:
145
+ return None, "finding %s carries no fix" % ident
146
+ if len(with_fix) > 1:
147
+ return None, "finding %s is raised on several nodes (%s); use code@node" % (code, ", ".join(sorted(f.get("node", "") for f in with_fix)))
148
+ return with_fix[0], ""
149
+
150
+
151
+ def _render_fix(ident, result):
152
+ if not result["ok"] and not result.get("applied"):
153
+ return "fix %s refused: %s" % (ident, result.get("error", "unknown"))
154
+ if not result.get("applied"):
155
+ return "fix %s (dry run, add --apply to perform): %s" % (ident, result["plan"]["describe"])
156
+ return "fix %s %s: %s (snapshot %s)" % (ident, "applied" if result["ok"] else "FAILED", result["detail"], result["snapshot"])
157
+
158
+
159
+ def main(argv=None):
160
+ """stack-guardian doctor | snapshot. Exit: 0 OK/INFO, 1 WARN, 2 FAIL, 3 usage, 4 crash in the doctor itself."""
161
+ if argv is None:
162
+ argv = sys.argv[1:]
163
+
164
+ class _Parser(argparse.ArgumentParser):
165
+ def error(self, message):
166
+ raise ValueError(message)
167
+
168
+ parser = _Parser(prog="stack-guardian")
169
+ sub = parser.add_subparsers(dest="command")
170
+ d = sub.add_parser("doctor")
171
+ d.add_argument("--checks-dir", default=DEFAULT_CHECKS_DIR, help="folder of check files (default: the shipped rules)")
172
+ d.add_argument("--snapshot", help="a snapshot JSON file")
173
+ d.add_argument("--nodes", help="a nodes JSON file: collect a live snapshot instead of reading one "
174
+ "(default $STACK_GUARDIAN_NODES when neither --snapshot nor --nodes is given)")
175
+ d.add_argument("--ctx", help="a JSON file of rule settings (expect, thresholds)")
176
+ d.add_argument("--state", help="state file (default $STACK_GUARDIAN_STATE or ~/.stack-guardian/state.json)")
177
+ d.add_argument("--json", action="store_true")
178
+ d.add_argument("--quiet", action="store_true")
179
+ d.add_argument("--fix", metavar="ID", help="run the typed fix of finding ID (code or code@node); a dry run unless --apply")
180
+ d.add_argument("--apply", action="store_true", help="with --fix: really perform it (snapshot first, one audit line after)")
181
+ d.add_argument("--config", help="with --fix: JSON file {\"lanes\": {name: [argv...]}} declaring the lanes restart_lane may run")
182
+ d.add_argument("--audit", help="with --fix --apply: audit file (default fixes.jsonl next to the state file)")
183
+ s = sub.add_parser("snapshot")
184
+ s.add_argument("--nodes", required=True)
185
+ try:
186
+ args = parser.parse_args(argv)
187
+ if args.command not in ("doctor", "snapshot"):
188
+ raise ValueError("a command is required: doctor | snapshot")
189
+ if args.command == "doctor" and not (args.snapshot or args.nodes):
190
+ args.nodes = os.environ.get(NODES_ENV) or None
191
+ if args.command == "doctor" and bool(args.snapshot) == bool(args.nodes):
192
+ raise ValueError("doctor needs exactly one of --snapshot FILE or --nodes FILE (or %s)" % NODES_ENV)
193
+ if args.command == "doctor" and not args.fix and (args.apply or args.config or args.audit):
194
+ raise ValueError("--apply, --config and --audit need --fix ID")
195
+ except ValueError as exc:
196
+ print("stack-guardian: usage: %s" % exc, file=sys.stderr)
197
+ return USAGE
198
+ try:
199
+ def read(path):
200
+ with open(path, encoding="utf-8") as fh:
201
+ return json.load(fh)
202
+ if args.command == "snapshot":
203
+ print(json.dumps(sg_collect.snapshot(read(args.nodes)), indent=1))
204
+ return 0
205
+ snapshot = read(args.snapshot) if args.snapshot else sg_collect.snapshot(read(args.nodes))
206
+ ctx = read(args.ctx) if args.ctx else {}
207
+ findings, code = doctor(args.checks_dir, snapshot, args.state, ctx=ctx)
208
+ if args.fix:
209
+ target, why = pick_fix_finding(findings, args.fix)
210
+ if target is None:
211
+ print("stack-guardian: usage: %s" % why, file=sys.stderr)
212
+ return USAGE
213
+ deps = real_deps(snapshot, _state_dir(args.state), args.audit)
214
+ result = sg_fixes.apply_fix(target["fix"], read(args.config) if args.config else {}, deps, apply=args.apply)
215
+ print(json.dumps(result) if args.json else _render_fix(args.fix, result))
216
+ return 0 if result["ok"] else 1
217
+ except Exception as exc: # noqa: BLE001 - one line, no traceback: the doctor never crashes the shell
218
+ print("stack-guardian: crash: %s: %s" % (type(exc).__name__, exc), file=sys.stderr)
219
+ return CRASH
220
+ if args.json:
221
+ print(json.dumps({"findings": findings, "worst": sg_finding.worst(findings)}))
222
+ elif not (args.quiet and code == 0):
223
+ print(sg_core.render(findings))
224
+ return code
225
+
226
+
227
+ def _selftest():
228
+ tmp = tempfile.mkdtemp()
229
+ try:
230
+ checks = os.path.join(tmp, "checks")
231
+ os.mkdir(checks)
232
+ with open(os.path.join(checks, "a.py"), "w", encoding="utf-8") as fh:
233
+ fh.write("import sg_finding as F\n"
234
+ "def down(s, c):\n"
235
+ " if not s['up']:\n"
236
+ " return F.make_finding('service.down', 'missing', 'FAIL', 'gpu-box-2', 'service down')\n"
237
+ " return None\n"
238
+ "CHECKS = [('service.down', down)]\n")
239
+ snap_down = os.path.join(tmp, "down.json")
240
+ snap_up = os.path.join(tmp, "up.json")
241
+ with open(snap_down, "w", encoding="utf-8") as fh:
242
+ json.dump({"up": False, "nodes": {"gpu-box-2": {}}}, fh)
243
+ with open(snap_up, "w", encoding="utf-8") as fh:
244
+ json.dump({"up": True, "nodes": {"gpu-box-2": {}}}, fh)
245
+ state = os.path.join(tmp, "state.json")
246
+
247
+ def t_fail_exit_2_and_likely():
248
+ f, code = doctor(checks, {"up": False, "nodes": {"gpu-box-2": {}}}, state, at="t1")
249
+ return code == 2 and f[0]["confidence"] == "LIKELY"
250
+
251
+ def t_second_run_verified():
252
+ f, code = doctor(checks, {"up": False, "nodes": {"gpu-box-2": {}}}, state, at="t2")
253
+ return code == 2 and f[0]["confidence"] == "VERIFIED"
254
+
255
+ def t_ok_run_records_clean():
256
+ f, code = doctor(checks, {"up": True, "nodes": {"gpu-box-2": {}}}, state, at="t3")
257
+ return code == 0 and f == [] and sg_state.streak(sg_state.load(state), "gpu-box-2", "service.down") == 0
258
+
259
+ def t_main_json():
260
+ return main(["doctor", "--checks-dir", checks, "--snapshot", snap_up, "--state", state, "--json"]) == 0
261
+
262
+ def t_main_usage_3():
263
+ return main(["doctor", "--checks-dir", checks]) == USAGE and main([]) == USAGE
264
+
265
+ def t_main_crash_4():
266
+ return main(["doctor", "--checks-dir", checks, "--snapshot", os.path.join(tmp, "absent.json"), "--state", state]) == CRASH
267
+
268
+ tests = [
269
+ ("fail_exit_2_and_likely", t_fail_exit_2_and_likely),
270
+ ("second_run_verified", t_second_run_verified),
271
+ ("ok_run_records_clean", t_ok_run_records_clean),
272
+ ("main_json", t_main_json),
273
+ ("main_usage_3", t_main_usage_3),
274
+ ("main_crash_4", t_main_crash_4),
275
+ ]
276
+ passed = 0
277
+ for name, fn in tests:
278
+ try:
279
+ ok = bool(fn())
280
+ except Exception as exc: # noqa: BLE001 - a crashed check is a failed check
281
+ ok = False
282
+ name = "%s (raised %s: %s)" % (name, type(exc).__name__, exc)
283
+ passed += ok
284
+ print(("ok " if ok else "FAIL ") + name)
285
+ failed = len(tests) - passed
286
+ print("sg_cli selftest: %d checks, %d passed, %d failed" % (len(tests), passed, failed))
287
+ return 0 if failed == 0 and tests else 1
288
+ finally:
289
+ shutil.rmtree(tmp, ignore_errors=True)
290
+
291
+
292
+ if __name__ == "__main__":
293
+ if "--selftest" in sys.argv:
294
+ sys.exit(_selftest())
295
+ sys.exit(main())