From 6058a0660e8412530485a9ede71bad5c0e3d0282 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 22:49:33 +0300 Subject: [PATCH] fix(test): select persistent UEFI test media without changing boot order --- dev/iso-smoke.py | 18 ++++++-- dev/laptop-smoke.py | 64 ++++++++++++++++++++++++----- docs/laptop-acceptance.md | 10 +++++ tests/test_iso_smoke.py | 24 +++++++++++ tests/test_laptop_smoke.py | 84 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 15 deletions(-) diff --git a/dev/iso-smoke.py b/dev/iso-smoke.py index dcd115c..2ac15ad 100644 --- a/dev/iso-smoke.py +++ b/dev/iso-smoke.py @@ -635,7 +635,7 @@ def qemu_path(path): return str(path) -def qemu_argv(qemu, scratch, disk, code, variables, iso, memory): +def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_drive=False): scratch = Path(scratch) owned_file(disk, scratch) owned_file(variables, scratch) @@ -650,8 +650,17 @@ def qemu_argv(qemu, scratch, disk, code, variables, iso, memory): if iso is not None: if not Path(iso).is_file(): raise SmokeError('Trusted ISO must exist as a regular file') - argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on', - '-boot', 'order=d,strict=on'] + if keep_cdrom_drive: + # Keep one SATA0 device even without media so OVMF retains its DVD boot + # entry across ISO-free boots. No file= means there is no optical medium. + optical = 'if=ide,index=0,id=acceptance-cdrom,media=cdrom,readonly=on' + if iso is not None: + optical += ',format=raw,file=' + qemu_path(iso) + argv += ['-drive', optical] + elif iso is not None: + argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on'] + if iso is not None: + argv += ['-boot', 'order=d,strict=on'] else: argv += ['-boot', 'order=c,strict=on'] return argv @@ -816,7 +825,8 @@ class VM: (scratch / name).unlink(missing_ok=True) self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0) self.process = subprocess.Popen(qemu_argv(args.qemu, scratch, disk, - args.ovmf_code, scratch / 'OVMF_VARS.fd', iso, args.memory), + args.ovmf_code, scratch / 'OVMF_VARS.fd', iso, args.memory, + keep_cdrom_drive=getattr(args, 'keep_cdrom_drive', False)), stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr) self.console = None self.qmp = None diff --git a/dev/laptop-smoke.py b/dev/laptop-smoke.py index d8c997c..3cfb7d2 100755 --- a/dev/laptop-smoke.py +++ b/dev/laptop-smoke.py @@ -357,14 +357,50 @@ def probe(console, mode, context, timeout=180): return json.loads(console.command("sudo -n python3 - <<'HYDRA_LAPTOP_PROBE'\n" + source + '\nHYDRA_LAPTOP_PROBE', timeout)) -def select_installed(console): - # This is a deliberate harness action AFTER proving installer preserved order. +def select_boot_entry(console, label, expected_path, create=None): + # Harness-only one-shot selection: never rewrite BootOrder or firmware VARS. output = console.command('sudo -n efibootmgr', 30).decode() - entries = re.findall(r'^Boot([0-9A-Fa-f]{4})\*?\s+HydraOS\s', output, re.M) - if len(entries) != 1: - raise SmokeError('Expected one registered HydraOS entry: ' + output) - console.command('sudo -n efibootmgr --bootnext ' + entries[0], 30) - return entries[0] + before = re.findall(r'^BootOrder:\s*([^\r\n]+)', output, re.M) + if len(before) != 1: + raise SmokeError('Expected one observable UEFI BootOrder: ' + output) + pattern = r'^Boot([0-9A-Fa-f]{4})(\*?)[ \t]+' + re.escape(label) + r' *\t([^\r\n]+)' + entries = re.findall(pattern, output, re.M) + if not entries and create: + # OVMF may prune create-only entries that are outside BootOrder after + # consuming BootNext. Recreate only our known loader; never reorder it. + console.command(create, 30) + output = console.command('sudo -n efibootmgr', 30).decode() + if re.findall(r'^BootOrder:\s*([^\r\n]+)', output, re.M) != before: + raise SmokeError('Create-only registration changed UEFI BootOrder') + entries = re.findall(pattern, output, re.M) + if len(entries) != 1 or entries[0][1] != '*': + raise SmokeError('Expected one active ' + label + ' entry: ' + output) + entry, _, path = entries[0] + if not re.fullmatch(expected_path, path, re.I): + raise SmokeError('Unexpected ' + label + ' boot device or loader: ' + path) + console.command('sudo -n efibootmgr --bootnext ' + entry, 30) + after = console.command('sudo -n efibootmgr', 30).decode() + if re.findall(r'^BootOrder:\s*([^\r\n]+)', after, re.M) != before: + raise SmokeError('BootNext selection changed UEFI BootOrder') + if re.findall(r'^BootNext:\s*([0-9A-Fa-f]{4})\s*$', after, re.M) != [entry]: + raise SmokeError('UEFI did not retain the requested BootNext') + return entry + + +def select_installed(console, *, recreate=False): + # Deliberate action AFTER proving the installer preserved the original order. + esp = console.command('sudo -n blkid -s PARTUUID -o value /dev/vda1', 30).decode().strip() + if not re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', esp): + raise SmokeError('Expected GPT identity of the disposable ESP') + path = re.escape('HD(1,GPT,' + esp + ',0x800,0x100000)/\\EFI\\HydraOS\\grubx64.efi') + create = ("sudo -n efibootmgr --create-only --disk /dev/vda --part 1 " + "--label HydraOS --loader '\\EFI\\HydraOS\\grubx64.efi'") + return select_boot_entry(console, 'HydraOS', path, create if recreate else None) + + +def select_live(console): + path = re.escape('PciRoot(0x0)/Pci(0x1f,0x2)/Sata(0,65535,0)') + r'(?:\{auto_created_boot_option\})?' + return select_boot_entry(console, 'UEFI QEMU DVD-ROM QM00001', path) def main(argv=None): @@ -373,6 +409,7 @@ def main(argv=None): if not path.is_file(): parser.error('Missing regular input: ' + str(path)) if args.memory < 2048 or min(args.boot_timeout, args.install_timeout) <= 0: parser.error('Memory >=2048 MiB and positive timeouts required') + args.keep_cdrom_drive = True args.iso, args.ovmf_code, args.ovmf_vars = (p.resolve() for p in (args.iso, args.ovmf_code, args.ovmf_vars)) scratch = Path(tempfile.mkdtemp(prefix='hydra-laptop-smoke-', dir=args.scratch_parent)) if len(str(scratch / 'serial.sock').encode()) >= 104: parser.error('Use shorter scratch parent such as /tmp') @@ -393,6 +430,9 @@ def main(argv=None): console = vm.console if live: console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout) + # Keep asynchronous mount diagnostics out of framed JSON on ttyS0; + # they remain available in the guest's dmesg/journal buffers. + console.command('sudo -n dmesg --console-level 3', 30) else: console.expect(rb'login: ', args.boot_timeout); console.send('hydratest\n') console.expect(rb'Password: ', 30); console.send(password + '\n') @@ -431,7 +471,8 @@ def main(argv=None): record('packaged_ascii_branding', json.loads(iso_smoke.installed_terminal_ux_probe(console))) iso_smoke.installed_admin(console, password) baseline = probe(console, 'baseline', context, 720); record('real_portage_baseline_doctor_vmdata', baseline) - record('signed_runtime_update_recovery', probe(console, 'runtime', context, 300)); shutdown(console) + record('signed_runtime_update_recovery', probe(console, 'runtime', context, 300)) + record('harness_checkpoint_bootnext', select_live(console)); shutdown(console) console = boot(True, 'live-checkpoint') base_before = probe(console, 'base-state', context); record('base_before_checkpoint', base_before) context['root_uuid'] = console.command('sudo -n blkid -s UUID -o value /dev/vda3').decode().strip() @@ -442,8 +483,9 @@ def main(argv=None): cp = json.loads(console.command(command + ' --execute --confirm-root-uuid ' + shlex.quote(context['root_uuid']), 300)) context['checkpoint'] = cp['id']; record('real_offline_checkpoint', cp) record('checkpoint_list', json.loads(console.command('sudo -n hydra-system list --root-uuid ' + shlex.quote(context['root_uuid']), 180))) - select_installed(console); shutdown(console) - console = boot(False, 'installed-upgrade'); record('real_portage_upgrade_current_home', probe(console, 'upgrade', context, 720)); shutdown(console) + select_installed(console, recreate=True); shutdown(console) + console = boot(False, 'installed-upgrade'); record('real_portage_upgrade_current_home', probe(console, 'upgrade', context, 720)) + record('harness_restore_bootnext', select_live(console)); shutdown(console) console = boot(True, 'live-restore') record('deliberate_kernel_efi_damage', probe(console, 'damage', context)) record('restore_sigkill_real_btrfs', probe(console, 'restore-interrupt', context, 300)) @@ -455,7 +497,7 @@ def main(argv=None): if base_after != base_before: raise SmokeError('Offline restore did not restore exact base/Portage/kernel/ownedEFI hashes') preserved_after = probe(console, 'preserved', context); verify_preserved(before, preserved_after) record('restored_base_foreign_invariants', {'base': base_after, 'preserved': preserved_after}) - select_installed(console); shutdown(console) + select_installed(console, recreate=True); shutdown(console) console = boot(False, 'installed-restored'); restored = probe(console, 'restored', context) if any(restored[k] != v for k, v in base_before.items()): raise SmokeError('Disk-only boot did not retain restored base') if restored['vm_data'] != baseline['vm_data']: raise SmokeError('VM disk/profile bytes changed') diff --git a/docs/laptop-acceptance.md b/docs/laptop-acceptance.md index d899a90..26c78a2 100644 --- a/docs/laptop-acceptance.md +++ b/docs/laptop-acceptance.md @@ -24,6 +24,16 @@ adds the following real guest operations: fallback bytes, and firmware BootOrder. Install Btrfs into `/dev/vda3` while retaining `/dev/vda1`, explicitly register a HydraOS entry, then verify those invariants before the harness deliberately sets BootNext to choose that entry. + Subsequent live boots also use verified one-shot BootNext selection. The same + SATA DVD drive stays attached throughout, empty during installed boots; no ISO + is available to those boots. This keeps OVMF's device entries stable without + resetting firmware variables or changing BootOrder. Every deliberate BootNext + write is read back and checked against the unchanged BootOrder. If OVMF prunes + HydraOS's create-only entry after consuming BootNext, the harness recreates only + that known ESP/loader entry before the next selection, and verifies that both + registration and selection preserve the original order. Live test consoles + reduce kernel console verbosity so asynchronous mount messages cannot corrupt + framed JSON; the messages remain in the guest kernel log. 2. Boot without ISO, verify the packaged Fastfetch art and doctor, install version 1.0 of a locally created EAPI 8 Portage package, and create a real qcow2 plus profile/data sentinels under HOME. The fixture has no sources or dependencies; diff --git a/tests/test_iso_smoke.py b/tests/test_iso_smoke.py index 29f29f9..bb50a25 100644 --- a/tests/test_iso_smoke.py +++ b/tests/test_iso_smoke.py @@ -63,6 +63,30 @@ class SmokeTests(unittest.TestCase): self.assertNotIn('media=cdrom', ' '.join(argv)) self.assertNotIn('-cdrom', argv) + def test_persistent_optical_drive_keeps_topology_with_no_installed_medium(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + iso = self.root / 'trusted.iso' + iso.write_bytes(b'ISO') + optical = [] + for medium in (iso, None): + argv = self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, medium, 2048, keep_cdrom_drive=True) + drives = [argv[i + 1] for i, value in enumerate(argv[:-1]) if value == '-drive'] + cdrom = [dict(field.split('=', 1) for field in drive.split(',')) + for drive in drives if 'media=cdrom' in drive] + self.assertEqual(len(cdrom), 1) + self.assertEqual(cdrom[0]['if'], 'ide') + self.assertEqual(cdrom[0]['index'], '0') + self.assertEqual(cdrom[0]['id'], 'acceptance-cdrom') + self.assertEqual(cdrom[0]['readonly'], 'on') + optical.append(cdrom[0]) + self.assertEqual(optical[0].pop('file'), str(iso)) + self.assertEqual(optical[0].pop('format'), 'raw') + self.assertNotIn('file', optical[1], 'Installed boot must have no ISO medium') + self.assertNotIn('format', optical[1], 'An empty drive must not request a raw block backend') + self.assertEqual(optical[0], optical[1]) + def test_hardlinked_disk_is_refused(self): disk = self.root / 'disk.qcow2' self.mod.reserve_disk(disk) diff --git a/tests/test_laptop_smoke.py b/tests/test_laptop_smoke.py index e78875a..e1c5366 100644 --- a/tests/test_laptop_smoke.py +++ b/tests/test_laptop_smoke.py @@ -9,6 +9,38 @@ import urllib.error import urllib.request +class BootConsole: + """Captured efibootmgr format with observable guest NVRAM mutations.""" + def __init__(self, entries=None, changed_order=False, drop_next=False, changed_create_order=False): + self.order = '0000,0001,0002,0003' + self.next = None + self.changed_order, self.drop_next = changed_order, drop_next + self.changed_create_order = changed_create_order + self.entries = entries if entries is not None else ( + 'Boot0000* UiApp\tFvVol(example)/FvFile(example)\n' + 'Boot0001* UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)/Pci(0x1f,0x2)/Sata(0,65535,0){auto_created_boot_option}\n' + 'Boot0002* UEFI Misc Device\tPciRoot(0x0)/Pci(0x3,0x0)\n' + 'Boot0003* EFI Internal Shell\tFvVol(example)/FvFile(example)\n' + 'Boot0004* HydraOS\tHD(1,GPT,5d24ab30-9c80-4f5d-8594-f9f08fdf31fb,0x800,0x100000)/\\EFI\\HydraOS\\grubx64.efi\n') + + def command(self, command, timeout): + if command == 'sudo -n blkid -s PARTUUID -o value /dev/vda1': + return b'5d24ab30-9c80-4f5d-8594-f9f08fdf31fb\n' + if command == "sudo -n efibootmgr --create-only --disk /dev/vda --part 1 --label HydraOS --loader '\\EFI\\HydraOS\\grubx64.efi'": + self.entries += ('Boot0004* HydraOS\tHD(1,GPT,5d24ab30-9c80-4f5d-8594-f9f08fdf31fb,0x800,0x100000)/\\EFI\\HydraOS\\grubx64.efi\n') + if self.changed_create_order: + self.order = '0004,0000,0001,0002,0003' + elif command.startswith('sudo -n efibootmgr --bootnext '): + if not self.drop_next: + self.next = command.split()[-1] + if self.changed_order: + self.order = '0001,0000,0002,0003' + elif command != 'sudo -n efibootmgr': + raise AssertionError('Unexpected firmware mutation: ' + command) + return ('BootCurrent: 0004\nTimeout: 1 seconds\nBootOrder: ' + self.order + '\n' + + ('BootNext: ' + self.next + '\n' if self.next else '') + self.entries).encode() + + SCRIPT = Path(__file__).resolve().parents[1] / 'dev/laptop-smoke.py' @@ -80,6 +112,58 @@ class LaptopSmokeTests(unittest.TestCase): with self.assertRaises(self.mod.SmokeError): self.mod.partition_installer_command('bad;token') + def test_live_boot_is_selected_once_without_changing_bootorder(self): + console = BootConsole() + self.mod.select_live(console) + self.assertEqual(console.next, '0001') + self.assertEqual(console.order, '0000,0001,0002,0003') + + def test_live_boot_refuses_missing_ambiguous_or_inactive_dvd_entry(self): + for entries in ('Boot0001* Unrelated DVD\tPciRoot(0x0)\n', + 'Boot0001* UEFI QEMU DVD-ROM QM00001 Other\tPciRoot(0x0)\n', + 'Boot0001 UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)\n', + 'Boot0001* UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)\n' + 'Boot0002* UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)\n'): + console = BootConsole(entries=entries) + with self.subTest(entries=entries), self.assertRaises(self.mod.SmokeError): + self.mod.select_live(console) + self.assertIsNone(console.next) + + def test_boot_selection_verifies_effective_bootnext_and_unchanged_order(self): + for selector in ('select_live', 'select_installed'): + for fault in ('changed_order', 'drop_next'): + with self.subTest(selector=selector, fault=fault), self.assertRaises(self.mod.SmokeError): + getattr(self.mod, selector)(BootConsole(**{fault: True})) + + def test_installed_boot_recreates_pruned_owned_entry_without_changing_order(self): + console = BootConsole() + console.entries = console.entries[:console.entries.index('Boot0004')] + with self.assertRaises(self.mod.SmokeError): + self.mod.select_installed(console) + self.assertIsNone(console.next, 'First selection must prove installer registration') + self.mod.select_installed(console, recreate=True) + self.assertEqual(console.next, '0004') + self.assertEqual(console.order, '0000,0001,0002,0003') + self.assertIn('\\EFI\\HydraOS\\grubx64.efi', console.entries) + + def test_recreated_entry_cannot_change_bootorder(self): + console = BootConsole(changed_create_order=True) + console.entries = console.entries[:console.entries.index('Boot0004')] + with self.assertRaises(self.mod.SmokeError): + self.mod.select_installed(console, recreate=True) + self.assertIsNone(console.next) + + def test_boot_selection_rejects_correct_label_with_foreign_device_or_loader(self): + for selector, before, after in ( + ('select_live', 'Sata(0,65535,0)', 'Sata(2,65535,0)'), + ('select_installed', '5d24ab30-9c80-4f5d-8594-f9f08fdf31fb', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'), + ('select_installed', '\\EFI\\HydraOS\\grubx64.efi', '\\EFI\\ForeignOS\\grubx64.efi')): + console = BootConsole() + console.entries = console.entries.replace(before, after) + with self.subTest(selector=selector, wrong=after), self.assertRaises(self.mod.SmokeError): + getattr(self.mod, selector)(console) + self.assertIsNone(console.next) + def test_base_state_compares_kernel_bytes_without_including_mounted_esp(self): # Offline /boot/efi is an empty mountpoint; on disk-only boot it contains # the mounted ESP. That mount must not produce a false base mismatch.