From b54693be0cc947ef20528d214c37d7a0d242e681 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 14 Oct 2026 07:37:24 +0300 Subject: [PATCH] Fix raw USB live boot and verify both installation media paths --- README.md | 6 + dev/iso-smoke.py | 47 +++++++- docs/install.md | 5 + docs/releases/2026-10-14-usb-fix/README.md | 49 ++++++++ .../cdrom-live-acceptance.json | 32 +++++ .../payload-comparison.json | 26 ++++ .../usb-live-acceptance.json | 32 +++++ hydra/build.py | 8 +- image/README.md | 7 ++ tests/test_image.py | 21 ++++ tests/test_iso_smoke.py | 114 +++++++++++++++++- 11 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 docs/releases/2026-10-14-usb-fix/README.md create mode 100644 docs/releases/2026-10-14-usb-fix/cdrom-live-acceptance.json create mode 100644 docs/releases/2026-10-14-usb-fix/payload-comparison.json create mode 100644 docs/releases/2026-10-14-usb-fix/usb-live-acceptance.json diff --git a/README.md b/README.md index 7586e47..91a7724 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ offline Btrfs base recovery and read-only laptop diagnostics. The complete sourc suite passed **431 tests**. The final ISO passed **13 whole-disk** and **23 preserved-partition/recovery** UEFI/KVM checks, with no skips. See the [release and acceptance record](docs/releases/2026-10-13-laptop-ready/README.md). +**USB correction (2026-10-14):** those installation tests used virtual CD-ROM +media. Raw USB boot exposed a duplicate HFS+ live-volume label in that ISO. +Use `hydra-os-usb-fixed.iso` for USB installation; its unchanged system payload +now passes both USB and CD-ROM live boot, graphical-session and shutdown checks. +The updated source suite passes **439 tests**. See the +[USB correction record](docs/releases/2026-10-14-usb-fix/README.md). The [previous terminal UX preview](docs/releases/2026-10-13-terminal-ux/README.md) passed 13 disposable UEFI/KVM checks against its earlier image. The first physical target is ASUS TUF Dash F15 FX517ZC; hardware checks remain pending. No physical diff --git a/dev/iso-smoke.py b/dev/iso-smoke.py index 54c79fd..cdecfbc 100644 --- a/dev/iso-smoke.py +++ b/dev/iso-smoke.py @@ -635,7 +635,12 @@ def qemu_path(path): return str(path) -def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_drive=False): +def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_drive=False, + live_media='cdrom'): + if live_media not in {'cdrom', 'usb'}: + raise SmokeError('Unknown live media; choose cdrom or usb') + if keep_cdrom_drive and live_media == 'usb': + raise SmokeError('keep_cdrom_drive cannot be combined with usb live media') scratch = Path(scratch) owned_file(disk, scratch) owned_file(variables, scratch) @@ -658,10 +663,14 @@ def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_d if iso is not None: optical += ',format=raw,file=' + qemu_path(iso) argv += ['-drive', optical] + elif iso is not None and live_media == 'usb': + argv += ['-drive', f'if=none,id=acceptance-usb,format=raw,readonly=on,file={qemu_path(iso)}', + '-device', 'qemu-xhci,id=acceptance-xhci', + '-device', 'usb-storage,bus=acceptance-xhci.0,drive=acceptance-usb,bootindex=1'] 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'] + argv += ['-boot', 'strict=on' if live_media == 'usb' else 'order=d,strict=on'] else: argv += ['-boot', 'order=c,strict=on'] return argv @@ -827,7 +836,8 @@ class VM: 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, - keep_cdrom_drive=getattr(args, 'keep_cdrom_drive', False)), + keep_cdrom_drive=getattr(args, 'keep_cdrom_drive', False), + live_media=getattr(args, 'live_media', 'cdrom')), stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr) self.console = None self.qmp = None @@ -910,6 +920,25 @@ for tool in hydra hydra-install qemu-system-x86_64 qemu-img remote-viewer foot l for service in NetworkManager hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done ''' +LIVE_GRAPHICAL_CHECK = r'''set -eu +systemctl is-active greetd +pgrep -u hydra-live -x labwc +pgrep -u hydra-live -x foot +''' + + +def live_graphical_probe(console, timeout=30): + # Serial login can become ready before greetd has launched the live shell. + deadline = time.monotonic() + timeout + while True: + try: + return console.command(LIVE_GRAPHICAL_CHECK, min(10, max(0, deadline - time.monotonic()))) + except SmokeError: + if time.monotonic() >= deadline: + raise + time.sleep(min(1, max(0, deadline - time.monotonic()))) + + INSTALLED_CHECK = r'''set -eu assert_no_match() { if grep "$@"; then @@ -1069,6 +1098,10 @@ def digest(path): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--iso', required=True, type=Path) + parser.add_argument('--live-media', choices=('cdrom', 'usb'), default='cdrom', + help='Attach ISO as optical media or a read-only raw USB disk') + parser.add_argument('--live-only', action='store_true', + help='Check live boot and graphical session, then power off without installing') parser.add_argument('--ovmf-code', required=True, type=Path) parser.add_argument('--ovmf-vars', required=True, type=Path) parser.add_argument('--qemu', default='qemu-system-x86_64') @@ -1092,6 +1125,7 @@ def main(argv=None): print(f'Acceptance artifacts: {scratch}', flush=True) report = {'schema': 1, 'status': 'running', 'scratch': str(scratch), 'iso': str(args.iso), 'iso_sha256': digest(args.iso), + 'live_media': args.live_media, 'scope': 'live-only' if args.live_only else 'install-reboot', 'checks': {}, 'physical_hardware': 'pending', 'nested_scope': 'Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot', 'ovmf_code_sha256': digest(args.ovmf_code), @@ -1115,6 +1149,13 @@ def main(argv=None): console = vm.console console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout) checkpoint('live_boot', console.command(LIVE_CHECK, 90)) + if args.live_only: + checkpoint('live_graphical_session', live_graphical_probe(console)) + console.send('sudo -n poweroff\n') + vm.wait_poweroff(90) + checkpoint('live_poweroff', b'Live-only check completed without invoking the installer') + report['status'] = 'passed' + return 0 console.buffer = b'' installer_token = secrets.token_hex(12) console.send(installer_command(installer_token) + '\n') diff --git a/docs/install.md b/docs/install.md index 10d7079..6638993 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,11 @@ # Install Hydra OS Hydra OS targets x86_64 UEFI systems. Disable Secure Boot for this first image. +For a USB flash drive, use the [2026-10-14 corrected ISO](releases/2026-10-14-usb-fix/README.md), +`hydra-os-usb-fixed.iso`. The original 0.2.0 ISO passed optical-media tests but has +a raw USB live-boot bug. Write the corrected image to the entire flash drive +using a host-native image writer; this erases that flash drive. Mounting it before +reboot is unnecessary. Physical firmware compatibility still requires testing. Boot the ISO and enter `:shell` in the terminal VM menu. The live session uses a locked `hydra-live` account with local sudo access; changes in the live filesystem are volatile. The image provides no default SSH login or universal installed password. diff --git a/docs/releases/2026-10-14-usb-fix/README.md b/docs/releases/2026-10-14-usb-fix/README.md new file mode 100644 index 0000000..51efd95 --- /dev/null +++ b/docs/releases/2026-10-14-usb-fix/README.md @@ -0,0 +1,49 @@ +# Hydra OS USB media correction — 2026-10-14 + +The original 0.2.0 ISO (`451e058228d7cbea87b5301ae6502b6be609cf7f52cc7c9e6fb477822501389b`) +boots as a virtual CD-ROM but fails when attached as raw USB storage. GRUB's HFS+ +hybrid uses the same `HYDRA_OS` label as ISO9660; dracut then selects the HFS+ +partition and fails to mount the live payload. This was reproduced under OVMF +with a read-only USB image and no physical disk access. + +The corrected image adds native xorriso `-hfsplus off` to `grub-mkrescue`. It keeps +GPT, the EFI partition and El Torito boot information, and removes HFS+/APM. +The builder now rejects HFS+/APM in its inspection report. + +- Local file: `build/artifacts/hydra-os-usb-fixed.iso` +- Size: 2,380,120,064 bytes +- SHA256: `3a126e1a1004ba24ead66971013ba4a8468508f6fac4dcaf04aa9770cb8df7e1` +- Frozen payload source: `ebeebc7fb1aed07a5f50bc62374365d614953dad` +- The ISO is local; this Git directory contains evidence, not an ISO download. + +Kernel, live initramfs, squashfs and embedded build manifest match the original +ISO byte-for-byte. [Payload hashes](payload-comparison.json) were computed from +the actual original and corrected ISO extents. This is a media-layout repair, +not a new system payload or signed runtime update. The original artifact and +release evidence remain unchanged. + +## Verification + +`python3 -m unittest discover -s tests -q` passed **439 tests** inside Ubuntu with +Python 3.12 and OpenSSL available. Initial host runs failed in pre-existing +OpenSSL-dependent updater/signature tests because OpenSSL was absent or lost +from the test PATH; these were not reported as successful runs. + +The same corrected ISO passed the real `dev/iso-smoke.py --live-only` checks in +both [USB](usb-live-acceptance.json) and [CD-ROM](cdrom-live-acceptance.json) modes: +KVM, UEFI live user and overlay root, readable squashfs, sudo, required tools, +NetworkManager and Hydra network services, active greetd/labwc/foot, and normal +shutdown. Each run used fresh OVMF variables and a disposable test disk. Neither +invoked the installer or accessed host disks. The earlier full installation and +recovery checks apply to the byte-identical system payload, not a newly repeated +full installation test of this repacked ISO. + +The remaining FAT12 EFI partition is unchanged. OVMF successfully starts it via +USB; this does not certify detection by every physical firmware. ASUS boot-menu +visibility, USB port behavior and actual laptop live boot still need physical +confirmation. Secure Boot must remain disabled. + +Reflash the corrected ISO using a host-native writer. For the existing NixOS +setup, GNOME Disks can be run temporarily; an Etcher process inside rootless +Distrobox could not elevate or write the raw USB device. Do not change SSD +partitions to repair an installation USB. diff --git a/docs/releases/2026-10-14-usb-fix/cdrom-live-acceptance.json b/docs/releases/2026-10-14-usb-fix/cdrom-live-acceptance.json new file mode 100644 index 0000000..6e84558 --- /dev/null +++ b/docs/releases/2026-10-14-usb-fix/cdrom-live-acceptance.json @@ -0,0 +1,32 @@ +{ + "schema": 1, + "status": "passed", + "scratch": "/tmp/hydra-iso-smoke-l7g8944d", + "iso": "/home/workspaces/ubuntu-dev/home/Projects/hydra-os/build/artifacts/usb-fix-nohfs/hydra-os.iso", + "iso_sha256": "3a126e1a1004ba24ead66971013ba4a8468508f6fac4dcaf04aa9770cb8df7e1", + "live_media": "cdrom", + "scope": "live-only", + "checks": { + "live_qmp_kvm": { + "status": "passed", + "output": "{\"enabled\": true, \"present\": true}" + }, + "live_boot": { + "status": "passed", + "output": "/usr/bin/hydra\n/usr/bin/hydra-install\n/usr/bin/qemu-system-x86_64\n/usr/bin/qemu-img\n/usr/bin/remote-viewer\n/usr/bin/foot\n/usr/bin/labwc\nactive\nactive\nactive\n" + }, + "live_graphical_session": { + "status": "passed", + "output": "active\n832\n854\n" + }, + "live_poweroff": { + "status": "passed", + "output": "Live-only check completed without invoking the installer" + } + }, + "physical_hardware": "pending", + "nested_scope": "Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot", + "ovmf_code_sha256": "f3ff7e73448ed2845ee15356f394882f5618eb5dab92c9a30ec6ee0e1468553a", + "ovmf_vars_input_sha256": "6ed987af3a3c155be71665f510eae3e007eda9b8b94afd59d45e91c4a11565cc", + "completed_utc": "2026-10-14T04:35:22Z" +} diff --git a/docs/releases/2026-10-14-usb-fix/payload-comparison.json b/docs/releases/2026-10-14-usb-fix/payload-comparison.json new file mode 100644 index 0000000..7ebae05 --- /dev/null +++ b/docs/releases/2026-10-14-usb-fix/payload-comparison.json @@ -0,0 +1,26 @@ +{ + "vmlinuz": { + "size": 22666224, + "original": "bb77e0f760f1ce97d2f6e23acad1fb2bb3dccab6c1381bec0868327e110ec3a5", + "candidate": "bb77e0f760f1ce97d2f6e23acad1fb2bb3dccab6c1381bec0868327e110ec3a5", + "matches": true + }, + "initramfs-live.img": { + "size": 83130912, + "original": "694778902d95bfc94da1d7566d371379fb4a60de95e49babb6500cf907509d8e", + "candidate": "694778902d95bfc94da1d7566d371379fb4a60de95e49babb6500cf907509d8e", + "matches": true + }, + "squashfs.img": { + "size": 2254630912, + "original": "ca550eb444e05505f815f4820b28527c3f8e8aee425005b73b7f02e4e74f7fc8", + "candidate": "ca550eb444e05505f815f4820b28527c3f8e8aee425005b73b7f02e4e74f7fc8", + "matches": true + }, + "build-manifest.json": { + "size": 142178, + "original": "126c66d84b7c2160a517cbd0c3649630982a4d734e113ecdd6ee48e0859f1630", + "candidate": "126c66d84b7c2160a517cbd0c3649630982a4d734e113ecdd6ee48e0859f1630", + "matches": true + } +} \ No newline at end of file diff --git a/docs/releases/2026-10-14-usb-fix/usb-live-acceptance.json b/docs/releases/2026-10-14-usb-fix/usb-live-acceptance.json new file mode 100644 index 0000000..329096a --- /dev/null +++ b/docs/releases/2026-10-14-usb-fix/usb-live-acceptance.json @@ -0,0 +1,32 @@ +{ + "schema": 1, + "status": "passed", + "scratch": "/tmp/hydra-iso-smoke-arp78mfm", + "iso": "/home/workspaces/ubuntu-dev/home/Projects/hydra-os/build/artifacts/usb-fix-nohfs/hydra-os.iso", + "iso_sha256": "3a126e1a1004ba24ead66971013ba4a8468508f6fac4dcaf04aa9770cb8df7e1", + "live_media": "usb", + "scope": "live-only", + "checks": { + "live_qmp_kvm": { + "status": "passed", + "output": "{\"enabled\": true, \"present\": true}" + }, + "live_boot": { + "status": "passed", + "output": "/usr/bin/hydra\n/usr/bin/hydra-install\n/usr/bin/qemu-system-x86_64\n/usr/bin/qemu-img\n/usr/bin/remote-viewer\n/usr/bin/foot\n/usr/bin/labwc\nactive\nactive\nactive\n" + }, + "live_graphical_session": { + "status": "passed", + "output": "active\n855\n869\n" + }, + "live_poweroff": { + "status": "passed", + "output": "Live-only check completed without invoking the installer" + } + }, + "physical_hardware": "pending", + "nested_scope": "Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot", + "ovmf_code_sha256": "f3ff7e73448ed2845ee15356f394882f5618eb5dab92c9a30ec6ee0e1468553a", + "ovmf_vars_input_sha256": "6ed987af3a3c155be71665f510eae3e007eda9b8b94afd59d45e91c4a11565cc", + "completed_utc": "2026-10-14T04:35:22Z" +} diff --git a/hydra/build.py b/hydra/build.py index 5ba2119..3fb8230 100644 --- a/hydra/build.py +++ b/hydra/build.py @@ -500,14 +500,18 @@ cp /hydra-output/artifacts/squashfs.img "$tree/LiveOS/squashfs.img" cp /hydra-source/image/grub.cfg "$tree/boot/grub/grub.cfg" cp /hydra-output/artifacts/rootfs-manifest.json "$tree/build-manifest.json" cp /hydra-output/artifacts/packages.json "$tree/packages.json" -grub-mkrescue --directory=/usr/lib/grub/x86_64-efi --output=/hydra-output/artifacts/hydra-os.iso "$tree" -- -volid HYDRA_OS -xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -pvd_info > /hydra-output/artifacts/iso-inspection.txt 2>&1 +# GRUB's default HFS+ hybrid duplicates HYDRA_OS on a second filesystem. +# On USB, dracut can select that label instead of the ISO9660 live payload. +grub-mkrescue --directory=/usr/lib/grub/x86_64-efi --output=/hydra-output/artifacts/hydra-os.iso "$tree" -- -volid HYDRA_OS -hfsplus off +xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -report_system_area plain -pvd_info > /hydra-output/artifacts/iso-inspection.txt 2>&1 chmod 0644 /hydra-output/artifacts/hydra-os.iso /hydra-output/artifacts/iso-inspection.txt """ self.pod('exec', assembler, '/bin/bash', '-euc', script) inspection = (self.work / 'artifacts/iso-inspection.txt').read_text() if 'HYDRA_OS' not in inspection or not re.search(r'El Torito boot img.*UEFI', inspection): raise BuildError('ISO does not report the expected volume label and UEFI boot image') + if re.search(r'\b(?:APM|HFSPLUS)\b', inspection): + raise BuildError('ISO contains an HFS+/APM hybrid that can shadow the live volume on USB') if image.source_identity(self.source_dir) != source_inputs: raise BuildError('Source inputs changed during ISO assembly; completion not recorded') checksum = image.digest(self.work / 'artifacts/hydra-os.iso') diff --git a/image/README.md b/image/README.md index 4e1a91e..0caf7fe 100644 --- a/image/README.md +++ b/image/README.md @@ -45,3 +45,10 @@ metamanifest verification; the builder continues to use its pinned snapshot. QEMU and remote-viewer retain local patches and are excluded from upstream binary packages on installed updates. Disposable boot/install validation is required before treating an emitted ISO as release ready. + +The x86_64 ISO disables GRUB's optional HFS+/APM hybrid. Otherwise that second +filesystem duplicates the `HYDRA_OS` label and can make dracut mount the wrong +live medium after raw USB writing. ISO inspection includes the system area and +rejects HFS+/APM. Test both optical and USB attachment with `dev/iso-smoke.py +--live-only --live-media cdrom` and `--live-only --live-media usb` (plus the normal +ISO and OVMF arguments); optical boot alone is insufficient. diff --git a/tests/test_image.py b/tests/test_image.py index 52a24db..239e52d 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -501,6 +501,27 @@ class ImageBuildTests(unittest.TestCase): path.write_text('[{"package":"app-emulation/qemu-8.0", "use": [], "build_time": null}]') self.assertFalse(self.builder.complete('packages')) + def test_iso_refuses_hfs_hybrid_that_shadows_live_volume_on_usb(self): + from hydra.build import BuildError + self.inventory() + self.builder.complete = lambda step: step == 'rootfs' + self.builder.assembly = lambda: 'isolated-assembler' + artifacts = self.builder.work / 'artifacts' + + def pod(*args, **kwargs): + self.assertEqual(args[:2], ('exec', 'isolated-assembler')) + (artifacts / 'hydra-os.iso').write_bytes(b'ISO fixture') + (artifacts / 'iso-inspection.txt').write_text( + 'Volume id : HYDRA_OS\n' + 'El Torito boot img : 1 UEFI y none\n' + 'System area summary: MBR protective-msdos-label GPT APM\n' + 'GPT partname local : 3 HFSPLUS\n') + + self.builder.pod = pod + with self.assertRaisesRegex(BuildError, 'HFS|APM'): + self.builder.iso() + self.assertFalse(self.builder.manifest()['iso']['complete']) + def test_malformed_inventory_with_matching_digest_is_refused(self): for data in ['{', '{}', '[]', '[{"package":"wrong"}]', '[{"package":"app-emulation/qemu-9.2.4", "use":"spice", "build_time":"123"}]', diff --git a/tests/test_iso_smoke.py b/tests/test_iso_smoke.py index bb50a25..05b543f 100644 --- a/tests/test_iso_smoke.py +++ b/tests/test_iso_smoke.py @@ -63,6 +63,103 @@ class SmokeTests(unittest.TestCase): self.assertNotIn('media=cdrom', ' '.join(argv)) self.assertNotIn('-cdrom', argv) + def test_usb_live_boot_uses_readonly_raw_storage_and_explicit_bootindex(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + iso = self.root / 'trusted.iso' + iso.write_bytes(b'ISO') + argv = self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, iso, 2048, live_media='usb') + drives = [argv[i + 1] for i, value in enumerate(argv[:-1]) if value == '-drive'] + devices = [argv[i + 1] for i, value in enumerate(argv[:-1]) if value == '-device'] + backend = next(dict(field.split('=', 1) for field in drive.split(',')) + for drive in drives if str(iso) in drive) + self.assertEqual(backend['file'], str(iso)) + self.assertEqual(backend['if'], 'none') + self.assertEqual(backend['format'], 'raw') + self.assertEqual(backend['readonly'], 'on') + self.assertNotIn('media', backend, 'USB must exercise a disk rather than a CD-ROM') + self.assertTrue(any(device.startswith('qemu-xhci,') for device in devices)) + storage = next(device for device in devices if device.startswith('usb-storage,')) + self.assertIn('drive=' + backend['id'], storage) + self.assertIn('bootindex=1', storage) + self.assertNotIn('order=d', ' '.join(argv)) + self.assertNotIn('media=cdrom', ' '.join(argv)) + + def test_usb_installed_reboot_removes_live_medium_and_keeps_disk_boot(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + argv = self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, None, 2048, live_media='usb') + self.assertNotIn('usb-storage', ' '.join(argv)) + self.assertNotIn('media=cdrom', ' '.join(argv)) + self.assertIn('order=c,strict=on', argv) + + def test_usb_cannot_be_combined_with_recovery_optical_topology(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + for medium in (self.code, None): + with self.subTest(medium=medium): + with self.assertRaisesRegex(self.mod.SmokeError, 'keep_cdrom_drive.*usb'): + self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, medium, 2048, live_media='usb', keep_cdrom_drive=True) + + def test_usb_rejects_devices_and_ambiguous_paths_as_live_media(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + ambiguous = self.root / 'image,readonly=off.iso' + ambiguous.write_bytes(b'ISO') + for medium in (Path('/dev/null'), ambiguous): + with self.subTest(medium=medium): + with self.assertRaises(self.mod.SmokeError): + self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, medium, 2048, live_media='usb') + + def test_unknown_live_media_is_refused_instead_of_silently_booting_cd(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + with self.assertRaisesRegex(self.mod.SmokeError, 'live media'): + self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, self.code, 2048, live_media='typo') + + def test_live_only_checks_boot_and_graphics_without_installing_or_rebooting(self): + import json + from unittest.mock import Mock, patch + iso = self.root / 'trusted.iso' + iso.write_bytes(b'ISO') + vm = Mock() + vm.kvm = {'enabled': True} + vm.console.command.return_value = b'live probe passed' + with patch.object(self.mod, 'VM', return_value=vm) as create_vm, \ + patch.object(self.mod.subprocess, 'run') as create_disk: + try: + status = self.mod.main(['--iso', str(iso), '--ovmf-code', str(self.code), + '--ovmf-vars', str(self.variables), '--scratch-parent', str(self.root), + '--live-media', 'usb', '--live-only']) + except SystemExit as exc: + self.fail(f'Live-only USB acceptance must be supported: {exc}') + self.assertEqual(status, 0) + create_vm.assert_called_once() + args, scratch, disk, medium, _password, phase = create_vm.call_args.args + self.assertEqual(args.live_media, 'usb') + self.assertEqual(medium, iso) + self.assertEqual(phase, 'live') + self.assertEqual(disk.parent, scratch) + self.assertEqual(create_disk.call_args.args[0][-2], str(disk)) + self.assertTrue(disk.is_file(), 'Only a newly reserved disposable disk is used') + vm.console.send.assert_called_once_with('sudo -n poweroff\n') + vm.console.expect_installer.assert_not_called() + self.assertEqual([call.args[0] for call in vm.console.command.call_args_list], + [self.mod.LIVE_CHECK, self.mod.LIVE_GRAPHICAL_CHECK]) + vm.wait_poweroff.assert_called_once_with(90) + vm.close.assert_called_once() + report = json.loads((scratch / 'report.json').read_text()) + self.assertEqual(report['status'], 'passed') + self.assertEqual(report['live_media'], 'usb') + self.assertEqual(report['scope'], 'live-only') + self.assertEqual(set(report['checks']), + {'live_qmp_kvm', 'live_boot', 'live_graphical_session', 'live_poweroff'}) + def test_persistent_optical_drive_keeps_topology_with_no_installed_medium(self): disk = self.root / 'disk.qcow2' self.mod.reserve_disk(disk) @@ -289,7 +386,7 @@ class SmokeTests(unittest.TestCase): self.mod.qemu_argv('qemu', self.root, comma, self.code, self.variables, None, 2048) - def probe_fixture(self, case, live=False): + def probe_fixture(self, case, live=False, graphical=False): # Run the production shell against ordinary scratch files. Commands that # query guest state are boundary fixtures; grep/find/test are real tools. import os @@ -349,16 +446,19 @@ elif name == 'getent': else: print('127.0.0.1 STREAM example') elif name == 'ip': print('10.203.0.1/24' if 'addr' in args else 'default via 10.0.2.2') elif name == 'nmcli': print('connected') +elif name == 'pgrep': + sys.exit(1 if args[-1] == case else 0) else: print('fixture tool') ''') dispatcher.chmod(0o755) for command in ('id', 'findmnt', 'systemctl', 'getent', 'ip', 'nft', - 'nmcli', 'sudo', 'hydra', 'hydra-install', + 'nmcli', 'pgrep', 'sudo', 'hydra', 'hydra-install', 'qemu-system-x86_64', 'qemu-img', 'remote-viewer', 'foot', 'labwc'): path = commands / command if not path.exists(): path.symlink_to(dispatcher) - probe = self.mod.LIVE_CHECK if live else self.mod.INSTALLED_CHECK + probe = (self.mod.LIVE_GRAPHICAL_CHECK if graphical else + self.mod.LIVE_CHECK if live else self.mod.INSTALLED_CHECK) for path in ('/etc/', '/usr/share', '/proc/cmdline', '/sys/firmware/efi', '/run/initramfs/live/LiveOS/squashfs.img'): probe = probe.replace(path, str(root) + path) import shutil @@ -405,6 +505,14 @@ else: print('fixture tool') with self.subTest(service=service): self.assertNotEqual(self.probe_fixture(service, live=True), 0) + def test_live_graphical_probe_requires_greetd_compositor_and_terminal(self): + self.assertTrue(hasattr(self.mod, 'LIVE_GRAPHICAL_CHECK'), + 'Live-only acceptance needs graphical session evidence') + self.assertEqual(self.probe_fixture('clean', live=True, graphical=True), 0) + for case in ('greetd', 'labwc', 'foot'): + with self.subTest(case=case): + self.assertNotEqual(self.probe_fixture(case, live=True, graphical=True), 0) + def tcp_probe(self, fragments): # Execute the exact Python client embedded in the guest network probe, # redirecting only its address to a fresh loopback socket.