diff --git a/.github/workflows/arm64-iso.yml b/.github/workflows/arm64-iso.yml new file mode 100644 index 000000000..382671fd9 --- /dev/null +++ b/.github/workflows/arm64-iso.yml @@ -0,0 +1,244 @@ +name: ARM64 ISO + +# Builds the generic aarch64 ISO on a native ARM runner: Arch Linux ARM's +# kernel, the published edge packages, nothing board-specific. This is the +# image a VM can boot, so it is the one CI can go on to install and test. +# +# The push trigger exists while this lives on a branch: a manual run needs the +# workflow on the default branch first. +on: + push: + branches: [arm64-generic] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: arm64-iso-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build the generic ISO + runs-on: namespace-profile-linux-arm64-16x32 + timeout-minutes: 120 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + submodules: recursive + persist-credentials: false + + - name: Runner + run: | + uname -srm + echo "$(nproc) CPUs, $(free -h | awk 'NR==2 {print $2}') memory, $(df -h --output=avail . | tail -1 | tr -d ' ') free disk" + echo "LSMs: $(cat /sys/kernel/security/lsm 2>/dev/null || echo unreadable)" + echo "KVM: $([[ -e /dev/kvm ]] && echo present || echo absent)" + docker version --format 'docker {{.Server.Version}}' || sudo docker version --format 'docker {{.Server.Version}}' + + # The packages the offline mirror is built from: 3.3 GB that otherwise + # come down again on every run. The build prunes what it no longer needs. + # Skipped, not failed, on a runner profile without a cache volume. + - name: Package cache (Namespace) + continue-on-error: true + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: ~/.cache/omarchy + + # The Omarchy packages are built from source, as the N1x image builds + # them: the published ones carry no aarch64 pacman configuration, and + # until omarchy#14637 reaches edge their installer looks for an x64 Node + # tarball on every architecture. omarchy's arm64-generic branch is n1x + # plus one line, the installed system's mirrorlist pointed at Omarchy's + # own mirror; it goes back to n1x when that line lands there. + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + repository: omacom/omarchy + ref: arm64-generic + path: src/omarchy + fetch-depth: 0 + persist-credentials: false + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + repository: omacom/omarchy-pkgs + ref: n1x + path: src/omarchy-pkgs + persist-credentials: false + + - name: Build the ISO + run: | + start=$(date +%s) + ./bin/omarchy-iso-make --arch aarch64 --platform generic --no-boot-offer \ + --local-source src/omarchy src/omarchy-pkgs + echo "Built in $(( $(date +%s) - start )) s" | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Inspect the ISO + run: | + set -euo pipefail + command -v bsdtar >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq libarchive-tools >/dev/null; } + iso=$(ls release/*.iso) + (cd release && sha256sum "$(basename "$iso")" | tee "$(basename "$iso").sha256") + bsdtar -tvf "$iso" >listing.txt + # What the firmware starts, what GRUB loads and what the installer installs from. + for want in EFI/BOOT/BOOTAA64.EFI arch/boot/aarch64/vmlinuz-linux-aarch64 \ + arch/boot/aarch64/initramfs-linux-aarch64.img arch/aarch64/airootfs.sfs; do + grep -Eiq " $want\$" listing.txt || { echo "::error::$iso has no $want"; exit 1; } + done + { + echo "\`$(basename "$iso")\`, $(( $(stat -c %s "$iso") / 1048576 )) MiB" + echo + echo '```' + grep -E ' (arch/boot/aarch64|EFI/BOOT|arch/aarch64)/[^/]+$' listing.txt | awk '{print $5, $NF}' + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" + + # For the boot job: Namespace's own artifact store is in the same place + # as its runners. GitHub's took 3 min 20 s to hand the ISO back. + - name: Keep the ISO in Namespace's artifact store + id: nsc + continue-on-error: true + run: | + command -v nsc >/dev/null || { curl -fsSL https://get.namespace.so/cloud/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"; } + cd release + iso=$(ls ./*.iso); iso=${iso#./} + printf '%s\n' "$iso" >NAME + for f in NAME "$iso" "$iso.sha256"; do + nsc artifact upload "$f" "arm64-iso/${{ github.run_id }}/$f" --expires_in 48h + done + rm NAME + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: omarchy-arm64-generic-${{ github.run_number }} + path: | + release/*.iso + release/*.sha256 + compression-level: 0 + retention-days: 3 + if-no-files-found: error + + # The image through its own boot path: firmware, GRUB, the live kernel and + # initramfs, the live root. Apple-backed runners are the ARM ones with KVM. + boot: + name: Boot the live ISO under KVM + needs: build + runs-on: namespace-profile-arm64-linux-apple-12x32 + timeout-minutes: 40 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + persist-credentials: false + + - name: Runner + run: | + uname -srm + echo "$(nproc) CPUs, $(free -h | awk 'NR==2 {print $2}') memory, $(df -h --output=avail . | tail -1 | tr -d ' ') free disk" + grep -m2 -E 'CPU implementer|CPU part' /proc/cpuinfo || true + ls -l /dev/kvm + [[ -r /dev/kvm && -w /dev/kvm ]] || sudo chmod 666 /dev/kvm + + - name: QEMU and firmware + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq qemu-system-arm qemu-efi-aarch64 >/dev/null + qemu-system-aarch64 --version | head -1 + + - name: ISO from Namespace's artifact store + id: fast + continue-on-error: true + run: | + command -v nsc >/dev/null || { curl -fsSL https://get.namespace.so/cloud/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"; } + mkdir -p iso && cd iso + nsc artifact download "arm64-iso/${{ github.run_id }}/NAME" NAME + iso=$(cat NAME) + time nsc artifact download "arm64-iso/${{ github.run_id }}/$iso" "$iso" + nsc artifact download "arm64-iso/${{ github.run_id }}/$iso.sha256" "$iso.sha256" + rm NAME + + - name: ISO from GitHub's artifact store (fallback, slow) + if: steps.fast.outcome != 'success' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: omarchy-arm64-generic-${{ github.run_number }} + path: iso + + - name: Boot it + run: | + (cd iso && sha256sum -c ./*.sha256) + ./test/arm64-live-boot iso/*.iso evidence + echo "The live system offered a login after $(cut -d= -f2 evidence/timing) s." >>"$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: arm64-live-boot-${{ github.run_number }} + path: evidence + retention-days: 7 + if-no-files-found: warn + + # The whole path with nobody at the keyboard: the installer answers from a + # cidata drive, writes the disk, and the machine restarts into what it wrote. + install: + name: Install it unattended and boot the result + needs: build + runs-on: namespace-profile-arm64-linux-apple-12x32 + timeout-minutes: 60 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + persist-credentials: false + + - name: Runner + run: | + uname -srm + echo "$(nproc) CPUs, $(free -h | awk 'NR==2 {print $2}') memory, $(df -h --output=avail . | tail -1 | tr -d ' ') free disk" + grep -m2 -E 'CPU implementer|CPU part' /proc/cpuinfo || true + ls -l /dev/kvm + [[ -r /dev/kvm && -w /dev/kvm ]] || sudo chmod 666 /dev/kvm + + - name: QEMU and firmware + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq qemu-system-arm qemu-efi-aarch64 qemu-utils \ + mtools dosfstools jq openssl openssh-client tesseract-ocr >/dev/null + qemu-system-aarch64 --version | head -1 + + - name: ISO from Namespace's artifact store + id: fast + continue-on-error: true + run: | + command -v nsc >/dev/null || { curl -fsSL https://get.namespace.so/cloud/install.sh | sh; export PATH="$HOME/.local/bin:$PATH"; } + mkdir -p iso && cd iso + nsc artifact download "arm64-iso/${{ github.run_id }}/NAME" NAME + iso=$(cat NAME) + time nsc artifact download "arm64-iso/${{ github.run_id }}/$iso" "$iso" + nsc artifact download "arm64-iso/${{ github.run_id }}/$iso.sha256" "$iso.sha256" + rm NAME + + - name: ISO from GitHub's artifact store (fallback, slow) + if: steps.fast.outcome != 'success' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: omarchy-arm64-generic-${{ github.run_number }} + path: iso + + - name: Install, reboot, check + run: | + (cd iso && sha256sum -c ./*.sha256) + ./test/arm64-install iso/*.iso evidence + { + echo "Installed, rebooted and checked in $(cut -d= -f2 evidence/timing) s." + echo + echo '```' + cat evidence/checks + echo '```' + } >>"$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: arm64-install-${{ github.run_number }} + path: evidence + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index e13df6c39..668083bb2 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -50,7 +50,7 @@ jobs: # mirror that drops one file must not fail the tests. sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist for attempt in 1 2 3; do - pacman -Syu --noconfirm --needed python parted diffutils jq zip unzip >/dev/null && break + pacman -Syu --noconfirm --needed python parted diffutils jq zip unzip openssh patch git >/dev/null && break [ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; } sleep 15 done diff --git a/README.md b/README.md index 6ac625cec..6d825f444 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,24 @@ Despite the local folder name, the first argument is the Omarchy source checkout Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. +### Snapdragon laptops + +```bash +./bin/omarchy-iso-make --arch aarch64 --platform qualcomm --edge --local-source ../omarchy-installer ../omarchy-pkgs +``` + +The Snapdragon image is the generic aarch64 image with one addition. These laptops get no device tree from their firmware, so the live system boots a UKI that carries one for every supported laptop, and systemd-stub picks by the machine's SMBIOS data. The N1x image is described in [N1X.md](N1X.md). + +The runtime the image installs must have the Snapdragon hardware setup (`install/hardware/qualcomm/`), which lists those device trees for the installed system's own UKI. The build refuses a runtime without it. On a laptop that still has Windows, the installer copies the vendor-signed firmware off the Windows partition before the disk is written. + +### NVIDIA GB10 desktops + +```bash +./bin/omarchy-iso-make --arch aarch64 --platform gb10 --edge --local-source ../omarchy-installer ../omarchy-pkgs +``` + +The GB10 image (DGX Spark, ASUS Ascent GX10) is the generic aarch64 image with the runtime's GB10 packages (`install/omarchy-aarch64-gb10.packages`) installed offline: the NVIDIA driver, the container toolkit, `rdma-core` and NVIDIA's profilers. The build refuses a runtime without that list. + ## Testing the ISO Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. diff --git a/bin/omarchy-iso-make b/bin/omarchy-iso-make index 0342b337c..d2122f23b 100755 --- a/bin/omarchy-iso-make +++ b/bin/omarchy-iso-make @@ -24,14 +24,19 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --platform) - # Board-specific overlay on top of the generic architecture build. Only - # the NVIDIA N1x laptop is known today; it selects the linux-omarchy-n1x - # kernel and the hardware-probe recovery entry. + # What the aarch64 image is built for. n1x is the NVIDIA N1x laptop: the + # linux-omarchy-n1x kernel and the hardware-probe recovery entry. generic + # is any machine whose firmware describes its hardware (VMs, servers): + # Arch Linux ARM's linux-aarch64 kernel and nothing board-specific. qualcomm + # is the Snapdragon laptops: the generic image plus a live UKI that carries + # their device trees, which their firmware does not provide. gb10 is the + # NVIDIA GB10 desktops (DGX Spark, ASUS Ascent GX10): the generic image plus + # the runtime's GB10 packages. OMARCHY_ARM_PLATFORM="${2:-}" case "$OMARCHY_ARM_PLATFORM" in - n1x) ;; + n1x|generic|qualcomm|gb10) ;; *) - echo "Error: --platform must be n1x" >&2 + echo "Error: --platform must be n1x, generic, qualcomm or gb10" >&2 exit 1 ;; esac @@ -109,6 +114,7 @@ while [[ $# -gt 0 ]]; do echo "Unknown option: $1" echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source ]" echo " $0 --arch aarch64 --platform n1x [--dev-ssh ] --package-dir DIR --local-source [...]" + echo " $0 --arch aarch64 --platform generic|qualcomm|gb10 --edge [--local-source ] [...]" exit 1 ;; esac @@ -122,41 +128,51 @@ if [[ -n $LOCAL_OMARCHY_PATH ]]; then fi # Architecture selection. x86_64 is unchanged. aarch64 builds run in a pinned -# Arch Linux ARM container against Omarchy's edge aarch64 repository, need the -# platform package bundle, and build the omarchy* packages from --local-source -# because the platform support is not in the published ones. +# Arch Linux ARM container against Omarchy's edge aarch64 repository. An N1x +# image also needs the platform package bundle, and builds the omarchy* +# packages from --local-source because the platform support is not in the +# published ones. A generic image needs neither: it is the published packages +# on a stock kernel. CONTAINER_IMAGE=ghcr.io/archlinux/archlinux:latest CONTAINER_PLATFORM_ARGS=() OMARCHY_KERNEL=linux-t2 if [[ $OMARCHY_ARCH == aarch64 ]]; then if [[ -z $OMARCHY_ARM_PLATFORM ]]; then - echo "Error: --arch aarch64 currently requires --platform n1x" >&2 + echo "Error: --arch aarch64 requires --platform n1x, generic, qualcomm or gb10" >&2 exit 1 fi - if [[ -z $PACKAGE_DIR ]]; then - echo "Error: --arch aarch64 requires --package-dir DIR" >&2 - exit 1 - fi - if [[ -z $LOCAL_OMARCHY_PATH ]]; then - echo "Error: --arch aarch64 requires --local-source; the published omarchy packages lack the platform support" >&2 - exit 1 - fi - if [[ $OMARCHY_MIRROR != edge ]]; then - echo "Error: --arch aarch64 builds use the edge channel, the only one Omarchy publishes for aarch64; drop --rc" >&2 + if [[ $OMARCHY_ARM_PLATFORM == n1x ]]; then + if [[ -z $PACKAGE_DIR ]]; then + echo "Error: --arch aarch64 requires --package-dir DIR" >&2 + exit 1 + fi + if [[ -z $LOCAL_OMARCHY_PATH ]]; then + echo "Error: --arch aarch64 requires --local-source; the published omarchy packages lack the platform support" >&2 + exit 1 + fi + elif [[ -n $PACKAGE_DIR || -n $N1X_DEV_SSH_KEY ]]; then + echo "Error: --package-dir and --dev-ssh are only valid with --platform n1x" >&2 exit 1 fi - [[ -d $PACKAGE_DIR ]] || { echo "Error: package directory not found at $PACKAGE_DIR" >&2; exit 1; } - PACKAGE_DIR=$(realpath "$PACKAGE_DIR") - if [[ ! -f $PACKAGE_DIR/SHA256SUMS ]]; then - echo "Error: $PACKAGE_DIR must contain SHA256SUMS" >&2 + if [[ ${OMARCHY_MIRROR:-} != edge ]]; then + echo "Error: --arch aarch64 builds use the edge channel, the only one Omarchy publishes for aarch64; pass --edge and drop --rc" >&2 exit 1 fi - if ! (cd "$PACKAGE_DIR" && sha256sum --check --strict --quiet SHA256SUMS); then - echo "Error: package bundle checksum verification failed in $PACKAGE_DIR" >&2 - exit 1 + if [[ -n $PACKAGE_DIR ]]; then + [[ -d $PACKAGE_DIR ]] || { echo "Error: package directory not found at $PACKAGE_DIR" >&2; exit 1; } + PACKAGE_DIR=$(realpath "$PACKAGE_DIR") + if [[ ! -f $PACKAGE_DIR/SHA256SUMS ]]; then + echo "Error: $PACKAGE_DIR must contain SHA256SUMS" >&2 + exit 1 + fi + if ! (cd "$PACKAGE_DIR" && sha256sum --check --strict --quiet SHA256SUMS); then + echo "Error: package bundle checksum verification failed in $PACKAGE_DIR" >&2 + exit 1 + fi fi case $OMARCHY_ARM_PLATFORM in n1x) OMARCHY_KERNEL=linux-omarchy-n1x ;; + generic|qualcomm|gb10) OMARCHY_KERNEL=linux-aarch64 ;; esac if [[ -n $N1X_DEV_SSH_KEY ]]; then [[ -f $N1X_DEV_SSH_KEY ]] || { echo "Error: SSH public key not found at $N1X_DEV_SSH_KEY" >&2; exit 1; } @@ -177,12 +193,10 @@ fi lint_file_permissions() { local profiledef="configs/profiledef.sh" - local -A declared=() + local declared local missing=() - while IFS= read -r path; do - [[ -n $path ]] && declared["$path"]=1 - done < <(grep -oP '^\s*\["\K[^"]+(?="\])' "$profiledef" || true) + declared=$(sed -n 's/^[[:space:]]*\["\([^"]*\)"\].*/\1/p' "$profiledef") while IFS= read -r f; do [[ -f $f ]] || continue @@ -195,7 +209,7 @@ lint_file_permissions() { continue fi - if [[ -z ${declared[$rel]+x} ]]; then + if ! grep -Fxq -- "$rel" <<<"$declared"; then missing+=("$rel") fi done < <(find configs/airootfs/usr/local/bin configs/airootfs/root -type f 2>/dev/null) @@ -257,7 +271,7 @@ if [[ -n $LOCAL_OMARCHY_PATH ]]; then ) fi -if [[ $OMARCHY_ARCH == aarch64 ]]; then +if [[ -n $PACKAGE_DIR ]]; then DOCKER_ARGS+=(-v "$PACKAGE_DIR:/packages:ro") fi diff --git a/builder/archiso-v87-aarch64-boot-efi.patch b/builder/archiso-v87-aarch64-boot-efi.patch new file mode 100644 index 000000000..f418ceb95 --- /dev/null +++ b/builder/archiso-v87-aarch64-boot-efi.patch @@ -0,0 +1,21 @@ +--- mkarchiso ++++ mkarchiso +@@ -430,11 +430,17 @@ + + # Copy kernel and initramfs to ISO 9660 + _make_boot_on_iso9660() { +- local ucode_image ++ local ucode_image uki + _msg_info "Preparing kernel and initramfs for the ISO 9660 file system..." + install -d -m 0755 -- "${isofs_dir}/${install_dir}/boot/${arch}" + install -m 0644 -- "${pacstrap_dir}/boot/initramfs-"*".img" "${isofs_dir}/${install_dir}/boot/${arch}/" + install -m 0644 -- "${pacstrap_dir}/boot/vmlinuz-"* "${isofs_dir}/${install_dir}/boot/${arch}/" ++ # A unified kernel image built into /boot travels with the kernel it wraps, ++ # where a GRUB entry can chainload it. ++ for uki in "${pacstrap_dir}/boot/"*.efi; do ++ [[ -e "${uki}" ]] || continue ++ install -m 0644 -- "${uki}" "${isofs_dir}/${install_dir}/boot/${arch}/" ++ done + + if (( need_external_ucodes )); then + for ucode_image in "${ucodes[@]}"; do diff --git a/builder/build-iso.sh b/builder/build-iso.sh index c2505f6fc..86b32ac9d 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -7,7 +7,8 @@ OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" OMARCHY_ARCH="${OMARCHY_ARCH:-x86_64}" OMARCHY_ARM_PLATFORM="${OMARCHY_ARM_PLATFORM:-}" OMARCHY_KERNEL="${OMARCHY_KERNEL:-linux-t2}" -export OMARCHY_ARCH +# profiledef.sh reads both in the mkarchiso environment. +export OMARCHY_ARCH OMARCHY_KERNEL source /builder/node-release.sh source /builder/arm64-kernel-image.sh @@ -15,26 +16,45 @@ source /builder/archiso-aarch64-mkinitcpio.sh source /builder/grub-platform.sh # The online pacman configuration drives every package download below. On -# aarch64 it points at the platform bundle mounted at /packages, Omarchy's edge -# aarch64 repository and Arch Linux ARM. +# aarch64 it points at Omarchy's edge aarch64 repository and Arch Linux ARM, +# and for an N1x image at the platform bundle mounted at /packages first. case "$OMARCHY_ARCH" in x86_64) online_pacman_conf="/configs/pacman-online-${OMARCHY_MIRROR}.conf" ;; aarch64) online_pacman_conf=/configs/pacman-online-aarch64.conf - if [[ $OMARCHY_ARM_PLATFORM != n1x || -z $OMARCHY_KERNEL || ! -f /builder/$OMARCHY_KERNEL.preset ]]; then - echo "ERROR: aarch64 builds need OMARCHY_ARM_PLATFORM=n1x and a platform kernel with a live preset (got '$OMARCHY_ARM_PLATFORM'/'$OMARCHY_KERNEL')" >&2 - exit 1 - fi - if [[ ! -d /packages || ! -f /packages/SHA256SUMS ]]; then - echo "ERROR: aarch64 builds require the platform package bundle mounted at /packages with SHA256SUMS" >&2 - exit 1 - fi - if [[ ! -d /omarchy-source || ! -d /omarchy-pkgs ]]; then - echo "ERROR: aarch64 builds require --local-source; the platform support is not in the published omarchy packages" >&2 - exit 1 - fi + case "$OMARCHY_ARM_PLATFORM" in + n1x) + if [[ -z $OMARCHY_KERNEL || ! -f /builder/$OMARCHY_KERNEL.preset ]]; then + echo "ERROR: N1x builds need a platform kernel with a live preset (got '$OMARCHY_KERNEL')" >&2 + exit 1 + fi + if [[ ! -d /packages || ! -f /packages/SHA256SUMS ]]; then + echo "ERROR: N1x builds require the platform package bundle mounted at /packages with SHA256SUMS" >&2 + exit 1 + fi + if [[ ! -d /omarchy-source || ! -d /omarchy-pkgs ]]; then + echo "ERROR: N1x builds require --local-source; the platform support is not in the published omarchy packages" >&2 + exit 1 + fi + ;; + generic|qualcomm|gb10) + # Arch Linux ARM's kernel, the published packages, no bundle. + if [[ $OMARCHY_KERNEL != linux-aarch64 ]]; then + echo "ERROR: $OMARCHY_ARM_PLATFORM aarch64 builds boot linux-aarch64 (got '$OMARCHY_KERNEL')" >&2 + exit 1 + fi + if [[ -d /packages ]]; then + echo "ERROR: $OMARCHY_ARM_PLATFORM aarch64 builds take no platform package bundle" >&2 + exit 1 + fi + ;; + *) + echo "ERROR: aarch64 builds need OMARCHY_ARM_PLATFORM=n1x, generic, qualcomm or gb10 (got '$OMARCHY_ARM_PLATFORM')" >&2 + exit 1 + ;; + esac if [[ $OMARCHY_MIRROR != edge ]]; then echo "ERROR: aarch64 builds use the edge channel, the only one Omarchy publishes for aarch64 (got '$OMARCHY_MIRROR')" >&2 exit 1 @@ -80,24 +100,30 @@ fi # Packages installed into the Arch container used to build the ISO. pacman-key --init if [[ $OMARCHY_ARCH == aarch64 ]]; then - # The platform bundle is the [platform] repo for this build. Verify it and - # index it before pacman first consults it. - if ! (cd /packages && sha256sum --check --strict --quiet SHA256SUMS); then - echo "ERROR: package bundle checksum verification failed" >&2 - exit 1 - fi - bundle_index=/tmp/packages-index - rm -rf "$bundle_index"; mkdir -p "$bundle_index" - # repo-add writes next to the archives, but /packages is read-only. Index a - # symlink farm instead and serve the repo through it. - for archive in /packages/*.pkg.tar.*; do - [[ $archive == *.sig ]] && continue - ln -s "$archive" "$bundle_index/${archive##*/}" - done - repo-add -q "$bundle_index/platform.db.tar.gz" "$bundle_index"/*.pkg.tar.* 2>/dev/null || \ - repo-add "$bundle_index/platform.db.tar.gz" "$bundle_index"/*.pkg.tar.* online_pacman_conf=/tmp/pacman-online-aarch64.conf - sed "s|^Server = file:///packages$|Server = file://$bundle_index|" /configs/pacman-online-aarch64.conf > "$online_pacman_conf" + if [[ -d /packages ]]; then + # The platform bundle is the [platform] repo for this build. Verify it and + # index it before pacman first consults it. + if ! (cd /packages && sha256sum --check --strict --quiet SHA256SUMS); then + echo "ERROR: package bundle checksum verification failed" >&2 + exit 1 + fi + bundle_index=/tmp/packages-index + rm -rf "$bundle_index"; mkdir -p "$bundle_index" + # repo-add writes next to the archives, but /packages is read-only. Index a + # symlink farm instead and serve the repo through it. + for archive in /packages/*.pkg.tar.*; do + [[ $archive == *.sig ]] && continue + ln -s "$archive" "$bundle_index/${archive##*/}" + done + repo-add -q "$bundle_index/platform.db.tar.gz" "$bundle_index"/*.pkg.tar.* 2>/dev/null || \ + repo-add "$bundle_index/platform.db.tar.gz" "$bundle_index"/*.pkg.tar.* + sed "s|^Server = file:///packages$|Server = file://$bundle_index|" /configs/pacman-online-aarch64.conf > "$online_pacman_conf" + else + # No bundle, so no [platform] repo: pacman refuses a repo it cannot sync. + awk '/^\[platform\]$/ { skip = 1; next } /^\[/ { skip = 0 } !skip' \ + /configs/pacman-online-aarch64.conf > "$online_pacman_conf" + fi # The container image arrives pointed at Arch Linux ARM's own mirrors. Its # packages come from the same place the ISO's do: Omarchy's mirror of them. @@ -116,6 +142,11 @@ if [[ $OMARCHY_ARCH == aarch64 ]]; then mkarchiso_command=/tmp/mkarchiso-aarch64 cp /archiso/archiso/mkarchiso "$mkarchiso_command" patch --batch --forward --fuzz=0 "$mkarchiso_command" "$build_cache_dir/airootfs/root/customize_airootfs.sh" <<'CUSTOMIZE' +#!/bin/bash +set -euo pipefail +bash /root/linux-aarch64-live.sh +bash /root/qualcomm-live-uki.sh +rm -f /root/linux-aarch64-live.sh /root/qualcomm-live-uki.sh +CUSTOMIZE + # The DSP driver stays out of the live initramfs (see grub.cfg). On the one + # laptop it is tested on, this starts it once the live root is in RAM, for + # USB hotplug and the battery reading. + install -Dm0755 /builder/qualcomm/omarchy-live-dsp "$build_cache_dir/airootfs/usr/local/bin/omarchy-live-dsp" + install -Dm0644 /builder/qualcomm/omarchy-live-dsp.service "$build_cache_dir/airootfs/etc/systemd/system/omarchy-live-dsp.service" + mkdir -p "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants" + ln -sf /etc/systemd/system/omarchy-live-dsp.service "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/omarchy-live-dsp.service" + printf '%s\n' 'file_permissions["/usr/local/bin/omarchy-live-dsp"]="0:0:755"' >>"$build_cache_dir/profiledef.sh" + fi rm -rf "$build_cache_dir/syslinux" "$build_cache_dir/efiboot" else boot_splash_kernel_options="quiet splash " @@ -299,6 +363,11 @@ if [[ $OMARCHY_ARCH == aarch64 ]]; then # Plymouth stays out of the live initramfs (archiso-aarch64-mkinitcpio.sh); # openssh and pciutils serve the recovery entry and remote debugging. arch_packages=("$OMARCHY_KERNEL" archlinuxarm-keyring git gum jq openssl openssh pciutils plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted) + if [[ $OMARCHY_ARM_PLATFORM == qualcomm ]]; then + # ukify builds the live UKI, and the installer saves the laptop's signed + # firmware from Windows with qcom-firmware-extract. + arch_packages+=(systemd-ukify linux-firmware-qcom qcom-firmware-extract) + fi else arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted) fi @@ -352,6 +421,32 @@ else done fi +# A Snapdragon laptop boots only with the device trees the runtime's hardware +# setup lists for its UKI, so refuse a runtime from before that setup. +if [[ $OMARCHY_ARM_PLATFORM == qualcomm ]]; then + qualcomm_setup=install/hardware/qualcomm/dtb-uki.sh + if [[ -d /omarchy-source ]]; then + [[ -f /omarchy-source/$qualcomm_setup ]] + else + bsdtar -tf "$omarchy_pkg" "usr/share/omarchy/$qualcomm_setup" >/dev/null 2>&1 + fi || { + echo "ERROR: the Omarchy runtime for this image ships no $qualcomm_setup." >&2 + echo " A Snapdragon laptop installed from it would have no device tree to boot with." >&2 + echo " Build with --local-source against a checkout that has the Snapdragon setup." >&2 + exit 1 + } +fi + +# A GB10 image is the generic image plus the runtime's GB10 package list, so +# refuse a runtime from before that list rather than build a generic image under +# the GB10 name. +if [[ $OMARCHY_ARM_PLATFORM == gb10 && ! -f $(dirname "${base_pkg_lists[0]}")/omarchy-aarch64-gb10.packages ]]; then + echo "ERROR: the Omarchy runtime for this image ships no install/omarchy-aarch64-gb10.packages." >&2 + echo " A GB10 installed from it would get none of its platform packages." >&2 + echo " Build with --local-source against a checkout that has the GB10 platform." >&2 + exit 1 +fi + # aarch64 takes the runtime's own default set, as omarchy-pkg-defaults composes # it: the base list, then the aarch64 additions (zram-generator, rtkit) and the # platform's own (the N1x kernel and NVIDIA stack), minus the base packages with @@ -449,16 +544,20 @@ mapfile -t all_packages < <( if [[ $OMARCHY_ARCH == aarch64 ]]; then source /builder/aarch64-package-filter.sh mapfile -t all_packages < <(filter_aarch64_packages "$OMARCHY_KERNEL" "${all_packages[@]}") - # The platform kernel pair and everything else in the bundle (the ASUS - # ProArt P14's amplifier firmware, say), the NVIDIA stack and pciutils the - # runtime's platform script installs, and the Limine/Snapper stack the - # installer adds on aarch64. - mapfile -t platform_packages < <( - for archive in /packages/*.pkg.tar.*; do - [[ $archive == *.sig ]] || pacman -Qqp "$archive" - done - ) - all_packages+=("$OMARCHY_KERNEL" "$OMARCHY_KERNEL-headers" "${platform_packages[@]}" archlinuxarm-keyring nvidia-open-dkms nvidia-utils libva-nvidia-driver limine limine-mkinitcpio-hook limine-snapper-sync snapper pciutils) + # The platform kernel pair and the Limine/Snapper stack the installer adds + # on aarch64. An N1x image adds everything else in the bundle (the ASUS + # ProArt P14's amplifier firmware, say) and the NVIDIA stack the runtime's + # platform script installs. + platform_packages=() + if [[ $OMARCHY_ARM_PLATFORM == n1x ]]; then + mapfile -t platform_packages < <( + for archive in /packages/*.pkg.tar.*; do + [[ $archive == *.sig ]] || pacman -Qqp "$archive" + done + ) + platform_packages+=(nvidia-open-dkms nvidia-utils libva-nvidia-driver) + fi + all_packages+=("$OMARCHY_KERNEL" "$OMARCHY_KERNEL-headers" "${platform_packages[@]}" archlinuxarm-keyring limine limine-mkinitcpio-hook limine-snapper-sync snapper pciutils) mapfile -t all_packages < <(printf '%s\n' "${all_packages[@]}" | sort -u) fi diff --git a/builder/grub-platform.sh b/builder/grub-platform.sh index cf5a30d26..66ce14c9a 100644 --- a/builder/grub-platform.sh +++ b/builder/grub-platform.sh @@ -12,7 +12,7 @@ configure_grub_platform() { -e '/^%NON_N1X_ONLY_BEGIN%$/,/^%NON_N1X_ONLY_END%$/d' \ "$config" ;; - "") + ""|generic|qualcomm|gb10) sed -i \ -e '/^%N1X_ONLY_BEGIN%$/,/^%N1X_ONLY_END%$/d' \ -e '/^%NON_N1X_ONLY_BEGIN%$/d' \ diff --git a/builder/linux-aarch64-live.sh b/builder/linux-aarch64-live.sh new file mode 100755 index 000000000..cb98edf43 --- /dev/null +++ b/builder/linux-aarch64-live.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Runs inside the live image after pacstrap, as archiso's customize_airootfs +# step, on generic aarch64 builds. It makes Arch Linux ARM's linux-aarch64 the +# live kernel under the names mkarchiso and GRUB expect: +# +# /boot/vmlinuz-linux-aarch64 the package's /boot/Image +# /boot/initramfs-linux-aarch64.img built here with the archiso hooks +# +# This cannot be staged before pacstrap the way the N1x preset is. The package +# owns /etc/mkinitcpio.d/linux-aarch64.preset, its preset names /boot/Image, +# and its install script builds an installed-system initramfs that cannot boot +# live media. The approach is the dragon branch's customize_airootfs.sh. +set -euo pipefail + +fail() { echo "linux-aarch64-live: $*" >&2; exit 1; } + +image=/boot/Image +[[ -s $image ]] || fail "no kernel at $image; is linux-aarch64 installed?" +# GRUB's arm64 loader takes the uncompressed EFI-stub Image: ARMd at offset 56. +magic=$(dd if="$image" bs=1 skip=56 count=4 status=none | od -An -tx1 | tr -d ' \n') +[[ $magic == 41524d64 ]] || fail "$image is not an uncompressed arm64 Image" + +releases=() +for modules in /usr/lib/modules/*/modules.builtin; do + [[ -e $modules ]] && releases+=("$(basename "$(dirname "$modules")")") +done +(( ${#releases[@]} == 1 )) || fail "expected one kernel's modules, found: ${releases[*]:-none}" +release=${releases[0]} + +cp -a "$image" /boot/vmlinuz-linux-aarch64 + +# The early-input modules are listed for the N1x kernel. Keep the ones this +# kernel has, as modules or built in; mkinitcpio fails on a name it cannot find. +config=/etc/mkinitcpio.conf.d/archiso.conf +if listed=$(grep -E '^MODULES=\(' "$config"); then + listed=${listed#MODULES=\(} + listed=${listed%\)} + kept=() + for module in $listed; do + if modinfo -k "$release" "$module" >/dev/null 2>&1; then + kept+=("$module") + else + echo "linux-aarch64-live: $release has no $module; leaving it out of the live initramfs" + fi + done + sed -i "s|^MODULES=.*$|MODULES=(${kept[*]})|" "$config" +fi + +cat >/etc/mkinitcpio.d/linux-aarch64.preset <&2 + exit 1 + fi +done +if ! command -v ukify >/dev/null; then + echo "live-uki: ukify not found; is systemd-ukify among the live packages?" >&2 + exit 1 +fi +if [[ ! -d $hwids ]]; then + echo "live-uki: no SMBIOS-ID database at $hwids (systemd >= 260 ships it)" >&2 + exit 1 +fi + +# Include supported Snapdragon generations while staying below the UKI section limit. +# EL2 variants share hardware IDs with their base DTBs and would be ambiguous. +shopt -s nullglob +dtbs=() +for dtb in /boot/dtbs/qcom/x1*.dtb /boot/dtbs/qcom/hamoa*.dtb \ + /boot/dtbs/qcom/glymur*.dtb /boot/dtbs/qcom/sc8280xp*.dtb; do + [[ $dtb == *-el2.dtb ]] && continue + dtbs+=("$dtb") +done +if (( ${#dtbs[@]} == 0 )); then + echo "live-uki: no Windows-on-ARM device trees under /boot/dtbs/qcom" >&2 + exit 1 +fi +# Reserve room for the UKI's non-DTB sections. +if (( ${#dtbs[@]} > 90 )); then + echo "live-uki: ${#dtbs[@]} device trees exceed the UKI section budget; narrow the prefixes" >&2 + exit 1 +fi + +args=() +for dtb in "${dtbs[@]}"; do + args+=("--devicetree-auto=$dtb") +done + +echo "live-uki: wrapping $kernel and $initrd with ${#dtbs[@]} device trees" +ukify build \ + --linux="$kernel" \ + --initrd="$initrd" \ + --hwids="$hwids" \ + "${args[@]}" \ + --output="$uki" + +# Verify that ukify embedded every required section. +sections="$(ukify inspect "$uki")" +n_dtb="$(grep -c '^\.dtbauto:' <<<"$sections" || true)" +if ! grep -q '^\.hwids:' <<<"$sections"; then + echo "live-uki: $uki has no .hwids section" >&2 + exit 1 +fi +if (( n_dtb != ${#dtbs[@]} )); then + echo "live-uki: $uki has $n_dtb .dtbauto sections, expected ${#dtbs[@]}" >&2 + exit 1 +fi +echo "live-uki: $uki: $(stat -c %s "$uki") bytes, $n_dtb .dtbauto sections, .hwids present" diff --git a/builder/qualcomm/omarchy-live-dsp b/builder/qualcomm/omarchy-live-dsp new file mode 100755 index 000000000..c63523cf5 --- /dev/null +++ b/builder/qualcomm/omarchy-live-dsp @@ -0,0 +1,75 @@ +#!/bin/bash +# Start the DSP only after archiso has detached the live root from USB. +set -euo pipefail + +check_only=false +case "${1:-}" in + --check) check_only=true ;; + '') ;; + *) echo "Usage: omarchy-live-dsp [--check]" >&2; exit 2 ;; +esac + +skip() { + echo "omarchy-live-dsp: skipping DSP startup: $*" + if $check_only; then exit 1; else exit 0; fi +} + +[[ $(uname -m) == aarch64 ]] || skip "not ARM64" +grep -Fqw 'omarchy.live_dsp=0' /proc/cmdline && skip "disabled on the kernel command line" + +# Keep automatic startup limited to hardware tested with this live sequence. +[[ -r /proc/device-tree/compatible ]] || skip "no device tree" +tr '\0' '\n' < /proc/device-tree/compatible | grep -Fxq 'lenovo,yoga-slim7x' || + skip "hardware has not been validated" + +[[ $(findmnt -rn -M / -o SOURCE,FSTYPE) == 'airootfs overlay' ]] || + skip "not an archiso overlay root" +[[ $(findmnt -rn -M /run/archiso/copytoram -o FSTYPE) == tmpfs ]] || + skip "no RAM copy of the live image" +[[ $(findmnt -rn -M /run/archiso/cowspace -o FSTYPE) == tmpfs ]] || + skip "the writable layer is not in RAM" + +lowerdir='' upperdir='' workdir='' +IFS=',' read -ra options <<< "$(findmnt -rn -M / -o FS-OPTIONS)" +for option in "${options[@]}"; do + case "$option" in + lowerdir=*) lowerdir=${option#*=} ;; + upperdir=*) upperdir=${option#*=} ;; + workdir=*) workdir=${option#*=} ;; + esac +done +[[ $lowerdir == /run/archiso/airootfs ]] || skip "unexpected overlay lower layer" +for directory in "$upperdir" "$workdir"; do + directory=$(realpath -e -- "$directory") || skip "missing overlay writable directory" + [[ $directory == /run/archiso/cowspace/* && + $(findmnt -rn -T "$directory" -o TARGET,FSTYPE) == '/run/archiso/cowspace tmpfs' ]] || + skip "overlay writable directory is not in RAM" +done + +[[ $(findmnt -rn -M "$lowerdir" -o FSTYPE) == squashfs ]] || + skip "unexpected live image filesystem" +loop_device=$(findmnt -rn -M "$lowerdir" -o SOURCE) || skip "missing live image mount" +[[ $loop_device =~ ^/dev/loop[0-9]+$ ]] || skip "live image is not a loop device" +backing=$(cat "/sys/class/block/${loop_device##*/}/loop/backing_file") || skip "missing loop backing file" +backing=$(realpath -e -- "$backing") || skip "unavailable loop backing file" +[[ $backing == /run/archiso/copytoram/* && + $(findmnt -rn -T "$backing" -o TARGET,FSTYPE) == '/run/archiso/copytoram tmpfs' ]] || + skip "live image is still backed by storage" + +for directory in /run/archiso/bootmnt /run/archiso/img_dev; do + if mountpoint -q "$directory"; then + skip "installation media is still mounted at $directory" + fi +done + +# Also refuse a manual retry after other disks or swap have been mounted. +sources=$(findmnt -ern -o SOURCE) || skip "cannot inspect mounted devices" +while IFS= read -r source; do + [[ $source != /dev/* || $source == "$loop_device" ]] || skip "another block device is mounted" +done <<< "$sources" +[[ $(wc -l < /proc/swaps) -eq 1 ]] || skip "swap is active" + +echo "omarchy-live-dsp: live root and writable layer verified in RAM" +$check_only && exit 0 +[[ ! -d /sys/module/qcom_q6v5_pas ]] || skip "driver already loaded" +exec modprobe -v qcom_q6v5_pas diff --git a/builder/qualcomm/omarchy-live-dsp.service b/builder/qualcomm/omarchy-live-dsp.service new file mode 100644 index 000000000..106e8e71f --- /dev/null +++ b/builder/qualcomm/omarchy-live-dsp.service @@ -0,0 +1,17 @@ +[Unit] +Description=Start Qualcomm DSP after the live root is in RAM +After=local-fs.target systemd-udev-trigger.service +Before=getty@tty1.service +ConditionArchitecture=arm64 +ConditionPathExists=/run/archiso/copytoram + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/omarchy-live-dsp +TimeoutStartSec=30s +RemainAfterExit=yes +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 1ae2c2c2c..a28018836 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -387,6 +387,35 @@ detect_bitlocker() { return 1 } +# Save vendor-signed Qualcomm firmware before a full-disk install removes Windows. +# Missing firmware does not abort installation. +stage_qualcomm_firmware() { + command -v qcom-firmware-extract >/dev/null 2>&1 || return 0 + [[ -d /sys/firmware/devicetree/base ]] || return 0 + + local stage=/run/omarchy/firmware + local missing saved + missing=$(qcom-firmware-extract --list-missing 2>/dev/null | grep -c . || true) + ((missing > 0)) || return 0 + + step "Saving Qualcomm firmware from Windows" + qcom-firmware-extract --stage "$stage" >/run/omarchy/firmware-stage.log 2>&1 || true + saved=$(grep -c . "$stage/manifest" 2>/dev/null || true) + if ((saved >= missing)); then + say "Saved $saved firmware files from Windows for the install." + elif ((saved > 0)); then + say "Saved $saved of $missing firmware files from Windows; the rest were not in the driver store." + else + say --foreground 1 "No Windows firmware found on this machine." + if grep -q BitLocker /run/omarchy/firmware-stage.log 2>/dev/null; then + say "The Windows partition is BitLocker-encrypted. Turn BitLocker off in Windows to let the firmware be read." + fi + say "Installation can continue, but GPU acceleration, audio and battery reporting may need" + say "\`sudo qcom-firmware-extract\` afterwards with a Windows driver store of this laptop model." + fi + echo +} + # Look for an existing Windows ESP (FAT partition containing EFI/Microsoft) on $disk. detect_windows_esp() { local p tmp_mp @@ -1099,12 +1128,14 @@ if $defer_provisioning; then # Jump straight to disk selection + overwrite confirmation (encrypted by # default, Ctrl+C toggles unencrypted). deferred provisioning is always a full-disk install. disk_form + stage_qualcomm_firmware until confirm_disk_overwrite; do disk_form done install_target="full_disk" else disk_form + stage_qualcomm_firmware select_installation fi diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 7b59fb5d0..3e9a7e418 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -191,7 +191,29 @@ def _early_packages() -> list[str]: # imports it, so no patching happens here. # ───────────────────────────────────────────────────────────────────────────── +# Stage vendor-signed Qualcomm firmware before the Windows partition is removed. +LIVE_FIRMWARE_STAGE = Path("/run/omarchy/firmware") +TARGET_FIRMWARE_STAGE = Path("var/lib/omarchy/firmware-stage") + + +def _stage_qualcomm_firmware() -> None: + tool = shutil.which("qcom-firmware-extract") + if not tool: + return + info("› saving Qualcomm firmware from Windows before the disk is written") + # Firmware extraction is idempotent and optional. + subprocess.run([tool, "--stage", str(LIVE_FIRMWARE_STAGE)], check=False) + + +def _copy_firmware_stage_into_target(ctx: InstallContext) -> None: + if not (LIVE_FIRMWARE_STAGE / "manifest").is_file(): + return + dst = ctx.target / TARGET_FIRMWARE_STAGE + shutil.copytree(LIVE_FIRMWARE_STAGE, dst, dirs_exist_ok=True) + + def prepare_live(ctx: InstallContext) -> None: + _stage_qualcomm_firmware() if ctx.is_protected: info("› protected mode: skipping whole-disk cleanup") else: @@ -1079,6 +1101,8 @@ def _prepare_target_setup(ctx: InstallContext) -> None: ctx.state["bind_mounts"].append(str(target_dst)) mounted.add(str(target_dst)) + _copy_firmware_stage_into_target(ctx) + ctx.state["target_setup_prepared"] = True @@ -1328,6 +1352,58 @@ def _stage_provisioning_luks_unlock(ctx: InstallContext, provisioning_dir) -> No files_dropin.write_text("FILES+=(/etc/omarchy/provisioning.key)\n") +INITRAMFS_MODULES_CONF = "zz-omarchy-kernel-modules.conf" + + +def _drop_initramfs_modules_the_kernel_lacks(ctx: InstallContext) -> None: + """Keep mkinitcpio from failing on a module the installed kernel was built + without. + + The Omarchy settings name modules for the kernels Omarchy ships, thunderbolt + among them. mkinitcpio reports a name it cannot find as an error, and + limine-mkinitcpio-hook then builds the unified kernel image but does not + install it: Arch Linux ARM's linux-aarch64 has no thunderbolt module, and + the machine was left with no kernel to boot. A drop-in that sorts last takes + the names this kernel lacks back out of MODULES. + """ + conf_dir = ctx.target / "etc" / "mkinitcpio.conf.d" + drop_in = conf_dir / INITRAMFS_MODULES_CONF + listed: list[str] = [] + for conf in sorted(conf_dir.glob("*.conf")) if conf_dir.is_dir() else []: + if conf.name == INITRAMFS_MODULES_CONF: + continue + for match in re.finditer(r"^\s*MODULES\+?=\(([^)]*)\)", conf.read_text(), re.M): + listed += [name.strip("\"'") for name in match.group(1).split()] + + missing: list[str] = [] + for directory, _ in _kernel_directories(ctx): + for module in listed: + if module in missing or not re.fullmatch(r"[A-Za-z0-9_-]+", module): + continue + found = subprocess.run( + ["modinfo", "--basedir", str(ctx.target), "-k", directory.name, module], + capture_output=True, + ) + if found.returncode != 0: + missing.append(module) + + if not missing: + drop_in.unlink(missing_ok=True) + return + info(f"› leaving out of the initramfs, not in this kernel: {' '.join(missing)}") + drop_in.write_text( + "# Written by the Omarchy installer. The installed kernel has no such\n" + "# modules, and mkinitcpio fails on a module it cannot find.\n" + f"_omarchy_absent=({' '.join(missing)})\n" + "_omarchy_kept=()\n" + 'for _omarchy_module in "${MODULES[@]}"; do\n' + ' [[ " ${_omarchy_absent[*]} " == *" $_omarchy_module "* ]] || _omarchy_kept+=("$_omarchy_module")\n' + "done\n" + 'MODULES=("${_omarchy_kept[@]}")\n' + "unset _omarchy_absent _omarchy_kept _omarchy_module\n" + ) + + def finalize_limine_boot(ctx: InstallContext) -> None: """Finalize Limine after target system setup has written all dynamic boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings). @@ -1363,6 +1439,7 @@ def finalize_limine_boot(ctx: InstallContext) -> None: if not limine_conf.exists(): raise RuntimeError(f"{limine_conf} missing") + _drop_initramfs_modules_the_kernel_lacks(ctx) subprocess.run(["arch-chroot", str(ctx.target), "limine-update"], check=True) subprocess.run( @@ -1749,14 +1826,17 @@ def validate_boot(ctx: InstallContext) -> None: if not limine_binary.exists() or limine_binary.stat().st_size == 0: raise RuntimeError(f"{limine_binary} missing or empty") - # Hardware packages (omarchy-hw-intel-ptl, …) can swap the kernel out - # from under us mid-install, so trust what's on disk over what we asked - # for and only fall back to the configured name when nothing's there. - uki_dir = esp_mount / "EFI" / "Linux" - candidates = _installed_kernels(ctx) or [kernel] - ukis = [uki_dir / f"{uki_prefix}_{name}.efi" for name in candidates] - if not any(uki.exists() and uki.stat().st_size for uki in ukis): - raise RuntimeError(f"{' / '.join(str(uki) for uki in ukis)} missing or empty") + if _limine_setting(config_text, "ENABLE_UKI", "yes") == "no": + _validate_linux_boot_entry(esp_mount, limine_conf_text, ctx.encrypt) + else: + # Hardware packages (omarchy-hw-intel-ptl, …) can swap the kernel out + # from under us mid-install, so trust what's on disk over what we asked + # for and only fall back to the configured name when nothing's there. + uki_dir = esp_mount / "EFI" / "Linux" + candidates = _installed_kernels(ctx) or [kernel] + ukis = [uki_dir / f"{uki_prefix}_{name}.efi" for name in candidates] + if not any(uki.exists() and uki.stat().st_size for uki in ukis): + raise RuntimeError(f"{' / '.join(str(uki) for uki in ukis)} missing or empty") post = _read_efibootmgr() if not _find_label_entries(post["entries"], "Limine"): @@ -1883,6 +1963,45 @@ def _validate_platform_boot_entries(limine_conf_text: str, arm_platform: str, ke raise RuntimeError(f"{rescue_name} entry does not boot to a text login; its own cmdline was overridden") +def _validate_linux_boot_entry(esp_mount: Path, config_text: str, encrypted: bool) -> None: + """Check the kernel and initramfs that limine-entry-tool copied to the ESP.""" + # limine-entry-tool writes an indented // entry per kernel under + # /+Omarchy; deeper entries belong to snapshots. + entries: list[list[str]] = [] + in_omarchy = False + current: list[str] | None = None + for line in config_text.splitlines(): + header = re.match(r"\s*(/+)\+?(.*)$", line) + if header: + depth = len(header.group(1)) + if depth == 1: + in_omarchy = header.group(2).startswith("Omarchy") + current = [] if in_omarchy and depth == 2 else None + if current is not None: + entries.append(current) + elif current is not None: + current.append(line) + + for entry in map("\n".join, entries): + if not re.search(r"(?m)^\s*protocol:\s*linux\s*$", entry): + continue + cmdline = re.search(r"(?m)^\s*cmdline:\s*(.*)$", entry) + if not cmdline or not re.search(r"(?:^|\s)root=\S+", cmdline.group(1)): + continue + if encrypted and not re.search(r"(?:^|\s)cryptdevice=\S+", cmdline.group(1)): + continue + kernel = re.findall(r"(?m)^\s*path:\s*(\S+)\s*$", entry) + modules = re.findall(r"(?m)^\s*module_path:\s*(\S+)\s*$", entry) + if not kernel or not modules: + continue + # limine-entry-tool writes boot():/#; resources elsewhere + # don't resolve on the ESP and so fail the check. + files = [esp_mount / r.removeprefix("boot():").split("#", 1)[0].lstrip("/") for r in kernel + modules] + if all(path.is_file() and path.stat().st_size for path in files): + return + raise RuntimeError(f"{esp_mount / 'limine.conf'} has no bootable Omarchy Linux entry with a kernel and initramfs") + + def _validate_provisioning_state(ctx: InstallContext) -> None: """An deferred-provisioning install that boots without a working first-boot setup is a user-less brick; insist the armed state is complete before reboot.""" @@ -1932,26 +2051,44 @@ def _assert_boot_hooks_restored(ctx: InstallContext) -> None: raise RuntimeError(f"{path} is missing — future kernel updates would ship no UKI") +# Arch's kernel packages leave their pkgbase next to their modules, which is +# also the name limine-mkinitcpio-hook builds the UKI under. Arch Linux ARM's +# linux-aarch64 leaves none, so a kernel's module directory without one is +# named by the kernel this image was built to install. modules.builtin marks +# a kernel's own directory: a leftover of out-of-tree modules has none. +def _kernel_directories(ctx: InstallContext) -> list[tuple[Path, str]]: + found = [] + modules = ctx.target / "usr" / "lib" / "modules" + for directory in sorted(modules.iterdir()) if modules.is_dir() else []: + pkgbase = directory / "pkgbase" + if pkgbase.is_file(): + name = pkgbase.read_text().strip() + elif (directory / "modules.builtin").is_file(): + name = iso_kernel() + else: + continue + if name: + found.append((directory, name)) + return found + + def _validate_kernel_headers(ctx: InstallContext) -> None: - kernels = sorted((ctx.target / "usr/lib/modules").glob("*/pkgbase")) + kernels = _kernel_directories(ctx) if not kernels: raise RuntimeError("no installed kernel found in target") - for pkgbase in kernels: - release = pkgbase.parent.name - header_release = pkgbase.parent / "build/include/config/kernel.release" + for directory, name in kernels: + release = directory.name + header_release = directory / "build/include/config/kernel.release" if not header_release.is_file(): - raise RuntimeError(f"{pkgbase.read_text().strip()} ({release}) has no kernel headers") + raise RuntimeError(f"{name} ({release}) has no kernel headers") if header_release.read_text().strip() != release: - raise RuntimeError(f"{pkgbase.read_text().strip()} headers do not match kernel {release}") + raise RuntimeError(f"{name} headers do not match kernel {release}") -# Every kernel package leaves its pkgbase next to its modules, which is also -# the name limine-mkinitcpio-hook builds the UKI under. def _installed_kernels(ctx: InstallContext) -> list[str]: names = [] - for pkgbase in sorted((ctx.target / "usr" / "lib" / "modules").glob("*/pkgbase")): - name = pkgbase.read_text().strip() - if name and name not in names: + for _, name in _kernel_directories(ctx): + if name not in names: names.append(name) return names diff --git a/configs/grub/grub.cfg b/configs/grub/grub.cfg index 43bd9c163..e111d0d80 100644 --- a/configs/grub/grub.cfg +++ b/configs/grub/grub.cfg @@ -52,6 +52,17 @@ timeout_style=hidden # Menu entries +# A Snapdragon image carries a UKI with the laptops' device trees, which their +# firmware does not provide; chainload it where it exists. The DSP driver stays +# off in the initramfs, where loading it can reset the USB-C port the install +# media is on. +if [ "${grub_cpu}" == 'arm64' -a -f '/%INSTALL_DIR%/boot/%ARCH%/omarchy-live.efi' ]; then + default=archlinux-uki + menuentry "Omarchy (%ARCH%, ${archiso_platform}, device tree)" --class arch --class gnu-linux --class gnu --class os --id 'archlinux-uki' { + chainloader /%INSTALL_DIR%/boot/%ARCH%/omarchy-live.efi archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash initramfs_async=0 clk_ignore_unused pd_ignore_unused arm64.nopauth systemd.tpm2_wait=0 modprobe.blacklist=qcom_q6v5_pas + } +fi + menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' { set gfxpayload=keep linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-%KERNEL% archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% %BOOT_SPLASH_KERNEL_OPTIONS%%KERNEL_OPTIONS% diff --git a/configs/profiledef.sh b/configs/profiledef.sh index 4c7f22058..d787a8d68 100644 --- a/configs/profiledef.sh +++ b/configs/profiledef.sh @@ -34,6 +34,18 @@ airootfs_image_tool_options=( '-b' '1M' '-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)' ) +# Arch Linux ARM builds linux-aarch64's squashfs without zstd: the live root +# fails to mount with 'Filesystem uses "zstd" compression. This is not +# supported'. Of what that kernel reads, lz4 keeps the reason for zstd above, +# fast decompression on the page-fault path, at the cost of a larger image. +if [[ ${OMARCHY_KERNEL:-} == linux-aarch64 ]]; then + airootfs_image_tool_options=( + '-comp' 'lz4' + '-Xhc' + '-b' '1M' + '-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)' + ) +fi bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19') file_permissions=( ["/etc/shadow"]="0:0:400" diff --git a/test/arm64-install b/test/arm64-install new file mode 100755 index 000000000..6a1e96228 --- /dev/null +++ b/test/arm64-install @@ -0,0 +1,188 @@ +#!/bin/bash +# +# Installs an aarch64 Omarchy ISO unattended in a VM, reboots into what it +# installed and checks the result over SSH: the whole path a machine takes, +# with nobody at the keyboard. +# +# test/arm64-install +# +# The answers are the ones the x86_64 integration harness gives its installs +# (build_cidata in test/integration.d/base-test.sh), changed where the +# architecture shows: Limine's binary, the kernel, the mirror. +# +# Needs an arm64 host with a usable /dev/kvm, qemu-system-aarch64, the AAVMF +# firmware, mtools, dosfstools, jq, openssl and tesseract. evidence_dir gets +# the serial log, screenshots, the installer's log and the checks' output. +# +# Env: TIMEOUT (seconds for install and first boot, default 1500), SMP (8), +# MEM (12G), MACHINE (see test/arm64-live-boot), KERNEL (linux-aarch64), +# MIRROR (where the installed system gets Arch Linux ARM's packages: +# Omarchy's own mirror of them). +set -euo pipefail +[[ $# -eq 2 ]] || { sed -n '2,20p' "$0" >&2; exit 2; } +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +iso=$(realpath "$1") +out=$(realpath -m "$2") +mkdir -p "$out/shots" + +[[ $(uname -m) == aarch64 ]] || { echo "arm64-install: this needs an arm64 host" >&2; exit 1; } +[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "arm64-install: /dev/kvm is not usable" >&2; exit 1; } +for firmware in /usr/share/AAVMF/AAVMF_CODE.fd:/usr/share/AAVMF/AAVMF_VARS.fd \ + /usr/share/edk2/aarch64/QEMU_CODE.fd:/usr/share/edk2/aarch64/QEMU_VARS.fd; do + [[ -f ${firmware%%:*} && -f ${firmware##*:} ]] && { code=${firmware%%:*}; vars=${firmware##*:}; break; } +done +: "${code:?no aarch64 UEFI firmware found}" + +state=$(mktemp -d /var/tmp/arm64-install.XXXXXX) +cleanup() { + local status=$? pid + if [[ -f $state/qemu.pid ]]; then + pid=$(cat "$state/qemu.pid") + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done + fi + rm -rf "$state" + exit "$status" +} +trap cleanup EXIT + +# --- answers --------------------------------------------------------------- +GUEST_USER=omarchy GUEST_PASSWORD=omarchy GUEST_HOSTNAME=omarchy-arm64 +BASE_DIR=$state SSH_KEY=$state/id_ed25519 CIDATA_IMG=$state/cidata.img +RUNTIME_PACKAGE=omarchy-dev SETTINGS_PACKAGE=omarchy-settings-dev +ssh-keygen -q -t ed25519 -N '' -C arm64-install -f "$SSH_KEY" +# The harness's own function, so the two cannot drift apart. It ends by +# writing the image; the architecture's answers are changed and it is redone. +eval "$(sed -n '/^build_cidata() {$/,/^ mcopy -i "\$CIDATA_IMG"/p' "$ROOT/test/integration.d/base-test.sh"; echo '}')" +build_cidata +answers=$state/cidata/user_configuration.json +jq --arg kernel "${KERNEL:-linux-aarch64}" --arg mirror "${MIRROR:-https://arm-mirror.omarchy.org/\$arch/\$repo}" ' + .omarchy_install.boot.efi_binary = "limine_aa64.efi" + | .omarchy_install.storage.kernel = $kernel + | .kernels = [$kernel] + | .mirror_config.custom_servers = [{url: $mirror}]' "$answers" >"$answers.new" +mv "$answers.new" "$answers" +# The live ISO runs cloud-init, and a CIDATA volume is where it looks. This +# gives the test a way in to the live system, to see why an install stopped. +printf 'instance-id: arm64-install\nlocal-hostname: archiso\n' >"$state/cidata/meta-data" +printf '#cloud-config\nusers:\n - name: root\n ssh_authorized_keys:\n - %s\n' "$(cat "$SSH_KEY.pub")" >"$state/cidata/user-data" +rm -f "$CIDATA_IMG" +truncate -s 4M "$CIDATA_IMG" +mkfs.vfat -n CIDATA "$CIDATA_IMG" >/dev/null +mcopy -i "$CIDATA_IMG" "$state"/cidata/* ::/ +cp "$answers" "$out/user_configuration.json" + +# --- machine --------------------------------------------------------------- +port=$(( 20000 + RANDOM % 10000 )) +qemu-img create -q -f qcow2 "$state/target.qcow2" 40G +cp "$vars" "$state/vars.fd" +: >"$out/serial.log" +# The empty disk is first in the boot order: the firmware falls through to the +# ISO now and finds Limine there after the install. +qemu-system-aarch64 -machine "${MACHINE:-virt,gic-version=3,its=off,acpi=off}" -accel kvm -cpu host \ + -smp "${SMP:-8}" -m "${MEM:-12G}" \ + -drive "if=pflash,format=raw,readonly=on,file=$code" -drive "if=pflash,format=raw,file=$state/vars.fd" \ + -drive "file=$state/target.qcow2,format=qcow2,if=none,id=target" -device virtio-blk-pci,drive=target,bootindex=0 \ + -device virtio-scsi-pci -drive "file=$iso,format=raw,if=none,id=iso,media=cdrom,readonly=on" \ + -device scsi-cd,drive=iso,bootindex=1 \ + -device qemu-xhci -device usb-kbd -device usb-tablet \ + -drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" -device usb-storage,drive=cidata \ + -device virtio-gpu-pci -device virtio-rng-pci \ + -netdev "user,id=net,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=net \ + -serial "file:$out/serial.log" -display none \ + -qmp "unix:$state/qmp.sock,server,nowait" -pidfile "$state/qemu.pid" -daemonize + +qmp() { # one QMP command, as JSON + python3 - "$state/qmp.sock" "$1" <<'PY' +import json, socket, sys +s = socket.socket(socket.AF_UNIX); s.settimeout(20); s.connect(sys.argv[1]); f = s.makefile("rw") +f.readline() +for command in ({"execute": "qmp_capabilities"}, json.loads(sys.argv[2])): + f.write(json.dumps(command) + "\n"); f.flush() + while "return" not in (reply := json.loads(f.readline())): + if "error" in reply: sys.exit(reply["error"]["desc"]) +PY +} +screen() { # file -> the text on the screen + qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$1\",\"format\":\"png\"}}" 2>/dev/null || return 0 + tesseract "$1" - 2>/dev/null | grep -v '^\s*$' || true +} +login() { # user, then a command: the live system as root, the installed one as the user + ssh -n -i "$SSH_KEY" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 -o LogLevel=ERROR "$1@127.0.0.1" "${@:2}" +} +guest() { login "$GUEST_USER" "$@"; } +live() { login root "$@"; } +# Why an install stopped: the installer's own log, kept and shown. +installer_log() { + live 'cat /var/log/omarchy-install.log' 2>/dev/null | sed -E 's/\x1b\[[0-9;?]*[A-Za-z]//g' >"$out/omarchy-install.log" || true + if [[ -s $out/omarchy-install.log ]]; then + echo "arm64-install: the installer's log ends:" >&2 + grep -av '^\s*$' "$out/omarchy-install.log" | tail -n 25 | cut -c1-200 >&2 + else + echo "arm64-install: the live system gave no installer log" >&2 + fi +} + +# --- install and first boot ------------------------------------------------ +# The live ISO runs sshd too, but only the installed system has this user. +start=$(date +%s) timeout=${TIMEOUT:-1500} tick=0 +until guest true 2>/dev/null; do + elapsed=$(( $(date +%s) - start )) + if ! kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null; then + echo "arm64-install: qemu exited after $elapsed s" >&2; exit 1 + fi + if (( elapsed > timeout )); then + screen "$out/shots/timeout.png" | tail -n 12 >&2 + installer_log + echo "arm64-install: no installed system on SSH after $timeout s" >&2; exit 1 + fi + # The installer says so in its log when it stops, and it leaves the live + # system up. Without this the wait above runs to its timeout on a dead install. + if halted=$(live 'grep -a -m1 -E "Installation halted|installer child exited with status [1-9]" /var/log/omarchy-install.log' 2>/dev/null); then + screen "$out/shots/halted.png" >/dev/null + installer_log + echo "arm64-install: the installer stopped after $elapsed s: $halted" >&2; exit 1 + fi + if (( ++tick % 2 == 0 )); then + text=$(screen "$out/shots/$(printf '%04d' "$elapsed").png") + echo " $elapsed s: $(tail -n 2 <<<"$text" | tr '\n' ' ' | cut -c1-120)" + if grep -qi 'Reboot Now' <<<"$text"; then + echo " $elapsed s: the installer is done; rebooting" + qmp '{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}' + fi + fi + sleep 10 +done +echo "installed_system_seconds=$(( $(date +%s) - start ))" | tee "$out/timing" + +# --- checks ---------------------------------------------------------------- +failures=0 +check() { # description, then a command run in the guest that must succeed + local what=$1; shift + if guest "$@" >/dev/null 2>&1; then echo "ok - $what"; else echo "not ok - $what"; failures=$((failures + 1)); fi +} +{ + check "the system reaches running or degraded" \ + 's=$(timeout 180 systemctl is-system-running --wait); [ "$s" = running ] || [ "$s" = degraded ]' + check "no unit failed" '[ -z "$(systemctl --failed --no-legend)" ]' + check "the desktop is up: Hyprland is running" 'for i in $(seq 1 60); do pgrep -x Hyprland && exit 0; sleep 1; done; exit 1' + check "it runs the kernel it was told to install" \ + "pacman -Qo /usr/lib/modules/\$(uname -r) | grep -q ' ${KERNEL:-linux-aarch64} '" + check "the user can use sudo" "echo $GUEST_PASSWORD | sudo -S -p '' true" + check "it booted through Limine's unified kernel image" \ + "echo $GUEST_PASSWORD | sudo -S -p '' sh -c 'ls /boot/EFI/Linux/omarchy_*.efi'" + check "the Omarchy runtime is installed" "pacman -Q $RUNTIME_PACKAGE" + check "Node came from the ISO, for this architecture" \ + 'node=$(ls ~/.local/share/mise/installs/node/*/bin/node | head -n 1); file -bL "$node" | grep -q aarch64' + check "packages come from Omarchy's mirror and nowhere else" \ + "grep -q '^Server.*omarchy\.org' /etc/pacman.d/mirrorlist && ! grep '^Server' /etc/pacman.d/mirrorlist | grep -vq 'omarchy\.org'" +} | tee "$out/checks" +failures=$(grep -c '^not ok' "$out/checks" || true) +guest 'uname -r; cat /proc/cmdline; systemctl --failed --no-legend; grep -E "^Server" /etc/pacman.d/mirrorlist' >"$out/facts" 2>&1 || true +ssh -i "$SSH_KEY" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$GUEST_USER@127.0.0.1" "sudo -S -p '' cat /var/log/omarchy-install.log" \ + <<<"$GUEST_PASSWORD" >"$out/omarchy-install.log" 2>/dev/null || true +screen "$out/shots/installed.png" >/dev/null +(( failures == 0 )) || { echo "arm64-install: $failures checks failed" >&2; exit 1; } +echo "arm64-install: installed, rebooted and checked in $(cut -d= -f2 "$out/timing") s" diff --git a/test/arm64-live-boot b/test/arm64-live-boot new file mode 100755 index 000000000..45a4a04f8 --- /dev/null +++ b/test/arm64-live-boot @@ -0,0 +1,89 @@ +#!/bin/bash +# +# Boots an aarch64 Omarchy ISO the way a machine would: UEFI firmware, GRUB +# from the ISO, the live kernel and initramfs, the live root. It passes when +# the live system offers a login on its serial port. +# +# test/arm64-live-boot +# +# Needs an arm64 host with a usable /dev/kvm, qemu-system-aarch64 and the +# AAVMF/edk2 firmware. evidence_dir gets serial.log, screen.png and timing. +# +# Env: TIMEOUT (seconds to wait for the login prompt, default 600), SMP (4), +# MEM (8G), MACHINE. The default machine is the one that works under +# nested KVM on Namespace's Apple-backed runners: with the interrupt +# translation service on, MSIs are lost and virtio disks hang. +set -euo pipefail +[[ $# -eq 2 ]] || { sed -n '2,16p' "$0" >&2; exit 2; } +iso=$(realpath "$1") +out=$(realpath -m "$2") +mkdir -p "$out" + +[[ $(uname -m) == aarch64 ]] || { echo "arm64-live-boot: this needs an arm64 host" >&2; exit 1; } +[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "arm64-live-boot: /dev/kvm is not usable" >&2; exit 1; } +for firmware in /usr/share/AAVMF/AAVMF_CODE.fd:/usr/share/AAVMF/AAVMF_VARS.fd \ + /usr/share/edk2/aarch64/QEMU_CODE.fd:/usr/share/edk2/aarch64/QEMU_VARS.fd; do + [[ -f ${firmware%%:*} && -f ${firmware##*:} ]] && { code=${firmware%%:*}; vars=${firmware##*:}; break; } +done +: "${code:?no aarch64 UEFI firmware found}" + +state=$(mktemp -d /var/tmp/arm64-live.XXXXXX) +cleanup() { + local status=$? pid + if [[ -f $state/qemu.pid ]]; then + pid=$(cat "$state/qemu.pid") + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done + fi + rm -rf "$state" + exit "$status" +} +trap cleanup EXIT +cp "$vars" "$state/vars.fd" +: >"$out/serial.log" + +screenshot() { + python3 - "$state/qmp.sock" "$out/screen.png" <<'PY' || echo "arm64-live-boot: no screenshot" >&2 +import json, socket, sys +s = socket.socket(socket.AF_UNIX); s.settimeout(20); s.connect(sys.argv[1]); f = s.makefile("rw") +f.readline() +for command in ({"execute": "qmp_capabilities"}, + {"execute": "screendump", "arguments": {"filename": sys.argv[2], "format": "png"}}): + f.write(json.dumps(command) + "\n"); f.flush() + while "return" not in (reply := json.loads(f.readline())): + if "error" in reply: sys.exit(reply["error"]["desc"]) +PY +} + +start=$(date +%s) +qemu-system-aarch64 -machine "${MACHINE:-virt,gic-version=3,its=off,acpi=off}" -accel kvm -cpu host \ + -smp "${SMP:-4}" -m "${MEM:-8G}" \ + -drive "if=pflash,format=raw,readonly=on,file=$code" -drive "if=pflash,format=raw,file=$state/vars.fd" \ + -device virtio-scsi-pci -drive "file=$iso,format=raw,if=none,id=iso,media=cdrom,readonly=on" \ + -device scsi-cd,drive=iso,bootindex=0 \ + -device virtio-gpu-pci -device qemu-xhci -device usb-kbd -device usb-tablet \ + -netdev user,id=net -device virtio-net-pci,netdev=net -device virtio-rng-pci \ + -serial "file:$out/serial.log" -display none \ + -qmp "unix:$state/qmp.sock,server,nowait" -pidfile "$state/qemu.pid" -daemonize + +timeout=${TIMEOUT:-600} +until grep -aq 'login:' "$out/serial.log"; do + elapsed=$(( $(date +%s) - start )) + if ! kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null; then + echo "arm64-live-boot: qemu exited after $elapsed s without a login prompt" >&2 + exit 1 + fi + if (( elapsed > timeout )); then + screenshot + echo "arm64-live-boot: no login prompt on the serial port after $timeout s; the last lines it wrote:" >&2 + tail -n 30 "$out/serial.log" | tr -d '\r' >&2 + exit 1 + fi + sleep 1 +done +elapsed=$(( $(date +%s) - start )) +# Give the installer a moment to draw before the picture is taken. +sleep 15 +screenshot +echo "login_prompt_seconds=$elapsed" | tee "$out/timing" +echo "arm64-live-boot: the live system offered a login after $elapsed s" diff --git a/test/unit/aarch64-build-test.sh b/test/unit/aarch64-build-test.sh index 83c0b3a19..cd1c8903d 100755 --- a/test/unit/aarch64-build-test.sh +++ b/test/unit/aarch64-build-test.sh @@ -57,7 +57,7 @@ for cfg in grub.cfg loopback.cfg; do grep -Fq -- "--id 'n1x-recovery'" "$src" || fail "$cfg lacks the N1x recovery entry" grep -Fq 'omarchy.n1x_recovery=1 acpi=nospcr' "$src" || fail "$cfg recovery entry lacks acpi=nospcr" source "$ROOT/builder/grub-platform.sh" - for platform in "" n1x; do + for platform in "" n1x generic qualcomm gb10; do cp "$src" "$fixture/$platform-$cfg" configure_grub_platform "$fixture/$platform-$cfg" "$platform" sed -i -e 's|%KERNEL%|linux-omarchy-n1x|g' -e 's|%BOOT_SPLASH_KERNEL_OPTIONS%|quiet splash |g' -e 's|%KERNEL_OPTIONS%|console=tty0|g' \ @@ -100,8 +100,91 @@ grep -Fq 'depend = limine-mkinitcpio-hook' "$ROOT/builder/build-iso.sh" \ && fail "N1x builds still require the aarch64 runtime to depend on Limine" grep -Fq 'pacman -Qqp "$archive"' "$ROOT/builder/build-iso.sh" \ || fail "the offline mirror does not carry every package in the platform bundle" -grep -Fq 'libva-nvidia-driver limine limine-mkinitcpio-hook limine-snapper-sync snapper pciutils' "$ROOT/builder/build-iso.sh" \ +grep -Fq 'archlinuxarm-keyring limine limine-mkinitcpio-hook limine-snapper-sync snapper pciutils' "$ROOT/builder/build-iso.sh" \ || fail "the offline mirror lacks the Limine/Snapper stack the installer adds" +grep -Fq 'platform_packages+=(nvidia-open-dkms nvidia-utils libva-nvidia-driver)' "$ROOT/builder/build-iso.sh" \ + || fail "the N1x offline mirror lacks the NVIDIA stack the runtime's platform script installs" + +# --- generic platform ------------------------------------------------------------ +# The same build without anything board-specific: Arch Linux ARM's kernel, the +# published packages, no bundle and no NVIDIA stack. +live="$ROOT/builder/linux-aarch64-live.sh" +bash -n "$live" +[[ -x $live ]] || fail "the generic live-kernel script is not executable" +grep -Fq 'generic|qualcomm|gb10) OMARCHY_KERNEL=linux-aarch64 ;;' "$ROOT/bin/omarchy-iso-make" || fail "the generic image does not boot linux-aarch64" +grep -Fq -- '--package-dir and --dev-ssh are only valid with --platform n1x' "$ROOT/bin/omarchy-iso-make" \ + || fail "a generic build would accept the N1x bundle or dev key" +grep -Fq 'install -Dm0755 /builder/linux-aarch64-live.sh "$build_cache_dir/airootfs/root/customize_airootfs.sh"' "$ROOT/builder/build-iso.sh" \ + || fail "generic builds do not make linux-aarch64 the live kernel" +grep -Fq 'kernel_options="console=ttyAMA0,115200 console=tty0 initramfs_async=0"' "$ROOT/builder/build-iso.sh" \ + || fail "a generic image cannot be watched over its serial port" +grep -Fq "cp -a \"\$image\" /boot/vmlinuz-linux-aarch64" "$live" || fail "mkarchiso would find no kernel to copy" +grep -Fq "archiso_image='/boot/initramfs-linux-aarch64.img'" "$live" || fail "GRUB would find no live initramfs" +# Without a bundle the [platform] repo must go: pacman refuses a repo it cannot sync. +strip=$(grep -F "awk '/^\\[platform\\]$/" "$ROOT/builder/build-iso.sh" | sed -E "s/.*awk '([^']*)'.*/\1/") +[[ -n $strip ]] || fail "generic builds keep the [platform] repo" +stripped=$(awk "$strip" "$ROOT/configs/pacman-online-aarch64.conf" | grep -E '^\[' | paste -sd' ') +[[ $stripped == '[options] [omarchy] [core] [extra] [alarm] [aur]' ]] || fail "generic repositories are wrong: $stripped" +# The entrypoint's contract, checked by running it up to its first refusal. +make_refuses() { # expected message, then arguments + local want=$1 out; shift + out=$(cd "$ROOT" && bash bin/omarchy-iso-make "$@" 2>&1) && fail "omarchy-iso-make $* was accepted" + grep -Fq -- "$want" <<<"$out" || fail "omarchy-iso-make $*: expected '$want', got: $out" +} +make_refuses 'requires --platform n1x, generic, qualcomm or gb10' --arch aarch64 --edge +make_refuses '--platform must be n1x, generic, qualcomm or gb10' --arch aarch64 --platform pi +make_refuses 'only valid with --platform n1x' --arch aarch64 --platform generic --edge --package-dir "$fixture" +make_refuses 'builds use the edge channel' --arch aarch64 --platform generic +make_refuses 'requires --package-dir DIR' --arch aarch64 --platform n1x --edge + +# --- qualcomm platform ----------------------------------------------------------- +# The generic image, plus a live UKI that carries the Snapdragon laptops' device +# trees, since their firmware provides none. +uki="$ROOT/builder/qualcomm/live-uki.sh" +bash -n "$uki" +make_refuses 'only valid with --platform n1x' --arch aarch64 --platform qualcomm --edge --package-dir "$fixture" +make_refuses 'builds use the edge channel' --arch aarch64 --platform qualcomm +grep -Fq 'arch_packages+=(systemd-ukify linux-firmware-qcom qcom-firmware-extract)' "$ROOT/builder/build-iso.sh" \ + || fail "the Snapdragon live image lacks ukify or the firmware tools" +# The live scripts run in order, from one customize step: the kernel and +# initramfs first, then the UKI that wraps them. +customize=$(sed -n "/<<'CUSTOMIZE'/,/^CUSTOMIZE$/p" "$ROOT/builder/build-iso.sh") +[[ $(grep -n -F -e linux-aarch64-live.sh -e qualcomm-live-uki.sh <<<"$customize" | head -2 | cut -d: -f2- | paste -sd' ') \ + == 'bash /root/linux-aarch64-live.sh bash /root/qualcomm-live-uki.sh' ]] || fail "the live UKI is not built after the live kernel" +grep -Fq -- '--devicetree-auto=$dtb' "$uki" || fail "the live UKI carries no device trees" +grep -Fq '*-el2.dtb' "$uki" || fail "EL2 device trees, which share hardware ids with their base trees, are not left out" +grep -Fq "chainloader /%INSTALL_DIR%/boot/%ARCH%/omarchy-live.efi" "$ROOT/configs/grub/grub.cfg" || fail "GRUB does not chainload the live UKI" +grep -Fq "modprobe.blacklist=qcom_q6v5_pas" "$ROOT/configs/grub/grub.cfg" || fail "the live UKI entry loads the DSP driver in the initramfs" +# mkarchiso copies the UKI onto the ISO only with this patch, applied after the GRUB one. +if [[ -f "$ROOT/archiso/archiso/mkarchiso" ]]; then + cp "$ROOT/archiso/archiso/mkarchiso" "$fixture/mkarchiso-qualcomm" + patch --batch --forward --fuzz=0 "$fixture/mkarchiso-qualcomm" <"$ROOT/builder/archiso-v87-aarch64-grub.patch" >/dev/null \ + && patch --batch --forward --fuzz=0 "$fixture/mkarchiso-qualcomm" <"$ROOT/builder/archiso-v87-aarch64-boot-efi.patch" >/dev/null \ + || fail "the archiso patch that copies the live UKI no longer applies to the pinned submodule" + grep -Fq 'install -m 0644 -- "${uki}" "${isofs_dir}/${install_dir}/boot/${arch}/"' "$fixture/mkarchiso-qualcomm" \ + || fail "the patched mkarchiso does not copy the live UKI" +fi +grep -Fq 'patch --batch --forward --fuzz=0 "$mkarchiso_command" "$fixture/gb10-lists/omarchy-base.packages" +gb10_runtime_ok() { # platform + (base_pkg_lists=("$fixture/gb10-lists/omarchy-base.packages"); OMARCHY_ARM_PLATFORM=$1; eval "$gb10_check") 2>/dev/null +} +gb10_runtime_ok gb10 && fail "a runtime without the GB10 list is accepted for a GB10 image" +gb10_runtime_ok generic || fail "a generic image asks for the GB10 list" +printf '%s\n' nvidia-container-toolkit >"$fixture/gb10-lists/omarchy-aarch64-gb10.packages" +gb10_runtime_ok gb10 || fail "a runtime with the GB10 list is refused" # --- package sources --------------------------------------------------------------- # Arch Linux ARM's packages come from Omarchy's own mirror of them: for the @@ -169,6 +252,16 @@ composed=$( ) [[ $composed == $'base-one\nbase-two\nzram-generator\nrtkit\nlinux-omarchy-n1x' ]] \ || fail "aarch64 does not get the base list plus its architecture's and platform's additions minus the x86_64-only packages: $composed" +printf '%s\n' '# gb10' nvidia-container-toolkit rdma-core >"$fixture/lists/omarchy-aarch64-gb10.packages" +composed=$( + base_pkg_lists=("$fixture/lists/omarchy-base.packages") + OMARCHY_ARCH=aarch64 + OMARCHY_ARM_PLATFORM=gb10 + eval "$(sed -n '/^# aarch64 takes the runtime/,/^fi$/p' "$ROOT/builder/build-iso.sh")" 2>/dev/null + cat "${base_pkg_lists[0]}" +) +[[ $composed == $'base-one\nbase-two\nzram-generator\nrtkit\nnvidia-container-toolkit\nrdma-core' ]] \ + || fail "a GB10 image does not get the GB10 list in place of other platforms': $composed" composed=$( rm "$fixture/lists/omarchy-aarch64.packages" "$fixture/lists/omarchy-x86_64-only.packages" "$fixture/lists/omarchy-aarch64-n1x.packages" base_pkg_lists=("$fixture/lists/omarchy-base.packages") diff --git a/test/unit/test_boot_validation.py b/test/unit/test_boot_validation.py new file mode 100644 index 000000000..66f039293 --- /dev/null +++ b/test/unit/test_boot_validation.py @@ -0,0 +1,155 @@ +"""Validate the boot artifacts selected by Limine's effective UKI setting.""" + +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso")) +sys.modules.setdefault("orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter")) +from orchestrator import phases_impl as phases # noqa: E402 + + +# The layout limine-entry-tool and limine-snapper-sync write. +LINUX_ENTRY = """/+Omarchy +comment: Omarchy + //linux-aarch64 + comment: kernel-id=linux-aarch64 + protocol: linux + module_path: boot():/machine/7.2/initramfs#abcd + path: boot():/machine/7.2/vmlinuz#abcd + cmdline: root=UUID=test rw +""" +SNAPSHOTS = """ + //Snapshots + ///1 | 2026-10-01 00:00:00 + ////linux-aarch64 + protocol: linux + module_path: boot():/machine/limine_history/initramfs#abcd + path: boot():/machine/limine_history/vmlinuz#abcd + cmdline: root=UUID=test rw rootflags=subvol=/@/.snapshots/1/snapshot +""" + + +class BootValidationTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.ctx = types.SimpleNamespace( + target=self.root, omarchy_install={"storage": {"kernel": "linux-aarch64"}}, + user_configuration={}, encrypt=False, is_protected=False, defer_provisioning=False, + ) + self.write("boot/limine.conf", LINUX_ENTRY) + self.write("etc/kernel/cmdline", "root=UUID=test rw\n") + self.write("etc/default/limine", "CUSTOM_UKI_NAME=omarchy\n") + self.write("etc/limine-entry-tool.d/omarchy-uki.conf", "ENABLE_UKI=yes\n") + self.write("boot/EFI/limine/limine_x64.efi", "bootloader") + self.write("boot/machine/7.2/vmlinuz", "kernel") + self.write("boot/machine/7.2/initramfs", "initramfs") + self.write("usr/lib/modules/7.2/pkgbase", "linux-aarch64\n") + self.write("usr/lib/modules/7.2/build/include/config/kernel.release", "7.2\n") + for patch in ( + # The fixtures are x86_64 ones; the Limine binary checked follows the host. + mock.patch.object(phases.platform, "machine", return_value="x86_64"), + mock.patch.object(phases, "_assert_boot_hooks_restored"), + mock.patch.object(phases.arch, "has_uefi", return_value=True, create=True), + mock.patch.object(phases, "_read_efibootmgr", return_value={"entries": {"0001": "Limine"}}), + ): + patch.start() + self.addCleanup(patch.stop) + + def write(self, name, text): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + def disable_uki(self): + self.write("etc/limine-entry-tool.d/zz-omarchy-dgx-spark.conf", "ENABLE_UKI=no\n") + + def test_disabled_uki_accepts_kernel_and_initramfs_without_uki(self): + self.disable_uki() + phases.validate_boot(self.ctx) + + def test_default_uki_requires_nonempty_uki(self): + uki = self.write("boot/EFI/Linux/omarchy_linux-aarch64.efi", "UKI") + phases.validate_boot(self.ctx) + uki.write_bytes(b"") + with self.assertRaisesRegex(RuntimeError, "missing or empty"): + phases.validate_boot(self.ctx) + + def test_default_limine_overrides_hardware_dropin(self): + self.disable_uki() + self.write("etc/default/limine", 'ENABLE_UKI="yes"\n') + with self.assertRaisesRegex(RuntimeError, "missing or empty"): + phases.validate_boot(self.ctx) + self.write("boot/EFI/Linux/omarchy_linux-aarch64.efi", "UKI") + phases.validate_boot(self.ctx) + + def test_disabled_uki_reads_each_kernel_entry_apart_from_snapshots(self): + self.disable_uki() + self.write("boot/machine/limine_history/vmlinuz", "kernel") + self.write("boot/machine/limine_history/initramfs", "initramfs") + second = LINUX_ENTRY.split("\n", 2)[2].replace("linux-aarch64", "linux-other") + self.write("boot/limine.conf", LINUX_ENTRY + second + SNAPSHOTS) + phases.validate_boot(self.ctx) + # A snapshot's kernel does not stand in for a missing current kernel. + (self.root / "boot/machine/7.2/vmlinuz").unlink() + self.write("boot/limine.conf", LINUX_ENTRY + SNAPSHOTS) + with self.assertRaisesRegex(RuntimeError, "no bootable Omarchy Linux entry"): + phases.validate_boot(self.ctx) + + def test_disabled_uki_rejects_missing_and_empty_boot_files(self): + self.disable_uki() + for name in ("vmlinuz", "initramfs"): + path = self.root / "boot/machine/7.2" / name + for content in (None, ""): + with self.subTest(name=name, content=content): + if content is None: + path.unlink() + else: + path.write_text(content) + with self.assertRaisesRegex(RuntimeError, "no bootable Omarchy Linux entry"): + phases.validate_boot(self.ctx) + path.write_text("boot file") + + def test_disabled_uki_rejects_incomplete_or_unrelated_entries(self): + self.disable_uki() + for entry in ( + LINUX_ENTRY.replace("protocol: linux", "protocol: efi"), + LINUX_ENTRY.replace(" module_path: boot():/machine/7.2/initramfs#abcd\n", ""), + LINUX_ENTRY.replace("root=UUID=test", "quiet"), + LINUX_ENTRY.replace("/+Omarchy", "/Other OS"), + LINUX_ENTRY.replace("boot():/machine/7.2/vmlinuz", "uuid(1234):/machine/7.2/vmlinuz"), + "/Omarchy\n protocol: efi\n" + LINUX_ENTRY.replace("/+Omarchy", "/Other OS"), + ): + with self.subTest(entry=entry): + self.write("boot/limine.conf", entry) + with self.assertRaises(RuntimeError): + phases.validate_boot(self.ctx) + + def test_encrypted_install_requires_cryptdevice_on_linux_entry(self): + self.disable_uki() + self.ctx.encrypt = True + # A comment or another entry containing cryptdevice is not sufficient. + self.write("boot/limine.conf", "# cryptdevice=UUID=test:root\n" + LINUX_ENTRY) + with self.assertRaisesRegex(RuntimeError, "no bootable Omarchy Linux entry"): + phases.validate_boot(self.ctx) + self.write("boot/limine.conf", LINUX_ENTRY.replace("rw", "rw cryptdevice=UUID=test:root")) + phases.validate_boot(self.ctx) + + def test_disabled_uki_still_requires_firmware_bootloader_and_entry(self): + self.disable_uki() + with mock.patch.object(phases, "_read_efibootmgr", return_value={"entries": {}}): + with self.assertRaisesRegex(RuntimeError, "no 'Limine' entry"): + phases.validate_boot(self.ctx) + (self.root / "boot/EFI/limine/limine_x64.efi").unlink() + with self.assertRaisesRegex(RuntimeError, "missing or empty"): + phases.validate_boot(self.ctx) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit/test_kernel_selection.py b/test/unit/test_kernel_selection.py index 2bed1c202..5c50c1e76 100644 --- a/test/unit/test_kernel_selection.py +++ b/test/unit/test_kernel_selection.py @@ -154,6 +154,31 @@ def test_validation_rejects_missing_or_mismatched_headers(self): header_release.write_text("7.2-test\n") phases_impl._validate_kernel_headers(ctx) + def test_a_kernel_without_a_pkgbase_is_the_one_the_image_installs(self): + # Arch Linux ARM's linux-aarch64 records no pkgbase next to its modules. + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) + ctx = types.SimpleNamespace(target=target) + modules = target / "usr/lib/modules/7.2.9-1-aarch64-ARCH" + modules.mkdir(parents=True) + (modules / "modules.builtin").write_text("kernel/fs/ext4/ext4.ko\n") + # Out-of-tree leftovers are not a kernel. + (target / "usr/lib/modules/7.2.8-gone/updates/dkms").mkdir(parents=True) + + with mock.patch.object(phases_impl, "iso_kernel", return_value=""): + self.assertEqual(phases_impl._installed_kernels(ctx), []) + with self.assertRaisesRegex(RuntimeError, "no installed kernel"): + phases_impl._validate_kernel_headers(ctx) + + with mock.patch.object(phases_impl, "iso_kernel", return_value="linux-aarch64"): + self.assertEqual(phases_impl._installed_kernels(ctx), ["linux-aarch64"]) + with self.assertRaisesRegex(RuntimeError, r"linux-aarch64 \(7\.2\.9-1-aarch64-ARCH\) has no kernel headers"): + phases_impl._validate_kernel_headers(ctx) + header_release = modules / "build/include/config/kernel.release" + header_release.parent.mkdir(parents=True) + header_release.write_text("7.2.9-1-aarch64-ARCH\n") + phases_impl._validate_kernel_headers(ctx) + def test_boot_validation_uses_default_or_explicit_kernel(self): for storage, configuration, expected in [ ({}, {}, "linux-omarchy"), diff --git a/test/unit/test_live_dsp.py b/test/unit/test_live_dsp.py new file mode 100644 index 000000000..495dec8d4 --- /dev/null +++ b/test/unit/test_live_dsp.py @@ -0,0 +1,174 @@ +"""Run the live DSP guard against disposable mount and device-tree fixtures.""" + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class LiveDspTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.tools = self.root / "tools" + self.tools.mkdir() + self.env = dict(os.environ, PATH=f"{self.tools}:{os.environ['PATH']}", + DSP_TEST_ROOT=str(self.root)) + script = (ROOT / "builder/qualcomm/omarchy-live-dsp").read_text() + # Redirect only filesystem paths, not the guard logic, into the fixture. + for prefix in ("/proc/", "/sys/", "/run/"): + script = script.replace(prefix, f"{self.root}{prefix}") + self.script = self.root / "live-dsp" + self.script.write_text(script) + self.write("proc/device-tree/compatible", "lenovo,yoga-slim7x\0qcom,x1e80100\0") + self.write("proc/cmdline", "archisobasedir=arch modprobe.blacklist=qcom_q6v5_pas\n") + self.write("proc/swaps", "Filename\tType\tSize\tUsed\tPriority\n") + self.ram = self.root / "run/archiso/copytoram" + self.cow = self.root / "run/archiso/cowspace" + self.lower = self.root / "run/archiso/airootfs" + self.upper = self.cow / "persistent_/aarch64/upperdir" + self.work = self.cow / "persistent_/aarch64/workdir" + for directory in (self.ram, self.lower, self.upper, self.work): + directory.mkdir(parents=True, exist_ok=True) + self.image = self.ram / "airootfs.sfs" + self.image.touch() + self.write("sys/class/block/loop7/loop/backing_file", str(self.image) + "\n") + self.mounts = { + "-rn -M / -o SOURCE,FSTYPE": "airootfs overlay", + "-rn -M / -o FS-OPTIONS": f"rw,lowerdir={self.lower},upperdir={self.upper},workdir={self.work}", + f"-rn -M {self.ram} -o FSTYPE": "tmpfs", + f"-rn -M {self.cow} -o FSTYPE": "tmpfs", + f"-rn -M {self.lower} -o FSTYPE": "squashfs", + f"-rn -M {self.lower} -o SOURCE": "/dev/loop7", + f"-rn -T {self.image} -o TARGET,FSTYPE": f"{self.ram} tmpfs", + f"-rn -T {self.upper} -o TARGET,FSTYPE": f"{self.cow} tmpfs", + f"-rn -T {self.work} -o TARGET,FSTYPE": f"{self.cow} tmpfs", + "-ern -o SOURCE": "proc\nsys\n/dev/loop7\nairootfs\ncowspace\ncopytoram", + } + self.stub("uname", '#!/bin/sh\necho "${DSP_TEST_ARCH:-aarch64}"\n') + self.stub("findmnt", '''#!/usr/bin/env python3 +import json, os, pathlib, sys +data = json.loads((pathlib.Path(os.environ["DSP_TEST_ROOT"]) / "mounts.json").read_text()) +key = " ".join(sys.argv[1:]) +if key not in data: + sys.exit(1) +print(data[key]) +''') + self.stub("mountpoint", '#!/bin/sh\ntest "$2" = "${DSP_TEST_MOUNTED:-}"\n') + self.stub("modprobe", '''#!/bin/sh +printf '%s\n' "$*" >> "$DSP_TEST_ROOT/modprobe.calls" +exit "${DSP_TEST_MODPROBE_STATUS:-0}" +''') + + def write(self, name, contents): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + return path + + def stub(self, name, contents): + self.write(f"tools/{name}", contents).chmod(0o755) + + def run_guard(self, *args): + self.write("mounts.json", json.dumps(self.mounts)) + return subprocess.run(["bash", str(self.script), *args], env=self.env, + capture_output=True, text=True, timeout=10) + + def assert_skipped(self, reason): + result = self.run_guard() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(reason, result.stdout) + self.assertFalse((self.root / "modprobe.calls").exists()) + + def test_ram_backed_live_root_loads_driver_once(self): + result = self.run_guard() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.root / "modprobe.calls").read_text(), "-v qcom_q6v5_pas\n") + + def test_check_mode_never_loads_driver(self): + result = self.run_guard("--check") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((self.root / "modprobe.calls").exists()) + self.mounts["-rn -M / -o SOURCE,FSTYPE"] = "/dev/nvme0n1p2 btrfs" + self.assertEqual(self.run_guard("--check").returncode, 1) + self.assertFalse((self.root / "modprobe.calls").exists()) + + def test_other_architecture_and_unvalidated_hardware_are_untouched(self): + self.env["DSP_TEST_ARCH"] = "x86_64" + self.assert_skipped("not ARM64") + self.env["DSP_TEST_ARCH"] = "aarch64" + self.write("proc/device-tree/compatible", "other,board\0qcom,x1e80100\0") + self.assert_skipped("hardware has not been validated") + (self.root / "proc/device-tree/compatible").unlink() + self.assert_skipped("no device tree") + + def test_command_line_can_disable_startup(self): + self.write("proc/cmdline", "archisobasedir=arch omarchy.live_dsp=0\n") + self.assert_skipped("disabled on the kernel command line") + + def test_installed_root_and_disk_backed_layers_are_rejected(self): + cases = [ + ("-rn -M / -o SOURCE,FSTYPE", "/dev/nvme0n1p2 btrfs", "not an archiso"), + (f"-rn -M {self.ram} -o FSTYPE", "ext4", "no RAM copy"), + (f"-rn -M {self.cow} -o FSTYPE", "ext4", "writable layer is not in RAM"), + (f"-rn -M {self.lower} -o SOURCE", "/dev/sda1", "not a loop device"), + (f"-rn -T {self.image} -o TARGET,FSTYPE", f"{self.image} ext4", "backed by storage"), + (f"-rn -T {self.upper} -o TARGET,FSTYPE", f"{self.upper} ext4", "writable directory is not in RAM"), + (f"-rn -T {self.work} -o TARGET,FSTYPE", f"{self.work} ext4", "writable directory is not in RAM"), + ("-ern -o SOURCE", "/dev/loop7\n/dev/sda1", "another block device is mounted"), + ] + for key, value, reason in cases: + with self.subTest(reason=reason, key=key): + original = self.mounts[key] + self.mounts[key] = value + self.assert_skipped(reason) + self.mounts[key] = original + + def test_absent_mounts_do_not_pass_as_ram(self): + del self.mounts[f"-rn -M {self.ram} -o FSTYPE"] + self.assert_skipped("no RAM copy") + + def test_extra_overlay_lower_layer_is_rejected(self): + self.mounts["-rn -M / -o FS-OPTIONS"] = f"lowerdir={self.lower}:/media/usb,upperdir={self.upper},workdir={self.work}" + self.assert_skipped("unexpected overlay lower layer") + + def test_disk_backed_loop_and_missing_image_are_rejected(self): + disk_image = self.write("run/archiso/bootmnt/airootfs.sfs", "image") + self.write("sys/class/block/loop7/loop/backing_file", str(disk_image)) + self.assert_skipped("backed by storage") + disk_image.unlink() + self.assert_skipped("unavailable loop backing file") + + def test_cow_symlink_cannot_escape_ram(self): + self.upper.rmdir() + disk_dir = self.root / "media/usb/upperdir" + disk_dir.mkdir(parents=True) + self.upper.symlink_to(disk_dir) + self.assert_skipped("writable directory is not in RAM") + + def test_mounted_media_and_active_swap_are_rejected(self): + for name in ("bootmnt", "img_dev"): + self.env["DSP_TEST_MOUNTED"] = str(self.root / "run/archiso" / name) + self.assert_skipped("installation media is still mounted") + del self.env["DSP_TEST_MOUNTED"] + self.write("proc/swaps", "Filename Type Size Used Priority\n/dev/sda3 partition 10 0 -2\n") + self.assert_skipped("swap is active") + + def test_already_loaded_driver_is_not_reloaded(self): + (self.root / "sys/module/qcom_q6v5_pas").mkdir(parents=True) + self.assert_skipped("driver already loaded") + + def test_module_failure_is_not_hidden(self): + self.env["DSP_TEST_MODPROBE_STATUS"] = "1" + result = self.run_guard() + self.assertEqual(result.returncode, 1) + self.assertEqual((self.root / "modprobe.calls").read_text(), "-v qcom_q6v5_pas\n") + + +if __name__ == "__main__": + unittest.main()