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/runners.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Boot runners: how a snapshot becomes a probeable target.
|
|
2
|
+
|
|
3
|
+
Interface: ``boot(snapshot, timeout) -> Target``. The runner owns
|
|
4
|
+
booting, readiness waiting, timeout, and teardown (``close()``).
|
|
5
|
+
|
|
6
|
+
* QemuRunner — the real path. Builds a real qemu-system-x86_64
|
|
7
|
+
command (drive in snapshot mode so the guest never writes the
|
|
8
|
+
source image, user networking with one hostfwd per checklist
|
|
9
|
+
port, no display), starts it, waits for readiness, kills it on
|
|
10
|
+
timeout or teardown. On a host without QEMU/KVM this fails fast
|
|
11
|
+
with a specific BootError — it never silently falls back to
|
|
12
|
+
simulation. Probes run through a GuestTarget (forwarded ports,
|
|
13
|
+
SSH unit checks), never against the host's localhost by mistake.
|
|
14
|
+
Readiness is an SSH command success when credentials are given,
|
|
15
|
+
else a TCP connect to the forwarded SSH port; a host that
|
|
16
|
+
forwards different ports overrides ``ready_check``.
|
|
17
|
+
* FixtureRunner — the simulated path. Returns a FixtureTarget from
|
|
18
|
+
fixture data, honouring a configured boot delay: if the delay
|
|
19
|
+
exceeds the timeout it raises BootTimeout, so timeout behaviour
|
|
20
|
+
is testable without a VM. Its boot_mode is always "simulated".
|
|
21
|
+
|
|
22
|
+
A simulated boot is labelled simulated in every report. Nothing in
|
|
23
|
+
this module lets a fixture result masquerade as a real boot.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import shutil
|
|
29
|
+
import socket
|
|
30
|
+
import subprocess
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from .errors import BootError, BootTimeout, SnapshotError
|
|
35
|
+
from .probes import FixtureTarget, Target
|
|
36
|
+
from .snapshots import Snapshot
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BootRunner:
|
|
40
|
+
boot_mode: str = "unknown"
|
|
41
|
+
|
|
42
|
+
def boot(self, snapshot: Snapshot, timeout: float) -> Target:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def kvm_available() -> bool:
|
|
50
|
+
"""True when the host offers KVM. Separated for testability —
|
|
51
|
+
tests monkeypatch this instead of faking /dev/kvm."""
|
|
52
|
+
return Path("/dev/kvm").exists()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def disk_format_for(path: str) -> str:
|
|
56
|
+
"""QEMU drive format from a disk-image suffix. Unknown suffixes
|
|
57
|
+
default to qcow2 (the common case); .img/.raw are raw images,
|
|
58
|
+
.vmdk is a VMware disk QEMU can read."""
|
|
59
|
+
suffix = Path(path).suffix.lower()
|
|
60
|
+
if suffix in (".img", ".raw"):
|
|
61
|
+
return "raw"
|
|
62
|
+
if suffix == ".vmdk":
|
|
63
|
+
return "vmdk"
|
|
64
|
+
return "qcow2"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def free_port() -> int:
|
|
68
|
+
"""An actually-free localhost port (bind port 0, read it back).
|
|
69
|
+
A small bind race remains, but it beats guessing a fixed port
|
|
70
|
+
that may already be taken — and a QEMU bind failure is still a
|
|
71
|
+
loud BootError, never a silent wrong target."""
|
|
72
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
73
|
+
s.bind(("127.0.0.1", 0))
|
|
74
|
+
return s.getsockname()[1]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def guest_ports_for_checklist(checklist) -> list[int]:
|
|
78
|
+
"""Guest ports a checklist needs forwarded: loopback port checks
|
|
79
|
+
plus loopback http URLs (explicit port, or 80/443 by scheme).
|
|
80
|
+
Non-loopback checks are NOT forwarded — GuestTarget errors them
|
|
81
|
+
instead of probing the host by mistake."""
|
|
82
|
+
import urllib.parse
|
|
83
|
+
|
|
84
|
+
from .probes import is_loopback
|
|
85
|
+
|
|
86
|
+
ports: list[int] = []
|
|
87
|
+
for check in checklist.checks:
|
|
88
|
+
if check.type == "port" and is_loopback(check.host):
|
|
89
|
+
if check.port not in ports:
|
|
90
|
+
ports.append(check.port)
|
|
91
|
+
elif check.type == "http":
|
|
92
|
+
parsed = urllib.parse.urlparse(check.url or "")
|
|
93
|
+
if parsed.scheme == "http" and is_loopback(parsed.hostname or ""):
|
|
94
|
+
port = parsed.port or 80
|
|
95
|
+
if port not in ports:
|
|
96
|
+
ports.append(port)
|
|
97
|
+
return ports
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_qemu_command(snapshot: Snapshot, memory_mb: int = 1024,
|
|
101
|
+
cpus: int = 1, ssh_host_port: int = 2222,
|
|
102
|
+
qemu_binary: str = "qemu-system-x86_64",
|
|
103
|
+
extra_args: list[str] | None = None,
|
|
104
|
+
forward_ports: dict[int, int] | None = None,
|
|
105
|
+
disk_format: str | None = None) -> list[str]:
|
|
106
|
+
"""Build the QEMU command for a snapshot. Pure function, fully
|
|
107
|
+
unit-testable without QEMU installed. Raises SnapshotError when
|
|
108
|
+
the snapshot carries no disk path, BootError when the binary is
|
|
109
|
+
missing. ``forward_ports`` maps guest port -> host port and
|
|
110
|
+
becomes one hostfwd entry each, so checklist ports actually
|
|
111
|
+
reach the guest instead of hitting the host's localhost.
|
|
112
|
+
``-machine accel=kvm`` is added only when /dev/kvm exists;
|
|
113
|
+
without KVM, QEMU falls back to TCG (slow, but functional)."""
|
|
114
|
+
if shutil.which(qemu_binary) is None:
|
|
115
|
+
raise BootError(f"QEMU binary not found: {qemu_binary} — "
|
|
116
|
+
f"install qemu-system and enable KVM "
|
|
117
|
+
f"(/dev/kvm) to run a real boot")
|
|
118
|
+
if snapshot.path is None:
|
|
119
|
+
raise SnapshotError(f"snapshot {snapshot.id} has no disk path — "
|
|
120
|
+
f"QEMU boot needs a qcow2/img file "
|
|
121
|
+
f"(directory snapshot source provides one)")
|
|
122
|
+
fmt = disk_format or disk_format_for(snapshot.path)
|
|
123
|
+
netdev = (f"user,id=net0,hostfwd=tcp::{ssh_host_port}-:22")
|
|
124
|
+
for guest_port, host_port in sorted((forward_ports or {}).items()):
|
|
125
|
+
netdev += f",hostfwd=tcp::{host_port}-:{guest_port}"
|
|
126
|
+
cmd = [qemu_binary,
|
|
127
|
+
"-m", str(memory_mb),
|
|
128
|
+
"-smp", str(cpus),
|
|
129
|
+
"-drive", f"file={snapshot.path},format={fmt},if=virtio,"
|
|
130
|
+
f"snapshot=on",
|
|
131
|
+
"-netdev", netdev,
|
|
132
|
+
"-device", "virtio-net-pci,netdev=net0",
|
|
133
|
+
"-display", "none",
|
|
134
|
+
"-serial", "none",
|
|
135
|
+
"-monitor", "none"]
|
|
136
|
+
if kvm_available():
|
|
137
|
+
cmd += ["-machine", "accel=kvm"]
|
|
138
|
+
if extra_args:
|
|
139
|
+
cmd += extra_args
|
|
140
|
+
return cmd
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class QemuRunner(BootRunner):
|
|
144
|
+
"""Real QEMU/KVM boot. Not runnable in this sandbox (no QEMU,
|
|
145
|
+
no /dev/kvm) — but the code is real: command construction,
|
|
146
|
+
process lifecycle, readiness loop, timeout kill, and teardown.
|
|
147
|
+
The returned target is a GuestTarget: checklist ports are
|
|
148
|
+
forwarded to host ports and remapped, so probes hit the GUEST,
|
|
149
|
+
never the host's localhost by mistake. Unit checks run over SSH
|
|
150
|
+
when credentials are given, and ERROR without them — host units
|
|
151
|
+
are never substituted for guest units. ``ready_check`` defaults
|
|
152
|
+
to an SSH ``true`` when credentials are configured (proves sshd
|
|
153
|
+
answers, not just that the port listens), else a TCP connect to
|
|
154
|
+
the forwarded SSH port; pass a custom callable for guests that
|
|
155
|
+
expose a different readiness signal."""
|
|
156
|
+
|
|
157
|
+
boot_mode = "qemu"
|
|
158
|
+
|
|
159
|
+
def __init__(self, memory_mb: int = 1024, cpus: int = 1,
|
|
160
|
+
ssh_host_port: int = 2222,
|
|
161
|
+
qemu_binary: str = "qemu-system-x86_64",
|
|
162
|
+
ready_check=None, extra_args=None,
|
|
163
|
+
checklist=None, ssh_user: str | None = None,
|
|
164
|
+
ssh_key: str | None = None):
|
|
165
|
+
self.memory_mb = memory_mb
|
|
166
|
+
self.cpus = cpus
|
|
167
|
+
self.ssh_host_port = ssh_host_port
|
|
168
|
+
self.qemu_binary = qemu_binary
|
|
169
|
+
self.ready_check = ready_check
|
|
170
|
+
self.extra_args = extra_args
|
|
171
|
+
self.checklist = checklist
|
|
172
|
+
self.ssh_user = ssh_user
|
|
173
|
+
self.ssh_key = ssh_key
|
|
174
|
+
self._proc: subprocess.Popen | None = None
|
|
175
|
+
self.port_map: dict[int, int] = {}
|
|
176
|
+
|
|
177
|
+
def _ssh_ready(self) -> bool:
|
|
178
|
+
from .probes import ssh_systemctl_is_active
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
outcome = ssh_systemctl_is_active(
|
|
182
|
+
self.ssh_user, self.ssh_key, self.ssh_host_port,
|
|
183
|
+
"__bootproof_ready__", 5)
|
|
184
|
+
except Exception:
|
|
185
|
+
return False
|
|
186
|
+
# Any answered systemctl call proves sshd works, even a
|
|
187
|
+
# "failed" answer for the nonsense unit name.
|
|
188
|
+
return outcome.status in ("pass", "fail")
|
|
189
|
+
|
|
190
|
+
def _wait_ready(self, timeout: float) -> None:
|
|
191
|
+
deadline = time.monotonic() + timeout
|
|
192
|
+
while time.monotonic() < deadline:
|
|
193
|
+
if self._proc is not None and self._proc.poll() is not None:
|
|
194
|
+
raise BootError(f"QEMU exited during boot (exit code "
|
|
195
|
+
f"{self._proc.returncode}) — guest "
|
|
196
|
+
f"failed to start")
|
|
197
|
+
try:
|
|
198
|
+
if self.ready_check is not None:
|
|
199
|
+
if self.ready_check():
|
|
200
|
+
return
|
|
201
|
+
elif self.ssh_user and self.ssh_key:
|
|
202
|
+
if self._ssh_ready():
|
|
203
|
+
return
|
|
204
|
+
else:
|
|
205
|
+
with socket.create_connection(
|
|
206
|
+
("127.0.0.1", self.ssh_host_port),
|
|
207
|
+
timeout=2):
|
|
208
|
+
return
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|
|
211
|
+
time.sleep(2)
|
|
212
|
+
raise BootTimeout(f"guest did not become reachable within "
|
|
213
|
+
f"{timeout}s — boot timed out")
|
|
214
|
+
|
|
215
|
+
def boot(self, snapshot: Snapshot, timeout: float) -> Target:
|
|
216
|
+
from .probes import GuestTarget
|
|
217
|
+
|
|
218
|
+
if self.checklist is not None:
|
|
219
|
+
for guest_port in guest_ports_for_checklist(self.checklist):
|
|
220
|
+
host_port = free_port()
|
|
221
|
+
while host_port == self.ssh_host_port \
|
|
222
|
+
or host_port in self.port_map.values():
|
|
223
|
+
host_port = free_port()
|
|
224
|
+
self.port_map[guest_port] = host_port
|
|
225
|
+
cmd = build_qemu_command(snapshot, self.memory_mb, self.cpus,
|
|
226
|
+
self.ssh_host_port, self.qemu_binary,
|
|
227
|
+
self.extra_args,
|
|
228
|
+
forward_ports=self.port_map)
|
|
229
|
+
try:
|
|
230
|
+
self._proc = subprocess.Popen(
|
|
231
|
+
cmd, stdout=subprocess.DEVNULL,
|
|
232
|
+
stderr=subprocess.DEVNULL)
|
|
233
|
+
except OSError as e:
|
|
234
|
+
raise BootError(f"failed to start QEMU: {e}")
|
|
235
|
+
try:
|
|
236
|
+
self._wait_ready(timeout)
|
|
237
|
+
except Exception:
|
|
238
|
+
self.close()
|
|
239
|
+
raise
|
|
240
|
+
return GuestTarget(self.port_map, self.ssh_user, self.ssh_key,
|
|
241
|
+
self.ssh_host_port)
|
|
242
|
+
|
|
243
|
+
def close(self) -> None:
|
|
244
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
245
|
+
self._proc.terminate()
|
|
246
|
+
try:
|
|
247
|
+
self._proc.wait(timeout=10)
|
|
248
|
+
except subprocess.TimeoutExpired:
|
|
249
|
+
self._proc.kill()
|
|
250
|
+
self._proc.wait(timeout=10)
|
|
251
|
+
self._proc = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class FixtureRunner(BootRunner):
|
|
255
|
+
"""Simulated boot from fixture data. ``boot_delay`` simulates a
|
|
256
|
+
slow guest; a delay past the timeout raises BootTimeout, exactly
|
|
257
|
+
like the real runner. boot_mode is always 'simulated'."""
|
|
258
|
+
|
|
259
|
+
boot_mode = "simulated"
|
|
260
|
+
|
|
261
|
+
def __init__(self, fixture_data: dict | None = None,
|
|
262
|
+
boot_delay: float = 0.0, sleep: bool = True):
|
|
263
|
+
self.fixture_data = fixture_data or {}
|
|
264
|
+
self.boot_delay = boot_delay
|
|
265
|
+
self._sleep = sleep
|
|
266
|
+
|
|
267
|
+
def boot(self, snapshot: Snapshot, timeout: float) -> Target:
|
|
268
|
+
if self.boot_delay > timeout:
|
|
269
|
+
raise BootTimeout(f"simulated guest did not become ready "
|
|
270
|
+
f"within {timeout}s (configured boot "
|
|
271
|
+
f"delay {self.boot_delay}s) — boot "
|
|
272
|
+
f"timed out")
|
|
273
|
+
if self._sleep and self.boot_delay > 0:
|
|
274
|
+
time.sleep(min(self.boot_delay, 5))
|
|
275
|
+
return FixtureTarget(self.fixture_data)
|
bootproof/snapshots.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Snapshot inventory: list/identify available snapshots.
|
|
2
|
+
|
|
3
|
+
A snapshot source is an abstraction — v1 ships two:
|
|
4
|
+
* FixtureSnapshotSource — reads a JSON file (or in-memory list) of
|
|
5
|
+
snapshots. This is what the sandbox uses.
|
|
6
|
+
* DirectorySnapshotSource — scans a directory for disk images
|
|
7
|
+
(*.qcow2, *.img, *.raw). On a real host this is the on-disk
|
|
8
|
+
inventory a QEMU boot draws from; each entry carries its path.
|
|
9
|
+
|
|
10
|
+
Future sources (restic/borg/Proxmox APIs) implement the same
|
|
11
|
+
SnapshotSource interface; they are not built in v1.
|
|
12
|
+
|
|
13
|
+
Selecting: ``select(snapshots, snapshot_id)`` returns the exact
|
|
14
|
+
match, auto-selects when there is exactly one snapshot and no id
|
|
15
|
+
was given, and raises SnapshotNotFound / AmbiguousSnapshot
|
|
16
|
+
otherwise. A missing snapshot is exit 2, never a silent default.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .errors import AmbiguousSnapshot, SnapshotError, SnapshotNotFound
|
|
26
|
+
|
|
27
|
+
IMAGE_SUFFIXES = (".qcow2", ".img", ".raw", ".qcow")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Snapshot:
|
|
32
|
+
id: str
|
|
33
|
+
name: str
|
|
34
|
+
source: str
|
|
35
|
+
path: str | None = None
|
|
36
|
+
created: str | None = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
d = {"id": self.id, "name": self.name, "source": self.source}
|
|
40
|
+
if self.path is not None:
|
|
41
|
+
d["path"] = self.path
|
|
42
|
+
if self.created is not None:
|
|
43
|
+
d["created"] = self.created
|
|
44
|
+
return d
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SnapshotSource:
|
|
48
|
+
kind: str = "source"
|
|
49
|
+
|
|
50
|
+
def list_snapshots(self) -> list[Snapshot]:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FixtureSnapshotSource(SnapshotSource):
|
|
55
|
+
kind = "fixture"
|
|
56
|
+
|
|
57
|
+
def __init__(self, snapshots: list[dict] | None = None,
|
|
58
|
+
path: str | Path | None = None):
|
|
59
|
+
if path is not None:
|
|
60
|
+
p = Path(path)
|
|
61
|
+
if not p.exists():
|
|
62
|
+
raise SnapshotError(f"snapshots file not found: {p}")
|
|
63
|
+
try:
|
|
64
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
65
|
+
except json.JSONDecodeError as e:
|
|
66
|
+
raise SnapshotError(f"snapshots file {p} is not valid "
|
|
67
|
+
f"JSON: {e}")
|
|
68
|
+
if isinstance(raw, dict):
|
|
69
|
+
snapshots = raw.get("snapshots")
|
|
70
|
+
else:
|
|
71
|
+
snapshots = raw
|
|
72
|
+
if not isinstance(snapshots, list):
|
|
73
|
+
raise SnapshotError(f"snapshots file {p} must hold a "
|
|
74
|
+
f"list or a 'snapshots' list")
|
|
75
|
+
self._snapshots = [self._coerce(s) for s in (snapshots or [])]
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _coerce(item: dict) -> Snapshot:
|
|
79
|
+
if not isinstance(item, dict) or "id" not in item:
|
|
80
|
+
raise SnapshotError(f"invalid snapshot entry (needs 'id'): "
|
|
81
|
+
f"{item!r}")
|
|
82
|
+
return Snapshot(id=str(item["id"]),
|
|
83
|
+
name=str(item.get("name", item["id"])),
|
|
84
|
+
source=str(item.get("source", "fixture")),
|
|
85
|
+
path=item.get("path"),
|
|
86
|
+
created=item.get("created"))
|
|
87
|
+
|
|
88
|
+
def list_snapshots(self) -> list[Snapshot]:
|
|
89
|
+
return list(self._snapshots)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DirectorySnapshotSource(SnapshotSource):
|
|
93
|
+
kind = "directory"
|
|
94
|
+
|
|
95
|
+
def __init__(self, directory: str | Path):
|
|
96
|
+
self.directory = Path(directory)
|
|
97
|
+
if not self.directory.is_dir():
|
|
98
|
+
raise SnapshotError(f"snapshot directory not found: "
|
|
99
|
+
f"{self.directory}")
|
|
100
|
+
|
|
101
|
+
def list_snapshots(self) -> list[Snapshot]:
|
|
102
|
+
found = []
|
|
103
|
+
for p in sorted(self.directory.iterdir()):
|
|
104
|
+
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES:
|
|
105
|
+
found.append(Snapshot(id=p.stem, name=p.name,
|
|
106
|
+
source="directory",
|
|
107
|
+
path=str(p)))
|
|
108
|
+
return found
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def select(snapshots: list[Snapshot],
|
|
112
|
+
snapshot_id: str | None) -> Snapshot:
|
|
113
|
+
"""Exact id match, or the single snapshot when no id is given.
|
|
114
|
+
Missing id and ambiguous inventory are errors, not guesses."""
|
|
115
|
+
if snapshot_id is not None:
|
|
116
|
+
for snap in snapshots:
|
|
117
|
+
if snap.id == snapshot_id:
|
|
118
|
+
return snap
|
|
119
|
+
available = ", ".join(s.id for s in snapshots) or "(none)"
|
|
120
|
+
raise SnapshotNotFound(f"snapshot not found: {snapshot_id} "
|
|
121
|
+
f"(available: {available})")
|
|
122
|
+
if len(snapshots) == 1:
|
|
123
|
+
return snapshots[0]
|
|
124
|
+
if not snapshots:
|
|
125
|
+
raise SnapshotNotFound("no snapshots available in this source")
|
|
126
|
+
available = ", ".join(s.id for s in snapshots)
|
|
127
|
+
raise AmbiguousSnapshot(f"no --snapshot given and {len(snapshots)} "
|
|
128
|
+
f"snapshots available ({available}) — "
|
|
129
|
+
f"pass --snapshot <id>")
|
bootproof/verdict.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Verdict engine: one GO / NO-GO from a checklist and a target.
|
|
2
|
+
|
|
3
|
+
Rules, stated plainly:
|
|
4
|
+
* GO only if every check passes. One fail, one error — no GO.
|
|
5
|
+
* A fail means the service was observed down (port closed, unit
|
|
6
|
+
failed, endpoint 500). Verdict NO-GO, exit 1.
|
|
7
|
+
* An error means the check could not be determined (probe blew up,
|
|
8
|
+
target not booted). Verdict ERROR, exit 2 — the run proves
|
|
9
|
+
nothing, and it must not be mistaken for a clean NO-GO either.
|
|
10
|
+
* Failing checks are named exactly: which port, which unit, which
|
|
11
|
+
endpoint, and what was observed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from .checklist import Check, Checklist
|
|
19
|
+
from .errors import ProbeError
|
|
20
|
+
from .probes import ProbeOutcome, Target
|
|
21
|
+
|
|
22
|
+
GO = "GO"
|
|
23
|
+
NO_GO = "NO-GO"
|
|
24
|
+
ERROR = "ERROR"
|
|
25
|
+
|
|
26
|
+
EXIT_GO = 0
|
|
27
|
+
EXIT_NO_GO = 1
|
|
28
|
+
EXIT_ERROR = 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CheckResult:
|
|
33
|
+
check: Check
|
|
34
|
+
status: str # "pass" | "fail" | "error"
|
|
35
|
+
observed: str
|
|
36
|
+
seconds: float = 0.0
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return {"check": self.check.describe(),
|
|
40
|
+
"type": self.check.type,
|
|
41
|
+
"status": self.status,
|
|
42
|
+
"observed": self.observed,
|
|
43
|
+
"seconds": round(self.seconds, 3)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Verdict:
|
|
48
|
+
verdict: str
|
|
49
|
+
results: list[CheckResult]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def passed(self) -> list[CheckResult]:
|
|
53
|
+
return [r for r in self.results if r.status == "pass"]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def failed(self) -> list[CheckResult]:
|
|
57
|
+
return [r for r in self.results if r.status == "fail"]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def errored(self) -> list[CheckResult]:
|
|
61
|
+
return [r for r in self.results if r.status == "error"]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def exit_code(self) -> int:
|
|
65
|
+
if self.verdict == GO:
|
|
66
|
+
return EXIT_GO
|
|
67
|
+
if self.verdict == NO_GO:
|
|
68
|
+
return EXIT_NO_GO
|
|
69
|
+
return EXIT_ERROR
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
return {"verdict": self.verdict,
|
|
73
|
+
"passed": len(self.passed),
|
|
74
|
+
"failed": len(self.failed),
|
|
75
|
+
"errored": len(self.errored),
|
|
76
|
+
"total": len(self.results),
|
|
77
|
+
"checks": [r.to_dict() for r in self.results]}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def evaluate(checklist: Checklist, target: Target) -> Verdict:
|
|
81
|
+
"""Evaluate every check against the target. A probe raising
|
|
82
|
+
ProbeError becomes an error result, never a pass. Any other
|
|
83
|
+
exception also becomes an error result — a crashing probe must
|
|
84
|
+
not take down the verdict, and must not pass either. An empty
|
|
85
|
+
checklist is an ERROR verdict here too (defense in depth: the
|
|
86
|
+
loader already rejects empty checklists, but a verdict over
|
|
87
|
+
zero checks must never be a vacuous GO through any path)."""
|
|
88
|
+
if not checklist.checks:
|
|
89
|
+
return Verdict(verdict=ERROR, results=[])
|
|
90
|
+
results: list[CheckResult] = []
|
|
91
|
+
for check in checklist.checks:
|
|
92
|
+
try:
|
|
93
|
+
outcome: ProbeOutcome = target.probe(check)
|
|
94
|
+
except ProbeError as e:
|
|
95
|
+
outcome = ProbeOutcome("error", str(e))
|
|
96
|
+
except Exception as e: # noqa: BLE001 — probe crash = error
|
|
97
|
+
outcome = ProbeOutcome("error",
|
|
98
|
+
f"probe failed: {e.__class__.__name__}: {e}")
|
|
99
|
+
results.append(CheckResult(check=check, status=outcome.status,
|
|
100
|
+
observed=outcome.observed,
|
|
101
|
+
seconds=outcome.seconds))
|
|
102
|
+
if any(r.status == "error" for r in results):
|
|
103
|
+
verdict = ERROR
|
|
104
|
+
elif any(r.status == "fail" for r in results):
|
|
105
|
+
verdict = NO_GO
|
|
106
|
+
else:
|
|
107
|
+
verdict = GO
|
|
108
|
+
return Verdict(verdict=verdict, results=results)
|