210 lines
9.2 KiB
Python
210 lines
9.2 KiB
Python
"""Synthetic behavioral checks for the pinned installer-state guard."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import contextlib
|
|
import io
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
HERE = Path(__file__).parent
|
|
sys.path.insert(0, str(HERE))
|
|
SPEC = importlib.util.spec_from_file_location("vm_install_boot", HERE / "install_boot.py")
|
|
assert SPEC and SPEC.loader
|
|
boot = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(boot)
|
|
|
|
|
|
class FakeVBox:
|
|
def __init__(self, root: Path, *, mutations: dict[str, str] | None = None,
|
|
extra_vms: tuple[str, ...] = (), start_result: int = 0):
|
|
self.root = root
|
|
self.mutations = mutations or {}
|
|
self.extra_vms = extra_vms
|
|
self.start_result = start_result
|
|
self.calls: list[tuple[str, ...]] = []
|
|
|
|
def __call__(self, argv):
|
|
args = tuple(argv)
|
|
self.calls.append(args)
|
|
if args[1:3] == ("list", "vms"):
|
|
rows = [f'"{boot.TARGET_NAME}" {{{boot.TARGET_UUID}}}', *self.extra_vms]
|
|
return subprocess.CompletedProcess(args, 0, "\n".join(rows) + "\n", "")
|
|
if args[1] == "showvminfo":
|
|
values = {
|
|
"name": boot.TARGET_NAME,
|
|
"UUID": boot.TARGET_UUID,
|
|
"description": f"{boot.TARGET_NAME}:{boot.TARGET_UUID}",
|
|
"CfgFile": str(self.root / boot.TARGET_NAME / f"{boot.TARGET_NAME}.vbox"),
|
|
"VMState": "poweroff",
|
|
"storagecontrollername0": "SATA",
|
|
"SATA-0-0": str(self.root / boot.TARGET_NAME / f"{boot.TARGET_NAME}.vdi"),
|
|
"SATA-1-0": str(self.root / boot.TARGET_NAME / f"Unattended-{boot.TARGET_UUID}-aux-iso.viso"),
|
|
**{f"SATA-{port}-0": "none" for port in range(2, 30)},
|
|
**{f"nic{slot}": "nat" if slot == 1 else "none" for slot in range(1, 9)},
|
|
"natpf1": "",
|
|
"SharedFolderNameMachineMapping1": "",
|
|
"SATA-ImageUUID-0-0": "{synthetic-disk-uuid}",
|
|
"SATA-IsEjected-1-0": "off",
|
|
}
|
|
values.update(self.mutations)
|
|
out = "\n".join(
|
|
f'{json.dumps(key) if "-" in key else key}={json.dumps(value)}'
|
|
for key, value in values.items()
|
|
) + "\n"
|
|
return subprocess.CompletedProcess(args, 0, out, "")
|
|
if args[1] == "startvm":
|
|
return subprocess.CompletedProcess(args, self.start_result, "", "")
|
|
return subprocess.CompletedProcess(args, 0, "", "")
|
|
|
|
|
|
class InstallBootTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.temp.name) / "lab"
|
|
self.root.mkdir(mode=0o700)
|
|
os.chmod(self.root, 0o700)
|
|
self.folder = self.root / boot.TARGET_NAME
|
|
self.folder.mkdir(mode=0o700)
|
|
os.chmod(self.folder, 0o700)
|
|
self.cfg = self.folder / f"{boot.TARGET_NAME}.vbox"
|
|
self.disk = self.folder / f"{boot.TARGET_NAME}.vdi"
|
|
self.viso = self.folder / f"Unattended-{boot.TARGET_UUID}-aux-iso.viso"
|
|
self.cfg.write_text("synthetic vbox", encoding="ascii")
|
|
self.disk.write_bytes(b"synthetic disk")
|
|
self.viso.write_bytes(b"synthetic viso; contents intentionally never read")
|
|
os.chmod(self.viso, 0o600)
|
|
manifest = {
|
|
"schema": 1,
|
|
"name": boot.TARGET_NAME,
|
|
"owner_uid": os.getuid(),
|
|
"uuid": boot.TARGET_UUID,
|
|
"host_marker": f"{boot.TARGET_NAME}:{boot.TARGET_UUID}",
|
|
}
|
|
(self.root / "target-identity.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
os.chmod(self.root / "target-identity.json", 0o600)
|
|
self.constants = mock.patch.multiple(
|
|
boot, LAB_ROOT=self.root, TARGET_FOLDER=self.folder,
|
|
EXPECTED_VDI=self.disk, EXPECTED_VISO=self.viso,
|
|
)
|
|
self.constants.start()
|
|
self.addCleanup(self.constants.stop)
|
|
self.fake = FakeVBox(self.root)
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def test_exact_installer_state_checks_read_only(self):
|
|
uid, info = boot.inspect_installer_state(lab_root=self.root, runner=self.fake)
|
|
self.assertEqual(uid, boot.TARGET_UUID)
|
|
self.assertEqual(info["SATA-1-0"], str(self.viso))
|
|
self.assertEqual([call[1] for call in self.fake.calls], ["list", "showvminfo"])
|
|
|
|
def test_start_uses_only_pinned_headless_target_after_inspection(self):
|
|
uid = boot.start_installer(lab_root=self.root, runner=self.fake)
|
|
self.assertEqual(uid, boot.TARGET_UUID)
|
|
self.assertEqual(self.fake.calls[-1], (
|
|
"VBoxManage", "startvm", boot.TARGET_UUID, "--type=headless",
|
|
))
|
|
|
|
def test_identity_media_power_network_and_forwarding_mismatches_refuse_before_start(self):
|
|
cases = (
|
|
({"description": "wrong marker"}, "host marker"),
|
|
({"SATA-2-0": "/tmp/extra.iso"}, "SATA media"),
|
|
({"SATA-1-0": "/tmp/elsewhere.viso"}, "SATA media"),
|
|
({"VMState": "running"}, "powered off"),
|
|
({"nic2": "nat"}, "NAT-only"),
|
|
({"nic9": "nat"}, "extra devices"),
|
|
({"natpf1": "guest,tcp,,2222,,22"}, "forwarding"),
|
|
({"SharedFolderNameMachineMapping1": "hostshare"}, "shared folders"),
|
|
({"IDE-0-0": "/tmp/extra.iso"}, "storage controller or media"),
|
|
)
|
|
for mutations, message in cases:
|
|
with self.subTest(mutations=mutations):
|
|
fake = FakeVBox(self.root, mutations=mutations)
|
|
with self.assertRaisesRegex(boot.InstallBootError, message):
|
|
boot.start_installer(lab_root=self.root, runner=fake)
|
|
self.assertNotIn("startvm", [call[1] for call in fake.calls])
|
|
|
|
def test_extra_registered_vm_row_is_rejected_without_start(self):
|
|
fake = FakeVBox(self.root, extra_vms=(
|
|
'"wga-m9-debian137-20261025" {12345678-1234-1234-1234-123456789012}',
|
|
))
|
|
with self.assertRaisesRegex(boot.InstallBootError, "missing, duplicated, or mismatched"):
|
|
boot.start_installer(lab_root=self.root, runner=fake)
|
|
self.assertEqual([call[1] for call in fake.calls], ["list"])
|
|
|
|
def test_target_folder_and_attached_viso_symlinks_refuse(self):
|
|
outside = Path(self.temp.name) / "outside"
|
|
self.folder.rename(outside)
|
|
self.folder.symlink_to(outside, target_is_directory=True)
|
|
fake = FakeVBox(self.root)
|
|
with self.assertRaisesRegex(boot.InstallBootError, "target folder"):
|
|
boot.start_installer(lab_root=self.root, runner=fake)
|
|
self.assertNotIn("startvm", [call[1] for call in fake.calls])
|
|
|
|
def test_viso_mode_and_missing_media_are_checked_without_reading_contents(self):
|
|
os.chmod(self.viso, 0o644)
|
|
fake = FakeVBox(self.root)
|
|
with self.assertRaisesRegex(boot.InstallBootError, "mode 0600"):
|
|
boot.start_installer(lab_root=self.root, runner=fake)
|
|
self.assertNotIn("startvm", [call[1] for call in fake.calls])
|
|
|
|
def test_target_vdi_hard_link_refuses_before_start(self):
|
|
other_vm_disk = Path(self.temp.name) / "other-vm.vdi"
|
|
os.link(self.disk, other_vm_disk)
|
|
fake = FakeVBox(self.root)
|
|
with self.assertRaisesRegex(boot.InstallBootError, "one hard link"):
|
|
boot.start_installer(lab_root=self.root, runner=fake)
|
|
self.assertNotIn("startvm", [call[1] for call in fake.calls])
|
|
|
|
def test_generated_private_file_symlink_is_rejected(self):
|
|
secret = self.folder / "preseed.cfg"
|
|
secret.symlink_to(self.viso)
|
|
fake = FakeVBox(self.root)
|
|
with self.assertRaisesRegex(boot.InstallBootError, "symlink"):
|
|
boot.start_installer(lab_root=self.root, runner=fake)
|
|
self.assertNotIn("startvm", [call[1] for call in fake.calls])
|
|
|
|
def test_failed_start_reports_may_have_started_and_never_retries(self):
|
|
fake = FakeVBox(self.root, start_result=1)
|
|
with self.assertRaisesRegex(boot.InstallBootError, "may have started"):
|
|
boot.start_installer(lab_root=self.root, runner=fake)
|
|
self.assertEqual([call[1] for call in fake.calls].count("startvm"), 1)
|
|
|
|
def test_start_timeout_is_bounded_and_reported_as_ambiguous(self):
|
|
fake = FakeVBox(self.root)
|
|
start_attempts = []
|
|
|
|
def timed_out(argv):
|
|
if tuple(argv)[1] == "startvm":
|
|
start_attempts.append(tuple(argv))
|
|
raise subprocess.TimeoutExpired(argv, 15)
|
|
return fake(argv)
|
|
|
|
with self.assertRaisesRegex(boot.InstallBootError, "may have started"):
|
|
boot.start_installer(lab_root=self.root, runner=timed_out)
|
|
self.assertEqual(start_attempts, [(
|
|
"VBoxManage", "startvm", boot.TARGET_UUID, "--type=headless",
|
|
)])
|
|
|
|
def test_cli_has_no_target_or_path_override(self):
|
|
with mock.patch.object(boot, "inspect_installer_state") as inspect:
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
with self.assertRaises(SystemExit) as raised:
|
|
boot.main(["check", "--lab-root", "/tmp/other"])
|
|
self.assertEqual(raised.exception.code, 2)
|
|
inspect.assert_not_called()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|