Fix raw USB live boot and verify both installation media paths
This commit is contained in:
@@ -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
|
||||
|
||||
+44
-3
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+6
-2
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}]',
|
||||
|
||||
+111
-3
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user