bootproof 0.2.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.
bootproof/probes.py ADDED
@@ -0,0 +1,399 @@
1
+ """Probe targets: the thing a checklist is evaluated against.
2
+
3
+ A target answers three questions: is this port listening, is this
4
+ systemd unit active, does this endpoint answer green? Two targets
5
+ ship in v1:
6
+
7
+ * LocalTarget — probes the machine bootproof runs on, with real
8
+ sockets, real HTTP (urllib), and real ``systemctl``. Used for
9
+ live-testing the checklist engine and for checking the current
10
+ box itself (a dry run of what the checklist would say).
11
+ * FixtureTarget — answers from a fixed data structure (ports,
12
+ units, endpoints). This is what a simulated boot returns: fully
13
+ deterministic, and it labels itself simulated everywhere.
14
+
15
+ Probe outcomes: ``pass`` (observed matches), ``fail`` (observed
16
+ does not match — port closed, unit failed, status 500), or
17
+ ``error`` (could not be determined — systemctl missing, target not
18
+ booted). Errors are never passes.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import shutil
24
+ import socket
25
+ import subprocess
26
+ import time
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from dataclasses import dataclass, replace
31
+
32
+ from .checklist import Check
33
+ from .errors import ProbeError
34
+
35
+ LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
36
+
37
+
38
+ # `systemctl is-enabled` states that mean the unit starts at boot.
39
+ BOOT_ENABLED_STATES = ("enabled", "static", "indirect", "generated",
40
+ "alias", "enabled-runtime")
41
+
42
+
43
+ def is_loopback(host: str) -> bool:
44
+ return host in LOOPBACK_HOSTS
45
+
46
+
47
+ def systemctl(args: list[str], timeout: float
48
+ ) -> subprocess.CompletedProcess:
49
+ """Run local ``systemctl``, resolved via PATH/PATHEXT. A missing
50
+ binary is a ProbeError (unverifiable, never a pass)."""
51
+ exe = shutil.which("systemctl")
52
+ if exe is None:
53
+ raise ProbeError("systemctl not found — unit state cannot "
54
+ "be verified on this host")
55
+ return subprocess.run([exe, *args], capture_output=True, text=True,
56
+ timeout=timeout)
57
+
58
+
59
+ @dataclass
60
+ class ProbeOutcome:
61
+ status: str # "pass" | "fail" | "error"
62
+ observed: str
63
+ seconds: float = 0.0
64
+
65
+
66
+ class Target:
67
+ """Interface every boot target implements."""
68
+
69
+ kind: str = "target"
70
+
71
+ def probe_port(self, check: Check) -> ProbeOutcome:
72
+ raise NotImplementedError
73
+
74
+ def probe_unit(self, check: Check) -> ProbeOutcome:
75
+ raise NotImplementedError
76
+
77
+ def probe_http(self, check: Check) -> ProbeOutcome:
78
+ raise NotImplementedError
79
+
80
+ def probe(self, check: Check) -> ProbeOutcome:
81
+ if check.type == "port":
82
+ return self.probe_port(check)
83
+ if check.type == "unit":
84
+ return self.probe_unit(check)
85
+ return self.probe_http(check)
86
+
87
+
88
+ class LocalTarget(Target):
89
+ """Probe the local machine. Real sockets, real HTTP, real
90
+ systemctl. A missing systemctl binary is an error, not a pass:
91
+ unit state cannot be verified without it."""
92
+
93
+ kind = "local"
94
+
95
+ def probe_port(self, check: Check) -> ProbeOutcome:
96
+ start = time.monotonic()
97
+ try:
98
+ with socket.create_connection((check.host, check.port),
99
+ timeout=check.timeout):
100
+ pass
101
+ except socket.timeout:
102
+ return ProbeOutcome("fail",
103
+ f"port {check.port} on {check.host} "
104
+ f"did not answer within {check.timeout}s",
105
+ time.monotonic() - start)
106
+ except OSError as e:
107
+ return ProbeOutcome("fail",
108
+ f"port {check.port} on {check.host} "
109
+ f"closed ({e.strerror or e})",
110
+ time.monotonic() - start)
111
+ return ProbeOutcome("pass",
112
+ f"port {check.port} on {check.host} "
113
+ f"listening",
114
+ time.monotonic() - start)
115
+
116
+ def probe_unit(self, check: Check) -> ProbeOutcome:
117
+ """Active now AND (unless require_enabled is false) set to
118
+ start at boot: a unit running by hand but disabled passes
119
+ is-active today and is gone after the reboot."""
120
+ start = time.monotonic()
121
+ try:
122
+ proc = systemctl(["is-active", check.name], check.timeout)
123
+ if proc.returncode == 0 and check.require_enabled:
124
+ enabled = systemctl(["is-enabled", check.name],
125
+ check.timeout)
126
+ except subprocess.TimeoutExpired:
127
+ return ProbeOutcome("error",
128
+ f"systemctl on {check.name} "
129
+ f"timed out after {check.timeout}s",
130
+ time.monotonic() - start)
131
+ state = proc.stdout.strip() or proc.stderr.strip() or "unknown"
132
+ elapsed = time.monotonic() - start
133
+ if not (proc.returncode == 0 and state == "active"):
134
+ return ProbeOutcome("fail", f"unit {check.name} {state}",
135
+ elapsed)
136
+ if not check.require_enabled:
137
+ return ProbeOutcome("pass", f"unit {check.name} active",
138
+ elapsed)
139
+ en = enabled.stdout.strip() or enabled.stderr.strip() or "unknown"
140
+ if en in BOOT_ENABLED_STATES:
141
+ return ProbeOutcome("pass", f"unit {check.name} active, "
142
+ f"{en}", elapsed)
143
+ if en in ("disabled", "masked", "linked", "linked-runtime",
144
+ "masked-runtime"):
145
+ return ProbeOutcome(
146
+ "fail", f"unit {check.name} active now but {en} — will "
147
+ f"not start at boot unless pulled in by another unit",
148
+ elapsed)
149
+ return ProbeOutcome("error", f"unit {check.name} active, but "
150
+ f"is-enabled said {en!r} — cannot tell if it "
151
+ f"starts at boot", elapsed)
152
+
153
+ def probe_http(self, check: Check) -> ProbeOutcome:
154
+ start = time.monotonic()
155
+ req = urllib.request.Request(check.url, method="GET")
156
+ try:
157
+ with urllib.request.urlopen(req, timeout=check.timeout) as resp:
158
+ status = resp.status
159
+ body = resp.read(65536).decode("utf-8", "replace")
160
+ except urllib.error.HTTPError as e:
161
+ body = e.read(65536).decode("utf-8", "replace") \
162
+ if e.fp is not None else ""
163
+ return ProbeOutcome(
164
+ "fail", f"{check.url} returned HTTP {e.code}"
165
+ + (f" (expected {check.expect_status})"
166
+ if e.code != check.expect_status else "")
167
+ + (f", body did not contain "
168
+ f"{check.body_contains!r}"
169
+ if check.body_contains
170
+ and check.body_contains not in body else ""),
171
+ time.monotonic() - start)
172
+ except urllib.error.URLError as e:
173
+ return ProbeOutcome("fail", f"{check.url} unreachable "
174
+ f"({e.reason})",
175
+ time.monotonic() - start)
176
+ except (TimeoutError, socket.timeout):
177
+ return ProbeOutcome("fail", f"{check.url} timed out after "
178
+ f"{check.timeout}s",
179
+ time.monotonic() - start)
180
+ except ValueError as e:
181
+ raise ProbeError(f"invalid url {check.url}: {e}")
182
+ elapsed = time.monotonic() - start
183
+ if status != check.expect_status:
184
+ return ProbeOutcome("fail", f"{check.url} returned HTTP "
185
+ f"{status} (expected "
186
+ f"{check.expect_status})", elapsed)
187
+ if check.body_contains is not None \
188
+ and check.body_contains not in body:
189
+ return ProbeOutcome("fail", f"{check.url} returned HTTP "
190
+ f"{status} but body did not contain "
191
+ f"{check.body_contains!r}", elapsed)
192
+ detail = f"HTTP {status}"
193
+ if check.body_contains:
194
+ detail += f", body contains {check.body_contains!r}"
195
+ return ProbeOutcome("pass", f"{check.url} {detail}", elapsed)
196
+
197
+
198
+ def ssh_systemctl_is_active(user: str, key: str, host_port: int,
199
+ unit: str, timeout: float) -> ProbeOutcome:
200
+ """Run ``systemctl is-active`` inside a guest over SSH.
201
+
202
+ BatchMode=yes so a failed key never hangs on a password prompt;
203
+ LogLevel=ERROR keeps host-key chatter out of the evidence.
204
+ A missing ssh binary is a ProbeError (unverifiable, never a
205
+ pass); a failed command is a fail with the observed state."""
206
+ start = time.monotonic()
207
+ # Resolve via PATH/PATHEXT so .cmd/.bat wrappers work on Windows too.
208
+ ssh = shutil.which("ssh")
209
+ if ssh is None:
210
+ raise ProbeError("ssh not found — guest unit state cannot be "
211
+ "verified without it")
212
+ cmd = [ssh, "-i", key, "-o", "BatchMode=yes",
213
+ "-o", "StrictHostKeyChecking=no",
214
+ "-o", "UserKnownHostsFile=/dev/null",
215
+ "-o", "LogLevel=ERROR",
216
+ "-o", f"ConnectTimeout={int(max(1, timeout))}",
217
+ "-p", str(host_port), f"{user}@127.0.0.1",
218
+ "systemctl", "is-active", unit]
219
+ try:
220
+ proc = subprocess.run(cmd, capture_output=True, text=True,
221
+ timeout=timeout + 5)
222
+ except FileNotFoundError:
223
+ raise ProbeError("ssh not found — guest unit state cannot be "
224
+ "verified without it")
225
+ except subprocess.TimeoutExpired:
226
+ return ProbeOutcome("error",
227
+ f"ssh systemctl is-active {unit} timed out "
228
+ f"after {timeout}s",
229
+ time.monotonic() - start)
230
+ state = proc.stdout.strip() or proc.stderr.strip() or "unknown"
231
+ elapsed = time.monotonic() - start
232
+ if proc.returncode == 0 and state == "active":
233
+ return ProbeOutcome("pass", f"unit {unit} active (guest)", elapsed)
234
+ return ProbeOutcome("fail", f"unit {unit} {state} (guest)", elapsed)
235
+
236
+
237
+ class GuestTarget(Target):
238
+ """Probe a QEMU guest through forwarded host ports.
239
+
240
+ ``port_map`` maps guest port -> host port (built by QemuRunner
241
+ from the checklist). Port and HTTP checks are remapped to the
242
+ forwarded host port; the observed text names the GUEST port, so
243
+ a report never confuses host and guest.
244
+
245
+ Only loopback hosts/URLs are forwarded. A non-loopback host
246
+ (e.g. the machine's LAN IP) is not reachable from the host
247
+ without bridging, so it becomes an ERROR — unverified, never a
248
+ pass, and never silently probed against the host. HTTPS URLs
249
+ are likewise ERROR: TLS to a forwarded port cannot present the
250
+ guest's hostname certificate. Unit checks run over SSH when
251
+ credentials are given, and ERROR without them — units must
252
+ never be probed against the host and mistaken for the guest.
253
+ """
254
+
255
+ kind = "guest"
256
+
257
+ def __init__(self, port_map: dict[int, int],
258
+ ssh_user: str | None = None,
259
+ ssh_key: str | None = None,
260
+ ssh_host_port: int = 2222):
261
+ self.port_map = dict(port_map)
262
+ self.ssh_user = ssh_user
263
+ self.ssh_key = ssh_key
264
+ self.ssh_host_port = ssh_host_port
265
+ self._local = LocalTarget()
266
+
267
+ def _remap_port_observed(self, outcome: ProbeOutcome,
268
+ guest_port: int, host_port: int
269
+ ) -> ProbeOutcome:
270
+ if guest_port == host_port:
271
+ return outcome
272
+ return ProbeOutcome(outcome.status,
273
+ outcome.observed.replace(
274
+ f"port {host_port} ", f"port {guest_port} "),
275
+ outcome.seconds)
276
+
277
+ def probe_port(self, check: Check) -> ProbeOutcome:
278
+ if not is_loopback(check.host):
279
+ return ProbeOutcome(
280
+ "error",
281
+ f"cannot probe host {check.host} against a QEMU guest "
282
+ f"— only loopback hosts are forwarded (use 127.0.0.1 "
283
+ f"for guest-local services)")
284
+ host_port = self.port_map.get(check.port)
285
+ if host_port is None:
286
+ return ProbeOutcome(
287
+ "error",
288
+ f"port {check.port} was not forwarded to the guest — "
289
+ f"checklist port missing from the forward map")
290
+ outcome = self._local.probe_port(
291
+ replace(check, host="127.0.0.1", port=host_port))
292
+ return self._remap_port_observed(outcome, check.port, host_port)
293
+
294
+ def probe_unit(self, check: Check) -> ProbeOutcome:
295
+ if self.ssh_user and self.ssh_key:
296
+ return ssh_systemctl_is_active(
297
+ self.ssh_user, self.ssh_key, self.ssh_host_port,
298
+ check.name, check.timeout)
299
+ return ProbeOutcome(
300
+ "error",
301
+ f"unit {check.name} cannot be verified against a QEMU "
302
+ f"guest without SSH credentials — pass --ssh-user and "
303
+ f"--ssh-key, or drop the unit check (host units are never "
304
+ f"substituted for guest units)")
305
+
306
+ def probe_http(self, check: Check) -> ProbeOutcome:
307
+ parsed = urllib.parse.urlparse(check.url)
308
+ if parsed.scheme == "https":
309
+ return ProbeOutcome(
310
+ "error",
311
+ f"cannot probe {check.url} against a QEMU guest — "
312
+ f"HTTPS over a forwarded port cannot present the "
313
+ f"guest's certificate (use an http loopback URL)")
314
+ if not is_loopback(parsed.hostname or ""):
315
+ return ProbeOutcome(
316
+ "error",
317
+ f"cannot probe {check.url} against a QEMU guest — "
318
+ f"only loopback URLs are forwarded")
319
+ guest_port = parsed.port or 80
320
+ host_port = self.port_map.get(guest_port)
321
+ if host_port is None:
322
+ return ProbeOutcome(
323
+ "error",
324
+ f"port {guest_port} was not forwarded to the guest — "
325
+ f"checklist URL port missing from the forward map")
326
+ rewritten = urllib.parse.urlunparse(
327
+ ("http", f"127.0.0.1:{host_port}", parsed.path or "/",
328
+ parsed.params, parsed.query, parsed.fragment))
329
+ outcome = self._local.probe_http(replace(check, url=rewritten))
330
+ if guest_port != host_port:
331
+ outcome = ProbeOutcome(
332
+ outcome.status,
333
+ outcome.observed.replace(
334
+ rewritten, check.url),
335
+ outcome.seconds)
336
+ return outcome
337
+
338
+
339
+ class FixtureTarget(Target):
340
+ """Deterministic answers from fixture data. Shape:
341
+
342
+ {"ports": {"443": true, "80": false},
343
+ "units": {"nginx.service": "active", "db.service": "failed"},
344
+ "endpoints": {"http://127.0.0.1:8080/health":
345
+ {"status": 200, "body": "ok"}}}
346
+
347
+ Anything not listed is observed as absent: a port not listed is
348
+ closed, a unit not listed is inactive, an endpoint not listed
349
+ is unreachable. That keeps fixtures honest — an unlisted check
350
+ fails loudly instead of passing by default.
351
+ """
352
+
353
+ kind = "fixture"
354
+
355
+ def __init__(self, data: dict):
356
+ self.data = data or {}
357
+ ports = self.data.get("ports", {})
358
+ self.ports = {str(k): bool(v) for k, v in ports.items()}
359
+ self.units = {str(k): str(v)
360
+ for k, v in self.data.get("units", {}).items()}
361
+ self.endpoints = {str(k): v
362
+ for k, v in self.data.get("endpoints", {}).items()}
363
+
364
+ def probe_port(self, check: Check) -> ProbeOutcome:
365
+ if self.ports.get(str(check.port), False):
366
+ return ProbeOutcome("pass", f"port {check.port} on "
367
+ f"{check.host} listening")
368
+ return ProbeOutcome("fail", f"port {check.port} on {check.host} "
369
+ f"closed")
370
+
371
+ def probe_unit(self, check: Check) -> ProbeOutcome:
372
+ state = self.units.get(check.name)
373
+ if state == "active":
374
+ return ProbeOutcome("pass", f"unit {check.name} active")
375
+ if state is None:
376
+ return ProbeOutcome("fail", f"unit {check.name} inactive "
377
+ f"(not present in booted snapshot)")
378
+ return ProbeOutcome("fail", f"unit {check.name} {state}")
379
+
380
+ def probe_http(self, check: Check) -> ProbeOutcome:
381
+ entry = self.endpoints.get(check.url)
382
+ if entry is None:
383
+ return ProbeOutcome("fail", f"{check.url} unreachable "
384
+ f"(not present in booted snapshot)")
385
+ if isinstance(entry, int):
386
+ status, body = entry, ""
387
+ else:
388
+ status = entry.get("status", 0)
389
+ body = entry.get("body", "")
390
+ if status != check.expect_status:
391
+ return ProbeOutcome("fail", f"{check.url} returned HTTP "
392
+ f"{status} (expected "
393
+ f"{check.expect_status})")
394
+ if check.body_contains is not None \
395
+ and check.body_contains not in body:
396
+ return ProbeOutcome("fail", f"{check.url} returned HTTP "
397
+ f"{status} but body did not contain "
398
+ f"{check.body_contains!r}")
399
+ return ProbeOutcome("pass", f"{check.url} HTTP {status}")
bootproof/report.py ADDED
@@ -0,0 +1,79 @@
1
+ """Report rendering: text for humans, JSON for CI.
2
+
3
+ Text form leads with the verdict on its own line — GO or NO-GO —
4
+ then names every failing check exactly (which port, which unit,
5
+ which endpoint, what was observed). The boot_mode line is always
6
+ present: a simulated boot says so, in the report itself, not just
7
+ in the README.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from .verdict import ERROR, GO, NO_GO, Verdict
15
+
16
+
17
+ def render_text(verdict: Verdict, snapshot_id: str,
18
+ boot_mode: str, snapshot_path: str | None = None) -> str:
19
+ lines = [f"bootproof verdict: {verdict.verdict}",
20
+ f"snapshot: {snapshot_id}",
21
+ f"boot: {boot_mode}"]
22
+ if snapshot_path:
23
+ lines.append(f"disk: {snapshot_path}")
24
+ lines.append(f"checks: {len(verdict.passed)} passed, "
25
+ f"{len(verdict.failed)} failed, "
26
+ f"{len(verdict.errored)} errored, "
27
+ f"{len(verdict.results)} total")
28
+ slowest = sorted(verdict.results, key=lambda r: r.seconds,
29
+ reverse=True)[:3]
30
+ if slowest and slowest[0].seconds > 0:
31
+ lines.append("slowest: " + "; ".join(
32
+ f"{r.check.describe()} ({r.seconds:.2f}s)" for r in slowest))
33
+ if verdict.verdict == GO:
34
+ lines.append("GO — every service-up check passed. "
35
+ "Safe to reboot.")
36
+ elif verdict.verdict == NO_GO:
37
+ lines.append("NO-GO — the following checks failed:")
38
+ for r in verdict.failed:
39
+ lines.append(f" FAIL: {r.check.describe()} — "
40
+ f"{r.observed}")
41
+ else:
42
+ lines.append("ERROR — the run could not verify the checklist:")
43
+ for r in verdict.errored:
44
+ lines.append(f" ERROR: {r.check.describe()} — "
45
+ f"{r.observed}")
46
+ for r in verdict.failed:
47
+ lines.append(f" FAIL: {r.check.describe()} — "
48
+ f"{r.observed}")
49
+ for r in verdict.passed:
50
+ lines.append(f" PASS: {r.check.describe()} — {r.observed}")
51
+ return "\n".join(lines)
52
+
53
+
54
+ def render_json(verdict: Verdict, snapshot_id: str,
55
+ boot_mode: str, snapshot_path: str | None = None) -> str:
56
+ payload = verdict.to_dict()
57
+ payload["snapshot"] = snapshot_id
58
+ payload["boot_mode"] = boot_mode
59
+ if snapshot_path:
60
+ payload["snapshot_path"] = snapshot_path
61
+ return json.dumps(payload, indent=2)
62
+
63
+
64
+ def render_error(message: str, as_json: bool = False) -> str:
65
+ if as_json:
66
+ return json.dumps({"verdict": ERROR, "error": message}, indent=2)
67
+ return f"error: {message}"
68
+
69
+
70
+ def render_snapshots(snapshots, as_json: bool = False) -> str:
71
+ if as_json:
72
+ return json.dumps({"snapshots": [s.to_dict() for s in snapshots]},
73
+ indent=2)
74
+ if not snapshots:
75
+ return "no snapshots available"
76
+ lines = [f"{s.id} {s.name} [{s.source}]"
77
+ + (f" {s.path}" if s.path else "")
78
+ for s in snapshots]
79
+ return "\n".join(lines)