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/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)