git mirror - github.com/owenewans/holy - branch master
clone: https://src.holypkg.eu/holy/

file tests/install-vm.py

#!/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)