From e93820bd52dcfa81a6a455adba8d458701d1acf6 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 04:30:20 +0300 Subject: [PATCH] Add disposable UEFI ISO install acceptance harness --- dev/iso-smoke.py | 506 ++++++++++++++++++++++++++++++++++++++++ docs/install.md | 85 +++++++ tests/test_iso_smoke.py | 143 ++++++++++++ 3 files changed, 734 insertions(+) create mode 100644 dev/iso-smoke.py create mode 100644 docs/install.md create mode 100644 tests/test_iso_smoke.py diff --git a/dev/iso-smoke.py b/dev/iso-smoke.py new file mode 100644 index 0000000..dd03f8e --- /dev/null +++ b/dev/iso-smoke.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""Boot/install/reboot acceptance on a fresh, isolated UEFI/KVM VM only. + +No existing disk option, host sudo, SSH, host TTY, or GUI is used. Artifacts and +logs remain in a new private scratch directory even after failure. +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import select +import shlex +import shutil +import socket +import stat +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from hydra.qmp import QMPClient, QMPError + + +class SmokeError(RuntimeError): + pass + + +def reserve_disk(path): + try: + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(fd) + except OSError as exc: + raise SmokeError('Test disk must be a newly created regular file') from exc + + +def owned_file(path, scratch): + path, scratch = Path(path), Path(scratch) + try: + info = path.lstat() + except OSError as exc: + raise SmokeError('Missing disposable drive') from exc + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 + or path.parent.resolve() != scratch.resolve() + or info.st_uid != os.getuid()): + raise SmokeError('Drive must be an owned, unlinked regular file directly in scratch') + + +def qemu_path(path): + # QEMU keyval arguments use commas as separators. Refuse ambiguous paths. + if any(c in str(path) for c in ',\n\r\x00'): + raise SmokeError('QEMU file paths cannot contain comma or control characters') + return str(path) + + +def qemu_argv(qemu, scratch, disk, code, variables, iso, memory): + scratch = Path(scratch) + owned_file(disk, scratch) + owned_file(variables, scratch) + argv = [qemu, '-machine', 'q35,accel=kvm', '-cpu', 'host', '-smp', '2', + '-m', str(memory), '-display', 'none', '-monitor', 'none', + '-no-reboot', '-nic', 'user,model=virtio-net-pci', + '-drive', f'if=pflash,format=raw,readonly=on,file={qemu_path(code)}', + '-drive', f'if=pflash,format=raw,file={qemu_path(variables)}', + '-drive', f'if=virtio,format=qcow2,file={qemu_path(disk)}', + '-chardev', f'socket,id=console,path={qemu_path(scratch / "serial.sock")},server=on,wait=off', + '-serial', 'chardev:console', '-qmp', f'unix:{qemu_path(scratch / "qmp.sock")},server=on,wait=off'] + 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'] + else: + argv += ['-boot', 'order=c,strict=on'] + return argv + + +def frame_command(command, token): + if not re.fullmatch(r'[a-zA-Z0-9]+', token): + raise SmokeError('Invalid framing token') + encoded = base64.b64encode(command.encode()).decode() + # Literal control bytes appear only in output, never in the echoed command. + return (f"printf '\\036{token}:BEGIN\\037'; " + f"sh -c \"$(printf %s {shlex.quote(encoded)} | base64 -d)\"; " + f"rc=$?; printf '\\036{token}:END:%s\\037' \"$rc\"") + + +def parse_frame(data, token): + begin = b'\x1e' + token.encode() + b':BEGIN\x1f' + start = data.find(begin) + if start < 0: + return None + end = re.search(rb'\x1e' + token.encode() + rb':END:(\d+)\x1f', data[start + len(begin):]) + if end is None: + return None + output = data[start + len(begin):start + len(begin) + end.start()] + return int(end.group(1)), output + + +class Console: + def __init__(self, connection, logfile, passwords): + self.sock = connection + self.log = open(logfile, 'ab', buffering=0) + self.passwords = [p.encode() for p in passwords if p] + self.pending_log = b'' + self.buffer = b'' + self.closed = False + + def record(self, data, final=False): + self.pending_log += data + for password in self.passwords: + self.pending_log = self.pending_log.replace(password, b'[REDACTED]') + keep = 0 + if not final: + for password in self.passwords: + for length in range(1, len(password)): + if self.pending_log.endswith(password[:length]): + keep = max(keep, length) + if keep: + self.log.write(self.pending_log[:-keep]) + self.pending_log = self.pending_log[-keep:] + else: + self.log.write(self.pending_log) + self.pending_log = b'' + + def close(self): + if not self.closed: + self.record(b'', final=True) + self.log.close() + self.sock.close() + self.closed = True + + def send(self, data): + self.sock.sendall(data.encode() if isinstance(data, str) else data) + + def read(self, timeout): + ready, _, _ = select.select([self.sock], [], [], max(0, timeout)) + if not ready: + return b'' + data = self.sock.recv(65536) + if not data: + raise SmokeError('Guest console closed') + self.record(data) + self.buffer += data + if len(self.buffer) > 16 * 1024 * 1024: + raise SmokeError('Console exceeded bounded capture size') + return data + + def expect(self, pattern, timeout): + deadline = time.monotonic() + timeout + while True: + match = re.search(pattern, self.buffer) + if match: + self.buffer = self.buffer[match.end():] + return match + remaining = deadline - time.monotonic() + if remaining <= 0: + raise SmokeError('Timed out waiting for guest console state') + self.read(min(remaining, 1)) + + def command(self, command, timeout=60): + token = secrets.token_hex(12) + self.buffer = b'' + self.send(frame_command(command, token) + '\n') + deadline = time.monotonic() + timeout + while True: + parsed = parse_frame(self.buffer, token) + if parsed is not None: + status, output = parsed + self.buffer = b'' + if status: + raise SmokeError(f'Guest probe failed with status {status}: {output.decode(errors="replace")[-3000:]}') + return output.replace(b'\r\n', b'\n') + remaining = deadline - time.monotonic() + if remaining <= 0: + raise SmokeError('Timed out waiting for framed guest command') + self.read(min(remaining, 1)) + + +def connect(path, process, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise SmokeError(f'Test QEMU exited with status {process.returncode}') + connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + connection.connect(str(path)) + return connection + except OSError: + connection.close() + time.sleep(0.1) + raise SmokeError('Timed out connecting to private test socket') + + +class VM: + def __init__(self, args, scratch, disk, iso, password, phase): + self.scratch = scratch + for name in ('serial.sock', 'qmp.sock'): + (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), + stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr) + self.console = None + self.qmp = None + try: + self.console = Console(connect(scratch / 'serial.sock', self.process), + scratch / f'{phase}-serial.log', [password]) + ready = connect(scratch / 'qmp.sock', self.process) + ready.close() + self.qmp = QMPClient(scratch / 'qmp.sock', timeout=5) + self.kvm = self.qmp.command('query-kvm') + if not self.kvm.get('enabled'): + raise SmokeError('Disposable QEMU is not using KVM') + except BaseException: + self.close() + raise + + def wait_poweroff(self, timeout): + try: + rc = self.process.wait(timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise SmokeError('Guest failed to power off in time') from exc + if rc: + raise SmokeError(f'Test QEMU failed during poweroff ({rc})') + + def close(self): + # Only the Popen child owned by this harness is signalled. + if self.qmp: + if self.process.poll() is None: + try: + self.qmp.command('quit') + self.process.wait(timeout=5) + except (QMPError, subprocess.TimeoutExpired): + pass + self.qmp.close() + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=10) + if self.console: + self.console.close() + self.stderr.close() + + +LIVE_CHECK = r'''set -eu +[ "$(id -un)" = hydra-live ] +test -e /sys/firmware/efi +test -f /etc/hydra/live-mode/active +findmnt -n -o FSTYPE / | grep -qx overlay +test -f /run/initramfs/live/LiveOS/squashfs.img +sudo -n true +for tool in hydra hydra-install qemu-system-x86_64 qemu-img virt-viewer foot labwc; do command -v "$tool"; done +systemctl is-active NetworkManager hydra-network hydra-dnsmasq +''' + +INSTALLED_CHECK = r'''set -eu +[ "$(findmnt -n -o FSTYPE /)" = ext4 ] +[ "$(readlink -f "$(findmnt -n -o SOURCE /)")" = /dev/vda2 ] +! grep -Eq 'root=live:|rd\.live' /proc/cmdline +! findmnt -n -o FSTYPE / | grep -Eq 'overlay|squashfs' +test ! -e /etc/hydra/live-mode +! getent passwd hydra-live +! find /etc/systemd/system /etc/sudoers.d -name 90-hydra-live.conf -o -name 90-hydra-live | grep . +test ! -e /etc/greetd/live.toml +! grep -R -E -- '--autologin|NOPASSWD' /etc/systemd/system/getty@*.service.d /etc/systemd/system/serial-getty@*.service.d /etc/sudoers.d 2>/dev/null +getent shadow root | cut -d: -f2 | grep -Eq '^[!*]' +getent shadow hydratest | cut -d: -f2 | grep -Eq '^\$' +for service in NetworkManager greetd hydra-network hydra-dnsmasq; do systemctl is-active "$service"; done +[ "$(systemctl show hydra-bridge-permissions -p Result --value)" = success ] +[ "$(systemctl show hydra-bridge-permissions -p ExecMainStatus --value)" = 0 ] +ip -4 -o addr show hydrabr0 | grep -F '10.203.0.1/24' +ip -4 route show default | grep '^default ' +nft list table ip hydra_nat +nmcli -t -f STATE general | grep -E '^connected' +getent ahostsv4 gentoo.org +for tool in hydra hydra-install qemu-system-x86_64 qemu-img virt-viewer foot labwc; do command -v "$tool"; done +hydra --help +qemu-system-x86_64 --version +qemu-img --version +test -r /etc/greetd/config.toml +find /usr/share -name OVMF_CODE.fd -print -quit | grep . +''' + +NETWORK_CHECK = r'''set -eu +[ "$(cat /proc/sys/net/ipv4/ip_forward)" = 1 ] +python3 - <<'NETWORK' +import pathlib, secrets, subprocess, time +tag = secrets.token_hex(3) +lan, guest = 'hl' + tag, 'hv' + tag +uplink, lanpeer = 'hu' + tag, 'hp' + tag +bridgepeer, guestpeer = 'hb' + tag, 'hg' + tag +created = [] +links = [] +server = None +def run(*args): + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=20).stdout +try: + for name in (lan, guest): + run('ip', 'netns', 'add', name) + created.append(name) + run('ip', 'link', 'add', uplink, 'type', 'veth', 'peer', 'name', lanpeer) + links.append(uplink) + run('ip', 'link', 'set', lanpeer, 'netns', lan) + run('ip', 'addr', 'add', '198.18.0.1/30', 'dev', uplink) + run('ip', 'link', 'set', uplink, 'up') + run('ip', '-n', lan, 'addr', 'add', '198.18.0.2/30', 'dev', lanpeer) + run('ip', '-n', lan, 'link', 'set', lanpeer, 'up') + run('ip', '-n', lan, 'link', 'set', 'lo', 'up') + run('ip', '-n', lan, 'route', 'add', '10.203.0.0/24', 'via', '198.18.0.1') + run('ip', 'link', 'add', bridgepeer, 'type', 'veth', 'peer', 'name', guestpeer) + links.append(bridgepeer) + run('ip', 'link', 'set', guestpeer, 'netns', guest) + run('ip', 'link', 'set', bridgepeer, 'master', 'hydrabr0') + run('ip', 'link', 'set', bridgepeer, 'up') + run('ip', '-n', guest, 'addr', 'add', '10.203.0.250/24', 'dev', guestpeer) + run('ip', '-n', guest, 'link', 'set', guestpeer, 'up') + run('ip', '-n', guest, 'link', 'set', 'lo', 'up') + run('ip', '-n', guest, 'route', 'add', 'default', 'via', '10.203.0.1') + port = 43117 + server_code = "import socket; s=socket.socket(); s.settimeout(15); s.bind(('10.203.0.250',43117)); s.listen(1); c,a=s.accept(); c.sendall(b'HYDRA_LAN_TO_VM_OK'); c.close(); s.close()" + server = subprocess.Popen(['ip', 'netns', 'exec', guest, 'python3', '-c', server_code], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + client_code = "import socket,time; deadline=time.monotonic()+10\nwhile True:\n try:\n s=socket.create_connection(('10.203.0.250',43117),1); break\n except OSError:\n if time.monotonic()>deadline: raise\n time.sleep(.1)\nassert s.recv(100)==b'HYDRA_LAN_TO_VM_OK'; s.close(); print('PASS: routed new LAN-to-VM TCP connection on arbitrary port 43117')" + print(run('ip', 'netns', 'exec', lan, 'python3', '-c', client_code), end='') + assert server.wait(timeout=10) == 0 +finally: + if server is not None and server.poll() is None: + server.terminate() + try: server.wait(timeout=5) + except subprocess.TimeoutExpired: server.kill(); server.wait(timeout=5) + for link in reversed(links): + subprocess.run(['ip', 'link', 'del', link], check=False, capture_output=True, timeout=10) + for name in reversed(created): + subprocess.run(['ip', 'netns', 'del', name], check=False, capture_output=True, timeout=10) +NETWORK +''' + +# A nested VM hardware smoke proves KVM initialisation/QMP, not guest OS boot. +NESTED_CHECK = r'''set -eu +if ! test -c /dev/kvm; then printf 'SKIP: guest /dev/kvm unavailable\n'; exit 0; fi +python3 - <<'NESTED' +import json, pathlib, socket, subprocess, tempfile, time +with tempfile.TemporaryDirectory(prefix='hydra-nested-') as tmp: + root = pathlib.Path(tmp) + disk = root / 'fresh.qcow2' + subprocess.run(['qemu-img', 'create', '-f', 'qcow2', str(disk), '64M'], check=True, timeout=30) + log = open(root / 'qemu.log', 'wb') + proc = subprocess.Popen(['qemu-system-x86_64', '-machine', 'q35,accel=kvm', '-cpu', 'host', '-m', '128', '-S', '-display', 'none', '-nodefaults', '-nic', 'bridge,br=hydrabr0,model=virtio-net-pci', '-drive', 'if=virtio,format=qcow2,file=' + str(disk), '-qmp', 'unix:' + str(root / 'qmp') + ',server=on,wait=off'], stdin=subprocess.DEVNULL, stdout=log, stderr=log) + sock = socket.socket(socket.AF_UNIX) + sock.settimeout(10) + try: + deadline = time.monotonic() + 15 + while True: + try: sock.connect(str(root / 'qmp')); break + except OSError: + if proc.poll() is not None or time.monotonic() >= deadline: raise RuntimeError('Nested KVM start failed') + time.sleep(.1) + stream = sock.makefile('rwb', buffering=0) + assert 'QMP' in json.loads(stream.readline()) + for request in ({'execute':'qmp_capabilities'}, {'execute':'query-kvm'}, {'execute':'quit'}): + stream.write(json.dumps(request).encode() + b'\n') + while True: + response = json.loads(stream.readline()) + if 'return' in response or 'error' in response: break + assert 'error' not in response, response + if request['execute'] == 'query-kvm': + assert response['return']['enabled'], response + print('PASS: normal-user nested KVM VM and bridge helper; fresh qcow2; private QMP') + assert proc.wait(timeout=10) == 0 + finally: + sock.close() + if proc.poll() is None: + proc.terminate() + try: proc.wait(timeout=5) + except subprocess.TimeoutExpired: proc.kill(); proc.wait(timeout=5) + log.close() +NESTED +''' + + +def digest(path): + result = hashlib.sha256() + with open(path, 'rb') as source: + for block in iter(lambda: source.read(1024 * 1024), b''): + result.update(block) + return result.hexdigest() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--iso', 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('--qemu', default='qemu-system-x86_64') + parser.add_argument('--qemu-img', default='qemu-img') + parser.add_argument('--scratch-parent', type=Path, default=Path(tempfile.gettempdir())) + parser.add_argument('--memory', type=int, default=4096) + parser.add_argument('--boot-timeout', type=int, default=360) + parser.add_argument('--install-timeout', type=int, default=1800) + args = parser.parse_args(argv) + for path in (args.iso, args.ovmf_code, args.ovmf_vars): + if not path.is_file(): + parser.error(f'Missing input file: {path}') + if args.memory < 2048 or args.boot_timeout <= 0 or args.install_timeout <= 0: + parser.error('Memory must be >=2048 MiB and timeouts positive') + args.iso = args.iso.resolve() + args.ovmf_code = args.ovmf_code.resolve() + args.ovmf_vars = args.ovmf_vars.resolve() + scratch = Path(tempfile.mkdtemp(prefix='hydra-iso-smoke-', dir=args.scratch_parent)) + if len(str(scratch / 'serial.sock').encode()) >= 104: + parser.error('Scratch path too long for Unix sockets; use /tmp') + print(f'Acceptance artifacts: {scratch}', flush=True) + report = {'schema': 1, 'status': 'running', 'scratch': str(scratch), + 'iso': str(args.iso), 'iso_sha256': digest(args.iso), + '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), + 'ovmf_vars_input_sha256': digest(args.ovmf_vars)} + password = secrets.token_urlsafe(24) + sentinel = secrets.token_hex(32) + vm = None + def checkpoint(name, output): + report['checks'][name] = {'status': 'skipped' if output.startswith(b'SKIP:') else 'passed', + 'output': output.decode(errors='replace')} + (scratch / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + try: + disk = scratch / 'fresh-install.qcow2' + reserve_disk(disk) + subprocess.run([args.qemu_img, 'create', '-f', 'qcow2', str(disk), '32G'], check=True, timeout=30, + stdout=subprocess.DEVNULL) + shutil.copyfile(args.ovmf_vars, scratch / 'OVMF_VARS.fd') + os.chmod(scratch / 'OVMF_VARS.fd', 0o600) + vm = VM(args, scratch, disk, args.iso, password, 'live') + checkpoint('live_qmp_kvm', json.dumps(vm.kvm).encode()) + console = vm.console + console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout) + checkpoint('live_boot', console.command(LIVE_CHECK, 90)) + console.buffer = b'' + console.send('sudo -n hydra-install --disk /dev/vda --user hydratest --execute --confirm-erase /dev/vda\n') + console.expect(rb'Installed account password: ', 120) + console.send(password + '\n') + console.expect(rb'Repeat password: ', 30) + console.send(password + '\n') + console.expect(rb'Installation completed\. Diagnostic log: [^\r\n]+', args.install_timeout) + checkpoint('real_installer', b'Installer completed via guest controlling TTY') + console.command("sudo -n mkdir -p /mnt/hydra-validation && sudo -n mount /dev/vda2 /mnt/hydra-validation && printf %s " + + shlex.quote(sentinel) + " | sudo -n tee /mnt/hydra-validation/var/lib/hydra-acceptance-sentinel >/dev/null && sudo -n umount /mnt/hydra-validation", 60) + console.send('sudo -n poweroff\n') + vm.wait_poweroff(90) + vm.close() + vm = None + checkpoint('live_poweroff', b'QEMU exited before ISO-free reboot') + vm = VM(args, scratch, disk, None, password, 'installed') + checkpoint('installed_qmp_kvm', json.dumps(vm.kvm).encode()) + console = vm.console + console.expect(rb'login: ', args.boot_timeout) + console.send('hydratest\n') + console.expect(rb'Password: ', 30) + console.send(password + '\n') + console.expect(rb'hydratest[^\r\n]*[#$] ', 60) + output = console.command('id -un; if sudo -n true 2>/dev/null; then exit 1; fi', 30) + if output.strip() != b'hydratest': + raise SmokeError('Installed account identity mismatch') + checkpoint('installed_password_login', output) + console.buffer = b'' + prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode() + console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n') + console.expect(rb'HYDRA_SUDO_PASSWORD: ', 30) + console.send(password + '\n') + console.expect(rb'root[^\r\n]*[#$] ', 30) + observed = console.command('cat /var/lib/hydra-acceptance-sentinel', 30) + if observed != sentinel.encode(): + raise SmokeError('Persistence sentinel did not survive installed disk boot') + checkpoint('disk_persistence', b'Sentinel content verified from command output frame') + checkpoint('installed_contract', console.command(INSTALLED_CHECK, 120)) + checkpoint('lan_to_vm_tcp', console.command(NETWORK_CHECK, 120)) + checkpoint('nested_kvm', console.command('runuser -u hydratest -- sh -c ' + shlex.quote(NESTED_CHECK), 90)) + console.send('poweroff\n') + vm.wait_poweroff(90) + checkpoint('installed_poweroff', b'Normal installed guest shutdown') + report['status'] = 'passed' + return 0 + except (SmokeError, QMPError, OSError, subprocess.SubprocessError) as exc: + report['status'] = 'failed' + report['error'] = str(exc).replace(password, '[REDACTED]') + print(f'Acceptance failed: {report["error"]}', file=sys.stderr) + return 1 + finally: + if vm: + vm.close() + report['completed_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + (scratch / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(f'Report: {scratch / "report.json"}', flush=True) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..a0b0c2f --- /dev/null +++ b/docs/install.md @@ -0,0 +1,85 @@ +# Install HydraOS + +HydraOS targets x86_64 UEFI systems. Disable Secure Boot for this first image. +Boot the ISO and open the recovery terminal. 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. + +`hydra-install` lists disks without selecting one. Inspect the devices, then ask +for a plan using the whole disk you intend to erase: + +```sh +hydra-install +hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER +``` + +Replace the placeholders with an actual whole disk and your desired account +name. The installer rejects mounted devices, the running root, the live medium, +and their backing disks. These checks help prevent mistakes; verify the chosen +disk and preserve backups before executing the plan. Physical disk installation +requires the operator's explicit choice. + +To execute, enter the identical resolved whole-disk path in the confirmation: + +```sh +sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER \ + --execute --confirm-erase /dev/CHOSEN_DISK +``` + +Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root, +extracts the prepared payload, and prompts twice on the local terminal for your +installed account password. Passwords have no command-line or configuration +option. The installed root account stays locked. Defaults are Europe/Moscow and +English UTF-8, with Russian UTF-8 also available; `--timezone` accepts an IANA +zone present in the payload. + +The installer removes the live account, local password-free sudo rule, and +live autologin overrides, then builds a normal disk initramfs and fallback UEFI +loader. Wait for `Installation completed`, power off, remove the ISO, and boot +the installed disk. Log in with the account and password you supplied. On +failure, read the printed live diagnostic path; when a target root was mounted, +a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk. + +## Disposable VM acceptance + +Run the repository harness with QEMU/KVM, `qemu-img`, Python 3, and an OVMF +CODE/VARS pair. Use trusted image and firmware files. For example: + +```sh +python3 dev/iso-smoke.py --iso /absolute/path/to/hydra-os.iso \ + --ovmf-code /absolute/path/to/OVMF_CODE.fd \ + --ovmf-vars /absolute/path/to/OVMF_VARS.fd +``` + +The harness creates a private directory under `/tmp`, a new 32 GiB sparse +qcow2 disk, and a private copy of OVMF variables. There is no existing disk +argument. QEMU attaches the ISO read-only; serial and QMP use private Unix +sockets. Tool overrides `--qemu` and `--qemu-img` support temporary development +environments. `--scratch-parent`, `--memory`, `--boot-timeout`, and +`--install-timeout` control retained artifacts and bounded resource use. + +The test boots the live image, runs the real installer through the guest's +controlling terminal using a generated password, writes a persistence sentinel, +shuts down, and launches a second QEMU with the ISO absent. It verifies password +login, disk root, removed live overrides, services, bridge/NAT configuration, +DNS, recovery tools, and a routed new TCP connection from a synthetic LAN to a +synthetic VM endpoint. Network namespaces and veth interfaces exist only inside +the disposable HydraOS guest. When guest KVM is available, it launches a further +fresh-disk QEMU as the installed account to exercise KVM, the bridge helper, +and private QMP; this checks VM launch, not a nested operating system boot. + +`report.json` records each observed check, ISO/firmware SHA256, failures, and +skipped nested virtualization. Serial/QEMU logs and the qcow2 remain in the +printed scratch directory for review; passwords are redacted even across serial +read boundaries. Cleanup controls only the QEMU process created by this run. +A passing unit suite establishes harness behavior, not ISO acceptance. An +actual successful report from the built ISO is required for boot/install/reboot +acceptance. + +## Checks requiring the laptop + +Disposable QEMU results cannot establish ASUS FX517ZC hardware support. Before +using HydraOS daily, boot the live ISO and observe Wi-Fi, touchpad, audio, +brightness, suspend/resume, USB device selection, input capture, and actual +display refresh. Physical installation and these checks remain pending until +explicitly performed and recorded. diff --git a/tests/test_iso_smoke.py b/tests/test_iso_smoke.py new file mode 100644 index 0000000..c0b1a10 --- /dev/null +++ b/tests/test_iso_smoke.py @@ -0,0 +1,143 @@ +"""Regression tests for disposable-VM isolation and trustworthy serial evidence.""" +import importlib.util +from pathlib import Path +import socket +import subprocess +import tempfile +import threading +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'dev/iso-smoke.py' + + +class SmokeTests(unittest.TestCase): + def setUp(self): + self.assertTrue(SCRIPT.is_file(), 'disposable ISO acceptance harness is missing') + spec = importlib.util.spec_from_file_location('iso_smoke', SCRIPT) + self.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.mod) + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.code = self.root / 'code.fd' + self.variables = self.root / 'vars.fd' + self.code.write_bytes(b'firmware') + self.variables.write_bytes(b'fresh vars') + + def test_existing_disk_or_symlink_cannot_be_a_test_drive(self): + disk = self.root / 'disk.qcow2' + disk.write_bytes(b'original') + with self.assertRaises(self.mod.SmokeError): + self.mod.reserve_disk(disk) + link = self.root / 'link.qcow2' + link.symlink_to(disk) + with self.assertRaises(self.mod.SmokeError): + self.mod.reserve_disk(link) + self.assertEqual(disk.read_bytes(), b'original') + + def test_qemu_only_accepts_owned_regular_disk_and_readonly_iso(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) + drives = [argv[i + 1] for i, value in enumerate(argv[:-1]) if value == '-drive'] + self.assertTrue(any('format=qcow2' in item and str(disk) in item for item in drives)) + self.assertTrue(any('media=cdrom' in item and 'readonly=on' in item for item in drives)) + self.assertIn('accel=kvm', ' '.join(argv)) + disk.unlink() + disk.symlink_to(iso) + with self.assertRaises(self.mod.SmokeError): + self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, iso, 2048) + + def test_installed_reboot_has_no_optical_medium(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) + self.assertNotIn('media=cdrom', ' '.join(argv)) + self.assertNotIn('-cdrom', argv) + + def test_hardlinked_disk_is_refused(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + (self.root / 'alias').hardlink_to(disk) + with self.assertRaises(self.mod.SmokeError): + self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, None, 2048) + + def test_result_frame_ignores_command_echo_and_preserves_failure(self): + command = self.mod.frame_command('printf sentinel; false', 'testtoken') + result = subprocess.run(['/bin/sh', '-c', command], capture_output=True) + echo = command.encode() + b'\r\n' + self.assertIsNone(self.mod.parse_frame(echo, 'testtoken')) + self.assertEqual(self.mod.parse_frame(echo + result.stdout, 'testtoken'), + (1, b'sentinel')) + + def test_console_redacts_password_even_when_split_across_reads(self): + one, two = socket.socketpair() + self.addCleanup(one.close) + self.addCleanup(two.close) + log = self.root / 'serial.log' + console = self.mod.Console(one, log, ['split-secret']) + self.addCleanup(console.close) + two.sendall(b'hello split-') + console.read(0.1) + two.sendall(b'secret goodbye') + console.read(0.1) + console.close() + self.assertNotIn(b'split-secret', log.read_bytes()) + self.assertIn(b'[REDACTED]', log.read_bytes()) + + def test_console_timeout_is_bounded_and_partial_frame_not_success(self): + one, two = socket.socketpair() + self.addCleanup(one.close) + self.addCleanup(two.close) + console = self.mod.Console(one, self.root / 'serial.log', []) + self.addCleanup(console.close) + two.sendall(b'\\036token:END:0\\037') + with self.assertRaises(self.mod.SmokeError): + console.command('true', timeout=0.05) + + def test_frame_real_shell_roundtrip_does_not_accept_sentinel_in_echo(self): + one, two = socket.socketpair() + self.addCleanup(one.close) + self.addCleanup(two.close) + console = self.mod.Console(one, self.root / 'serial.log', []) + self.addCleanup(console.close) + def guest(): + data = b'' + while not data.endswith(b'\n'): + data += two.recv(65536) + two.sendall(data) + result = subprocess.run(['/bin/sh', '-c', data.decode()], capture_output=True) + two.sendall(result.stdout) + thread = threading.Thread(target=guest) + thread.start() + self.assertEqual(console.command("printf 'persisted-data'", timeout=2), b'persisted-data') + thread.join(timeout=2) + + def test_firmware_vars_must_be_private_copy_in_scratch(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + self.variables.unlink() + self.variables.symlink_to(self.code) + with self.assertRaises(self.mod.SmokeError): + self.mod.qemu_argv('qemu', self.root, disk, self.code, + self.variables, None, 2048) + + def test_drive_outside_scratch_and_ambiguous_qemu_paths_are_refused(self): + disk = self.root / 'disk.qcow2' + self.mod.reserve_disk(disk) + other = self.root / 'other' + other.mkdir() + with self.assertRaises(self.mod.SmokeError): + self.mod.qemu_argv('qemu', other, disk, self.code, + self.variables, None, 2048) + comma = self.root / 'disk,cache=unsafe.qcow2' + self.mod.reserve_disk(comma) + with self.assertRaises(self.mod.SmokeError): + self.mod.qemu_argv('qemu', self.root, comma, self.code, + self.variables, None, 2048)