name: Installer test # Installs a release's x86_64 ISO into a virtual machine (KVM) the way a person would, minus the # questions: the ISO's own kickstart (the Herald OS image inside it) plus an unattended disk layout. # Then it boots the installed system and screenshots it as it comes up, for a look at what it shows. # It runs twice, once for each answer to "Encrypt my data": # encrypted the ISO's default. The ISO's kickstart must pre-select it (autopart --encrypted, no # passphrase, so the person is asked); the run gives the passphrase a person would type, # checks that the installed disk is LUKS and opens with it, and types it at boot over # the serial console (the boot splash is off for this, so the prompt is there). # unencrypted the box unticked: the same layout without --encrypted, and no LUKS on the disk. # A kickstart cannot click the box on Anaconda's storage screen, so the encrypted run checks the # ISO's default by reading its kickstart, and both runs then state the layout outright. # With an account name given, it also tests an unattended account and SSH: the account %pre of # linux/image/unattended.ks (ACCOUNT and a throwaway key in SSH_KEYS) goes into the kickstart, and once # the system is up, the key must sign in as that account and nothing else may (root, passwords). Left # empty, as it is by default, the run is the same as without the option. on: workflow_dispatch: inputs: tag: description: The release to install (the encrypted run needs one whose installer encrypts by default) default: v0.1.0-alpha.5 account: description: "Also test an unattended account and SSH key login: the account's name (empty: not tested). Needs a release whose image has herald-os setup ssh." required: false default: "" permissions: # Write, only so a draft release (visible with push access) can be tested before it is published. contents: write jobs: install: name: Install and boot (x86_64, ${{ matrix.disk }}) runs-on: ubuntu-24.04 timeout-minutes: 120 strategy: fail-fast: false matrix: disk: [encrypted, unencrypted] env: DISK: ${{ matrix.disk }} # Typed at boot with the serial console, so letters and digits only. PASSPHRASE: heraldinstalltest2026 steps: - uses: actions/checkout@v7 - name: QEMU with KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm.rules sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm sudo apt-get update -qq && sudo apt-get install -y -qq qemu-system-x86 qemu-utils ovmf p7zip-full netpbm socat cryptsetup-bin ls -l /dev/kvm - name: The installer, from the release env: GH_TOKEN: ${{ github.token }} run: | gh release download "${{ inputs.tag }}" --repo "$GITHUB_REPOSITORY" --pattern '*-x86_64.iso*' ls -l name="$(ls *-x86_64.iso.sha256)"; name="${name%.sha256}" [[ -f "$name" ]] || cat "$name".part* >"$name" sha256sum -c "$name.sha256" mv "$name" herald.iso echo "LABEL=$(blkid -o value -s LABEL herald.iso)" >>"$GITHUB_ENV" 7z l herald.iso | grep -Ei '\.ks|vmlinuz|initrd|grub.cfg' || true - name: The ISO's kickstart, unattended env: ACCOUNT: ${{ inputs.account }} run: | 7z e -oiso herald.iso images/pxeboot/vmlinuz images/pxeboot/initrd.img EFI/BOOT/grub.cfg >/dev/null grep -E 'linux|inst\.' iso/grub.cfg | head -n 6 || true ks="$(grep -o 'inst.ks=[^ ]*' iso/grub.cfg | head -n 1 | sed 's/.*://')" ks="${ks:-$(7z l -ba herald.iso | awk '{print $NF}' | grep -E '\.ks$' | head -n 1)}" [[ -n "$ks" ]] || { echo "::error::no kickstart in the ISO"; exit 1; } echo "kickstart in the ISO: $ks" 7z e -oiso herald.iso "${ks#/}" >/dev/null cp "iso/$(basename "$ks")" ks.cfg # The default the person sees: the automatic layout encrypted, the passphrase left to them. default="$(grep -E '^autopart( |$)' ks.cfg || true)" echo "the ISO's layout: ${default:-(none)}" if [[ "$DISK" == encrypted ]]; then grep -qE -- '--encrypted( |$)' <<<"$default" && ! grep -q -- '--passphrase' <<<"$default" || { echo "::error::the ISO's installer does not pre-select Encrypt my data (autopart --encrypted without --passphrase); releases built before it did fail here" exit 1 } fi # The person's answers, given up front: wipe the one disk, encrypted or not, a serial # console. The encrypted run turns the boot splash off, so the passphrase prompt is on the # serial console rather than drawn by Plymouth. sed -i '/^autopart\( \|$\)/d' ks.cfg append="console=tty0 console=ttyS0,115200" { echo 'text' echo 'zerombr' echo 'clearpart --all --initlabel --disklabel=gpt' if [[ "$DISK" == encrypted ]]; then echo "autopart --type=btrfs --noswap --encrypted --passphrase=$PASSPHRASE" append="$append rd.plymouth=0 plymouth.enable=0" else echo 'autopart --type=btrfs --noswap' fi echo "bootloader --append=\"$append\"" echo 'rootpw --lock' echo 'poweroff' } >>ks.cfg if [[ -n "$ACCOUNT" ]]; then # The account and a key made for this run only; the private half never leaves the runner. ssh-keygen -q -t ed25519 -N '' -C installer-test -f test-key bash linux/vm/account-kickstart.sh "$ACCOUNT" test-key.pub >>ks.cfg fi echo "----- ks.cfg"; sed "s/$PASSPHRASE//" ks.cfg - name: Install run: | mkdir -p shots && cp /usr/share/OVMF/OVMF_VARS_4M.fd vars.fd qemu-img create -f qcow2 disk.qcow2 40G (python3 -m http.server 8000 >/dev/null 2>&1 &) timeout 75m qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 6144 -machine q35 \ -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd -drive if=pflash,format=raw,file=vars.fd \ -drive file=disk.qcow2,if=virtio -drive file=herald.iso,media=cdrom,readonly=on \ -kernel iso/vmlinuz -initrd iso/initrd.img \ -append "inst.stage2=hd:LABEL=$LABEL inst.ks=http://10.0.2.2:8000/ks.cfg inst.text console=ttyS0,115200" \ -netdev user,id=net0 -device virtio-net-pci,netdev=net0 \ -display none -vga virtio -serial file:install.log -no-reboot tail -n 40 install.log grep -q -i 'reboot: Power down\|Power down' install.log || { echo "::error::the install did not finish"; exit 1; } - name: The disk is encrypted, or not run: | # A copy of the installed disk, its partitions read from a loop device: LUKS where the # encrypted run put it, opening with the passphrase it was given, and none in the other. qemu-img convert -O raw disk.qcow2 disk.raw loop="$(sudo losetup --find --show --read-only --partscan disk.raw)" # Where the kernel did not read the partition table when the loop device was set up. [[ -e "${loop}p1" ]] || sudo partx -a "$loop" 2>/dev/null || true sudo udevadm settle || true luks=() for part in "$loop"p*; do type="$(sudo blkid -p -o value -s TYPE "$part" || true)" echo "$part: ${type:-(none)}" [[ "$type" == crypto_LUKS ]] && luks+=("$part") done failed="" if [[ "$DISK" == encrypted ]]; then (( ${#luks[@]} > 0 )) || { echo "::error::no LUKS partition on the installed disk"; failed=1; } for part in "${luks[@]}"; do printf '%s' "$PASSPHRASE" | sudo cryptsetup open --test-passphrase --disable-external-tokens --key-file=- "$part" \ || { echo "::error::the passphrase does not open $part"; failed=1; } printf '%s' "not-$PASSPHRASE" | sudo cryptsetup open --test-passphrase --disable-external-tokens --key-file=- "$part" \ && { echo "::error::another passphrase opens $part"; failed=1; } done else (( ${#luks[@]} == 0 )) || { echo "::error::LUKS on a disk installed without encryption: ${luks[*]}"; failed=1; } fi sudo losetup -d "$loop" rm -f disk.raw [[ -z "$failed" ]] - name: Boot what was installed env: ACCOUNT: ${{ inputs.account }} run: | (qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 6144 -machine q35 \ -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd -drive if=pflash,format=raw,file=vars.fd \ -drive file=disk.qcow2,if=virtio -netdev "user,id=net0${ACCOUNT:+,hostfwd=tcp:127.0.0.1:2222-:22}" -device virtio-net-pci,netdev=net0 \ -display none -vga virtio -chardev socket,id=serial0,path=serial.sock,server=on,wait=off,logfile=boot.log -serial chardev:serial0 \ -monitor unix:monitor.sock,server,nowait &) failed="" if [[ "$DISK" == encrypted ]]; then # The disk password, typed at systemd-cryptsetup's prompt as a person would at the keyboard. asked="" for _ in $(seq 1 72); do sleep 5 if grep -aq 'passphrase for disk' boot.log 2>/dev/null; then asked=1; break; fi done if [[ -n "$asked" ]]; then sleep 2 printf '%s\r' "$PASSPHRASE" | socat -u - UNIX-CONNECT:serial.sock echo "typed the disk password at the prompt" else echo "::error::the installed system never asked for the disk password"; failed=1 fi fi for minute in 2 4 6 8 10 14 18; do sleep $(( minute == 2 ? 120 : (minute <= 10 ? 120 : 240) )) echo "screendump shots/boot-$(printf %02d "$minute")min.ppm" | socat - UNIX-CONNECT:monitor.sock >/dev/null sleep 2 pnmtopng "shots/boot-$(printf %02d "$minute")min.ppm" >"shots/boot-$(printf %02d "$minute")min.png" && rm "shots/boot-$(printf %02d "$minute")min.ppm" echo "--- $minute min"; tail -n 5 boot.log done if [[ "$DISK" == encrypted && -n "$asked" ]]; then # A wrong or lost password brings the prompt back; the system's own messages may not reach # the serial console (the image boots with quiet), so the screenshots show the rest. prompts="$(grep -ac 'passphrase for disk' boot.log || true)" (( prompts == 1 )) || { echo "::error::the disk asked for its password $prompts times"; failed=1; } fi if [[ -n "$ACCOUNT" ]]; then # The key signs in as the account; root and passwords do not. A try every 30 s stays under # the per-address limit (4 new connections a minute), which counts the runner as one address. ssh_to() { ssh -p 2222 -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=known_hosts "$@"; } signed_in="" for _ in $(seq 1 20); do if ssh_to -i test-key "$ACCOUNT@127.0.0.1" 'id -un; systemctl is-active sshd herald-os-ssh-limit; cat /etc/herald-os/install.conf' >ssh.log 2>&1; then signed_in=1 break fi sleep 30 done grep -qx "$ACCOUNT" ssh.log || { echo "::error::the key did not sign in as $ACCOUNT"; failed=1; } sleep 30 if ssh_to -i test-key "root@127.0.0.1" true >>ssh.log 2>&1; then echo "::error::root signed in over SSH"; failed=1; fi sleep 30 if ssh_to -o PubkeyAuthentication=no -o PreferredAuthentications=password,keyboard-interactive "$ACCOUNT@127.0.0.1" true >>ssh.log 2>&1; then echo "::error::signed in without the key"; failed=1 fi grep -q 'Permission denied (publickey)' ssh.log || { echo "::error::sshd offered more than keys"; failed=1; } echo "----- ssh.log (signed in: ${signed_in:-no})"; cat ssh.log fi echo "quit" | socat - UNIX-CONNECT:monitor.sock >/dev/null || true grep -aE 'herald-os|Herald OS|Reached target|Failed|passphrase|Cryptography' boot.log | tail -n 60 || true [[ -z "$failed" ]] - uses: actions/upload-artifact@v4 if: always() with: name: installer-test-${{ matrix.disk }} path: | shots/ install.log boot.log ks.cfg ssh.log