commit 8e20868e60c9efa2c664034409dd9bb8c7e2c541 equwal <13551856+equwal@users.noreply.github.com> 2026-10-04 01:40:42 -0700 Add waybase: multi-base Wayland + Nix installer image One POSIX sh core with backends for KISS, Gentoo, Arch and Alpine; Nix multi-user daemon; sway, river-classic, labwc or dwl; LUKS2; UEFI+BIOS GRUB. QEMU harness installs unattended and checks login, nix and headless start.
.gitattributes | 1 + .github/workflows/ci.yml | 26 ++++ .shellcheckrc | 5 + LICENSE | 21 ++++ README.md | 109 +++++++++++++++++ answers.example | 15 +++ backends/alpine.sh | 39 ++++++ backends/arch.sh | 44 +++++++ backends/gentoo.sh | 51 ++++++++ backends/kiss.sh | 67 +++++++++++ build-image.sh | 34 ++++++ install.sh | 307 +++++++++++++++++++++++++++++++++++++++++++++++ test-qemu.py | 157 ++++++++++++++++++++++++ test.sh | 9 ++ 14 files changed, 885 insertions(+)
diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cbfa033 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: ci +on: [push, pull_request, workflow_dispatch] +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: shellcheck install.sh build-image.sh test.sh backends/*.sh + - run: python3 -m py_compile test-qemu.py + qemu-smoke: + # Alpine + sway, LUKS: build the image, install, boot, check (about 20 min). + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - name: enable 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 + - run: mkdir -p out && sh build-image.sh out + - run: sh test.sh out alpine sway encrypt + - if: always() + uses: actions/upload-artifact@v4 + with: + name: qemu-log + path: out/*.log diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..cd209c3 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,5 @@ +# $M is the fixed mount point /mnt/waybase; package lists split on purpose. +disable=SC2086,SC2012,SC2016,SC2153 +# variables are shared with the sourced backends +disable=SC2034 +shell=sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..160e1db --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 equwal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfa5950 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# waybase + +A bootable USB/SD installer that puts a fresh Linux on a blank disk: your pick +of **KISS, Gentoo, Arch or Alpine**, plus the **Nix** package manager and a +**Wayland-only** desktop (no X11) with the compositor you choose. + +One POSIX `sh` installer core (`install.sh`, about 300 lines) and one short +backend per base (`backends/*.sh`). It asks a few questions from plain text +menus, or reads an answers file for unattended installs. + +## Quickstart + +```sh +git clone https://github.com/equwal/waybase && cd waybase +sh build-image.sh out # needs docker; writes out/waybase.img +dd if=out/waybase.img of=/dev/sdX bs=4M conv=fsync # sdX = your USB stick or SD card +``` + +Boot the stick (UEFI or BIOS), log in as `root` (no password), connect +Ethernet, and run: + +```sh +sh /media/*/waybase/install.sh # interactive +sh /media/*/waybase/install.sh answers.txt # unattended, see answers.example +``` + +Reboot, log in on tty1, and the compositor starts. Super+Return opens foot, +Super+d opens fuzzel (dwl: Alt+Shift+Return and Alt+p, its built-in keys). + +## What you get + +| | KISS | Gentoo | Arch | Alpine | +|---|---|---|---|---| +| Bootstrap | official chroot tarball + `baseinit` | desktop-openrc stage3 + binary packages only (`getbinpkg`, `usepkgonly`) | official bootstrap tarball + pacman | `apk --root` | +| Init | busybox init + runit | OpenRC | systemd | OpenRC | +| Seat | seatd (Nix) | elogind | systemd-logind | seatd | +| Kernel | Alpine linux-lts + mkinitfs | gentoo-kernel-bin + dracut | linux + mkinitcpio | linux-lts + mkinitfs | +| Nix | release tarball, multi-user daemon (runit) | release tarball, multi-user daemon (OpenRC) | release tarball, multi-user daemon (systemd socket) | Alpine `nix` package, daemon (OpenRC) | +| Desktop packages | all from Nix | binpkg when one exists, else Nix | native | native (dwl from Nix) | + +Every install also gets PipeWire + WirePlumber, the foot terminal, the fuzzel +launcher, optional LUKS2 encryption of the root partition, GRUB for both UEFI +(removable path, no NVRAM writes) and BIOS, and one user account in the +`wheel`, `video`, `input` and `audio` groups. + +Disk layout (GPT): 1 MiB BIOS boot, 512 MiB ESP, 1 GiB `/boot` (ext4, +unencrypted), the rest is `/` (ext4, on LUKS2 if you choose encryption). + +Compositors: **sway, river (river-classic 0.3; river 0.4+ needs a separate +window manager), labwc, dwl**. Hyprland is left out: it has no headless +software-rendering mode to test and needs a GPU. When a base has no native +package, the installer takes it from Nix into the system profile +`/nix/var/nix/profiles/waybase` and links Mesa to `/run/opengl-driver`, so Nix +GL programs find drivers. `/etc/waybase-from-nix` lists what came from Nix. + +## Tested matrix + +`test.sh` boots the image in QEMU (KVM, `-cpu Nehalem` so nothing may need +AVX), installs unattended, boots the result by BIOS and by UEFI, unlocks LUKS, +logs in as the new user, and checks `nix --version`, the Nix daemon, and that +each compositor starts headless (`WLR_BACKENDS=headless`) and opens a Wayland +socket. + +MATRIX + +Run one yourself: `sh build-image.sh out && sh test.sh out arch sway encrypt`. + +## Security notes + +- The installer erases the whole target disk. Interactive mode makes you type + the disk name twice; `YES=1` in an answers file skips that. +- An answers file is shell code, sourced as root, and holds passwords in plain + text. Keep it off shared media and delete it after the install. +- `/boot` is not encrypted (kernel and initramfs are readable and could be + replaced by someone with physical access). Only `/` is on LUKS2. +- Live image: `root` has no password, as on the stock Alpine ISO. Do not boot + it on an untrusted network longer than the install takes. +- Downloads come over HTTPS from the official mirrors (Alpine, Arch, Gentoo, + GitHub for KISS, releases.nixos.org). Arch and Gentoo verify package + signatures with their own keyrings; the KISS tarball and the Nix tarball are + trusted on HTTPS alone. +- On KISS, the root-run `seatd` and `udevd` come from the root-owned Nix + profile, which users cannot modify. + +## Licences and why no image is released + +waybase itself is MIT. The image is the official Alpine ISO with these +scripts added; it contains GPL software, so redistributing it would oblige us +to also ship the matching sources. The bases (KISS, Gentoo, Arch), Nix (LGPL +2.1) and linux-firmware (mixed, redistributable) are downloaded from their +official mirrors at install time and never redistributed by this project. So +this repo ships the build script, not a built image: `build-image.sh` takes +about a minute. + +## Prior art + +archinstall (Arch only), setup-alpine (Alpine only), Gentoo has no official +installer, nixos-anywhere (NixOS only), KISS has a manual install guide. None +of them offers a choice of base, Nix on that base, and a ready Wayland +session from one image. waybase does, with an unattended mode tested in CI. + +## Known gaps + +- GPU, audio and input on real hardware are not covered by the QEMU tests. +- Nix-supplied compositors on KISS and Gentoo use Nix's Mesa; NVIDIA needs + extra work. +- Kernel updates on Gentoo produce new file names; `/boot/grub/grub.cfg` then + needs the new names by hand. +- No Wi-Fi setup: install over Ethernet. diff --git a/answers.example b/answers.example new file mode 100644 index 0000000..e8856f3 --- /dev/null +++ b/answers.example @@ -0,0 +1,15 @@ +# waybase answers file: sh install.sh answers.example +# Any variable left out is asked for interactively. +BASE=alpine # kiss | gentoo | arch | alpine +COMPOSITOR=sway # sway | river | labwc | dwl (a list installs several) +DISK=/dev/vda # ALL DATA ON IT IS ERASED +YES=1 # skip the "type the disk name again" check +ENCRYPT=yes # LUKS2 on the root partition +LUKS_PASS=change-me +USERNAME=user +USER_PASS=change-me +ROOT_PASS=change-me +HOSTNAME_NEW=waybase +#FIRMWARE=no # skip linux-firmware (VMs) +#SERIAL=1 # console and login on ttyS0 (tests) +#NIXPKGS=github:NixOS/nixpkgs/nixos-unstable diff --git a/backends/alpine.sh b/backends/alpine.sh new file mode 100644 index 0000000..78be09b --- /dev/null +++ b/backends/alpine.sh @@ -0,0 +1,39 @@ +# Alpine: apk --root from the live system. OpenRC, eudev, seatd, dbus. +# shellcheck shell=sh disable=SC2034 +bootstrap() { + mkdir -p $M/etc/apk/keys $M/etc/mkinitfs + cp /etc/apk/keys/* $M/etc/apk/keys/ + grep '^http' /etc/apk/repositories > $M/etc/apk/repositories + apk add -q --root $M --initdb alpine-base + chroot_mounts + echo 'features="ata base cryptsetup ext4 keymap mmc nvme scsi usb virtio"' > $M/etc/mkinitfs/mkinitfs.conf + fw=linux-firmware; [ "${FIRMWARE:-}" = no ] && fw=linux-firmware-none + ch "apk add -q linux-lts $fw mkinitfs cryptsetup e2fsprogs eudev seatd dbus shadow \ + mesa-dri-gallium font-dejavu fontconfig" + for s in devfs dmesg udev udev-trigger udev-settle; do ch "rc-update -q add $s sysinit"; done + for s in modules sysctl hostname bootmisc syslog hwclock; do ch "rc-update -q add $s boot"; done + for s in networking seatd dbus; do ch "rc-update -q add $s default"; done + for s in killprocs savecache mount-ro; do ch "rc-update -q add $s shutdown"; done + printf 'auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n' > $M/etc/network/interfaces +} +pkg() { case $1 in pipewire) echo "pipewire pipewire-pulse" ;; river) echo river-classic ;; *) echo "$1" ;; esac; } +pkg_install() { ch "apk add -q $*"; } +extra_pkgs() { :; } +user_add() { + ch "adduser -D -s /bin/sh $1" + for g in wheel video input audio seat; do ch "addgroup $1 $g" 2>/dev/null || true; done +} +serial_getty() { echo 'ttyS0::respawn:/sbin/getty -L 115200 ttyS0 vt100' >> $M/etc/inittab; } +nix_native() { ch "apk add -q nix"; } +nix_service() { ch "rc-update -q add nix-daemon default"; } +boot_cmd() { + printf '#!/bin/sh\n%s\n' "$1" >> $M/etc/local.d/waybase.start + chmod 755 $M/etc/local.d/waybase.start + ch "rc-update -q add local default" +} +kernel() { + ch "mkinitfs \$(ls /lib/modules | head -1)" >/dev/null + KERNEL=vmlinuz-lts INITRD=initramfs-lts + KARGS="modules=sd-mod,usb-storage,ext4" + CRYPTARGS="cryptroot=UUID=$UL cryptdm=root" +} diff --git a/backends/arch.sh b/backends/arch.sh new file mode 100644 index 0000000..0394e26 --- /dev/null +++ b/backends/arch.sh @@ -0,0 +1,44 @@ +# Arch: the official bootstrap tarball as the new root, then pacman inside it. +# systemd, systemd-logind (no seatd needed), systemd-networkd. +# shellcheck shell=sh disable=SC2034 +ARCH_MIRROR=${ARCH_MIRROR:-https://geo.mirror.pkgbuild.com} +bootstrap() { + curl -fsSL "$ARCH_MIRROR/iso/latest/archlinux-bootstrap-x86_64.tar.zst" | + zstd -dc | tar -x -C $M --strip-components=1 --numeric-owner + echo "Server = $ARCH_MIRROR/\$repo/os/\$arch" > $M/etc/pacman.d/mirrorlist + sed -i 's/^CheckSpace/#CheckSpace/' $M/etc/pacman.conf # / is not a mount point in the chroot + chroot_mounts + ch "pacman-key --init && pacman-key --populate archlinux" >/dev/null 2>&1 + fw=linux-firmware; [ "${FIRMWARE:-}" = no ] && fw="" + ch "pacman -Syu --noconfirm --needed base linux $fw mkinitcpio cryptsetup e2fsprogs sudo \ + mesa ttf-dejavu" >/dev/null + echo LANG=C.UTF-8 > $M/etc/locale.conf + printf '[Match]\nName=en* eth*\n\n[Network]\nDHCP=yes\n' > $M/etc/systemd/network/20-wired.network + ch "systemctl enable systemd-networkd systemd-resolved" >/dev/null 2>&1 +} +pkg() { case $1 in pipewire) echo "pipewire pipewire-pulse" ;; river) echo river-classic ;; *) echo "$1" ;; esac; } +pkg_install() { ch "pacman -S --noconfirm --needed $*" >/dev/null; } +extra_pkgs() { :; } +user_add() { ch "useradd -m -G wheel,video,input,audio -s /bin/bash $1"; } +serial_getty() { ch "systemctl enable serial-getty@ttyS0" >/dev/null 2>&1; } +nix_native() { return 1; } +nix_service() { + for u in nix-daemon.socket nix-daemon.service; do + ln -sf /nix/var/nix/profiles/default/lib/systemd/system/$u $M/etc/systemd/system/$u + done + ch "systemctl enable nix-daemon.socket" >/dev/null 2>&1 +} +boot_cmd() { + printf '[Unit]\nDescription=waybase boot command\n[Service]\nType=oneshot\nExecStart=/bin/sh -c "%s"\n[Install]\nWantedBy=multi-user.target\n' \ + "$1" > $M/etc/systemd/system/waybase-boot.service + ch "systemctl enable waybase-boot" >/dev/null 2>&1 +} +kernel() { + # No autodetect hook: the disk may move to other hardware. + sed -i 's/^HOOKS=.*/HOOKS=(base udev modconf kms keyboard keymap block encrypt filesystems fsck)/' $M/etc/mkinitcpio.conf + ch "mkinitcpio -P" >/dev/null 2>&1 + # last step that needs DNS in the chroot is done; hand resolv.conf to resolved + ln -sf /run/systemd/resolve/stub-resolv.conf $M/etc/resolv.conf + KERNEL=vmlinuz-linux INITRD=initramfs-linux.img KARGS="" + CRYPTARGS="cryptdevice=UUID=$UL:root" +} diff --git a/backends/gentoo.sh b/backends/gentoo.sh new file mode 100644 index 0000000..4d49eb9 --- /dev/null +++ b/backends/gentoo.sh @@ -0,0 +1,51 @@ +# Gentoo: desktop OpenRC stage3, then binary packages only (getbinpkg, +# usepkgonly), so nothing compiles. A package without a binary comes from Nix. +# OpenRC, elogind, dbus, dhcpcd, gentoo-kernel-bin + dracut. +# shellcheck shell=sh disable=SC2034 +GENTOO_MIRROR=${GENTOO_MIRROR:-https://distfiles.gentoo.org} +E="emerge --quiet --getbinpkgonly --usepkgonly --noreplace" +bootstrap() { + b=$GENTOO_MIRROR/releases/amd64/autobuilds + f=$(curl -fsSL "$b/latest-stage3-amd64-desktop-openrc.txt" | grep -o '^[^# ]*stage3[^ ]*\.tar\.xz' | head -1) + curl -fsSL "$b/$f" | tar -xJp -C $M --xattrs-include='*.*' --numeric-owner + chroot_mounts + printf 'FEATURES="getbinpkg binpkg-request-signature"\nACCEPT_LICENSE="*"\n' >> $M/etc/portage/make.conf + mkdir -p $M/etc/portage/package.use $M/etc/dracut.conf.d + echo 'sys-kernel/installkernel dracut' > $M/etc/portage/package.use/waybase + printf 'hostonly="no"\nadd_dracutmodules+=" crypt "\n' > $M/etc/dracut.conf.d/waybase.conf + ch "emerge-webrsync -q" >/dev/null 2>&1 + ch "getuto" >/dev/null 2>&1 || true + ch "$E sys-fs/cryptsetup net-misc/dhcpcd sys-auth/elogind sys-apps/dbus app-admin/sudo" + ch "rc-update -q add elogind boot; rc-update -q add dbus default; rc-update -q add dhcpcd default" +} +pkg() { + case $1 in + sway | labwc | dwl) echo "gui-wm/$1" ;; # river: 0.4+ is not monolithic; Nix has river-classic + foot | fuzzel | wmenu) echo "gui-apps/$1" ;; + pipewire | wireplumber) echo "media-video/$1" ;; + esac +} +pkg_install() { ch "$E $*"; } +extra_pkgs() { :; } +user_add() { ch "useradd -m -G wheel,video,input,audio,usb -s /bin/bash $1"; } +serial_getty() { echo 's0:12345:respawn:/sbin/agetty -L 115200 ttyS0 vt100' >> $M/etc/inittab; } +nix_native() { return 1; } +nix_service() { + printf '#!/sbin/openrc-run\ncommand=/nix/var/nix/profiles/default/bin/nix-daemon\ncommand_background=yes\npidfile=/run/nix-daemon.pid\n' \ + > $M/etc/init.d/nix-daemon + chmod 755 $M/etc/init.d/nix-daemon + ch "rc-update -q add nix-daemon default" +} +boot_cmd() { + printf '#!/bin/sh\n%s\n' "$1" >> $M/etc/local.d/waybase.start + chmod 755 $M/etc/local.d/waybase.start + ch "rc-update -q add local default" +} +kernel() { + fw=sys-kernel/linux-firmware; [ "${FIRMWARE:-}" = no ] && fw="" + ch "$E sys-kernel/installkernel $fw sys-kernel/gentoo-kernel-bin" >/dev/null + KERNEL=$(cd $M/boot && ls vmlinuz-* | tail -1) + INITRD=$(cd $M/boot && ls initramfs-* | tail -1) + KARGS="" + CRYPTARGS="rd.luks.uuid=$UL rd.luks.name=$UL=root" +} diff --git a/backends/kiss.sh b/backends/kiss.sh new file mode 100644 index 0000000..4aac9b6 --- /dev/null +++ b/backends/kiss.sh @@ -0,0 +1,67 @@ +# KISS: the official chroot tarball plus baseinit (busybox init + runit). +# KISS builds every package from source, so the desktop (compositor, foot, +# PipeWire, seatd, udev) comes from Nix and the kernel is Alpine's linux-lts +# with an Alpine mkinitfs initramfs. Nothing compiles. +# shellcheck shell=sh disable=SC2034 +bootstrap() { + v=$(curl -fsSL https://api.github.com/repos/kisslinux/repo/releases/latest | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p') + curl -fsSL "https://github.com/kisslinux/repo/releases/download/$v/kiss-chroot-$v.tar.xz" | + tar -xJ -C $M --numeric-owner + chroot_mounts + ch "git clone -q https://github.com/kisslinux/repo /var/db/kiss/repo" + ch "KISS_PATH=/var/db/kiss/repo/core KISS_PROMPT=0 kiss build baseinit" >/dev/null + mkdir -p $M/etc/sv $M/var/service + sv() { # sv NAME COMMAND: runit service + mkdir -p $M/etc/sv/$1 + printf '#!/bin/sh\nexec %s\n' "$2" > $M/etc/sv/$1/run + chmod 755 $M/etc/sv/$1/run + ln -sfn /etc/sv/$1 $M/var/service/$1 + } + p=/nix/var/nix/profiles/waybase/bin + sv udevd "$p/udevd" + sv seatd "$p/seatd -g video" + # udev database for libinput, then DHCP on the first wired interface + printf '%s\n' "$p/udevadm trigger --action=add 2>/dev/null; $p/udevadm settle 2>/dev/null" \ + 'for i in /sys/class/net/e*; do [ -e "$i" ] && udhcpc -b -i "${i##*/}" -s /etc/udhcpc.script >/dev/null 2>&1 && break; done' \ + > $M/etc/rc.d/waybase.boot + cat > $M/etc/udhcpc.script <<'EOF' +#!/bin/sh +[ "$1" = bound ] || [ "$1" = renew ] || exit 0 +ip addr flush dev "$interface"; ip addr add "$ip/${mask:-24}" dev "$interface" +[ -n "$router" ] && ip route add default via "${router%% *}" dev "$interface" +: > /etc/resolv.conf; for d in $dns; do echo "nameserver $d" >> /etc/resolv.conf; done +EOF + chmod 755 $M/etc/udhcpc.script +} +pkg() { :; } +pkg_install() { return 1; } +extra_pkgs() { echo seatd eudev; } +user_add() { + ch "adduser -D -s /bin/sh $1" + for g in wheel video input audio; do ch "addgroup $1 $g" 2>/dev/null || true; done +} +serial_getty() { echo 'ttyS0::respawn:/usr/bin/getty -L 115200 ttyS0 vt100' >> $M/etc/inittab; } +nix_native() { return 1; } +nix_service() { + mkdir -p $M/etc/sv/nix-daemon + printf '#!/bin/sh\nexec /nix/var/nix/profiles/default/bin/nix-daemon\n' > $M/etc/sv/nix-daemon/run + chmod 755 $M/etc/sv/nix-daemon/run + ln -sfn /etc/sv/nix-daemon $M/var/service/nix-daemon +} +boot_cmd() { echo "$1" >> $M/etc/rc.d/waybase.boot; } +kernel() { + k=/tmp/waybase-kernel; rm -rf $k; mkdir -p $k + apk fetch -q -o $k linux-lts + [ "${FIRMWARE:-}" = no ] || apk fetch -q -R -o $k linux-firmware + for f in "$k"/*.apk; do tar -xzf "$f" -C $M --keep-directory-symlink --exclude='.[A-Z]*' 2>/dev/null; done + rm -rf $k + kv=$(ls $M/lib/modules | head -1) + # Alpine's mkinitfs (live system) with the target's modules + mount --bind $M/lib/modules /lib/modules + mkinitfs -F "ata base cryptsetup ext4 keymap mmc nvme scsi usb virtio" -o $M/boot/initramfs-lts "$kv" >/dev/null + umount /lib/modules + KERNEL=vmlinuz-lts INITRD=initramfs-lts + KARGS="modules=sd-mod,usb-storage,ext4" + CRYPTARGS="cryptroot=UUID=$UL cryptdm=root" +} diff --git a/build-image.sh b/build-image.sh new file mode 100644 index 0000000..04fa43c --- /dev/null +++ b/build-image.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# build-image.sh - build waybase.img: the official Alpine "standard" ISO with +# the installer added under /waybase. The result is a hybrid image (BIOS and +# UEFI, CD or USB/SD). Needs docker, or run it on Alpine as root with DOCKER=0. +# sh build-image.sh [OUT_DIR] (default: ./out) +set -eu +here=$(cd "$(dirname "$0")" && pwd) +mkdir -p "${1:-$here/out}"; OUT=$(cd "${1:-$here/out}" && pwd) +build() { + set -eu + apk add -q xorriso + V=$(cut -d. -f1,2 /etc/alpine-release) R=$(cat /etc/alpine-release) + ISO=$OUT/alpine-standard-$R-x86_64.iso + [ -s "$ISO" ] || wget -q -O "$ISO" "https://dl-cdn.alpinelinux.org/alpine/v$V/releases/x86_64/${ISO##*/}" + W=$(mktemp -d); mkdir -p "$W/waybase" + cp -r "$SRC/install.sh" "$SRC/answers.example" "$SRC/backends" "$W/waybase/" + # serial console as well as the screen, so the installer runs headless too + xorriso -osirrox on -indev "$ISO" -extract /boot/grub/grub.cfg "$W/grub.cfg" \ + -extract /boot/syslinux/syslinux.cfg "$W/syslinux.cfg" 2>/dev/null + chmod u+w "$W"/*.cfg + sed -i '/modules=/s/$/ console=ttyS0,115200 console=tty0/' "$W/grub.cfg" "$W/syslinux.cfg" + rm -f "$OUT/waybase.img" + xorriso -indev "$ISO" -outdev "$OUT/waybase.img" -map "$W/waybase" /waybase \ + -map "$W/grub.cfg" /boot/grub/grub.cfg -map "$W/syslinux.cfg" /boot/syslinux/syslinux.cfg \ + -boot_image any replay 2>&1 | grep -E 'Written to|FAILURE' || true + rm -rf "$W" + ls -l "$OUT/waybase.img" +} +if [ "${DOCKER:-1}" = 0 ]; then + SRC=$here; build +else + docker run --rm -i -v "$here:/src:ro" -v "$OUT:/out" alpine:latest sh -c \ + "$(sed -n '/^build() {/,/^}/p' "$0"); SRC=/src OUT=/out build" +fi diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..65ffae8 --- /dev/null +++ b/install.sh @@ -0,0 +1,307 @@ +#!/bin/sh +# waybase installer: install KISS, Gentoo, Arch or Alpine with Nix and a +# Wayland compositor onto a blank disk. Runs as root from the waybase live +# image (Alpine), with network: sh /media/*/waybase/install.sh [ANSWERS] +# +# ANSWERS is an optional shell file that presets any of the variables below +# (see answers.example). Unset variables are asked for interactively. +# BASE kiss | gentoo | arch | alpine +# COMPOSITOR sway | river (river-classic 0.3) | labwc | dwl (a list installs several; the +# first one starts on tty1 login) +# DISK target disk, for example /dev/sda. ALL DATA ON IT IS ERASED. +# ENCRYPT yes | no (LUKS2 on the root partition) +# LUKS_PASS, USERNAME, USER_PASS, ROOT_PASS, HOSTNAME_NEW +# YES=1 skip the "type the disk name again" check +# SERIAL=1 login getty on ttyS0 and kernel console there (for tests) +# NIXPKGS flake ref for Nix-supplied packages (default: nixpkgs) +# FIRMWARE=no skip linux-firmware (VMs; real hardware usually needs it) +set -eu +D=$(cd "$(dirname "$0")" && pwd) +M=/mnt/waybase +BASES="kiss gentoo arch alpine" +COMPOSITORS="sway river labwc dwl" +# shellcheck disable=SC1090 +[ -n "${1:-}" ] && . "$(cd "$(dirname "$1")" && pwd)/${1##*/}" + +log() { printf '\n=== %s\n' "$*"; } +die() { echo "ERROR: $*" >&2; exit 1; } +ask() { # ask VAR "prompt" [secret]: read VAR unless the answers file set it + eval "[ -n \"\${$1:-}\" ]" && return 0 + printf '%s: ' "$2" + [ -n "${3:-}" ] && stty -echo + read -r v + [ -n "${3:-}" ] && { stty echo; echo; } + eval "$1=\$v" +} +menu() { # menu VAR "prompt" choice...: numbered menu unless VAR is set + eval "[ -n \"\${$1:-}\" ]" && return 0 + var=$1 prompt=$2; shift 2 + i=0; for o; do i=$((i + 1)); echo " $i) $o"; done + while :; do + printf '%s [1-%d]: ' "$prompt" $#; read -r n + case $n in '' | *[!0-9]*) continue ;; esac + [ "$n" -ge 1 ] && [ "$n" -le $# ] && break + done + eval "$var=\${$n}" +} +in_list() { case " $2 " in *" $1 "*) return 0 ;; esac; return 1; } +ch() { chroot $M /bin/sh -c "$*"; } + +# --- questions ----------------------------------------------------------- +menu BASE "base system" $BASES +in_list "$BASE" "$BASES" || die "unknown BASE: $BASE" +menu COMPOSITOR "compositor" $COMPOSITORS +for c in $COMPOSITOR; do in_list "$c" "$COMPOSITORS" || die "unknown compositor: $c"; done +lsblk -dno NAME,SIZE,MODEL,TRAN | grep -v '^loop' || true +ask DISK "target disk (for example /dev/sda) - ALL DATA ON IT IS ERASED" +[ -b "$DISK" ] || die "not a block device: $DISK" +grep -q "^$DISK" /proc/mounts && die "$DISK is mounted (is it the boot media?)" +if [ "${YES:-}" != 1 ]; then + printf 'Type %s again to erase it: ' "$DISK"; read -r again + [ "$again" = "$DISK" ] || die aborted +fi +menu ENCRYPT "encrypt the root partition with LUKS2" yes no +if [ "$ENCRYPT" = yes ]; then ask LUKS_PASS "LUKS passphrase" s; fi +ask USERNAME "user name" +echo "$USERNAME" | grep -Eq '^[a-z_][a-z0-9_-]{0,31}$' || die "bad user name: $USERNAME" +ask USER_PASS "password for $USERNAME" s +ask ROOT_PASS "password for root" s +HOSTNAME_NEW=${HOSTNAME_NEW:-waybase} +NIXPKGS=${NIXPKGS:-nixpkgs} +if [ "${SERIAL:-}" = 1 ]; then CONSOLE="console=tty0 console=ttyS0,115200"; else CONSOLE=""; fi + +# --- live tools ---------------------------------------------------------- +log "live tools" +if ! ip route | grep -q '^default'; then + for i in /sys/class/net/e*; do + i=${i##*/}; ip link set "$i" up && timeout 30 udhcpc -q -n -i "$i" >/dev/null 2>&1 && break + done +fi +ip route | grep -q '^default' || die "no network (plug in Ethernet, or run setup-interfaces)" +V=$(cut -d. -f1,2 /etc/alpine-release) +grep -q '^http' /etc/apk/repositories || + printf 'https://dl-cdn.alpinelinux.org/alpine/v%s/%s\n' "$V" main "$V" community >> /etc/apk/repositories +apk update -q +apk add -q sfdisk wipefs e2fsprogs cryptsetup dosfstools lsblk blkid grub grub-efi grub-bios \ + curl tar xz zstd mkinitfs util-linux-misc ca-certificates +modprobe -a ext4 vfat nls_cp437 nls_iso8859-1 dm_crypt 2>/dev/null || true + +# --- disk ---------------------------------------------------------------- +# GPT: BIOS boot, ESP, /boot (ext4, unencrypted so GRUB needs no LUKS +# support), root (ext4, optionally on LUKS2). +log "partition $DISK" +case $DISK in *[0-9]) P=${DISK}p ;; *) P=$DISK ;; esac +wipefs -aq "$DISK" +sfdisk -q "$DISK" <<EOF +label: gpt +size=1MiB, type=21686148-6449-6E6F-744E-656564454649, name=bios +size=512MiB, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name=esp +size=1GiB, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name=boot +type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, name=root +EOF +mdev -s 2>/dev/null || true; sleep 1 +mkfs.vfat -F32 -n ESP "${P}2" >/dev/null +mkfs.ext4 -qF -L boot "${P}3" +ROOTDEV=${P}4 +if [ "$ENCRYPT" = yes ]; then + printf %s "$LUKS_PASS" | cryptsetup -q luksFormat --type luks2 "${P}4" - + printf %s "$LUKS_PASS" | cryptsetup open "${P}4" root - + ROOTDEV=/dev/mapper/root +fi +mkfs.ext4 -qF -L root "$ROOTDEV" +mkdir -p $M && mount "$ROOTDEV" $M +mkdir -p $M/boot && mount "${P}3" $M/boot +mkdir -p $M/boot/efi && mount "${P}2" $M/boot/efi +UB=$(blkid -s UUID -o value "${P}3") UE=$(blkid -s UUID -o value "${P}2") +UL=$(blkid -s UUID -o value "${P}4") UR=$(blkid -s UUID -o value "$ROOTDEV") + +chroot_mounts() { + mkdir -p $M/proc $M/sys $M/dev $M/run $M/tmp + for f in proc sys dev; do mount --rbind /$f $M/$f; done + mount -t tmpfs tmpfs $M/run + rm -f $M/etc/resolv.conf; cp /etc/resolv.conf $M/etc/resolv.conf +} + +# --- base system --------------------------------------------------------- +# Each backend defines: +# bootstrap base system in $M (calls chroot_mounts), init, seat, network +# pkg NAME native package(s) for a logical name; empty = take it from Nix +# pkg_install P.. install native packages; nonzero = fall back to Nix +# extra_pkgs logical names this base also needs (from Nix when not native) +# user_add USER, serial_getty, nix_service, boot_cmd CMD (run CMD at boot) +# nix_native install Nix from the base's own packages; nonzero = use the +# release tarball +# kernel kernel + initramfs; sets KERNEL, INITRD (relative to /boot), +# KARGS, CRYPTARGS +# shellcheck disable=SC1090 +. "$D/backends/$BASE.sh" +log "bootstrap $BASE" +bootstrap +echo "$HOSTNAME_NEW" > $M/etc/hostname +cat > $M/etc/fstab <<EOF +UUID=$UR / ext4 defaults,noatime 0 1 +UUID=$UB /boot ext4 defaults,noatime 0 2 +UUID=$UE /boot/efi vfat defaults 0 2 +EOF + +log "user $USERNAME" +user_add "$USERNAME" +printf 'root:%s\n%s:%s\n' "$ROOT_PASS" "$USERNAME" "$USER_PASS" | ch chpasswd +[ "${SERIAL:-}" = 1 ] && serial_getty + +# --- Nix ----------------------------------------------------------------- +log "Nix (multi-user daemon)" +mkdir -p $M/etc/nix +printf 'experimental-features = nix-command flakes\nbuild-users-group = nixbld\n' > $M/etc/nix/nix.conf +if ! nix_native; then + # Official release tarball, registered by hand (what its installer does, + # without needing bash or useradd, which KISS lacks). + NV=$(curl -fsSL https://nixos.org/nix/install | grep -o 'releases.nixos.org/nix/nix-[0-9.]*[0-9]' | head -1) + NV=${NV##*/nix-} + T=/tmp/nix-$NV-x86_64-linux + curl -fsSL "https://releases.nixos.org/nix/nix-$NV/nix-$NV-x86_64-linux.tar.xz" | tar -xJ -C $M/tmp + mkdir -p $M/nix/store $M/nix/var/nix/profiles/per-user $M/nix/var/nix/gcroots + mv $M$T/store/* $M/nix/store/ + NIX=$(cd $M/nix/store && ls -d ./*-nix-"$NV" | head -1); NIX=/nix/store/${NIX#./} + CA=$(cd $M/nix/store && ls -d ./*-nss-cacert-* | head -1); CA=/nix/store/${CA#./} + ch "$NIX/bin/nix-store --load-db < $T/.reginfo && $NIX/bin/nix-env -p /nix/var/nix/profiles/default -i $NIX $CA" >/dev/null + rm -rf "$M$T" + if ! grep -q '^nixbld:' $M/etc/group; then + members="" + i=1; while [ $i -le 32 ]; do + members="$members${members:+,}nixbld$i" + echo "nixbld$i:x:$((30000 + i)):30000:Nix build user $i:/var/empty:/sbin/nologin" >> $M/etc/passwd + echo "nixbld$i:!:19000:0:99999:7:::" >> $M/etc/shadow + i=$((i + 1)) + done + echo "nixbld:x:30000:$members" >> $M/etc/group + fi + ch "chgrp nixbld /nix/store && chmod 1775 /nix/store" +fi +nix_service +NIXP=/nix/var/nix/profiles/waybase +cat > $M/etc/profile.d/waybase.sh <<EOF +# waybase: Nix in PATH, Nix-supplied programs, compositor on tty1 login. +for f in /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh /etc/profile.d/nix-daemon.sh.nix; do + [ -r "\$f" ] && . "\$f" && break +done +export PATH="\$HOME/.nix-profile/bin:$NIXP/bin:\$PATH" +export XDG_DATA_DIRS="$NIXP/share:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +if [ "\$(id -u)" -ne 0 ] && [ -z "\${WAYLAND_DISPLAY:-}" ] && [ "\$(tty)" = /dev/tty1 ]; then + exec waybase-session +fi +EOF + +# --- compositor, terminal, launcher, audio ------------------------------ +log "Wayland session: $COMPOSITOR" +want="$COMPOSITOR foot fuzzel pipewire wireplumber" +in_list dwl "$COMPOSITOR" && want="$want wmenu" +fromnix="" +for p in $want $(extra_pkgs); do + n=$(pkg "$p") + if [ -n "$n" ] && pkg_install "$n"; then echo "native: $p ($n)"; else fromnix="$fromnix $p"; fi +done +[ -e $M/etc/fonts/fonts.conf ] || fromnix="$fromnix dejavu_fonts fontconfig" +if [ -n "$fromnix" ]; then + # Nix GL apps on a foreign distro find Mesa through /run/opengl-driver. + fromnix="$fromnix mesa" + echo "from Nix:$fromnix" + refs="" + for p in $fromnix; do + case $p in river) p=river-classic ;; esac # river 0.4+ needs a separate WM + refs="$refs $NIXPKGS#$p" + done + ch "f=/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh; [ -r \$f ] && . \$f; nix profile install --profile $NIXP $refs" + [ -e $M/etc/fonts/fonts.conf ] || { mkdir -p $M/etc/fonts && cat > $M/etc/fonts/fonts.conf <<EOF +<?xml version="1.0"?> +<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"> +<fontconfig><dir>$NIXP/share/fonts</dir><cachedir>/var/cache/fontconfig</cachedir></fontconfig> +EOF + } + boot_cmd "ln -sfn $NIXP /run/opengl-driver" +fi +echo "$fromnix" > $M/etc/waybase-from-nix +set -- $COMPOSITOR +cat > $M/usr/local/bin/waybase-session <<EOF +#!/bin/sh +# Start the default compositor ($1) with PipeWire. +: "\${XDG_RUNTIME_DIR:=/tmp/runtime-\$(id -u)}"; export XDG_RUNTIME_DIR +mkdir -p -m 700 "\$XDG_RUNTIME_DIR" +if [ ! -d /run/systemd/system ]; then # systemd user units start PipeWire on Arch + pipewire & sleep 1; wireplumber & pipewire-pulse & +fi +command -v dbus-run-session >/dev/null && exec dbus-run-session -- "\${1:-$1}" +exec "\${1:-$1}" +EOF +chmod 755 $M/usr/local/bin/waybase-session + +# Small per-compositor configs: Super+Return = foot, Super+d = fuzzel. +H=$M/home/$USERNAME/.config +for c in $COMPOSITOR; do case $c in +sway) mkdir -p $H/sway && cat > $H/sway/config <<'EOF' +set $mod Mod4 +bindsym $mod+Return exec foot +bindsym $mod+d exec fuzzel +bindsym $mod+Shift+q kill +bindsym $mod+Shift+e exit +bindsym $mod+h focus left +bindsym $mod+l focus right +bindsym $mod+1 workspace number 1 +bindsym $mod+2 workspace number 2 +bindsym $mod+Shift+1 move container to workspace number 1 +bindsym $mod+Shift+2 move container to workspace number 2 +EOF +;; +river) mkdir -p $H/river && cat > $H/river/init <<'EOF' +#!/bin/sh +riverctl map normal Super Return spawn foot +riverctl map normal Super D spawn fuzzel +riverctl map normal Super+Shift Q close +riverctl map normal Super+Shift E exit +riverctl map normal Super J focus-view next +riverctl map normal Super K focus-view previous +riverctl default-layout rivertile +rivertile -view-padding 4 -outer-padding 4 & +EOF +chmod 755 $H/river/init ;; +labwc) mkdir -p $H/labwc && cat > $H/labwc/rc.xml <<'EOF' +<?xml version="1.0"?> +<labwc_config> + <keyboard> + <default /> + <keybind key="W-Return"><action name="Execute" command="foot" /></keybind> + <keybind key="W-d"><action name="Execute" command="fuzzel" /></keybind> + </keyboard> +</labwc_config> +EOF +;; +dwl) ;; # dwl is configured at build time: Alt+Shift+Return = foot, Alt+p = wmenu +esac; done +ch "chown -R $USERNAME:\$(id -g $USERNAME) /home/$USERNAME" + +# --- kernel, initramfs, bootloader -------------------------------------- +log "kernel + initramfs" +kernel +if [ "$ENCRYPT" = yes ]; then ROOTARG="root=/dev/mapper/root $CRYPTARGS"; else ROOTARG="root=UUID=$UR"; fi +log "GRUB (UEFI removable path + BIOS)" +grub-install --target=x86_64-efi --efi-directory=$M/boot/efi --boot-directory=$M/boot --removable --no-nvram >/dev/null +grub-install --target=i386-pc --boot-directory=$M/boot "$DISK" >/dev/null +cat > $M/boot/grub/grub.cfg <<EOF +set timeout=3 +menuentry "waybase: $BASE" { + search --no-floppy --fs-uuid --set=root $UB + linux /$KERNEL $ROOTARG rootfstype=ext4 rw $KARGS $CONSOLE + initrd /$INITRD +} +EOF + +log "unmount" +sync +for p in /proc/[0-9]*; do # daemons left by package scripts (gpg-agent, ...) + [ "$(readlink "$p/root" 2>/dev/null)" = $M ] && kill "${p#/proc/}" 2>/dev/null || true +done +sleep 2 +umount -l $M/proc $M/sys $M/dev $M/run 2>/dev/null || true +umount $M/boot/efi $M/boot $M +[ "$ENCRYPT" = yes ] && cryptsetup close root +log "DONE: $BASE with $COMPOSITOR on $DISK. Remove the install media and reboot." diff --git a/test-qemu.py b/test-qemu.py new file mode 100644 index 0000000..3d7d12d --- /dev/null +++ b/test-qemu.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Unattended QEMU test of waybase.img for one base. + +Usage: test-qemu.py IMG WORKDIR BASE "COMPOSITORS" [encrypt|plain] +Stdlib only. Env: QEMU, QEMU_IMG, OVMF (UEFI firmware), MEM (MiB, 2048), +ACCEL (kvm|tcg), CPU (default Nehalem = x86-64-v2, so no binary may need AVX). + +1. UEFI boot of the live image as a USB stick; install.sh with an answers file. +2. BIOS boot of the installed disk to the passphrase or login prompt. +3. UEFI boot of the installed disk: unlock, log in as the user, then check + `nix --version`, the nix daemon, and that each compositor starts headless + and creates a Wayland socket. +Exit 0 only if every check passes. The serial log goes to WORKDIR/BASE.log. +""" +import os, re, socket, subprocess, sys, time + +img, work, base, comps = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +encrypt = (sys.argv[5] if len(sys.argv) > 5 else "encrypt") == "encrypt" +disk = os.path.join(work, f"{base}.qcow2") +QEMU = os.environ.get("QEMU", "qemu-system-x86_64") +OVMF = os.environ.get("OVMF", "/usr/share/ovmf/bios.bin") +ACCEL, CPU = os.environ.get("ACCEL", "kvm"), os.environ.get("CPU", "Nehalem") +LOG = open(os.path.join(work, f"{base}.log"), "a", encoding="utf-8", errors="replace") +PW = {"luks": "test-luks", "user": "test-user", "root": "test-root"} + + +def note(s): + LOG.write(f"\n##### {s}\n"); LOG.flush(); print(f"##### {base}: {s}", flush=True) + + +class VM: + def __init__(self, uefi, live, port=45560): + args = [QEMU, "-accel", ACCEL, "-cpu", CPU, "-m", os.environ.get("MEM", "2048"), "-smp", "2", + "-display", "none", "-monitor", "none", + "-chardev", f"socket,id=s0,host=127.0.0.1,port={port},server=on,wait=on", + "-serial", "chardev:s0", "-nic", "user,model=virtio-net-pci", + "-drive", f"file={disk},if=virtio,format=qcow2,cache=unsafe"] + if uefi: + args += ["-bios", OVMF] + if live: + args += ["-device", "qemu-xhci", "-drive", f"if=none,id=usb,file={img},format=raw,readonly=on", + "-device", "usb-storage,drive=usb,bootindex=0"] + self.p = subprocess.Popen(args) + for _ in range(150): + try: + self.s = socket.create_connection(("127.0.0.1", port)); break + except OSError: + time.sleep(0.2) + else: + sys.exit("FAIL: no QEMU serial socket") + self.buf = "" + + def expect(self, pat, timeout): + end = time.time() + timeout + while True: + m = re.search(pat, self.buf, re.S) + if m: + self.buf = self.buf[m.end():] + return m + if time.time() > end or self.p.poll() is not None: + self.kill() + sys.exit(f"FAIL: {base}: timeout or QEMU exit waiting for {pat!r}") + self.s.settimeout(5) + try: + d = self.s.recv(65536) + except socket.timeout: + continue + if not d: + sys.exit(f"FAIL: {base}: serial closed waiting for {pat!r}") + t = d.decode("utf-8", "replace") + if "\x1b[6n" in t: # busybox ash asks for the cursor position + self.s.sendall(b"\x1b[50;200R") + LOG.write(t); LOG.flush() + self.buf = (self.buf + t)[-200000:] + + def send(self, line): + self.s.sendall((line + "\r").encode()) + + def run(self, script, timeout=600): + """Run script with sh and return the output between markers.""" + self.buf = "" + self.send("sh <<'EOS'\recho @@BEG\"\"@@\r" + script.strip().replace("\n", "\r") + "\recho @@END\"\"@@\rEOS") + return self.expect(r"@@BEG@@(.*?)@@END@@", timeout).group(1) + + def kill(self): + self.p.kill(); self.p.wait() + + +def login(vm, user, pw): + vm.expect(r"login: ", 1800) + vm.send(user) + vm.expect("Password:", 60) + vm.send(pw) + time.sleep(10) + + +fails = [] +def check(name, ok, detail=""): + note(f"CHECK {name}: {'ok' if ok else 'FAIL'} {detail}") + ok or fails.append(name) + + +if os.path.exists(disk): + os.remove(disk) +subprocess.run([os.environ.get("QEMU_IMG", "qemu-img"), "create", "-q", "-f", "qcow2", disk, "32G"], check=True) +note(f"compositors={comps} encrypt={encrypt} accel={ACCEL} cpu={CPU}") + +note("1/3 UEFI live boot + unattended install") +t0 = time.time() +vm = VM(uefi=True, live=True) +vm.expect("login:", 900); vm.send("root"); time.sleep(5) +answers = (f"BASE={base}\nCOMPOSITOR='{comps}'\nDISK=/dev/vda\nYES=1\nSERIAL=1\nFIRMWARE=no\n" + f"ENCRYPT={'yes' if encrypt else 'no'}\nLUKS_PASS={PW['luks']}\nUSERNAME=tester\n" + f"USER_PASS={PW['user']}\nROOT_PASS={PW['root']}\n") +vm.run(f"cat > /tmp/answers <<'EOA'\n{answers}EOA") +vm.send("sh /media/*/waybase/install.sh /tmp/answers; echo INSTALL_RC=$?; sleep 2; poweroff") +m = vm.expect(r"INSTALL_RC=(\d+)", 4 * 3600) +vm.p.wait(300) +note(f"install took {int(time.time() - t0)} s") +if int(m.group(1)): + sys.exit(f"FAIL: {base}: install.sh rc={m.group(1)}") + +prompt = "Enter passphrase|Passphrase|passphrase for" if encrypt else "login: " +note("2/3 BIOS boot of the installed disk") +vm = VM(uefi=False, live=False); vm.expect(prompt, 900); vm.kill() +check("BIOS boot reaches " + ("passphrase" if encrypt else "login"), True) + +note("3/3 UEFI boot of the installed disk + checks") +vm = VM(uefi=True, live=False) +if encrypt: + vm.expect(prompt, 900); time.sleep(2); vm.send(PW["luks"]) +login(vm, "tester", PW["user"]) +r = vm.run(r""" +. /etc/profile >/dev/null 2>&1 +nix --version 2>&1 | head -1 | sed 's/^/NIXVER /' +nix store info --store daemon >/dev/null 2>&1 || nix store ping --store daemon >/dev/null 2>&1 && echo NIXDAEMON_OK +for b in foot fuzzel pipewire wireplumber waybase-session; do command -v $b >/dev/null || echo MISSING:$b; done +echo FROMNIX $(cat /etc/waybase-from-nix) +for c in %s; do + d=$(mktemp -d); chmod 700 $d + XDG_RUNTIME_DIR=$d WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 $c >/tmp/$c.log 2>&1 & + pid=$!; i=0 + while [ $i -lt 30 ] && ! ls $d | grep -q '^wayland-[0-9]*$'; do sleep 1; i=$((i+1)); done + if ls $d | grep -q '^wayland-[0-9]*$'; then echo UP:$c; else echo DOWN:$c; tail -5 /tmp/$c.log; fi + kill $pid 2>/dev/null; sleep 1 +done +""" % comps, 900) +check("nix --version", "NIXVER nix (Nix)" in r, (re.search(r"NIXVER (.*)", r) or [None, ""])[1].strip()) +check("nix daemon reachable", "NIXDAEMON_OK" in r) +check("session programs", "MISSING:" not in r, " ".join(re.findall(r"MISSING:\S+", r))) +note("supplied by Nix: " + (re.search(r"FROMNIX(.*)", r) or [None, ""])[1].strip()) +for c in comps.split(): + check(f"{c} starts headless", f"UP:{c}" in r) +vm.send("exit"); time.sleep(3) +vm.kill() +note(f"QEMU TEST {base} " + ("PASSED" if not fails else "FAILED: " + ", ".join(fails))) +sys.exit(1 if fails else 0) diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..7dc16ba --- /dev/null +++ b/test.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# test.sh WORKDIR BASE "COMPOSITORS" [encrypt|plain] +# Runs test-qemu.py on WORKDIR/waybase.img inside an Alpine container with KVM. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +W=$(cd "$1" && pwd); shift +docker run --rm -i --device /dev/kvm -v "$here:/src:ro" -v "$W:/work" alpine:latest sh -c \ + 'apk add -q qemu-system-x86_64 qemu-img ovmf python3 >/dev/null 2>&1 || command -v qemu-img >/dev/null + exec python3 -u /src/test-qemu.py /work/waybase.img /work "$@"' sh "$@"