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/cli.py ADDED
@@ -0,0 +1,322 @@
1
+ """bootproof CLI — pre-reboot boot verification, Linux-only v1.
2
+
3
+ Commands:
4
+ check boot a snapshot, run the checklist, print GO / NO-GO
5
+ snapshots list available snapshots for the machine
6
+ validate validate a checklist file without booting anything
7
+ init write example checklist + snapshot + fixture files
8
+ capture draft a checklist from this machine (never runs a check)
9
+ preflight static host boot checks: fstab, initramfs, /boot, dkms
10
+
11
+ Exit codes: 0 = GO, 1 = NO-GO, 2 = error (missing snapshot, bad
12
+ checklist, boot timeout, unverified checks, usage error). A GO
13
+ means every check passed; anything unverified can never be a GO.
14
+
15
+ Boot modes: --boot simulated (default; fixture guest state, the
16
+ only mode that runs without KVM/QEMU) or --boot qemu (real
17
+ QEMU/KVM boot; needs a snapshot with a disk path and a host with
18
+ qemu-system-x86_64 and /dev/kvm). --local skips booting entirely
19
+ and probes the current machine — a checklist dry run, not a boot
20
+ verification, and labelled as such.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ from . import __version__
31
+ from . import preflight
32
+ from . import report as reportmod
33
+ from .capture import capture
34
+ from .checklist import load_checklist
35
+ from .errors import BootproofError
36
+ from .probes import LocalTarget
37
+ from .runners import FixtureRunner, QemuRunner
38
+ from .snapshots import (DirectorySnapshotSource, FixtureSnapshotSource,
39
+ select)
40
+ from .verdict import evaluate
41
+
42
+ EXAMPLE_CHECKLIST = """# bootproof checklist: what "up" means for this machine.
43
+ checks:
44
+ - type: port
45
+ port: 443
46
+ - type: port
47
+ port: 5432
48
+ - type: unit
49
+ name: nginx.service
50
+ - type: http
51
+ url: http://127.0.0.1:8080/health
52
+ expect_status: 200
53
+ body_contains: ok
54
+ """
55
+
56
+ EXAMPLE_SNAPSHOTS = {
57
+ "snapshots": [
58
+ {"id": "snap-2026-10-27", "name": "pre-kernel-update snapshot",
59
+ "source": "fixture"},
60
+ ]
61
+ }
62
+
63
+ EXAMPLE_FIXTURE_GO = {
64
+ "ports": {"443": True, "5432": True},
65
+ "units": {"nginx.service": "active"},
66
+ "endpoints": {"http://127.0.0.1:8080/health":
67
+ {"status": 200, "body": "ok"}},
68
+ }
69
+
70
+ EXAMPLE_FIXTURE_NOGO = {
71
+ "ports": {"443": True, "5432": False},
72
+ "units": {"nginx.service": "active"},
73
+ "endpoints": {"http://127.0.0.1:8080/health":
74
+ {"status": 200, "body": "ok"}},
75
+ }
76
+
77
+
78
+ def _snapshot_source(args):
79
+ if getattr(args, "snapshot_dir", None):
80
+ return DirectorySnapshotSource(args.snapshot_dir)
81
+ if getattr(args, "snapshots", None):
82
+ return FixtureSnapshotSource(path=args.snapshots)
83
+ raise BootproofError("no snapshot source: pass --snapshots FILE "
84
+ "or --snapshot-dir DIR")
85
+
86
+
87
+ def _load_fixture(path: str | None) -> dict:
88
+ if path is None:
89
+ return {}
90
+ p = Path(path)
91
+ if not p.exists():
92
+ raise BootproofError(f"fixture file not found: {p}")
93
+ try:
94
+ data = json.loads(p.read_text(encoding="utf-8"))
95
+ except json.JSONDecodeError as e:
96
+ raise BootproofError(f"fixture file {p} is not valid JSON: {e}")
97
+ if not isinstance(data, dict):
98
+ raise BootproofError(f"fixture file {p} must hold a JSON object")
99
+ return data
100
+
101
+
102
+ def cmd_check(args) -> int:
103
+ try:
104
+ checklist = load_checklist(args.checklist)
105
+ except BootproofError as e:
106
+ print(reportmod.render_error(str(e), args.json))
107
+ return 2
108
+ if args.local:
109
+ verdict = evaluate(checklist, LocalTarget())
110
+ if args.json:
111
+ print(reportmod.render_json(verdict, "(local, no snapshot)",
112
+ "local"))
113
+ else:
114
+ print(reportmod.render_text(verdict, "(local, no snapshot)",
115
+ "local"))
116
+ return verdict.exit_code
117
+ try:
118
+ source = _snapshot_source(args)
119
+ snapshots = source.list_snapshots()
120
+ snapshot = select(snapshots, args.snapshot)
121
+ except BootproofError as e:
122
+ print(reportmod.render_error(str(e), args.json))
123
+ return 2
124
+ try:
125
+ if args.boot == "qemu":
126
+ runner = QemuRunner(
127
+ checklist=checklist,
128
+ ssh_user=getattr(args, "ssh_user", None),
129
+ ssh_key=getattr(args, "ssh_key", None),
130
+ ssh_host_port=getattr(args, "ssh_port", 2222))
131
+ boot_mode = runner.boot_mode
132
+ else:
133
+ fixture = _load_fixture(args.fixture)
134
+ boot_delay = fixture.pop("boot_delay", 0.0) \
135
+ if isinstance(fixture, dict) else 0.0
136
+ runner = FixtureRunner(fixture_data=fixture,
137
+ boot_delay=float(boot_delay))
138
+ boot_mode = runner.boot_mode
139
+ try:
140
+ target = runner.boot(snapshot, timeout=args.timeout)
141
+ verdict = evaluate(checklist, target)
142
+ finally:
143
+ runner.close()
144
+ except BootproofError as e:
145
+ print(reportmod.render_error(str(e), args.json))
146
+ return 2
147
+ if args.json:
148
+ print(reportmod.render_json(verdict, snapshot.id, boot_mode,
149
+ snapshot.path))
150
+ else:
151
+ print(reportmod.render_text(verdict, snapshot.id, boot_mode,
152
+ snapshot.path))
153
+ return verdict.exit_code
154
+
155
+
156
+ def cmd_snapshots(args) -> int:
157
+ try:
158
+ source = _snapshot_source(args)
159
+ snapshots = source.list_snapshots()
160
+ except BootproofError as e:
161
+ print(reportmod.render_error(str(e), args.json))
162
+ return 2
163
+ print(reportmod.render_snapshots(snapshots, args.json))
164
+ return 0
165
+
166
+
167
+ def cmd_validate(args) -> int:
168
+ try:
169
+ checklist = load_checklist(args.checklist)
170
+ except BootproofError as e:
171
+ print(reportmod.render_error(str(e), args.json))
172
+ return 2
173
+ if args.json:
174
+ print(json.dumps({"ok": True, "checks": len(checklist),
175
+ "detail": [c.describe()
176
+ for c in checklist.checks]},
177
+ indent=2))
178
+ else:
179
+ print(f"checklist valid: {len(checklist)} checks")
180
+ for c in checklist.checks:
181
+ print(f" - {c.describe()}")
182
+ return 0
183
+
184
+
185
+ def cmd_init(args) -> int:
186
+ out = Path(args.output)
187
+ out.mkdir(parents=True, exist_ok=True)
188
+ files = {
189
+ "bootproof-checklist.yaml": EXAMPLE_CHECKLIST,
190
+ "snapshots.json": json.dumps(EXAMPLE_SNAPSHOTS, indent=2) + "\n",
191
+ "fixture-go.json": json.dumps(EXAMPLE_FIXTURE_GO, indent=2) + "\n",
192
+ "fixture-nogo.json": json.dumps(EXAMPLE_FIXTURE_NOGO,
193
+ indent=2) + "\n",
194
+ }
195
+ for name, content in files.items():
196
+ (out / name).write_text(content, encoding="utf-8")
197
+ print(f"wrote {out / name}")
198
+ print("try: bootproof check --checklist "
199
+ f"{out / 'bootproof-checklist.yaml'} --snapshots "
200
+ f"{out / 'snapshots.json'} --snapshot snap-2026-10-27 "
201
+ f"--fixture {out / 'fixture-go.json'}")
202
+ return 0
203
+
204
+
205
+ def cmd_capture(args) -> int:
206
+ try:
207
+ capture(args.output, force=args.force)
208
+ except BootproofError as e:
209
+ print(reportmod.render_error(str(e)))
210
+ return 2
211
+ print(f"wrote DRAFT {args.output} — review it, then: bootproof "
212
+ f"validate --checklist {args.output}")
213
+ return 0
214
+
215
+
216
+ def cmd_preflight(args) -> int:
217
+ findings = preflight.run(args.root)
218
+ print(preflight.render(findings, args.json))
219
+ return preflight.EXIT[preflight.verdict_of(findings)]
220
+
221
+
222
+ def build_parser() -> argparse.ArgumentParser:
223
+ parser = argparse.ArgumentParser(
224
+ prog="bootproof",
225
+ description="Pre-reboot boot verification: boot a snapshot in "
226
+ "an isolated VM, run the service-up checklist, "
227
+ "print GO or NO-GO before you reboot the real box.")
228
+ parser.add_argument("--version", action="version",
229
+ version=f"bootproof {__version__}")
230
+ sub = parser.add_subparsers(dest="command", required=True)
231
+
232
+ def snapshot_args(p):
233
+ p.add_argument("--snapshots",
234
+ help="JSON snapshots file (fixture source)")
235
+ p.add_argument("--snapshot-dir",
236
+ help="directory of disk images "
237
+ "(*.qcow2/*.img/*.raw)")
238
+
239
+ def json_arg(p):
240
+ p.add_argument("--json", action="store_true",
241
+ help="machine-readable JSON output")
242
+
243
+ p_check = sub.add_parser(
244
+ "check", help="boot a snapshot, run the checklist, print the "
245
+ "verdict")
246
+ p_check.add_argument("--checklist", required=True,
247
+ help="checklist file (JSON or YAML)")
248
+ p_check.add_argument("--snapshot",
249
+ help="snapshot id (auto-selected when the "
250
+ "source holds exactly one)")
251
+ snapshot_args(p_check)
252
+ p_check.add_argument("--fixture",
253
+ help="fixture guest-state JSON (simulated "
254
+ "boot only)")
255
+ p_check.add_argument("--boot", choices=["simulated", "qemu"],
256
+ default="simulated",
257
+ help="boot mode (default: simulated)")
258
+ p_check.add_argument("--ssh-user",
259
+ help="guest SSH user for unit checks in qemu "
260
+ "mode (units ERROR without it)")
261
+ p_check.add_argument("--ssh-key",
262
+ help="guest SSH private key for unit checks "
263
+ "in qemu mode")
264
+ p_check.add_argument("--ssh-port", type=int, default=2222,
265
+ help="host port forwarded to guest SSH "
266
+ "(default: 2222)")
267
+ p_check.add_argument("--local", action="store_true",
268
+ help="probe the current machine instead of "
269
+ "booting a snapshot (checklist dry run)")
270
+ p_check.add_argument("--timeout", type=float, default=120,
271
+ help="boot timeout in seconds (default: 120)")
272
+ json_arg(p_check)
273
+ p_check.set_defaults(func=cmd_check)
274
+
275
+ p_snaps = sub.add_parser("snapshots",
276
+ help="list available snapshots")
277
+ snapshot_args(p_snaps)
278
+ json_arg(p_snaps)
279
+ p_snaps.set_defaults(func=cmd_snapshots)
280
+
281
+ p_val = sub.add_parser("validate",
282
+ help="validate a checklist file without "
283
+ "booting")
284
+ p_val.add_argument("--checklist", required=True)
285
+ json_arg(p_val)
286
+ p_val.set_defaults(func=cmd_validate)
287
+
288
+ p_init = sub.add_parser("init",
289
+ help="write example checklist, snapshots, "
290
+ "and fixture files")
291
+ p_init.add_argument("--output", default="bootproof-examples",
292
+ help="directory to write into (default: "
293
+ "bootproof-examples)")
294
+ p_init.set_defaults(func=cmd_init)
295
+
296
+ p_cap = sub.add_parser(
297
+ "capture", help="draft a checklist from this machine's "
298
+ "listening ports and running services")
299
+ p_cap.add_argument("--output", default="bootproof-checklist.yaml",
300
+ help="file to write (default: "
301
+ "bootproof-checklist.yaml)")
302
+ p_cap.add_argument("--force", action="store_true",
303
+ help="overwrite an existing file")
304
+ p_cap.set_defaults(func=cmd_capture)
305
+
306
+ p_pre = sub.add_parser(
307
+ "preflight", help="static host boot checks (fstab, initramfs, "
308
+ "/boot space, dkms) — read-only, no VM")
309
+ p_pre.add_argument("--root", default="/",
310
+ help="filesystem root to inspect (default: /)")
311
+ json_arg(p_pre)
312
+ p_pre.set_defaults(func=cmd_preflight)
313
+ return parser
314
+
315
+
316
+ def main(argv=None) -> int:
317
+ args = build_parser().parse_args(argv)
318
+ return args.func(args)
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())
bootproof/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ """Error types. Every failure mode has a distinct, specific error —
2
+ never a silent pass, never a GO on an unverified check."""
3
+
4
+
5
+ class BootproofError(Exception):
6
+ """Base class; CLI maps these to exit code 2."""
7
+
8
+
9
+ class ChecklistError(BootproofError):
10
+ """Checklist file missing, malformed, or invalid (bad check type,
11
+ missing field, empty checklist)."""
12
+
13
+
14
+ class SnapshotError(BootproofError):
15
+ """Snapshot inventory problem."""
16
+
17
+
18
+ class SnapshotNotFound(SnapshotError):
19
+ """No snapshot with the requested id exists in the source."""
20
+
21
+
22
+ class AmbiguousSnapshot(SnapshotError):
23
+ """No snapshot id given and the source holds more than one."""
24
+
25
+
26
+ class BootError(BootproofError):
27
+ """The boot itself failed (e.g. QEMU binary missing, guest disk
28
+ unreadable)."""
29
+
30
+
31
+ class BootTimeout(BootError):
32
+ """The guest did not become reachable within the boot timeout."""
33
+
34
+
35
+ class ProbeError(BootproofError):
36
+ """A check could not be determined at all (e.g. systemctl missing,
37
+ target not booted). Probe errors are verdict errors, not passes
38
+ and not plain failures: the check is unverified."""
bootproof/preflight.py ADDED
@@ -0,0 +1,185 @@
1
+ """``bootproof preflight``: static, read-only host boot checks.
2
+
3
+ No VM, no reboot. Four checks against a root directory (``/`` by
4
+ default; injectable for tests):
5
+
6
+ 1. fstab: every ``UUID=`` / ``LABEL=`` / ``PARTUUID=`` entry resolves
7
+ under ``/dev/disk/by-*``, unless its options say ``nofail`` or
8
+ ``noauto``. An unresolved entry drops a real boot into emergency
9
+ mode, so it is FAIL.
10
+ 2. initramfs: every ``/boot/vmlinuz-<v>`` has an ``initrd.img-<v>``,
11
+ ``initramfs-<v>.img`` or ``initrd-<v>`` beside it. Missing is FAIL.
12
+ 3. /boot space: under 50 MB free is WARN (does not block GO).
13
+ 4. dkms: when ``dkms`` is installed, every module must be
14
+ ``installed`` for the newest /boot kernel (version-sorted), else
15
+ FAIL naming the module. No dkms binary is SKIP, never a pass.
16
+
17
+ Same verdict rules and exit codes as ``check``: any error is ERROR
18
+ (2), else any fail is NO-GO (1), else GO (0).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ import shutil
26
+ import subprocess
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+
30
+ from .verdict import ERROR, EXIT_ERROR, EXIT_GO, EXIT_NO_GO, GO, NO_GO
31
+
32
+ BOOT_MODE = "preflight (static host checks — not a boot)"
33
+ MIN_BOOT_FREE_MB = 50
34
+ BY_DIR = {"UUID": "by-uuid", "LABEL": "by-label",
35
+ "PARTUUID": "by-partuuid"}
36
+ INITRD_NAMES = ("initrd.img-{v}", "initramfs-{v}.img", "initrd-{v}")
37
+ EXIT = {GO: EXIT_GO, NO_GO: EXIT_NO_GO, ERROR: EXIT_ERROR}
38
+
39
+
40
+ @dataclass
41
+ class Finding:
42
+ check: str
43
+ status: str # "pass" | "fail" | "warn" | "skip" | "error"
44
+ observed: str
45
+
46
+
47
+ def check_fstab(root: Path) -> Finding:
48
+ try:
49
+ lines = (root / "etc" / "fstab").read_text().splitlines()
50
+ except OSError as e:
51
+ return Finding("fstab", "error", f"cannot read /etc/fstab ({e})")
52
+ bad, tolerated, checked = [], [], 0
53
+ for line in lines:
54
+ fields = line.split("#", 1)[0].split()
55
+ if len(fields) < 2 or "=" not in fields[0]:
56
+ continue
57
+ kind, _, value = fields[0].partition("=")
58
+ if kind not in BY_DIR:
59
+ continue
60
+ checked += 1
61
+ if (root / "dev" / "disk" / BY_DIR[kind] / value).exists():
62
+ continue
63
+ opts = fields[3].split(",") if len(fields) > 3 else []
64
+ if "nofail" in opts or "noauto" in opts:
65
+ tolerated.append(f"{fields[0]} ({fields[1]})")
66
+ else:
67
+ bad.append(f"{fields[0]} ({fields[1]})")
68
+ if bad:
69
+ return Finding("fstab", "fail",
70
+ "not found under /dev/disk/by-*, no nofail/noauto "
71
+ "— boot would stop in emergency mode: "
72
+ + ", ".join(bad))
73
+ note = (f"; missing but nofail/noauto: {', '.join(tolerated)}"
74
+ if tolerated else "")
75
+ return Finding("fstab", "pass",
76
+ f"{checked} UUID/LABEL/PARTUUID entries checked{note}")
77
+
78
+
79
+ def _version_key(v: str) -> list:
80
+ # "6.8.0-9" < "6.8.0-50": compare digit runs as numbers.
81
+ return [int(x) if x.isdigit() else x for x in re.split(r"(\d+)", v)]
82
+
83
+
84
+ def kernel_versions(root: Path) -> list[str]:
85
+ return sorted((p.name[len("vmlinuz-"):]
86
+ for p in (root / "boot").glob("vmlinuz-*")),
87
+ key=_version_key)
88
+
89
+
90
+ def check_initramfs(root: Path) -> Finding:
91
+ versions = kernel_versions(root)
92
+ if not versions:
93
+ return Finding("initramfs", "error",
94
+ "no /boot/vmlinuz-* found — cannot check")
95
+ missing = [v for v in versions
96
+ if not any((root / "boot" / n.format(v=v)).exists()
97
+ for n in INITRD_NAMES)]
98
+ if missing:
99
+ return Finding("initramfs", "fail",
100
+ "no initramfs for kernel " + ", ".join(missing))
101
+ return Finding("initramfs", "pass",
102
+ f"initramfs present for {len(versions)} kernel(s)")
103
+
104
+
105
+ def check_boot_space(root: Path) -> Finding:
106
+ try:
107
+ free_mb = shutil.disk_usage(root / "boot").free // (1024 * 1024)
108
+ except OSError as e:
109
+ return Finding("/boot space", "error", f"cannot stat /boot ({e})")
110
+ if free_mb < MIN_BOOT_FREE_MB:
111
+ return Finding("/boot space", "warn",
112
+ f"{free_mb} MB free on /boot (under "
113
+ f"{MIN_BOOT_FREE_MB} MB)")
114
+ return Finding("/boot space", "pass", f"{free_mb} MB free on /boot")
115
+
116
+
117
+ def check_dkms(root: Path) -> Finding:
118
+ exe = shutil.which("dkms")
119
+ if exe is None:
120
+ return Finding("dkms", "skip",
121
+ "dkms not installed — not checked")
122
+ versions = kernel_versions(root)
123
+ if not versions:
124
+ return Finding("dkms", "error",
125
+ "no /boot/vmlinuz-* found — no newest kernel")
126
+ newest = versions[-1]
127
+ try:
128
+ proc = subprocess.run([exe, "status"], capture_output=True,
129
+ text=True, timeout=30)
130
+ except (OSError, subprocess.TimeoutExpired) as e:
131
+ return Finding("dkms", "error", f"dkms status failed ({e})")
132
+ if proc.returncode != 0:
133
+ return Finding("dkms", "error", f"dkms status exited "
134
+ f"{proc.returncode}: {proc.stderr.strip()}")
135
+ out = proc.stdout
136
+ modules, ok = set(), set()
137
+ for line in out.splitlines():
138
+ left, sep, state = line.rpartition(": ")
139
+ if not sep:
140
+ continue
141
+ parts = [x.strip() for x in left.split(",")]
142
+ # new format "mod/ver, kernel, arch"; old "mod, ver, kernel, arch"
143
+ if "/" not in parts[0] and len(parts) > 1:
144
+ parts = [f"{parts[0]}/{parts[1]}"] + parts[2:]
145
+ modules.add(parts[0])
146
+ if len(parts) > 1 and parts[1] == newest and state.strip().startswith("installed"):
147
+ ok.add(parts[0])
148
+ missing = sorted(modules - ok)
149
+ if missing:
150
+ return Finding("dkms", "fail",
151
+ f"not installed for newest kernel {newest}: "
152
+ + ", ".join(missing))
153
+ return Finding("dkms", "pass",
154
+ f"{len(modules)} module(s) installed for {newest}")
155
+
156
+
157
+ def run(root: str = "/") -> list[Finding]:
158
+ r = Path(root)
159
+ return [check_fstab(r), check_initramfs(r), check_boot_space(r),
160
+ check_dkms(r)]
161
+
162
+
163
+ def verdict_of(findings: list[Finding]) -> str:
164
+ statuses = {f.status for f in findings}
165
+ if "error" in statuses:
166
+ return ERROR
167
+ if "fail" in statuses:
168
+ return NO_GO
169
+ return GO
170
+
171
+
172
+ def render(findings: list[Finding], as_json: bool) -> str:
173
+ verdict = verdict_of(findings)
174
+ if as_json:
175
+ return json.dumps({"verdict": verdict, "boot_mode": BOOT_MODE,
176
+ "checks": [asdict(f) for f in findings]},
177
+ indent=2)
178
+ lines = [f"bootproof verdict: {verdict}", f"boot: {BOOT_MODE}"]
179
+ if verdict == GO:
180
+ lines.append("GO — static boot checks passed. This is not a "
181
+ "boot test.")
182
+ order = ("error", "fail", "warn", "skip", "pass")
183
+ for f in sorted(findings, key=lambda f: order.index(f.status)):
184
+ lines.append(f" {f.status.upper()}: {f.check} — {f.observed}")
185
+ return "\n".join(lines)