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/__init__.py +15 -0
- bootproof/__main__.py +8 -0
- bootproof/capture.py +120 -0
- bootproof/checklist.py +263 -0
- bootproof/cli.py +322 -0
- bootproof/errors.py +38 -0
- bootproof/preflight.py +185 -0
- bootproof/probes.py +399 -0
- bootproof/report.py +79 -0
- bootproof/runners.py +275 -0
- bootproof/snapshots.py +129 -0
- bootproof/verdict.py +108 -0
- bootproof-0.2.0.dist-info/METADATA +342 -0
- bootproof-0.2.0.dist-info/RECORD +18 -0
- bootproof-0.2.0.dist-info/WHEEL +5 -0
- bootproof-0.2.0.dist-info/entry_points.txt +2 -0
- bootproof-0.2.0.dist-info/licenses/LICENSE +21 -0
- bootproof-0.2.0.dist-info/top_level.txt +1 -0
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)
|