#!/usr/bin/env python3 import json import os from pathlib import Path import re import shutil import subprocess import sys import tempfile import time from qemu import accelerator, digest, quit_qemu def require(condition, message, status=6): if not condition: print('holy-install-vm: ' + message, file=sys.stderr) raise SystemExit(status) def command(*args): subprocess.run(args, check=True) def boot(qemu, accel, run, overlay, iso, stage, expected, seconds, firmware=None): serial = run / (stage + '.serial.log') qmp = run / (stage + '.qmp') args = [qemu, '-accel', accel, '-m', '1024', '-display', 'none', '-monitor', 'none', '-net', 'none', '-boot', 'd' if iso else 'c', '-serial', 'file:' + str(serial)] if iso: args.extend(['-cdrom', str(iso)]) if firmware: code, variables = firmware args.extend(['-drive', 'if=pflash,format=raw,readonly=on,file=' + str(code), '-drive', 'if=pflash,format=raw,file=' + str(variables)]) args.extend(['-blockdev', json.dumps({'driver': 'qcow2', 'node-name': 'holy-target', 'file': {'driver': 'file', 'filename': str(overlay)}}), '-device', 'virtio-blk-pci,drive=holy-target,serial=HOLY-INSTALL-VM-1' + ('' if iso else ',bootindex=1'), '-qmp', 'unix:' + str(qmp) + ',server=on,wait=off']) started = time.monotonic() reason = 'timeout' lines = set() with (run / (stage + '.stdout')).open('wb') as stdout, \ (run / (stage + '.stderr')).open('wb') as stderr: process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=stdout, stderr=stderr) try: while time.monotonic() - started < seconds: if serial.exists(): data = serial.read_text(errors='replace') lines = set(data.rsplit('\n', 1)[0].splitlines()) if any(line.endswith('result fail') for line in lines): reason = 'guest-failure' break if expected <= lines: reason = 'markers-complete' break if process.poll() is not None: reason = 'early-exit' break time.sleep(0.1) if process.poll() is None: try: quit_qemu(qmp) except (OSError, ValueError) as exc: reason = 'qmp-failure: ' + str(exc) process.terminate() try: code = process.wait(timeout=5) except subprocess.TimeoutExpired: reason = 'shutdown-timeout' process.kill() code = process.wait() finally: if process.poll() is None: process.kill() process.wait() return {'result': 'pass' if reason == 'markers-complete' and code == 0 else 'fail', 'reason': reason, 'exit': code, 'elapsed_seconds': time.monotonic() - started, 'argv': args, 'serial': str(serial), 'serial_sha256': digest(serial), 'observed_markers': sorted(expected & lines), 'missing_markers': sorted(expected - lines)} def main(): arch = os.environ.get('ARCH', 'x86_64') require(arch in ('i686', 'x86_64'), 'ARCH must be i686 or x86_64', 2) iso = Path(os.environ.get('ISO', '')) report_dir = Path(os.environ.get('REPORT_DIR', '')) plan = os.environ.get('BOOT_PLAN', '') require(iso.is_file() and report_dir.is_dir(), 'ISO and report directory required') require(len(plan) == 64 and all(c in '0123456789abcdef' for c in plan), 'BOOT_PLAN must be SHA-256', 2) qemu = shutil.which('qemu-system-i386' if arch == 'i686' else 'qemu-system-x86_64') qemu_img = shutil.which('qemu-img') require(qemu and qemu_img, 'QEMU and qemu-img required') firmware = os.environ.get('INSTALL_FIRMWARE') or ('bios' if arch == 'i686' else 'both') require(firmware in (('bios',) if arch == 'i686' else ('bios', 'both')), 'i686 supports BIOS; x86_64 supports BIOS or both', 2) code = Path(os.environ.get('UEFI_CODE') or '/usr/share/qemu/edk2-x86_64-code.fd') variables = Path(os.environ.get('UEFI_VARS') or '/usr/share/qemu/edk2-i386-vars.fd') if firmware == 'both': require(code.is_file() and variables.is_file(), 'UEFI_CODE and UEFI_VARS firmware files required') accel = accelerator() run = Path(tempfile.mkdtemp(prefix='holy-install-vm-', dir=report_dir)).resolve() frozen_iso = run / 'input.iso' shutil.copyfile(iso, frozen_iso) frozen_iso.chmod(0o444) disk = run / 'target.raw' with disk.open('wb') as stream: stream.truncate(1 << 30) base_hash = digest(disk) disk.chmod(0o444) overlay = run / 'target.qcow2' command(qemu_img, 'create', '-q', '-f', 'qcow2', '-F', 'raw', '-b', str(disk), str(overlay)) expected_install = { 'HOLY-INSTALL-1 devices target-and-media', 'HOLY-INSTALL-1 disk prepared', 'HOLY-INSTALL-1 root mounted', 'HOLY-INSTALL-1 package-set committed', 'HOLY-INSTALL-1 doas in-reviewed-set', 'HOLY-INSTALL-1 account prepared', 'HOLY-INSTALL-1 esp files-and-package-state', 'HOLY-INSTALL-1 result pass', } expected_boot = { 'HOLY-BOOT-1 installed-disk ext4', 'HOLY-BOOT-1 user-login authenticated', 'HOLY-BOOT-1 doas password-and-root-uid', 'HOLY-BOOT-1 root ext4', 'HOLY-BOOT-1 plan ' + plan, 'HOLY-BOOT-1 pid1 dinit', 'HOLY-BOOT-1 shell busybox', 'HOLY-BOOT-1 static-core verified', 'HOLY-BOOT-1 esp mounted-writable', 'HOLY-BOOT-1 docs installed-man-bundle', 'HOLY-BOOT-1 pkg holypkg', 'HOLY-BOOT-1 transaction install-check-remove', 'HOLY-BOOT-1 installer root-plan-apply', 'HOLY-BOOT-1 result pass', } first = boot(qemu, accel, run, overlay, frozen_iso, 'install', expected_install, 600) disk_plans = re.findall(r'^HOLY-INSTALL-1 disk-plan ([0-9a-f]{64})\r?$', Path(first['serial']).read_text(errors='replace'), re.MULTILINE) guest_disk_plan = disk_plans[0] if len(disk_plans) == 1 else None second = third = None if first['result'] == 'pass': overlay.chmod(0o444) bios_overlay = run / 'bios.qcow2' command(qemu_img, 'create', '-q', '-f', 'qcow2', '-F', 'qcow2', '-b', str(overlay), str(bios_overlay)) second = boot(qemu, accel, run, bios_overlay, None, 'installed-bios', expected_boot, 180) if second['result'] == 'pass' and firmware == 'both': code_copy = run / 'uefi-code.fd' vars_copy = run / 'uefi-vars.fd' shutil.copyfile(code, code_copy) code_copy.chmod(0o444) shutil.copyfile(variables, vars_copy) uefi_overlay = run / 'uefi.qcow2' command(qemu_img, 'create', '-q', '-f', 'qcow2', '-F', 'qcow2', '-b', str(overlay), str(uefi_overlay)) third = boot(qemu, accel, run, uefi_overlay, None, 'installed-uefi', expected_boot, 180, (code_copy, vars_copy)) unchanged = digest(disk) == base_hash result = ('pass' if unchanged and guest_disk_plan and second and second['result'] == 'pass' and (firmware == 'bios' or third and third['result'] == 'pass') else 'fail') not_tested = ['account-menu', 'pam-nss', 'network'] if arch == 'i686': not_tested += ['i686-uefi-installed-boot', 'x86_64-installed-boot'] else: not_tested += ['i686-installed-boot'] if firmware == 'bios': not_tested += ['uefi-installed-boot'] observed_install = set(first['observed_markers']) observed_boot = set(second['observed_markers']) if second else set() coverage = [] for check, marker in ( ('live-iso', 'HOLY-INSTALL-1 devices target-and-media'), ('guest-partitioning', 'HOLY-INSTALL-1 disk prepared'), ('guest-filesystems', 'HOLY-INSTALL-1 root mounted'), ('guest-limine-bios-install', 'HOLY-INSTALL-1 disk prepared'), ('guest-root-package-set', 'HOLY-INSTALL-1 package-set committed'), ('guest-esp-files', 'HOLY-INSTALL-1 esp files-and-package-state')): if marker in observed_install: coverage.append(check) for check, marker in ( ('guest-user-password-login', 'HOLY-BOOT-1 user-login authenticated'), ('guest-doas-password-root-uid', 'HOLY-BOOT-1 doas password-and-root-uid')): if marker in observed_boot: coverage.append(check) if second and second['result'] == 'pass': coverage.append('bios-installed-disk-boot') if third and third['result'] == 'pass': coverage.append('uefi-installed-disk-boot') report = {'schema': 'holy-install-vm-7', 'result': result, 'arch': arch, 'accelerator': accel, 'boot_plan': plan, 'inputs': {'iso_sha256': digest(frozen_iso), 'target_base_sha256': base_hash, 'guest_disk_plan_sha256': guest_disk_plan}, 'base_unchanged': unchanged, 'overlay_sha256': digest(overlay), 'firmware': {'mode': firmware, 'code_sha256': digest(code_copy) if third else None, 'vars_input_sha256': digest(variables) if third else None}, 'install': first, 'installed_boot': second, 'uefi_boot': third, 'coverage': coverage, 'not_tested': not_tested} (run / 'report.json').write_text(json.dumps(report, indent=2) + '\n') print('holy-install-vm:', result, 'report', run / 'report.json') return 0 if result == 'pass' else 4 if __name__ == '__main__': try: sys.exit(main()) except (OSError, subprocess.CalledProcessError, ValueError) as exc: print('holy-install-vm: ' + str(exc), file=sys.stderr) sys.exit(1)