Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54693be0c |
@@ -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
|
suite passed **431 tests**. The final ISO passed **13 whole-disk** and **23
|
||||||
preserved-partition/recovery** UEFI/KVM checks, with no skips. See the
|
preserved-partition/recovery** UEFI/KVM checks, with no skips. See the
|
||||||
[release and acceptance record](docs/releases/2026-10-13-laptop-ready/README.md).
|
[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)
|
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
|
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
|
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)
|
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)
|
scratch = Path(scratch)
|
||||||
owned_file(disk, scratch)
|
owned_file(disk, scratch)
|
||||||
owned_file(variables, 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:
|
if iso is not None:
|
||||||
optical += ',format=raw,file=' + qemu_path(iso)
|
optical += ',format=raw,file=' + qemu_path(iso)
|
||||||
argv += ['-drive', optical]
|
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:
|
elif iso is not None:
|
||||||
argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on']
|
argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on']
|
||||||
if iso is not None:
|
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:
|
else:
|
||||||
argv += ['-boot', 'order=c,strict=on']
|
argv += ['-boot', 'order=c,strict=on']
|
||||||
return argv
|
return argv
|
||||||
@@ -827,7 +836,8 @@ class VM:
|
|||||||
self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0)
|
self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0)
|
||||||
self.process = subprocess.Popen(qemu_argv(args.qemu, scratch, disk,
|
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)),
|
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)
|
stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr)
|
||||||
self.console = None
|
self.console = None
|
||||||
self.qmp = 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
|
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
|
INSTALLED_CHECK = r'''set -eu
|
||||||
assert_no_match() {
|
assert_no_match() {
|
||||||
if grep "$@"; then
|
if grep "$@"; then
|
||||||
@@ -1069,6 +1098,10 @@ def digest(path):
|
|||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('--iso', required=True, type=Path)
|
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-code', required=True, type=Path)
|
||||||
parser.add_argument('--ovmf-vars', required=True, type=Path)
|
parser.add_argument('--ovmf-vars', required=True, type=Path)
|
||||||
parser.add_argument('--qemu', default='qemu-system-x86_64')
|
parser.add_argument('--qemu', default='qemu-system-x86_64')
|
||||||
@@ -1092,6 +1125,7 @@ def main(argv=None):
|
|||||||
print(f'Acceptance artifacts: {scratch}', flush=True)
|
print(f'Acceptance artifacts: {scratch}', flush=True)
|
||||||
report = {'schema': 1, 'status': 'running', 'scratch': str(scratch),
|
report = {'schema': 1, 'status': 'running', 'scratch': str(scratch),
|
||||||
'iso': str(args.iso), 'iso_sha256': digest(args.iso),
|
'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',
|
'checks': {}, 'physical_hardware': 'pending',
|
||||||
'nested_scope': 'Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot',
|
'nested_scope': 'Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot',
|
||||||
'ovmf_code_sha256': digest(args.ovmf_code),
|
'ovmf_code_sha256': digest(args.ovmf_code),
|
||||||
@@ -1115,6 +1149,13 @@ def main(argv=None):
|
|||||||
console = vm.console
|
console = vm.console
|
||||||
console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout)
|
console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout)
|
||||||
checkpoint('live_boot', console.command(LIVE_CHECK, 90))
|
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''
|
console.buffer = b''
|
||||||
installer_token = secrets.token_hex(12)
|
installer_token = secrets.token_hex(12)
|
||||||
console.send(installer_command(installer_token) + '\n')
|
console.send(installer_command(installer_token) + '\n')
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Install Hydra OS
|
# Install Hydra OS
|
||||||
|
|
||||||
Hydra OS targets x86_64 UEFI systems. Disable Secure Boot for this first image.
|
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
|
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
|
`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.
|
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-source/image/grub.cfg "$tree/boot/grub/grub.cfg"
|
||||||
cp /hydra-output/artifacts/rootfs-manifest.json "$tree/build-manifest.json"
|
cp /hydra-output/artifacts/rootfs-manifest.json "$tree/build-manifest.json"
|
||||||
cp /hydra-output/artifacts/packages.json "$tree/packages.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
|
# GRUB's default HFS+ hybrid duplicates HYDRA_OS on a second filesystem.
|
||||||
xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -pvd_info > /hydra-output/artifacts/iso-inspection.txt 2>&1
|
# 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
|
chmod 0644 /hydra-output/artifacts/hydra-os.iso /hydra-output/artifacts/iso-inspection.txt
|
||||||
"""
|
"""
|
||||||
self.pod('exec', assembler, '/bin/bash', '-euc', script)
|
self.pod('exec', assembler, '/bin/bash', '-euc', script)
|
||||||
inspection = (self.work / 'artifacts/iso-inspection.txt').read_text()
|
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):
|
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')
|
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:
|
if image.source_identity(self.source_dir) != source_inputs:
|
||||||
raise BuildError('Source inputs changed during ISO assembly; completion not recorded')
|
raise BuildError('Source inputs changed during ISO assembly; completion not recorded')
|
||||||
checksum = image.digest(self.work / 'artifacts/hydra-os.iso')
|
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
|
QEMU and remote-viewer retain local patches and are excluded from upstream
|
||||||
binary packages on installed updates. Disposable boot/install validation is
|
binary packages on installed updates. Disposable boot/install validation is
|
||||||
required before treating an emitted ISO as release ready.
|
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}]')
|
path.write_text('[{"package":"app-emulation/qemu-8.0", "use": [], "build_time": null}]')
|
||||||
self.assertFalse(self.builder.complete('packages'))
|
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):
|
def test_malformed_inventory_with_matching_digest_is_refused(self):
|
||||||
for data in ['{', '{}', '[]', '[{"package":"wrong"}]',
|
for data in ['{', '{}', '[]', '[{"package":"wrong"}]',
|
||||||
'[{"package":"app-emulation/qemu-9.2.4", "use":"spice", "build_time":"123"}]',
|
'[{"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('media=cdrom', ' '.join(argv))
|
||||||
self.assertNotIn('-cdrom', 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):
|
def test_persistent_optical_drive_keeps_topology_with_no_installed_medium(self):
|
||||||
disk = self.root / 'disk.qcow2'
|
disk = self.root / 'disk.qcow2'
|
||||||
self.mod.reserve_disk(disk)
|
self.mod.reserve_disk(disk)
|
||||||
@@ -289,7 +386,7 @@ class SmokeTests(unittest.TestCase):
|
|||||||
self.mod.qemu_argv('qemu', self.root, comma, self.code,
|
self.mod.qemu_argv('qemu', self.root, comma, self.code,
|
||||||
self.variables, None, 2048)
|
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
|
# Run the production shell against ordinary scratch files. Commands that
|
||||||
# query guest state are boundary fixtures; grep/find/test are real tools.
|
# query guest state are boundary fixtures; grep/find/test are real tools.
|
||||||
import os
|
import os
|
||||||
@@ -349,16 +446,19 @@ elif name == 'getent':
|
|||||||
else: print('127.0.0.1 STREAM example')
|
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 == '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 == 'nmcli': print('connected')
|
||||||
|
elif name == 'pgrep':
|
||||||
|
sys.exit(1 if args[-1] == case else 0)
|
||||||
else: print('fixture tool')
|
else: print('fixture tool')
|
||||||
''')
|
''')
|
||||||
dispatcher.chmod(0o755)
|
dispatcher.chmod(0o755)
|
||||||
for command in ('id', 'findmnt', 'systemctl', 'getent', 'ip', 'nft',
|
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'):
|
'qemu-system-x86_64', 'qemu-img', 'remote-viewer', 'foot', 'labwc'):
|
||||||
path = commands / command
|
path = commands / command
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
path.symlink_to(dispatcher)
|
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'):
|
for path in ('/etc/', '/usr/share', '/proc/cmdline', '/sys/firmware/efi', '/run/initramfs/live/LiveOS/squashfs.img'):
|
||||||
probe = probe.replace(path, str(root) + path)
|
probe = probe.replace(path, str(root) + path)
|
||||||
import shutil
|
import shutil
|
||||||
@@ -405,6 +505,14 @@ else: print('fixture tool')
|
|||||||
with self.subTest(service=service):
|
with self.subTest(service=service):
|
||||||
self.assertNotEqual(self.probe_fixture(service, live=True), 0)
|
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):
|
def tcp_probe(self, fragments):
|
||||||
# Execute the exact Python client embedded in the guest network probe,
|
# Execute the exact Python client embedded in the guest network probe,
|
||||||
# redirecting only its address to a fresh loopback socket.
|
# redirecting only its address to a fresh loopback socket.
|
||||||
|
|||||||
Reference in New Issue
Block a user