fix(test): select persistent UEFI test media without changing boot order
This commit is contained in:
+14
-4
@@ -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
|
||||
|
||||
+53
-11
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user