diff --git a/.gitignore b/.gitignore index 22f81aca..9bef1834 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ __pycache__/ # Acceptance test harness artifacts test-runs/** + +# Agent harness artifacts +.superlite/ diff --git a/bin/omarchy-iso-boot-aarch64 b/bin/omarchy-iso-boot-aarch64 new file mode 100755 index 00000000..5f1b93ab --- /dev/null +++ b/bin/omarchy-iso-boot-aarch64 @@ -0,0 +1,73 @@ +#!/bin/bash +# +# Boot an aarch64 Omarchy ISO in QEMU under UEFI. +# +# The x86_64 counterpart is bin/omarchy-iso-boot, which hardcodes +# qemu-system-x86_64 and OVMF. This is the ARM equivalent, kept separate until +# plans/aarch64-support.md item 9 folds both into one arch-aware script. +# +# Firmware: Arch Linux ARM does not package edk2-armvirt, so AAVMF is staged +# from Debian's architecture-independent qemu-efi-aarch64 package into +# ~/.local/share/aavmf. See docs/alarm-iso.md. +# +# No KVM here: on Snapdragon X the Gunyah hypervisor owns EL2 and Linux runs at +# EL1, so this is TCG emulation and boots slowly even on an ARM host. +# +# Usage: omarchy-iso-boot-aarch64 [release/omarchy.iso] [--serial] [--memory MB] + +set -euo pipefail + +FW_DIR="${OMARCHY_AAVMF_DIR:-$HOME/.local/share/aavmf}" +CODE="$FW_DIR/AAVMF_CODE.no-secboot.fd" +VARS_TEMPLATE="$FW_DIR/AAVMF_VARS.fd" +VARS="/tmp/omarchy-aavmf-vars.fd" + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +ISO="" +MEMORY=4096 +SERIAL=false + +while (($#)); do + case "$1" in + --serial) SERIAL=true ;; + --memory) MEMORY="$2"; shift ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) ISO="$1" ;; + esac + shift +done + +[[ -n $ISO ]] || ISO=$(\ls -t "$ROOT"/release/*.iso 2>/dev/null | head -n1 || true) +[[ -n $ISO && -f $ISO ]] || { echo "No ISO found; pass one explicitly." >&2; exit 1; } + +command -v qemu-system-aarch64 >/dev/null || + { echo "qemu-system-aarch64 not installed: sudo pacman -S qemu-system-aarch64 qemu-ui-gtk" >&2; exit 1; } +[[ -f $CODE ]] || + { echo "UEFI firmware missing at $CODE" >&2; exit 1; } + +# The vars store is written to, so never hand QEMU the pristine template. +cp -f "$VARS_TEMPLATE" "$VARS" + +args=( + -machine virt + -cpu max + -m "$MEMORY" + -smp 4 + -drive "if=pflash,format=raw,unit=0,file=$CODE,readonly=on" + -drive "if=pflash,format=raw,unit=1,file=$VARS" + # The ISO is a hybrid image with its own GPT, so it is attached as a plain + # disk rather than a CD -- which is also how the USB stick presents it. + -drive "if=none,id=iso,format=raw,readonly=on,file=$ISO" + -device virtio-blk-pci,drive=iso,bootindex=0 + -device virtio-rng-pci +) + +if $SERIAL; then + # Headless: GRUB and the kernel both talk to the virt machine's PL011. + args+=(-nographic) +else + args+=(-device virtio-gpu-pci -display gtk -serial mon:stdio) +fi + +echo "Booting ${ISO##*/} (TCG, no KVM — expect this to be slow)" +exec qemu-system-aarch64 "${args[@]}" diff --git a/bin/omarchy-iso-make b/bin/omarchy-iso-make index 5dbbefff..c2bd034e 100755 --- a/bin/omarchy-iso-make +++ b/bin/omarchy-iso-make @@ -8,6 +8,17 @@ LOCAL_OMARCHY_PATH="" LOCAL_PKGS_PATH="" while [[ $# -gt 0 ]]; do case $1 in + --arch) + OMARCHY_ARCH="${2:-}" + case $OMARCHY_ARCH in + x86_64 | aarch64) ;; + *) + echo "Error: --arch must be x86_64 or aarch64" >&2 + exit 1 + ;; + esac + shift 2 + ;; --no-cache) NO_CACHE=1 shift @@ -57,7 +68,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 "Usage: $0 [--arch x86_64|aarch64] [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source ]" exit 1 ;; esac @@ -119,12 +130,33 @@ mkdir -p "$BUILD_RELEASE_PATH" OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" +OMARCHY_ARCH="${OMARCHY_ARCH:-x86_64}" + +# Vanilla Arch is x86_64-only, so aarch64 builds run against Arch Linux ARM. +# Building aarch64 on an x86_64 host works through binfmt/QEMU but is slow; +# a native arm64 host is much faster. +case $OMARCHY_ARCH in +aarch64) + BUILD_IMAGE="menci/archlinuxarm:base-devel" + BUILD_PLATFORM="linux/arm64" + ;; +*) + BUILD_IMAGE="archlinux/archlinux:latest" + BUILD_PLATFORM="linux/amd64" + ;; +esac DOCKER_ARGS=( --rm --privileged + --platform "$BUILD_PLATFORM" -e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF" -e "OMARCHY_MIRROR=$OMARCHY_MIRROR" + -e "OMARCHY_ARCH=$OMARCHY_ARCH" + -e "OMARCHY_PKGS_MIRROR=${OMARCHY_PKGS_MIRROR:-}" + -e "OMARCHY_TARGET_PKGS_MIRROR=${OMARCHY_TARGET_PKGS_MIRROR:-}" + -e "OMARCHY_BASE_MIRROR=${OMARCHY_BASE_MIRROR:-}" + -e "OMARCHY_EXTRA_PKGBUILDS=${OMARCHY_EXTRA_PKGBUILDS:-}" -e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}" -e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)" @@ -148,8 +180,10 @@ fi # Mount the build cache directory where packages are downloaded. Channels use # different package snapshots and must never share an offline mirror cache. +# Architectures must not share one either: the cached packages are arch-specific, +# so a shared key would let an aarch64 build consume x86_64 packages and vice versa. if [[ -z "$NO_CACHE" ]]; then - OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy" + OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}_${OMARCHY_ARCH}/airootfs/var/cache/omarchy" mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR" DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy") else @@ -166,7 +200,7 @@ if ! docker version &>/dev/null; then DOCKER=(sudo docker) fi -"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT +"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" "$BUILD_IMAGE" /$BUILD_SCRIPT latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1) iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso" diff --git a/builder/aarch64-excludes.packages b/builder/aarch64-excludes.packages new file mode 100644 index 00000000..49f5ad00 --- /dev/null +++ b/builder/aarch64-excludes.packages @@ -0,0 +1,83 @@ +# Packages excluded from aarch64 builds. +# +# Omarchy's package lists are written for x86_64. Most entries here are hardware +# or platform support that cannot exist on ARM; a smaller group is software Arch +# Linux ARM simply does not carry. Anything listed is dropped before the offline +# mirror is populated, because pacman aborts the whole transaction on the first +# unresolvable target. +# +# NOT excluded, and must be built for aarch64 and published to the package repo: +# tzupdate (also needed by the live ISO itself) +# tensaku +# hyprland-preview-share-picker + +# --- CPU microcode (x86-only concept) --- +# +# archinstall.packages deliberately lists both so the mirror contains whichever +# the target CPU needs. Neither exists for ARM. +amd-ucode +intel-ucode + +# --- Apple T2 / Intel Macs (x86-only by definition) --- +apple-bcm-firmware +apple-t2-audio-config +asdcontrol +linux-t2 +linux-t2-headers +macbook12-spi-driver-dkms +t2fanrd + +# --- Intel-specific --- +intel-ipu7-camera +intel-lpmd +intel-media-driver +libva-intel-driver +linux-ptl +linux-ptl-headers +thermald +vpl-gpu-rt + +# --- NVIDIA and 32-bit x86 --- +lib32-nvidia-580xx-utils +lib32-nvidia-utils +nvidia-580xx-dkms +nvidia-580xx-utils + +# --- x86 laptop/vendor hardware --- +asusctl +dell-xps-touchpad-haptics +dell-xps13-sidecar-amps +qmk-hid +tuxedo-drivers-nocompatcheck-dkms +yt6801-dkms + +# --- x86 firmware, boot, and VM guest tooling --- +broadcom-wl +hyperv +memtest86+-efi +open-vm-tools +qemu-user-static-binfmt +refind +virtualbox-guest-utils-nox + +# --- Arch releng live-ISO entries with no ARM equivalent --- +# +# The live ISO package list is derived from releng's packages.x86_64, which +# carries x86 rescue and firmware tooling. +b43-fwcutter +edk2-shell +linux-firmware-marvell +memtest86+ +syslinux + +# --- Not packaged for Arch Linux ARM --- +# +# These are a genuine reduction in what the installed system offers, not an +# architecture impossibility. Build them for aarch64 and remove them here if any +# turn out to matter. +dotnet-runtime +obs-studio +obsidian +pinta +reflector +yay-debug diff --git a/builder/build-iso.sh b/builder/build-iso.sh index db8daa2c..666c33d5 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -23,6 +23,49 @@ esac : "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}" export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE +: "${OMARCHY_ARCH:=x86_64}" +export OMARCHY_ARCH + +# aarch64 pulls its base from Arch Linux ARM, whose repo set and directory layout +# both differ from Arch's, so it gets its own pacman configs rather than the +# x86_64 ones aimed at another host. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + pacman_online_conf="/configs/aarch64/pacman-online-${OMARCHY_MIRROR}.conf" +else + pacman_online_conf="/configs/pacman-online-${OMARCHY_MIRROR}.conf" +fi + +# The tracked configs name the public mirrors. OMARCHY_PKGS_MIRROR and +# OMARCHY_BASE_MIRROR retarget them per build, so pointing at a different package +# repo needs no edit to a tracked file -- necessary while pkgs.omarchy.org +# publishes no aarch64 tree, and useful afterwards for anyone self-hosting. +# /configs is mounted read-only, so the override lands on a writable copy. +if [[ -n ${OMARCHY_PKGS_MIRROR:-} || -n ${OMARCHY_BASE_MIRROR:-} ]]; then + cp "$pacman_online_conf" /tmp/pacman-online.conf + [[ -n ${OMARCHY_PKGS_MIRROR:-} ]] && + sed -i "s|^Server = https://pkgs\.omarchy\.org/.*|Server = ${OMARCHY_PKGS_MIRROR}|" /tmp/pacman-online.conf + if [[ -n ${OMARCHY_BASE_MIRROR:-} ]]; then + # Replace the complete global fallback set with exactly one pinned server + # in each ALARM base repo. Matching hostnames is insufficient now that the + # defaults intentionally span archlinuxarm.org and independent HTTPS hosts. + awk -v server="$OMARCHY_BASE_MIRROR" ' + /^\[(core|extra|alarm)\]$/ { + in_base = 1 + print + print "Server = " server + next + } + /^\[/ { in_base = 0 } + in_base && /^Server = / { next } + { print } + ' /tmp/pacman-online.conf > /tmp/pacman-online.conf.pinned + mv /tmp/pacman-online.conf.pinned /tmp/pacman-online.conf + fi + pacman_online_conf=/tmp/pacman-online.conf + echo "Mirror overrides applied:" + grep -E '^\[|^Server' /tmp/pacman-online.conf | sed 's/^/ /' +fi + # Packages installed into the Arch container used to build the ISO. pacman-key --init pacman --noconfirm -Sy archlinux-keyring @@ -30,7 +73,27 @@ pacman --noconfirm -Sy archlinux-keyring # so this container can be months behind the mirror it installs from. A plain # -Sy install is then a partial upgrade — new packages linked against a glibc # the container doesn't have yet. -pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli +# +# Arch Linux ARM does not package archiso. The submodule this repo already pins +# ships mkarchiso itself, so aarch64 installs what that script calls out to and +# runs the vendored copy. mkinitcpio-archiso is a separate package and is the +# part that matters most: it provides the archiso* mkinitcpio hooks the live +# initramfs is built from, and it IS in ALARM. +build_tools=(git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli) +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + build_tools+=(mkinitcpio-archiso arch-install-scripts squashfs-tools libisoburn mtools dosfstools erofs-utils) +else + build_tools+=(archiso) +fi + +# makepkg runs with --nodeps, so whatever OMARCHY_EXTRA_PKGBUILDS needs in order +# to build has to already be in the container. The current extras (tzupdate, +# tensaku, hyprland-preview-share-picker) are all Rust. Extras needing another +# toolchain will need it added here. +if [[ -n ${OMARCHY_EXTRA_PKGBUILDS:-} ]]; then + build_tools+=(rust) +fi +pacman --noconfirm -Syu "${build_tools[@]}" # Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo # during the build without keyserver lookups). @@ -38,14 +101,20 @@ pacman-key --add /builder/omarchy.gpg pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571 # omarchy-keyring is needed inside the offline mirror too. -pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring +pacman --config "$pacman_online_conf" --noconfirm -Sy omarchy-keyring pacman-key --populate omarchy -# Append the [omarchy] repo to the container's /etc/pacman.conf so subsequent -# tools (notably makepkg in build-omarchy-packages.sh) can resolve omarchy- -# only build deps like limine-snapper-sync and limine-mkinitcpio-hook. +# Prepend the [omarchy] repo to the container's /etc/pacman.conf so subsequent +# tools (notably makepkg in build-omarchy-packages.sh) can resolve Omarchy-only +# build deps and compatibility overrides. Repository order is package priority +# in pacman; appending this section would silently select a broken ALARM package +# with the same name instead of the tested overlay version. if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then - awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf >> /etc/pacman.conf + { + awk '/^\[omarchy\]$/,/^$/' "$pacman_online_conf" + cat /etc/pacman.conf + } >/tmp/pacman.conf.with-omarchy + install -m 0644 /tmp/pacman.conf.with-omarchy /etc/pacman.conf fi # Build locations @@ -57,13 +126,159 @@ mkdir -p "$build_cache_dir" "$offline_mirror_dir" cp -r /archiso/configs/releng/* "$build_cache_dir/" rm "$build_cache_dir/airootfs/etc/motd" +# Keep the hardware catalog inside the live root as well as at build time. +# The installer uses this same file to configure the installed bootloader, so +# product matching, DTB staging, and post-install kernel updates cannot drift. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + install -Dm644 /configs/aarch64/platforms.json \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso/aarch64-platforms.json" +fi + +# releng only ships packages.x86_64. Derive the aarch64 list from it and drop the +# entries that are x86-only, or that Arch Linux ARM does not carry at all, so the +# first pacman resolve does not fail on packages that cannot exist for ARM. +# +# Arch Linux ARM has no package named plain "linux"; its generic ARMv8 kernel is +# linux-aarch64. Leaving releng's "linux" in place fails the first resolve, so it +# is remapped rather than dropped -- unlike x86_64, aarch64 has no second kernel +# to fall back on, so this list's kernel is the one the live ISO boots. +pkg_list="$build_cache_dir/packages.${OMARCHY_ARCH}" +if [[ $OMARCHY_ARCH == "aarch64" && ! -f $pkg_list ]]; then + sed -E 's/^linux$/linux-aarch64/' "$build_cache_dir/packages.x86_64" | + grep -Fxv -f <(grep -hv '^#\|^$' /builder/aarch64-excludes.packages) >"$pkg_list" + rm -f "$build_cache_dir/packages.x86_64" +fi + # We rely on the global CDN; drop reflector. rm -rf "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service" rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d" rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector" +# Do not make the live console wait for wall-clock synchronization. Archiso's +# releng profile enables systemd-time-wait-sync in sysinit.target, and on an +# isolated or slow-to-associate machine that leaves tty1 displaying only a +# cursor until NTP succeeds or times out. The installer uses its bundled, +# unsigned offline repository and initializes the installed system's keyrings +# explicitly, so its correctness does not depend on the live clock being +# synchronized. Keep systemd-timesyncd enabled: it can correct the clock in the +# background without holding the configurator behind time-sync.target. +rm -f "$build_cache_dir/airootfs/etc/systemd/system/sysinit.target.wants/systemd-time-wait-sync.service" + # Bring in our archiso profile additions. cp -r /configs/* "$build_cache_dir/" + +# uefi.grub installs every entry in efiboot/loader/entries, so the entry for the +# other architecture would show up in the boot menu pointing at a kernel this ISO +# does not carry. Keep only this build's entry and aim loader.conf at it. +_boot_entries="$build_cache_dir/efiboot/loader/entries" +find "$_boot_entries" -name '01-archiso-*-linux.conf' \ + ! -name "01-archiso-${OMARCHY_ARCH}-linux.conf" -delete +sed -i "s|^default .*|default 01-archiso-${OMARCHY_ARCH}-linux.conf|" \ + "$build_cache_dir/efiboot/loader/loader.conf" + +# BIOS is x86-only, so the syslinux tree is dead weight on aarch64 (profiledef +# drops bios.syslinux from bootmodes there). +# +# The GRUB configs template their paths through %ARCH%, but name the kernel +# outright: aarch64 boots linux-aarch64, not the T2 kernel. The grub_cpu guards +# elsewhere in those files are evaluated by GRUB at boot and never match on ARM, +# so they need no edit. xe.enable_panel_replay is an Intel Xe knob with nothing +# to act on here. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + rm -rf "$build_cache_dir/syslinux" + # linux-aarch64's stock preset writes /boot/initramfs-linux.img, so the boot + # configs name that rather than a per-kernel variant. + sed -i -e 's/vmlinuz-linux-t2/vmlinuz-linux-aarch64/g' \ + -e 's/initramfs-linux-t2\.img/initramfs-linux.img/g' \ + -e 's/ xe\.enable_panel_replay=0//g' \ + "$build_cache_dir/grub/grub.cfg" "$build_cache_dir/grub/loopback.cfg" + + # Presets are keyed by pkgbase. linux-t2 does not exist on ARM, and releng's + # linux.preset is keyed to a pkgbase nothing here provides, so both are dead + # weight. linux-aarch64 ships its own preset and owns that path, so the + # profile cannot supply a replacement without a pacstrap file conflict. + # + # That stock preset builds against /etc/mkinitcpio.conf -- it does NOT pick up + # mkinitcpio.conf.d/archiso.conf, because a preset's config is passed as -c and + # -c ignores drop-ins. Since omarchy-settings replaces /etc/mkinitcpio.conf + # with the target system's hooks, the initramfs pacstrap produces has no + # archiso hook at all. customize_airootfs.sh below rebuilds it explicitly. + rm -f "$build_cache_dir/airootfs/etc/mkinitcpio.d/linux-t2.preset" \ + "$build_cache_dir/airootfs/etc/mkinitcpio.d/linux.preset" + + # Drop the two x86-only hooks from the live initramfs. + # + # microcode has no CPU microcode to bundle on ARM. memdisk exists to boot an + # ISO that syslinux MEMDISK loaded into RAM -- a BIOS mechanism with no UEFI + # equivalent -- and it needs memdiskfind from syslinux, which is excluded here + # as an x86 bootloader. Left in place it fails the initramfs build outright: + # "binary not found: 'memdiskfind'" and "module not found: 'phram'". + sed -i -e 's/ microcode / /' -e 's/ memdisk / /' \ + "$build_cache_dir/airootfs/etc/mkinitcpio.conf.d/archiso.conf" + + # archiso globs ${pacstrap_dir}/boot/vmlinuz-* for both the ISO 9660 tree and + # the FAT EFI image, but Arch Linux ARM installs its kernel as /boot/Image -- + # the ARM64 convention -- so those globs match nothing and the build dies with + # no kernel to copy. Bridge the naming in the chroot, after pacstrap has put + # the kernel there and before the boot stages look for it. + # + # customize_airootfs.sh is deprecated upstream but is the only post-pacstrap + # in-chroot hook archiso offers. A pacman hook in the target root would be the + # non-deprecated alternative if this stops being supported. + cat >"$build_cache_dir/airootfs/root/customize_airootfs.sh" <<'CUSTOMIZE' +#!/bin/bash +set -e + +# The package-owned preset carries the authoritative kernel version for the +# kernel that was just installed. Read it before anything else touches /boot, +# and leave the file alone -- replacing it would destroy the one reliable +# source of this value. +# shellcheck source=/dev/null +source /etc/mkinitcpio.d/linux-aarch64.preset +kver=${ALL_kver:?linux-aarch64 preset defines no ALL_kver} +[[ -d /usr/lib/modules/$kver ]] || + { echo "ERROR: no modules for linux-aarch64 kernel $kver" >&2; exit 1; } + +# Unconditionally, so a reused root cannot keep an alias for an older kernel. +cp -a /boot/Image /boot/vmlinuz-linux-aarch64 + +# Regenerate the live initramfs against archiso's config. +# +# The stock preset builds against /etc/mkinitcpio.conf, which omarchy-settings +# replaces with the TARGET system's hooks (encrypt, fsck, btrfs-overlayfs and +# friends). The resulting image has no archiso hook, so it cannot find or mount +# the squashfs, and the ISO panics instead of booting. +# +# -c is what releng's preset does too: mkinitcpio translates archiso_config= +# straight into -c, and -c ignores drop-ins rather than layering over the main +# config. A HOOKS-only config is the point here -- MODULES/BINARIES/FILES come +# out empty and compression falls back to mkinitcpio's default, none of which we +# want inherited from the target system's config anyway. +mkinitcpio -c /etc/mkinitcpio.conf.d/archiso.conf -k "$kver" -g /boot/initramfs-linux.img + +# Fail loudly rather than shipping an ISO that builds and then panics. mkarchiso +# runs this script under set -e, so a failure here fails the build -- which a +# post-transaction pacman hook could not do, since AbortOnFail applies only to +# pre-transaction hooks. +initramfs_hooks=$(lsinitcpio /boot/initramfs-linux.img | grep -oE 'hooks/[a-z_0-9-]+$' | sed 's|hooks/||' | sort -u) +for required in archiso archiso_loop_mnt; do + printf '%s\n' "$initramfs_hooks" | grep -qx "$required" || { + echo "ERROR: live initramfs has no '$required' hook; it would not boot" >&2 + echo " hooks present: $(printf '%s' "$initramfs_hooks" | tr '\n' ' ')" >&2 + exit 1 + } +done + +# A mismatch here means the initramfs was built for a kernel other than the one +# /boot/vmlinuz-linux-aarch64 now is. +lsinitcpio -a /boot/initramfs-linux.img | grep -q "Kernel: $kver" || { + echo "ERROR: initramfs kernel does not match installed $kver" >&2 + exit 1 +} +CUSTOMIZE + chmod +x "$build_cache_dir/airootfs/root/customize_airootfs.sh" +fi + mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror" echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref" @@ -105,10 +320,15 @@ if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then fi # Node.js binary for offline mise install. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + NODE_TARBALL_SUFFIX="linux-arm64.tar.gz" +else + NODE_TARBALL_SUFFIX="linux-x64.tar.gz" +fi NODE_DIST_URL="https://nodejs.org/dist/latest" NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt") -NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}') -NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}') +NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "$NODE_TARBALL_SUFFIX" | awk '{print $2}') +NODE_SHA=$(echo "$NODE_SHASUMS" | grep "$NODE_TARBALL_SUFFIX" | awk '{print $1}') curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME" echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c - mkdir -p "$build_cache_dir/airootfs/opt/packages/" @@ -118,8 +338,23 @@ cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/" # The selected omarchy-settings package is needed here so its post_install hook # drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the # live initramfs. -arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted) -printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64" +arch_packages=(git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted) + +# linux-t2 is the Apple T2 kernel and exists only for x86_64. aarch64 boots the +# linux-aarch64 remapped into the list from releng above, so no kernel is added +# here for ARM. +if [[ $OMARCHY_ARCH != "aarch64" ]]; then + arch_packages=(linux-t2 "${arch_packages[@]}") +else + # Snapdragon laptops (Lenovo ThinkPad X13s and friends) need Qualcomm firmware + # loaded before the display controller, GPU, and USB/PCIe links come up. Without + # it the kernel boots to a black panel with no console, which is the failure we + # are chasing on real hardware -- QEMU never needed it because virtio needs no + # blobs. releng's list carries only linux-firmware, which no longer bundles the + # qcom blobs since upstream split them out. + arch_packages+=(linux-firmware-qcom) +fi +printf '%s\n' "${arch_packages[@]}" >> "$pkg_list" # The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so # stock linux is a second kernel nobody boots: ~147MB of ISO, plus its own archiso @@ -132,7 +367,13 @@ printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64" # install is entirely offline and the live environment needs no Wi-Fi driver. # # Anchored so linux-t2 and linux-firmware are untouched. -sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64" +# +# x86_64 only: on aarch64 there is no second kernel to drop -- linux-aarch64 is +# the one the ISO boots -- and broadcom-wl was already pruned when the list was +# derived from releng above. +if [[ $OMARCHY_ARCH != "aarch64" ]]; then + sed -i -E '/^(linux|broadcom-wl)$/d' "$pkg_list" +fi # Build the offline mirror: everything pacstrap might want during the target # install. With --local-source, the omarchy* packages we just built are @@ -147,8 +388,11 @@ else bootstrap_cache_dir=/tmp/omarchy-pkg-bootstrap rm -rf "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap /tmp/omarchy-pkglists mkdir -p "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap - pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_RUNTIME_PACKAGE" --cachedir "$bootstrap_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null - omarchy_pkg=$(find "$bootstrap_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_RUNTIME_PACKAGE-*.pkg.tar.zst" | sort | head -1) + pacman --config "$pacman_online_conf" --noconfirm -Syw "$OMARCHY_RUNTIME_PACKAGE" --cachedir "$bootstrap_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null + # Package compression follows the distribution: Arch ships zstd, Arch Linux ARM + # xz. Accept either rather than assuming the archive this repo was written on. + omarchy_pkg=$(find "$bootstrap_cache_dir" -maxdepth 1 -type f \ + \( -name "$OMARCHY_RUNTIME_PACKAGE-*.pkg.tar.zst" -o -name "$OMARCHY_RUNTIME_PACKAGE-*.pkg.tar.xz" \) | sort | head -1) if [[ -z $omarchy_pkg ]]; then echo "ERROR: downloaded package for $OMARCHY_RUNTIME_PACKAGE not found in $bootstrap_cache_dir" >&2 exit 1 @@ -168,6 +412,19 @@ mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" +# These shipped copies are what the installer pacstraps from at install time, so +# they must exclude the same packages the offline mirror does. Filtering only the +# mirror would produce an ISO that builds cleanly and then fails during install +# on the first x86-only package the list still names. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + for shipped in omarchy-base.packages omarchy-other.packages; do + shipped_path="$build_cache_dir/airootfs/usr/share/omarchy-iso/$shipped" + grep -Fxv -f <(grep -hv '^#\|^$' /builder/aarch64-excludes.packages) \ + "$shipped_path" >"$shipped_path.filtered" || true + mv "$shipped_path.filtered" "$shipped_path" + done +fi + # The configurator's setup form comes from the runtime this ISO bundles, so the # installer and the first-boot setup that finishes a deferred install can never # disagree. A runtime predating the split ships no such file, which would leave @@ -191,31 +448,59 @@ cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh" declare -a all_packages mapfile -t all_packages < <( { - cat "$build_cache_dir/packages.x86_64" + cat "$pkg_list" grep -hv '^#\|^$' "${base_pkg_lists[@]}" + # Microcode is handled by aarch64-excludes.packages, applied below. grep -hv '^#\|^$' /builder/archinstall.packages # Always include the selected Omarchy packages so the target install can # find the runtime and companion packages in the offline mirror. printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE" + # Platform packages are downloaded into the offline mirror but are not + # installed in the live root. The installer selects only the packages for + # the machine whose SMBIOS record matched the AArch64 platform manifest. + if [[ $OMARCHY_ARCH == "aarch64" ]]; then + # Installed during the early bootstrap so the target trusts ALARM package + # signatures before its first network-backed pacman transaction. + printf '%s\n' archlinuxarm-keyring + jq -r '.platforms[].packages[]?' /configs/aarch64/platforms.json + fi } | sort -u ) +# Omarchy's package lists are written for x86_64 and name hardware support that +# cannot exist on ARM (Apple T2, Intel graphics, NVIDIA, x86 laptop drivers) plus +# a few things Arch Linux ARM does not carry. pacman aborts the entire -Syw on +# the first unresolvable target, so these are dropped before the mirror is built. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + before=${#all_packages[@]} + mapfile -t all_packages < <( + printf '%s\n' "${all_packages[@]}" | + grep -Fxv -f <(grep -hv '^#\|^$' /builder/aarch64-excludes.packages) + ) + echo "aarch64: excluded $((before - ${#all_packages[@]})) x86-only packages from the offline mirror" +fi + # With --local-source we already built these omarchy* packages directly into # the mirror; strip them from the pacman -Syw list so it doesn't try to fetch # the published versions on top. if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then + # OMARCHY_EXTRA_PKGBUILDS were built locally too, so they must be stripped as + # well -- they are precisely the packages with no published build for this + # architecture, and leaving them in makes pacman abort the whole -Syw. + read -ra _extra_built <<<"${OMARCHY_EXTRA_PKGBUILDS:-}" mapfile -t all_packages < <( printf '%s\n' "${all_packages[@]}" | grep -Fxv \ -e "$OMARCHY_RUNTIME_PACKAGE" \ -e "$OMARCHY_SETTINGS_PACKAGE" \ - -e "$OMARCHY_NVIM_PACKAGE" || true + -e "$OMARCHY_NVIM_PACKAGE" \ + "${_extra_built[@]/#/-e}" || true ) fi mkdir -p /tmp/offlinedb download_offline_packages() { - pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \ + pacman --config "$pacman_online_conf" --noconfirm -Syw \ "${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed } @@ -233,7 +518,7 @@ fi # newest version of every cached package name) removes packages that have left # the lists or dependency closure, such as an old Electron major version. if ! resolved_package_files="$( - pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \ + pacman --config "$pacman_online_conf" --noconfirm \ --dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}" )"; then echo "ERROR: could not resolve the package files required by the offline mirror" >&2 @@ -245,8 +530,13 @@ mapfile -t required_package_files <<< "$resolved_package_files" # checkouts. Add those exact artifacts back to the keep-set after verifying # that the local build left exactly one file for each selected package name. if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then + # OMARCHY_EXTRA_PKGBUILDS were built into the mirror alongside the Omarchy + # packages and are equally absent from the -Syw resolution, so they need the + # same treatment or the prune below deletes them again. + read -ra _extra_local <<<"${OMARCHY_EXTRA_PKGBUILDS:-}" for local_package_name in \ - "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do + "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE" \ + "${_extra_local[@]}"; do local_package_file="" for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do [[ -f $candidate && $candidate != *.sig ]] || continue @@ -272,7 +562,79 @@ printf '%s\n' "${required_package_files[@]}" | # Rebuild the offline repo db from scratch so size/checksum/depends entries # always reflect only the package files selected for this build. rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files* -repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst +# Index whichever compression this distribution's packages use -- zstd on Arch, +# xz on Arch Linux ARM. A glob for one alone silently indexes nothing on the +# other, producing an ISO that boots and then installs no packages. +shopt -s nullglob +offline_pkgs=("$offline_mirror_dir"/*.pkg.tar.zst "$offline_mirror_dir"/*.pkg.tar.xz) +shopt -u nullglob +if (( ${#offline_pkgs[@]} == 0 )); then + echo "ERROR: no packages found in $offline_mirror_dir to index" >&2 + exit 1 +fi +repo-add "$offline_mirror_dir/offline.db.tar.gz" "${offline_pkgs[@]}" + +# aarch64 boot: stage Snapdragon X device trees where GRUB can read them. +# +# Qualcomm's UEFI publishes ACPI only, and Linux has no ACPI support for +# x1e80100 -- it needs a flattened device tree or it dies before any console +# exists, which is the silent black screen a Yoga Slim 7x shows. GRUB's +# `devicetree` command installs one into the EFI configuration table, so the +# blob has to be reachable from the boot medium *before* the kernel is loaded. +# +# The DTBs are already on the ISO: linux-aarch64 ships /boot/dtbs/ and the live +# root carries all 1507 of them. But they are inside the squashfs, which nothing +# can read at GRUB time, so a second copy is staged outside it. +# +# It goes into the profile's grub/ directory rather than the ESP on purpose. +# mkarchiso's _make_bootmode_uefi.grub copies every non-*.cfg entry of +# ${profile}/grub/ straight into ISO 9660 at /boot/grub/ with cp -r (see +# archiso/archiso/mkarchiso, "Copy GRUB files"; shopt -s extglob is set at the +# top of that script), so this needs no mkarchiso patch. And the FAT ESP is +# sized for BOOTAA64.EFI alone -- _make_boot_on_fat is not even called on the +# grub path, so the kernel, initramfs and these blobs all live on ISO 9660. +# +# The source is the package file the live root is actually pacstrapped from +# (pacman-offline.conf's only repo is this mirror, and it has just been pruned +# to the resolved set), so the staged DTB can never be a different kernel +# version from the one that boots it. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + # This is the canonical list of machines whose UEFI does not provide Linux + # with a usable hardware description. Do not stage all kernel DTBs: normal + # ARM firmware already supplies one, and loading the wrong board's DTB is + # unsafe. The manifest will also drive live and installed boot selection. + platform_manifest=/configs/aarch64/platforms.json + mapfile -t platform_dtbs < <( + jq -er '.platforms[] | select(.boot.hardware_description == "dtb-override") | .boot.dtb' \ + "$platform_manifest" | sort -u + ) + (( ${#platform_dtbs[@]} > 0 )) || { + echo "ERROR: no AArch64 platform DTBs in $platform_manifest" >&2 + exit 1 + } + + # Anchored on a digit: "linux-aarch64-" also prefixes linux-aarch64-headers. + kernel_pkg_file=$(printf '%s\n' "${offline_pkgs[@]}" | + grep -E '/linux-aarch64-[0-9]' | head -1) + if [[ -z $kernel_pkg_file ]]; then + echo "ERROR: no linux-aarch64 package in the offline mirror to take DTBs from" >&2 + exit 1 + fi + + dtb_stage_dir="$build_cache_dir/grub/dtbs" + mkdir -p "$dtb_stage_dir" + bsdtar -xf "$kernel_pkg_file" -C "$dtb_stage_dir" \ + --strip-components=2 "${platform_dtbs[@]/#/boot/dtbs/}" + + # Fail the build rather than ship an ISO whose Snapdragon entry silently does + # not exist: the grub.cfg guard is an -f test, so a missing blob is invisible + # at boot -- the menu entry just is not there. + for _dtb in "${platform_dtbs[@]}"; do + [[ -s "$dtb_stage_dir/$_dtb" ]] || + { echo "ERROR: $_dtb missing from $(basename "$kernel_pkg_file")" >&2; exit 1; } + done + echo "aarch64: staged ${#platform_dtbs[@]} platform device tree(s) from $(basename "$kernel_pkg_file")" +fi # mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the # container (the airootfs path); symlink rather than duplicate. @@ -306,7 +668,18 @@ resolve_expected_packages() { "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \ "$OMARCHY_NVIM_PACKAGE" - } | sort -u + [[ $OMARCHY_ARCH == "aarch64" ]] && printf '%s\n' archlinuxarm-keyring + } | sort -u | { + # The shipped omarchy-base.packages is already filtered, but + # archinstall.packages is read raw here and still names both microcode + # packages. Apply the same exclusions so this resolves against exactly + # what the mirror holds. + if [[ $OMARCHY_ARCH == "aarch64" ]]; then + grep -Fxv -f <(grep -hv '^#\|^$' /builder/aarch64-excludes.packages) + else + cat + fi + } ) pacman --config "$build_cache_dir/pacman-offline.conf" \ @@ -343,11 +716,73 @@ else echo "Target install resolves to $expected_packages packages." fi -# Live ISO uses the same offline pacman.conf. +# Preserve the network configuration that the installed aarch64 system should +# use after every offline install/finalization step is complete. The build can +# download from a file:// cache, so that override is not automatically suitable +# for the installed machine; OMARCHY_TARGET_PKGS_MIRROR separates the two. For +# normal network builds, reuse OMARCHY_PKGS_MIRROR. Otherwise fall back to the +# channel's tracked public Omarchy URL. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + target_pkgs_mirror="${OMARCHY_TARGET_PKGS_MIRROR:-${OMARCHY_PKGS_MIRROR:-}}" + if [[ $target_pkgs_mirror != http://* && $target_pkgs_mirror != https://* ]]; then + target_pkgs_mirror=$(awk ' + /^\[omarchy\]$/ { in_omarchy=1; next } + /^\[/ { in_omarchy=0 } + in_omarchy && /^Server = / { sub(/^Server = /, ""); print; exit } + ' "/configs/aarch64/pacman-online-${OMARCHY_MIRROR}.conf") + fi + [[ $target_pkgs_mirror == http://* || $target_pkgs_mirror == https://* ]] || + { echo "ERROR: no HTTP(S) Omarchy repository configured for the installed aarch64 system" >&2; exit 1; } + + install -Dm644 /configs/aarch64/pacman-target.conf \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso/pacman-target.conf" + sed -i "s|@@OMARCHY_PKGS_MIRROR@@|$target_pkgs_mirror|" \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso/pacman-target.conf" + install -Dm644 /configs/aarch64/mirrorlist-target \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso/mirrorlist-target" +fi + +# Live ISO uses the offline pacman.conf throughout installation. cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf" # Build the ISO. -mkarchiso -v -w "$build_cache_dir/work/" -o /out/ "$build_cache_dir/" +# On aarch64 there is no archiso package, so run the pinned submodule's copy. +if [[ $OMARCHY_ARCH == "aarch64" ]]; then + # /archiso is mounted read-only and is a pinned upstream checkout, so patch a + # working copy instead of the source. + # + # archiso's grub-mkstandalone module list is x86-derived. arm64-efi ships no + # PS/2 keyboard or USB-serial modules, and grub-mkstandalone aborts on the + # first module it cannot open rather than skipping it. EFI firmware provides + # console input on ARM, so dropping them costs nothing. Everything else in the + # list exists for arm64-efi. + install -m755 /archiso/archiso/mkarchiso "$build_cache_dir/mkarchiso" + sed -i -E '/grubmodules=\(/,/zstd\)/ s/\b(at_keyboard|keylayouts|usb|usbserial_common|usbserial_ftdi|usbserial_pl2303|usbserial_usbdebug)\b ?//g' \ + "$build_cache_dir/mkarchiso" + + # Force a FAT32 ESP. + # + # archiso sizes the EFI image from its contents plus 8 MiB of slack, then only + # asks for FAT32 at >= 36 MiB. On aarch64 the ESP holds BOOTAA64.EFI and + # nothing else (_make_boot_on_fat is not called on the grub path -- the kernel + # and initramfs are read off ISO 9660), so it lands at 16 MiB and mkfs.fat + # picks FAT16. + # + # The UEFI spec only mandates FAT32 for the ESP on non-removable media, so + # that is legal -- but several ARM64 firmwares, Qualcomm's among the + # more-reported, simply do not enumerate a FAT16 ESP on removable media. The + # stick then never appears as a boot option at all, which looks identical to a + # corrupt image. + # + # Raising the floor to 40 MiB trips archiso's own existing FAT32 branch rather + # than duplicating its logic, and costs ~24 MiB of ISO. + sed -i -E 's/^( )if \(\( imgsize_kib >= 36864 \)\); then$/\1(( imgsize_kib < 40960 )) \&\& imgsize_kib=40960\n\1if (( imgsize_kib >= 36864 )); then/' \ + "$build_cache_dir/mkarchiso" + mkarchiso_bin="$build_cache_dir/mkarchiso" +else + mkarchiso_bin=mkarchiso +fi +"$mkarchiso_bin" -v -w "$build_cache_dir/work/" -o /out/ "$build_cache_dir/" # Match host UID/GID on output. if [[ -n $HOST_UID && -n $HOST_GID ]]; then diff --git a/builder/build-omarchy-packages.sh b/builder/build-omarchy-packages.sh index 5f7173fb..eb9a180b 100755 --- a/builder/build-omarchy-packages.sh +++ b/builder/build-omarchy-packages.sh @@ -1,6 +1,6 @@ #!/bin/bash # Build Omarchy packages from mounted source (/omarchy-source + /omarchy-pkgs) -# and place the resulting .pkg.tar.zst files in the offline mirror. +# and place the resulting package files in the offline mirror. set -e @@ -42,11 +42,31 @@ packages=( "$OMARCHY_NVIM_PACKAGE" ) +# Space-separated extra pkgbuilds from omarchy-pkgs to build alongside the +# Omarchy packages. Needed when a package the install lists require has no build +# published for this architecture yet -- on aarch64 that is tzupdate, tensaku, +# and hyprland-preview-share-picker, which the live ISO and target install both +# expect. The live ISO pacstraps from the offline mirror (profiledef.sh sets +# pacman_conf=pacman-offline.conf), so building them here is enough; they do not +# need publishing to a repo first. +if [[ -n ${OMARCHY_EXTRA_PKGBUILDS:-} ]]; then + read -ra _extra <<<"$OMARCHY_EXTRA_PKGBUILDS" + packages+=("${_extra[@]}") + echo "Also building: ${_extra[*]}" +fi + # Local-source packages must replace every cached build of the same package, # even when the checkout's generated pkgver sorts below a published build. # Otherwise the later generic cache pruning can silently keep edge instead. for pkg in "${packages[@]}"; do rm -f "$offline_mirror_dir/$pkg-"*.pkg.tar.* + + # The host's pacman cache is bind-mounted in and shared across builds. A local + # rebuild produces the same filename with different content, and pacman + # prefers a cached file over the mirror copy -- so a stale entry fails + # checksum validation during pacstrap. Drop only what we are rebuilding, + # rather than the whole host cache. + rm -f "/var/cache/pacman/pkg/$pkg-"*.pkg.tar.* done for pkg in "${packages[@]}"; do @@ -61,16 +81,40 @@ for pkg in "${packages[@]}"; do cp -a "/omarchy-pkgs/pkgbuilds/$pkg" "$pkg_work" chown -R builder:builder "$pkg_work" + # Omarchy's pkgbuilds declare arch=('x86_64') because that is all upstream + # targets, but they build from source and carry nothing x86-specific, so + # makepkg's architecture check is the only thing stopping them on ARM. None + # declare arch=('any'), so the output is still tagged aarch64 correctly. + # The cleaner fix is adding aarch64 to those arch=() arrays in omarchy-pkgs. + makepkg_args=(--noconfirm --skippgpcheck --skipchecksums -f) + [[ ${OMARCHY_ARCH:-x86_64} == "aarch64" ]] && makepkg_args+=(--ignorearch) + + # The Omarchy packages depend on each other and on packages this build has not + # published yet, so they are built --nodeps deliberately. The extras are + # ordinary third-party software whose dependencies all resolve from the + # distribution repos -- gtk4, libadwaita and the Rust toolchain among them -- + # so let makepkg install them. builder has passwordless sudo for pacman above, + # which is what --syncdeps needs. + if [[ " ${_extra[*]:-} " == *" $pkg "* ]]; then + makepkg_args+=(--syncdeps) + else + makepkg_args+=(--nodeps) + fi + su builder -c " cd '$pkg_work' && PKGDEST='$work_dir' \ OMARCHY_SRC=/omarchy-source \ - makepkg --noconfirm --skippgpcheck --skipchecksums --nodeps -f + makepkg ${makepkg_args[*]} " done mkdir -p "$offline_mirror_dir" -for package_file in "$work_dir"/*.pkg.tar.zst; do + +# makepkg's PKGEXT is distribution-set: Arch defaults to .pkg.tar.zst, Arch Linux +# ARM to .pkg.tar.xz. Match whatever this container produced rather than assuming. +shopt -s nullglob +for package_file in "$work_dir"/*.pkg.tar.zst "$work_dir"/*.pkg.tar.xz; do destination="$offline_mirror_dir/$(basename "$package_file")" # A cached signature belongs to the previously downloaded or locally built @@ -82,4 +126,4 @@ done echo echo "Built Omarchy packages, placed in $offline_mirror_dir:" -ls "$offline_mirror_dir"/omarchy*.pkg.tar.zst | sed 's|^| |' +ls "$offline_mirror_dir"/omarchy*.pkg.tar.zst "$offline_mirror_dir"/omarchy*.pkg.tar.xz 2>/dev/null | sed 's|^| |' diff --git a/configs/aarch64/mirrorlist-target b/configs/aarch64/mirrorlist-target new file mode 100644 index 00000000..edf60796 --- /dev/null +++ b/configs/aarch64/mirrorlist-target @@ -0,0 +1,7 @@ +# HTTPS endpoints verified across core, extra, and alarm. The CDN gives global +# reach; the remaining mirrors provide independent European and US fallbacks. +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo diff --git a/configs/aarch64/pacman-online-edge.conf b/configs/aarch64/pacman-online-edge.conf new file mode 100644 index 00000000..4d08b447 --- /dev/null +++ b/configs/aarch64/pacman-online-edge.conf @@ -0,0 +1,45 @@ +# +# /etc/pacman.conf (aarch64, edge channel) +# +# Arch proper is x86_64-only -- there is no core/os/aarch64 -- so the ARM base +# comes from Arch Linux ARM. ALARM also lays its tree out differently: the path +# is $arch/$repo, not Arch's $repo/os/$arch, and there is no /os/ component. The +# Server lines below therefore cannot be produced by re-pointing the x86_64 +# config at another host. +# +# ALARM carries a third base repo, [alarm], alongside core and extra. There is no +# [multilib] (32-bit x86) and no [arch-mact2] (Apple T2 Macs, x86-only). +# +[options] +HoldPkg = pacman glibc +Architecture = auto +ParallelDownloads = 5 +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# Keep the Omarchy compatibility overlay first so tested ARM overrides win +# during an incomplete ALARM ABI transition. +[omarchy] +SigLevel = Optional TrustAll +Server = https://pkgs.omarchy.org/edge/$arch + +[core] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo + +[extra] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo + +[alarm] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo diff --git a/configs/aarch64/pacman-online-rc.conf b/configs/aarch64/pacman-online-rc.conf new file mode 100644 index 00000000..d0a041f0 --- /dev/null +++ b/configs/aarch64/pacman-online-rc.conf @@ -0,0 +1,45 @@ +# +# /etc/pacman.conf (aarch64, rc channel) +# +# Arch proper is x86_64-only -- there is no core/os/aarch64 -- so the ARM base +# comes from Arch Linux ARM. ALARM also lays its tree out differently: the path +# is $arch/$repo, not Arch's $repo/os/$arch, and there is no /os/ component. The +# Server lines below therefore cannot be produced by re-pointing the x86_64 +# config at another host. +# +# ALARM carries a third base repo, [alarm], alongside core and extra. There is no +# [multilib] (32-bit x86) and no [arch-mact2] (Apple T2 Macs, x86-only). +# +[options] +HoldPkg = pacman glibc +Architecture = auto +ParallelDownloads = 5 +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# Keep the Omarchy compatibility overlay first so tested ARM overrides win +# during an incomplete ALARM ABI transition. +[omarchy] +SigLevel = Optional TrustAll +Server = https://pkgs.omarchy.org/rc/$arch + +[core] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo + +[extra] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo + +[alarm] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo diff --git a/configs/aarch64/pacman-online-stable.conf b/configs/aarch64/pacman-online-stable.conf new file mode 100644 index 00000000..0c4b83df --- /dev/null +++ b/configs/aarch64/pacman-online-stable.conf @@ -0,0 +1,45 @@ +# +# /etc/pacman.conf (aarch64) +# +# Arch proper is x86_64-only -- there is no core/os/aarch64 -- so the ARM base +# comes from Arch Linux ARM. ALARM also lays its tree out differently: the path +# is $arch/$repo, not Arch's $repo/os/$arch, and there is no /os/ component. The +# Server lines below therefore cannot be produced by re-pointing the x86_64 +# config at another host. +# +# ALARM carries a third base repo, [alarm], alongside core and extra. There is no +# [multilib] (32-bit x86) and no [arch-mact2] (Apple T2 Macs, x86-only). +# +[options] +HoldPkg = pacman glibc +Architecture = auto +ParallelDownloads = 5 +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# Keep the Omarchy compatibility overlay first so tested ARM overrides win +# during an incomplete ALARM ABI transition. +[omarchy] +SigLevel = Optional TrustAll +Server = https://pkgs.omarchy.org/stable/$arch + +[core] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo + +[extra] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo + +[alarm] +Server = https://cdnmirror.com/archlinuxarm/$arch/$repo +Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo +Server = https://de3.mirror.archlinuxarm.org/$arch/$repo +Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo +Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo diff --git a/configs/aarch64/pacman-target.conf b/configs/aarch64/pacman-target.conf new file mode 100644 index 00000000..501d8f51 --- /dev/null +++ b/configs/aarch64/pacman-target.conf @@ -0,0 +1,30 @@ +# See the pacman.conf(5) manpage for option and repository directives + +[options] +Color +ILoveCandy +VerbosePkgLists +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +ParallelDownloads = 5 +DownloadUser = alpm + +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# The Omarchy ARM repository is an overlay, not just an add-on repository. It +# must precede ALARM so a tested compatibility package can quarantine a broken +# upstream transition without requiring a full downstream mirror. +[omarchy] +SigLevel = Optional TrustAll +Server = @@OMARCHY_PKGS_MIRROR@@ + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[alarm] +Include = /etc/pacman.d/mirrorlist diff --git a/configs/aarch64/platforms.json b/configs/aarch64/platforms.json new file mode 100644 index 00000000..d3e339f0 --- /dev/null +++ b/configs/aarch64/platforms.json @@ -0,0 +1,113 @@ +{ + "schema_version": 1, + "description": "AArch64 UEFI platform boot and kernel requirements. A DTB override is only declared when firmware does not provide Linux with a usable hardware description.", + "platforms": [ + { + "id": "lenovo-yoga-slim7x", + "name": "Lenovo Yoga Slim 7x", + "match": [ + { + "sys_vendor": "LENOVO", + "product_name": "83ED" + } + ], + "boot": { + "hardware_description": "dtb-override", + "dtb": "qcom/x1e80100-lenovo-yoga-slim7x.dtb", + "kernel_cmdline": [ + "rd.udev.event_timeout=5", + "clk_ignore_unused", + "pd_ignore_unused", + "cma=128M", + "efi=noruntime", + "console=tty0" + ] + }, + "kernel": { + "package": "linux-aarch64", + "availability": "iso" + }, + "initramfs": { + "modules": [ + "sbsa_gwdt", + "msm", + "dispcc-x1e80100", + "gpucc-x1e80100", + "phy-qcom-edp", + "ps883x", + "qrtr", + "pmic_glink", + "pmic_glink_altmode", + "ucsi_glink", + "i2c_hid_of", + "hid_multitouch", + "hid_lenovo", + "gpio_keys" + ], + "files": [ + "/usr/lib/firmware/qcom/gen70500_sqe.fw", + "/usr/lib/firmware/qcom/gen70500_gmu.bin", + "/usr/lib/firmware/qcom/x1e80100/LENOVO/83ED/qcdxkmsuc8380.mbn" + ] + }, + "packages": [ + "linux-firmware-qcom" + ] + }, + { + "id": "nvidia-dgx-spark", + "name": "NVIDIA DGX Spark", + "match": [ + { + "sys_vendor": "NVIDIA", + "product_name": "NVIDIA_DGX_Spark" + } + ], + "boot": { + "hardware_description": "firmware", + "kernel_cmdline": [ + "plymouth.enable=0", + "nvidia_drm.fbdev=1", + "console=tty0", + "loglevel=7" + ] + }, + "kernel": { + "package": "linux-aarch64", + "availability": "iso" + }, + "packages": [ + "linux-aarch64-headers", + "linux-firmware-nvidia", + "nvidia-open-dkms", + "nvidia-utils", + "nvidia-container-toolkit" + ] + }, + { + "id": "asus-ascent-gx10", + "name": "ASUS Ascent GX10", + "match": [ + { + "sys_vendor": "ASUSTeK COMPUTER INC.", + "product_name": "GX10" + } + ], + "boot": { + "hardware_description": "firmware", + "kernel_cmdline": [] + }, + "kernel": { + "package": "linux-aarch64", + "availability": "iso" + }, + "packages": [ + "linux-aarch64-headers", + "linux-firmware-nvidia", + "nvidia-open-dkms", + "nvidia-utils", + "nvidia-container-toolkit" + ] + } + ] +} diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 007b9331..da5fc0ee 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -9,6 +9,35 @@ OMARCHY_SETTINGS_PACKAGE="${OMARCHY_SETTINGS_PACKAGE:-omarchy-settings}" LOGO_PATH="$OMARCHY_PATH/logo.txt" +# Name of the limine loader as it will sit on the ESP. Limine ships every +# architecture's binary in one package, so the installer cannot assume: copying +# the x86_64 loader onto an ARM64 ESP succeeds and leaves a system the firmware +# cannot boot. The live ISO installs to the machine it runs on, so uname is the +# target's architecture. Mirrors efi_binary_name() in the orchestrator's +# context.py -- change both together. +case "$(uname -m)" in +aarch64) OMARCHY_EFI_BINARY="limine_aa64.efi" ;; +*) OMARCHY_EFI_BINARY="limine_x64.efi" ;; +esac + +# Mirrors archinstall writes into the installed system. Arch proper publishes no +# aarch64 tree, so ARM installs come from Arch Linux ARM -- which also lays its +# repos out as $arch/$repo rather than Arch's $repo/os/$arch, so these cannot be +# the same URLs pointed at a different host. Single-quoted: the $repo and $arch +# placeholders belong to pacman, and the heredocs below expand only one level, so +# they survive into the generated config intact. +if [[ $(uname -m) == "aarch64" ]]; then + MIRROR_SERVERS_JSON=' {"url": "https://cdnmirror.com/archlinuxarm/$arch/$repo"}, + {"url": "https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo"}, + {"url": "https://de3.mirror.archlinuxarm.org/$arch/$repo"}, + {"url": "https://ca.us.mirror.archlinuxarm.org/$arch/$repo"}, + {"url": "https://fl.us.mirror.archlinuxarm.org/$arch/$repo"}' +else + MIRROR_SERVERS_JSON=' {"url": "https://mirror.omarchy.org/$repo/os/$arch"}, + {"url": "https://mirror.rackspace.com/archlinux/$repo/os/$arch"}, + {"url": "https://geo.mirror.pkgbuild.com/$repo/os/$arch"}' +fi + # The setup form — keyboard, account, hostname, and timezone questions, plus the # rules their answers are checked against — shared verbatim with the first-boot # owner setup that finishes a deferred install. build-iso.sh vendors it out of @@ -412,6 +441,13 @@ to_gb() { # T2 Macs need their own kernel for keyboard/wifi drivers. detect_kernel() { + # Arch Linux ARM ships no package named plain "linux" -- its generic ARMv8 + # kernel is linux-aarch64 -- and there are no T2 Macs to probe for on ARM. + if [[ $(uname -m) == "aarch64" ]]; then + echo "linux-aarch64" + return + fi + if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then echo "linux-t2" else @@ -805,7 +841,7 @@ run_partition_execute() { "boot": { "esp_mount": "$esp_mount_in_target", "esp_path": "/EFI/limine", - "efi_binary": "limine_x64.efi", + "efi_binary": "$OMARCHY_EFI_BINARY", "enable_fallback": false }, "storage": { @@ -837,9 +873,7 @@ run_partition_execute() { "mirror_config": { "custom_repositories": [], "custom_servers": [ - {"url": "https://mirror.omarchy.org/\$repo/os/\$arch"}, - {"url": "https://mirror.rackspace.com/archlinux/\$repo/os/\$arch"}, - {"url": "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"} +$MIRROR_SERVERS_JSON ], "mirror_regions": {}, "optional_repositories": [] @@ -1144,7 +1178,7 @@ cat <<-_EOF_ >user_configuration.json "boot": { "esp_mount": "/boot", "esp_path": "/EFI/limine", - "efi_binary": "limine_x64.efi", + "efi_binary": "$OMARCHY_EFI_BINARY", "enable_fallback": true }, "storage": { @@ -1226,9 +1260,7 @@ cat <<-_EOF_ >user_configuration.json "mirror_config": { "custom_repositories": [], "custom_servers": [ - {"url": "https://mirror.omarchy.org/\$repo/os/\$arch"}, - {"url": "https://mirror.rackspace.com/archlinux/\$repo/os/\$arch"}, - {"url": "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"} +$MIRROR_SERVERS_JSON ], "mirror_regions": {}, "optional_repositories": [] diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 9b5c50ff..60df2317 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -6,11 +6,47 @@ import json import os +import platform import secrets from dataclasses import dataclass, field from pathlib import Path from typing import Any +# Limine ships every architecture's EFI binary in one package, so the right one +# has to be chosen rather than assumed. The live ISO installs to the machine it +# is running on, so the running architecture is the target's. +# +# Getting this wrong does not fail loudly. BOOTX64.EFI exists on aarch64 too, +# so an x86_64 loader would be copied onto an ARM64 ESP and the install would +# report success while producing a system the firmware cannot boot. +# +# First element is the name limine ships; second is what the copy is called on +# the ESP. +_EFI_NAMES = { + "x86_64": ("BOOTX64.EFI", "limine_x64.efi"), + "aarch64": ("BOOTAA64.EFI", "limine_aa64.efi"), +} + + +def _efi_names() -> tuple[str, str]: + machine = platform.machine() + try: + return _EFI_NAMES[machine] + except KeyError: + raise RuntimeError( + f"no limine EFI binary is mapped for architecture {machine!r}" + ) from None + + +def efi_source_name() -> str: + """The limine binary to copy, as shipped in /usr/share/limine.""" + return _efi_names()[0] + + +def efi_binary_name() -> str: + """What that binary is called once copied onto the ESP.""" + return _efi_names()[1] + @dataclass class InstallContext: @@ -184,7 +220,7 @@ def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: "boot": { "esp_mount": "/boot", "esp_path": "/EFI/limine", - "efi_binary": "limine_x64.efi", + "efi_binary": efi_binary_name(), "enable_fallback": mode == "full_disk", }, "storage": {}, diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 9b6ebf5b..18b8c07a 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -37,6 +37,7 @@ def build_phases(ctx: InstallContext): stage_provisioning_state, finalize_limine_boot, run_chroot_finalizer, + configure_package_repositories, configure_dns_resolver, configure_login, configure_ssh_access, @@ -56,6 +57,7 @@ def build_phases(ctx: InstallContext): ("Staging provisioning", stage_provisioning_state), ("Finalizing Limine boot", finalize_limine_boot), ("Finalizing user", run_chroot_finalizer), + ("Configuring package repositories", configure_package_repositories), ("Configuring login", configure_login), ("Configuring SSH access", configure_ssh_access), ("Configuring Tailscale", configure_tailscale), 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 6d486245..0d575a26 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -23,8 +23,11 @@ from __future__ import annotations import hashlib +import json import os +import platform import re +import shlex import shutil import subprocess import textwrap @@ -34,11 +37,43 @@ from . import archinstall_adapter as arch from .command import capture, capture_identifier, require_text -from .context import InstallContext +from .context import InstallContext, efi_binary_name, efi_source_name from .keyboard import configure_keyboard from .ui import error, info +AARCH64_PLATFORM_MANIFEST = Path("/usr/share/omarchy-iso/aarch64-platforms.json") +AARCH64_TARGET_PACMAN_CONF = Path("/usr/share/omarchy-iso/pacman-target.conf") +AARCH64_TARGET_MIRRORLIST = Path("/usr/share/omarchy-iso/mirrorlist-target") +LIVE_PACMAN_CONF = Path("/etc/pacman.conf") +DMI_ID_ROOT = Path("/sys/class/dmi/id") + + +def _current_aarch64_platform() -> dict | None: + """Return the manifest entry matching this machine's SMBIOS identity.""" + if platform.machine() != "aarch64" or not AARCH64_PLATFORM_MANIFEST.exists(): + return None + + document = json.loads(AARCH64_PLATFORM_MANIFEST.read_text()) + dmi = {} + for field in ("sys_vendor", "product_name", "product_version"): + try: + dmi[field] = (DMI_ID_ROOT / field).read_text().strip() + except OSError: + dmi[field] = "" + + for candidate in document.get("platforms", []): + for selector in candidate.get("match", []): + if selector and all(dmi.get(key) == value for key, value in selector.items()): + return candidate + return None + + +def _aarch64_platform_packages() -> list[str]: + matched = _current_aarch64_platform() + return list(matched.get("packages", [])) if matched else [] + + # Package targets are written by builder/build-iso.sh. Stable ISOs use the # stable package names, while dev/local-source ISOs install the dev package # names explicitly instead of relying on provides=omarchy resolution. @@ -129,6 +164,7 @@ def _omarchy_nvim_package() -> str: "efibootmgr", "omarchy-keyring", ] +AARCH64_KEYRING_PACKAGE = "archlinuxarm-keyring" # Install LuaRocks before omarchy-nvim pulls in lua51-lpeg. Arch's lua-luarocks # post_install script tries to rebuild manifests for existing rocks trees before @@ -142,7 +178,10 @@ def _omarchy_nvim_package() -> str: def _early_bootstrap_packages() -> list[str]: - return [*EARLY_BOOTSTRAP_BASE_PACKAGES, _omarchy_settings_package()] + packages = [*EARLY_BOOTSTRAP_BASE_PACKAGES] + if platform.machine() == "aarch64": + packages.append(AARCH64_KEYRING_PACKAGE) + return [*packages, _omarchy_settings_package()] def _early_user_seed_packages() -> list[str]: @@ -226,7 +265,14 @@ def arch_install_system(ctx: InstallContext) -> None: config = handler.config pre_mounted = arch.is_pre_mount(config) - if not pre_mounted: + if pre_mounted: + # The pre-mounted contract only guarantees the root target. Reassert + # the ESP mount at the last boundary before pacstrap/kernel packages + # can write into /boot. This is intentionally keyed to archinstall's + # config, not Omarchy's mode label, so a metadata mismatch cannot turn + # the ESP path into an ordinary directory on the root filesystem. + verify_protected_mounts(ctx) + else: info("› partitioning + formatting + encrypting") arch.perform_filesystem_operations(config) @@ -332,7 +378,7 @@ def _configure_limine_boot(ctx: InstallContext, installer, config) -> None: info("› writing Limine config") if arch.is_pre_mount(config): - _write_pre_mounted_limine_defaults(ctx) + _write_pre_mounted_limine_defaults(ctx, config) else: _write_limine_defaults_from_config(ctx, installer, config) @@ -387,7 +433,7 @@ def _install_pre_mounted_limine(ctx: InstallContext) -> None: disk=Path(disk), part=part, esp_path=boot.get("esp_path", "/EFI/limine"), - efi_binary=boot.get("efi_binary", "limine_x64.efi"), + efi_binary=boot.get("efi_binary", efi_binary_name()), pre_state=pre_state, ) @@ -405,15 +451,17 @@ def _install_limine_efi( part: int, removable: bool = False, esp_path: str = "/EFI/limine", - efi_binary: str = "limine_x64.efi", + efi_binary: str | None = None, pre_state: dict | None = None, ) -> None: if removable: esp_path = "/EFI/BOOT" - efi_binary = "BOOTX64.EFI" + efi_binary = efi_source_name() + elif efi_binary is None: + efi_binary = efi_binary_name() limine_path = ctx.target / "usr" / "share" / "limine" - source_name = "BOOTX64.EFI" + source_name = efi_source_name() target_dir = Path(esp_mount) / esp_path.lstrip("/") target_path = target_dir / efi_binary _copy_required(limine_path / source_name, ctx.target / target_path.relative_to("/")) @@ -529,7 +577,19 @@ def _write_limine_defaults_from_config(ctx: InstallContext, installer, config) - raise RuntimeError(f"Could not detect root at mountpoint {ctx.target}") cmdline = " ".join(installer._get_kernel_params(root)) - _write_limine_defaults(ctx, cmdline, esp_mount=_installer_esp_mount(installer)) + _write_limine_defaults( + ctx, + cmdline, + esp_mount=_installer_esp_mount(installer), + enable_uki=_configured_uki(config), + ) + + +def _configured_uki(config) -> bool | None: + """Return an explicitly configured UKI choice, preserving "unspecified".""" + bootloader = getattr(config, "bootloader_config", None) + uki = getattr(bootloader, "uki", None) if bootloader is not None else None + return bool(uki) if uki is not None else None def _write_limine_defaults( @@ -538,6 +598,7 @@ def _write_limine_defaults( *, esp_mount: str, enable_fallback: bool | None = None, + enable_uki: bool | None = None, ) -> None: if not cmdline.strip(): raise RuntimeError("Could not compute kernel cmdline from install config") @@ -549,6 +610,11 @@ def _write_limine_defaults( default_text = re.sub(r'^ESP_PATH=.*$', f'ESP_PATH="{esp_mount}"', default_text, flags=re.MULTILINE) if enable_fallback is not None: default_text = default_text.rstrip() + f"\nENABLE_LIMINE_FALLBACK={'yes' if enable_fallback else 'no'}\n" + if enable_uki is not None: + # /etc/default/limine has higher priority than Omarchy's installed + # /etc/limine-entry-tool.d/omarchy-uki.conf. Without this override an + # explicit archinstall `uki: false` is silently changed back to yes. + default_text = default_text.rstrip() + f"\nENABLE_UKI={'yes' if enable_uki else 'no'}\n" if not arch.has_uefi(): default_text = default_text.rstrip() + "\nENABLE_UKI=no\nENABLE_LIMINE_FALLBACK=no\n" @@ -632,6 +698,29 @@ def _install_early_packages(installer) -> None: info(f"› installing early Omarchy packages: {', '.join(bootstrap_packages)}") installer.add_additional_packages(bootstrap_packages) + if platform.machine() == "aarch64": + # The ISO's offline repository deliberately disables signature checks, + # so this package can bootstrap ALARM's trust root without a circular + # dependency. Make the post-install result explicit: a missing or + # unpopulated keyring must fail the install here, not the user's first + # network pacman transaction with an opaque "unknown trust" error. + subprocess.run( + ["arch-chroot", str(installer.target), "pacman-key", "--init"], + check=True, + ) + subprocess.run( + [ + "arch-chroot", + str(installer.target), + "pacman-key", + "--populate", + "archlinux", + "archlinuxarm", + "omarchy", + ], + check=True, + ) + info(f"› installing LuaRocks prerequisites: {', '.join(EARLY_LUAROCKS_PACKAGES)}") installer.add_additional_packages(EARLY_LUAROCKS_PACKAGES) @@ -742,6 +831,9 @@ def _runtime_package_list(ctx: InstallContext) -> list[str]: continue if s not in already_installed and s not in pkgs: pkgs.append(s) + for package in _aarch64_platform_packages(): + if package not in already_installed and package not in pkgs: + pkgs.append(package) return pkgs @@ -755,7 +847,7 @@ def _boot_intent(ctx: InstallContext) -> dict: boot = dict(ctx.omarchy_install.get("boot") or {}) boot.setdefault("esp_mount", "/boot") boot.setdefault("esp_path", "/EFI/limine") - boot.setdefault("efi_binary", "limine_x64.efi") + boot.setdefault("efi_binary", efi_binary_name()) boot.setdefault("enable_fallback", not ctx.is_protected) return boot @@ -786,9 +878,13 @@ def verify_protected_mounts(ctx: InstallContext) -> None: esp_mp = target / boot["esp_mount"].lstrip("/") if not _is_mountpoint(esp_mp): esp_dev = storage["esp_device"] - info(f"› remounting protected ESP {esp_dev} at {esp_mp}") + info(f"› mounting protected ESP {esp_dev} at {esp_mp}") esp_mp.mkdir(parents=True, exist_ok=True) subprocess.run(["mount", esp_dev, str(esp_mp)], check=True) + if not _is_mountpoint(esp_mp): + raise RuntimeError( + f"protected mode: mount reported success but {esp_mp} is not a mountpoint" + ) info(f"› protected target verified: kernel={storage.get('kernel', 'linux')} esp={boot['esp_mount']}") @@ -876,7 +972,7 @@ def _build_pre_mounted_cmdline(ctx: InstallContext, btrfs_uuid: str) -> str: ) -def _write_pre_mounted_limine_defaults(ctx: InstallContext) -> None: +def _write_pre_mounted_limine_defaults(ctx: InstallContext, config=None) -> None: boot = _boot_intent(ctx) btrfs_uuid = _blkid_uuid(_btrfs_root_device(ctx)) cmdline = _build_pre_mounted_cmdline(ctx, btrfs_uuid) @@ -887,6 +983,7 @@ def _write_pre_mounted_limine_defaults(ctx: InstallContext) -> None: cmdline, esp_mount=boot["esp_mount"], enable_fallback=bool(boot.get("enable_fallback")), + enable_uki=_configured_uki(config) if config is not None else None, ) @@ -1007,7 +1104,7 @@ def _prepare_target_setup(ctx: InstallContext) -> None: if ctx.state.get("target_setup_prepared"): return - shutil.copy("/etc/pacman.conf", str(ctx.target / "etc" / "pacman.conf")) + shutil.copy(LIVE_PACMAN_CONF, ctx.target / "etc" / "pacman.conf") bind_mounts = [ ("/var/cache/omarchy/mirror/offline", "/var/cache/omarchy/mirror/offline"), @@ -1068,6 +1165,11 @@ def _target_user_env(ctx: InstallContext, user: str) -> list[str]: def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str | None = None) -> None: _prepare_target_setup(ctx) + # omarchy-apply-system restores the runtime's normal network pacman.conf at + # the end of system setup, but user finalization must remain offline too. + # Reassert the live ISO config before every target-side setup command. The + # final network configuration is installed afterwards. + _restore_aarch64_offline_pacman(ctx) omarchy_start_time, omarchy_start_epoch = _ensure_finalizer_log_started(ctx) target_log = ctx.target / "var" / "log" / "omarchy-install.log" @@ -1129,6 +1231,11 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str pass +def _restore_aarch64_offline_pacman(ctx: InstallContext) -> None: + if platform.machine() == "aarch64" and AARCH64_TARGET_PACMAN_CONF.is_file(): + shutil.copy(LIVE_PACMAN_CONF, ctx.target / "etc" / "pacman.conf") + + def run_system_finalizer(ctx: InstallContext) -> None: if ctx.defer_provisioning: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] @@ -1161,6 +1268,12 @@ def run_system_finalizer(ctx: InstallContext) -> None: PROVISION_KEYFILE = "etc/omarchy/provisioning.key" NODE_PACKAGES_DIR = Path("/opt/packages") +# Node's release tarballs use their own architecture tokens rather than uname's. +# builder/build-iso.sh picks the matching one at build time (NODE_TARBALL_SUFFIX) +# and this has to agree with it: a mismatch makes the bundled tarball invisible +# to the glob below, which hard-errors on every install. +NODE_ARCH_TOKENS = {"x86_64": "x64", "aarch64": "arm64"} + def stage_provisioning_state(ctx: InstallContext) -> None: # World-readable: first-boot finalization reads the Node tarball as the @@ -1200,7 +1313,11 @@ def stage_provisioning_state(ctx: InstallContext) -> None: def _stage_node_tarball(ctx: InstallContext, provisioning_dir) -> None: - tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.tar.gz")) + machine = platform.machine() + node_arch = NODE_ARCH_TOKENS.get(machine) + if node_arch is None: + raise RuntimeError(f"no Node tarball architecture is mapped for {machine!r}") + tarballs = sorted(NODE_PACKAGES_DIR.glob(f"node-v*-linux-{node_arch}.tar.gz")) if not tarballs: # Hard error on every install, not just deferred-provisioning installs: the stash is what lets a # later factory reset finalize the next owner offline, and an ISO @@ -1270,6 +1387,21 @@ 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). """ + if ctx.is_protected: + # Reassert this at the write boundary as well as before pacstrap. It + # turns any intervening loss of the ESP mount into an immediate mount + # error instead of letting limine-update write into the root volume. + verify_protected_mounts(ctx) + + # Arch Linux ARM's linux-aarch64 package uses the conventional /boot/Image + # name and, unlike Arch's kernel packages, ships neither of the files that + # limine-mkinitcpio-hook enumerates and consumes under usr/lib/modules: + # pkgbase and vmlinuz. Without this bridge limine-update processes zero + # kernels, exits successfully, and leaves the branding-only limine.conf in + # place. The later cryptdevice assertion then hides the real failure. + _prepare_aarch64_limine_kernel_layout(ctx) + matched_platform = _configure_aarch64_platform_boot(ctx) + if not (ctx.target / "usr" / "bin" / "limine-update").exists(): raise RuntimeError("/usr/bin/limine-update missing in target") @@ -1312,6 +1444,239 @@ def finalize_limine_boot(ctx: InstallContext) -> None: raise RuntimeError(f"{limine_conf} has no Omarchy entry") if "cryptdevice=" in cmdline and "cryptdevice=" not in limine_conf.read_text(): raise RuntimeError(f"encrypted install but {limine_conf} has no cryptdevice=") + if matched_platform: + boot = matched_platform["boot"] + if boot["hardware_description"] == "dtb-override": + expected = f'dtb_path: boot():/dtbs/{boot["dtb"]}' + if expected not in limine_conf.read_text(): + raise RuntimeError( + f"{limine_conf} has no DTB for {matched_platform['name']}: {expected}" + ) + + +def _configure_aarch64_platform_boot(ctx: InstallContext) -> dict | None: + """Install persistent boot configuration for the matched ARM machine. + + limine-entry-tool regenerates limine.conf on every kernel update, so a DTB + added only during installation disappears at the next update. Its post-hook + directory is the durable boundary: patch every generated Linux entry before + the later config-enrollment hook sees it. + """ + matched = _current_aarch64_platform() + if not matched: + return None + + info(f"› matched AArch64 platform: {matched['name']}") + target_manifest = ctx.target / "usr/share/omarchy-iso/aarch64-platforms.json" + target_manifest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(AARCH64_PLATFORM_MANIFEST, target_manifest) + + boot = matched["boot"] + arguments = boot.get("kernel_cmdline", []) + if arguments: + cmdline_dropin = ( + ctx.target / "etc/limine-entry-tool.d/80-omarchy-aarch64-platform.conf" + ) + cmdline_dropin.parent.mkdir(parents=True, exist_ok=True) + cmdline_dropin.write_text( + f'KERNEL_CMDLINE[default]+=" {" ".join(arguments)}"\n' + ) + + # mkinitcpio's autodetect runs while the installer is booted from its own + # root and may omit DT-described platform devices needed before the target + # root can be unlocked. Keep the board's early-boot set explicit so both + # the initial image and every later kernel-update image contain it. + initramfs = matched.get("initramfs", {}) + initramfs_modules = initramfs.get("modules", []) + initramfs_files = initramfs.get("files", []) + omitted_initramfs_hooks = initramfs.get("omit_hooks", []) + if initramfs_modules or initramfs_files or omitted_initramfs_hooks: + initramfs_dropin = ( + ctx.target / "etc/mkinitcpio.conf.d/zz-omarchy-aarch64-platform.conf" + ) + initramfs_dropin.parent.mkdir(parents=True, exist_ok=True) + lines = [] + if omitted_initramfs_hooks: + omitted = " ".join(shlex.quote(hook) for hook in omitted_initramfs_hooks) + lines.append( + textwrap.dedent( + f"""\ + for _omarchy_omit_hook in {omitted}; do + _omarchy_platform_hooks=() + for _omarchy_hook in "${{HOOKS[@]}}"; do + if [[ $_omarchy_hook != "$_omarchy_omit_hook" ]]; then + _omarchy_platform_hooks+=("$_omarchy_hook") + fi + done + HOOKS=("${{_omarchy_platform_hooks[@]}}") + done + unset _omarchy_platform_hooks _omarchy_hook _omarchy_omit_hook + """ + ) + ) + if initramfs_modules: + modules = " ".join(shlex.quote(module) for module in initramfs_modules) + lines.append(f"MODULES+=({modules})\n") + if initramfs_files: + files = " ".join(shlex.quote(path) for path in initramfs_files) + lines.append(f"FILES+=({files})\n") + initramfs_dropin.write_text("".join(lines)) + + if boot["hardware_description"] != "dtb-override": + return matched + + dtb = boot["dtb"] + esp_mount = _boot_intent(ctx)["esp_mount"] + dtb_file = ctx.target / esp_mount.lstrip("/") / "dtbs" / dtb + if not dtb_file.is_file() or dtb_file.stat().st_size == 0: + raise RuntimeError(f"device tree for {matched['name']} missing: {dtb_file}") + + hook = ctx.target / "etc/boot/hooks/post.d/80-omarchy-aarch64-platform" + hook.parent.mkdir(parents=True, exist_ok=True) + hook_text = """\ +#!/bin/bash +set -euo pipefail + +esp=${OMARCHY_LIMINE_ESP:-@@ESP@@} +config="$esp/limine.conf" +dtb_file="$esp/dtbs/@@DTB@@" +limine_dtb="boot():/dtbs/@@DTB@@" + +if [[ ! -s $config || ! -s $dtb_file ]]; then + echo "omarchy: cannot add platform DTB; missing $config or $dtb_file" >&2 + exit 100 +fi + +temporary=$(mktemp "${config}.omarchy.XXXXXX") +trap 'rm -f "$temporary"' EXIT +awk -v dtb="$limine_dtb" ' + { + option = $0 + sub(/^[[:space:]]*/, "", option) + if (option ~ /^dtb_path:/) next + + print + if ($0 ~ /^[[:space:]]*protocol:[[:space:]]*linux[[:space:]]*$/) { + match($0, /^[[:space:]]*/) + print substr($0, 1, RLENGTH) "dtb_path: " dtb + } + } +' "$config" >"$temporary" + +grep -qF "dtb_path: $limine_dtb" "$temporary" || { + echo "omarchy: Limine config contains no Linux entry for platform DTB" >&2 + exit 100 +} +mv "$temporary" "$config" +trap - EXIT +""" + hook.write_text( + hook_text.replace("@@ESP@@", shlex.quote(esp_mount)).replace("@@DTB@@", dtb) + ) + hook.chmod(0o755) + return matched + + +def _prepare_aarch64_limine_kernel_layout(ctx: InstallContext) -> None: + """Bridge Arch Linux ARM's kernel layout to limine-mkinitcpio-hook's. + + linux-aarch64's package-owned preset is the authoritative source for the + installed kernel version. Keep the compatibility files beside that + version's modules so the hook can discover the kernel and copy the exact + Image that the package installed on the ESP. + """ + if platform.machine() != "aarch64": + return + + preset = ctx.target / "etc/mkinitcpio.d/linux-aarch64.preset" + if not preset.exists(): + return + + match = re.search( + r"^\s*ALL_kver\s*=\s*(?:\"([^\"]+)\"|'([^']+)'|([^\s#]+))", + preset.read_text(), + flags=re.MULTILINE, + ) + if not match: + raise RuntimeError(f"{preset} has no ALL_kver") + kver = next(value for value in match.groups() if value is not None) + + modules_dir = ctx.target / "usr/lib/modules" / kver + image = ctx.target / "boot/Image" + if not modules_dir.is_dir(): + raise RuntimeError(f"linux-aarch64 modules directory missing: {modules_dir}") + if not image.is_file() or image.stat().st_size == 0: + raise RuntimeError(f"linux-aarch64 kernel image missing or empty: {image}") + + info(f"› preparing linux-aarch64 {kver} for Limine") + (modules_dir / "pkgbase").write_text("linux-aarch64\n") + shutil.copy2(image, modules_dir / "vmlinuz") + + _write_aarch64_mkinitcpio_module_compat(ctx) + + # limine-mkinitcpio-hook <= 1.36 rejects generated pkgbase files: its + # process_kernel() insists `pacman -Qqo` owns the marker before it calls + # set_kernel_context(). linux-aarch64 cannot satisfy that rule because its + # package contains no pkgbase at all. Permit this one generated marker only + # when the corresponding kernel package is installed; all other unowned + # markers retain the upstream rejection behavior. + # + # Version 1.38 discovers the package from its owned modules.builtin instead. + # That works for linux-aarch64 without modifying the hook, while our vmlinuz + # bridge above still supplies the kernel image at the path Limine consumes. + hook = ctx.target / "usr/share/libalpm/scripts/limine-mkinitcpio-install" + if not hook.exists(): + raise RuntimeError(f"Limine mkinitcpio hook missing: {hook}") + + marker = 'pacman -Qqo "$pkgbase_file" &>/dev/null || return 0' + modules_builtin_marker = ( + 'kernel_name="$(pacman -Qqo "${kernel_dir}/modules.builtin" 2>/dev/null)" ' + '|| return 0' + ) + replacement = """\ +if ! pacman -Qqo "$pkgbase_file" &>/dev/null; then + [[ $(<"$pkgbase_file") == "linux-aarch64" ]] && + pacman -Q linux-aarch64 &>/dev/null || return 0 +fi""" + hook_text = hook.read_text() + if replacement not in hook_text: + if marker in hook_text: + hook.write_text(hook_text.replace(marker, replacement, 1)) + elif modules_builtin_marker not in hook_text: + raise RuntimeError( + f"cannot add linux-aarch64 support: kernel ownership check not recognized in {hook}" + ) + + +def _write_aarch64_mkinitcpio_module_compat(ctx: InstallContext) -> None: + """Ignore Omarchy's optional Thunderbolt module when a kernel lacks it. + + omarchy-settings currently requests `thunderbolt` unconditionally. The + linux-aarch64 kernel used on Snapdragon does not ship that module, and + mkinitcpio treats the missing explicit MODULES entry as an error. Test the + kernel being built rather than the host architecture so this automatically + stops filtering if a future ARM kernel gains Thunderbolt support. + """ + # mkinitcpio orders drop-ins with `sort -V`. Numeric prefixes sort before + # alphabetic names, so a 99-* file would run before + # thunderbolt_module.conf and have nothing to remove yet. + dropin = ctx.target / "etc/mkinitcpio.conf.d/zz-omarchy-module-compat.conf" + dropin.parent.mkdir(parents=True, exist_ok=True) + dropin.write_text( + """\ +# The Omarchy defaults request thunderbolt on every architecture. Keep it +# when the selected kernel provides it; otherwise prevent a missing optional +# module from aborting the entire initramfs/boot-entry build. +if [[ -n ${KERNELVERSION:-} ]] && ! modinfo -k "$KERNELVERSION" thunderbolt &>/dev/null; then + _omarchy_modules=() + for _omarchy_module in "${MODULES[@]}"; do + [[ $_omarchy_module == thunderbolt ]] || _omarchy_modules+=("$_omarchy_module") + done + MODULES=("${_omarchy_modules[@]}") + unset _omarchy_modules _omarchy_module +fi +""" + ) def _strip_shell_quotes(value: str) -> str: @@ -1370,6 +1735,26 @@ def run_chroot_finalizer(ctx: InstallContext) -> None: ) +def configure_package_repositories(ctx: InstallContext) -> None: + """Switch an installed aarch64 system from the ISO mirror to ALARM. + + This build-stamped config can use a temporary package repository such as + the Snapdragon bootstrap. Later Omarchy package refreshes deliberately + replace that URL from their aarch64 template while retaining ALARM repos. + """ + if platform.machine() != "aarch64": + return + + source_conf = AARCH64_TARGET_PACMAN_CONF + source_mirrorlist = AARCH64_TARGET_MIRRORLIST + if not source_conf.is_file() or not source_mirrorlist.is_file(): + raise RuntimeError("aarch64 target package repository configuration is missing") + + (ctx.target / "etc" / "pacman.d").mkdir(parents=True, exist_ok=True) + shutil.copy(source_conf, ctx.target / "etc" / "pacman.conf") + shutil.copy(source_mirrorlist, ctx.target / "etc" / "pacman.d" / "mirrorlist") + + def configure_dns_resolver(ctx: InstallContext) -> None: """Put the installed system in systemd-resolved stub mode. @@ -1669,18 +2054,32 @@ def validate_boot(ctx: InstallContext) -> None: kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux"])[0] if arch.has_uefi(): - limine_binary = esp_mount / boot.get("esp_path", "/EFI/limine").lstrip("/") / boot.get("efi_binary", "limine_x64.efi") + limine_binary = esp_mount / boot.get("esp_path", "/EFI/limine").lstrip("/") / boot.get("efi_binary", efi_binary_name()) 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") == "yes": + uki_dir = esp_mount / "EFI" / "Linux" + 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") + else: + machine_id = (ctx.target / "etc/machine-id").read_text().strip() + kernel_dirs = [esp_mount / machine_id / name for name in candidates] + if not any( + (directory / "vmlinuz").is_file() + and (directory / "vmlinuz").stat().st_size + and (directory / "initramfs").is_file() + and (directory / "initramfs").stat().st_size + for directory in kernel_dirs + ): + raise RuntimeError( + f"no complete Limine kernel/initramfs entry under {esp_mount / machine_id}" + ) post = _read_efibootmgr() if not _find_label_entries(post["entries"], "Limine"): @@ -1742,8 +2141,9 @@ def _assert_boot_hooks_restored(ctx: InstallContext) -> None: raise RuntimeError(f"{path} is missing — future kernel updates would ship no UKI") -# Every kernel package leaves its pkgbase next to its modules, which is also -# the name limine-mkinitcpio-hook builds the UKI under. +# Arch kernel packages leave pkgbase next to their modules, which is also the +# name limine-mkinitcpio-hook builds the boot entry under. linux-aarch64 is +# normalized to that layout by _prepare_aarch64_limine_kernel_layout(). def _installed_kernels(ctx: InstallContext) -> list[str]: names = [] for pkgbase in sorted((ctx.target / "usr" / "lib" / "modules").glob("*/pkgbase")): diff --git a/configs/efiboot/loader/entries/01-archiso-aarch64-linux.conf b/configs/efiboot/loader/entries/01-archiso-aarch64-linux.conf new file mode 100644 index 00000000..1494ce48 --- /dev/null +++ b/configs/efiboot/loader/entries/01-archiso-aarch64-linux.conf @@ -0,0 +1,5 @@ +title Omarchy (aarch64, UEFI) +sort-key 01 +linux /%INSTALL_DIR%/boot/aarch64/vmlinuz-linux-aarch64 +initrd /%INSTALL_DIR%/boot/aarch64/initramfs-linux.img +options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash initramfs_async=0 diff --git a/configs/grub/grub.cfg b/configs/grub/grub.cfg index a0f67ef6..35fca47a 100644 --- a/configs/grub/grub.cfg +++ b/configs/grub/grub.cfg @@ -49,6 +49,25 @@ default=archlinux timeout=0 timeout_style=hidden +# aarch64 shows the menu; x86_64 keeps booting silently. +# +# ARM64 UEFI boxes are not interchangeable the way PCs are. Some need a device +# tree (see the Snapdragon entry below) and some must not be given one, and +# there is no way to tell from inside GRUB that is worth trusting when a black +# screen is the failure mode. A visible menu is what lets a person pick. +# +# It is also the only diagnostic this ISO has on a laptop that comes up +# headless. With timeout=0 and a hidden menu, "firmware never loaded +# BOOTAA64.EFI" and "the kernel died before it had a console" are the same black +# screen. A menu that renders proves GRUB ran, which splits the two. +# +# Gated on the DTB directory, which builder/build-iso.sh only stages on aarch64, +# so this needs no grub_cpu test and is inert on x86_64. +if [ -d /boot/grub/dtbs ]; then + timeout=10 + timeout_style=menu +fi + # Menu entries @@ -64,6 +83,73 @@ menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --h initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img } +# Snapdragon X (Qualcomm x1e80100) laptops. +# +# Four things this platform needs that no other supported aarch64 target does: +# +# devicetree Qualcomm's UEFI publishes ACPI only, and Linux has no +# ACPI support for x1e80100. Without an FDT in the EFI +# configuration table the kernel dies before it reaches a +# console. Loading it from the default entry would break +# every target that works today -- QEMU virt, Graviton and +# Ampere all boot from their own DT or from ACPI, and this +# DT describes hardware they do not have. Hence a separate +# entry rather than a change to the default one. +# +# clk_ignore_unused The x1e80100 clock and power-domain drivers gate anything +# pd_ignore_unused with no bound consumer. Display, USB and PCIe bind late, +# so the kernel powers off the panel and the links it is +# about to need. +# +# efi=noruntime Qualcomm's UEFI runtime services hang the kernel. Note +# this also takes efivars away for the rest of the session, +# which the installer's efibootmgr call cannot survive. +# +# cma=128M linux-aarch64 reserves 64M (CONFIG_CMA_SIZE_MBYTES=64); +# Adreno and MDSS want more. +# +# rd.udev.event_timeout=5 +# Limit a stuck initramfs udev event itself. This is not +# the same as `udevadm settle --timeout=5`: settle only +# stops waiting for the queue, while udevd otherwise lets +# one worker block for its 180-second default. +# +# console=tty0 puts kernel messages on the panel, and quiet/splash are dropped: +# there is no serial port on these laptops, so an unreadable failure here is +# just another black screen. Plymouth on a display stack that has not been +# proven to come up is the last thing this entry should be doing. +# +# The kernel and initramfs use the same tokens as the entries above so that +# builder/build-iso.sh's single aarch64 rewrite catches all three. The -f guard +# means this entry only appears on an ISO that actually carries the blob. +if [ -f /boot/grub/dtbs/qcom/x1e80100-lenovo-yoga-slim7x.dtb ]; then + menuentry "Omarchy for Snapdragon X (Lenovo Yoga Slim 7x)" --hotkey x --class arch --class gnu-linux --class gnu --class os --id 'snapdragon-yoga-slim7x' { + set gfxpayload=keep + linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% initramfs_async=0 rd.udev.event_timeout=5 clk_ignore_unused pd_ignore_unused cma=128M efi=noruntime console=tty0 + initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img + devicetree /boot/grub/dtbs/qcom/x1e80100-lenovo-yoga-slim7x.dtb + } + + # Pre-select it when SMBIOS says this is that machine, so an unattended boot + # lands on the right entry with nobody at the keyboard. + # + # Advisory only: this moves the highlight, it never loads a DTB. Firmware + # with no SMBIOS3 table, or a Lenovo model-string change, just leaves the + # generic default selected and the menu still visible. + # + # smbios is not in mkarchiso's grubmodules preload list, but + # grub-mkstandalone packs the whole arm64-efi module directory into the + # image's memdisk, so insmod resolves it from there. SMBIOS type 1 offset 5 + # is Product Name; this laptop reports "83ED". + if insmod smbios; then + if smbios --type 1 --get-string 5 --set omarchy_dmi_product; then + if [ "${omarchy_dmi_product}" = "83ED" ]; then + default=snapdragon-yoga-slim7x + fi + fi + fi +fi + if [ "${grub_platform}" == 'efi' -a "${grub_cpu}" == 'x86_64' -a -f '/boot/memtest86+/memtest.efi' ]; then menuentry 'Run Memtest86+ (RAM test)' --class memtest86 --class memtest --class gnu --class tool { @@ -103,4 +189,3 @@ menuentry 'System restart' --class reboot --class restart { reboot } - diff --git a/configs/grub/loopback.cfg b/configs/grub/loopback.cfg index 395a2911..618ce722 100644 --- a/configs/grub/loopback.cfg +++ b/configs/grub/loopback.cfg @@ -25,6 +25,12 @@ default=archlinux timeout=0 timeout_style=hidden +# aarch64 shows the menu; see the matching comment in grub.cfg. +if [ -d /boot/grub/dtbs ]; then + timeout=10 + timeout_style=menu +fi + # Menu entries @@ -40,6 +46,19 @@ menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --h initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img } +# Snapdragon X (Qualcomm x1e80100) laptops. See grub.cfg for why each of the +# devicetree line and the four extra kernel arguments is required; this is the +# loopback twin of that entry, and it is what makes the recipe testable from an +# already-installed GRUB without writing a USB stick. +if [ -f /boot/grub/dtbs/qcom/x1e80100-lenovo-yoga-slim7x.dtb ]; then + menuentry "Omarchy for Snapdragon X (Lenovo Yoga Slim 7x)" --hotkey x --class arch --class gnu-linux --class gnu --class os --id 'snapdragon-yoga-slim7x' { + set gfxpayload=keep + linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" initramfs_async=0 rd.udev.event_timeout=5 clk_ignore_unused pd_ignore_unused cma=128M efi=noruntime console=tty0 + initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img + devicetree /boot/grub/dtbs/qcom/x1e80100-lenovo-yoga-slim7x.dtb + } +fi + if [ "${grub_platform}" == 'efi' -a "${grub_cpu}" == 'x86_64' -a -f '/boot/memtest86+/memtest.efi' ]; then menuentry 'Run Memtest86+ (RAM test)' --class memtest86 --class memtest --class gnu --class tool { diff --git a/configs/profiledef.sh b/configs/profiledef.sh index d7d483cf..5b61660a 100644 --- a/configs/profiledef.sh +++ b/configs/profiledef.sh @@ -8,8 +8,15 @@ iso_application="Omarchy Installer" iso_version="$(date --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y.%m.%d)" install_dir="arch" buildmodes=('iso') -bootmodes=('bios.syslinux' 'uefi.grub') -arch="x86_64" +arch="${OMARCHY_ARCH:-x86_64}" +# aarch64 has no BIOS, so syslinux has nothing to boot there; UEFI is the only +# path on ARM. mkarchiso sources this file with bash, so OMARCHY_ARCH arrives +# through the environment the builder exports. +if [[ $arch == "aarch64" ]]; then + bootmodes=('uefi.grub') +else + bootmodes=('bios.syslinux' 'uefi.grub') +fi pacman_conf="pacman-offline.conf" airootfs_image_type="squashfs" # Package archives in the offline mirror are already zstd-compressed. Storing @@ -22,12 +29,27 @@ airootfs_image_type="squashfs" # cold on every boot: kernel, plymouth, systemd, python, archinstall, gum. The # whole ISO grows well under a percent for it, and dropping the x86 BCJ filter # also removes one of the blockers listed in plans/aarch64-support.md. -airootfs_image_tool_options=( - '-comp' 'zstd' - '-Xcompression-level' '19' - '-b' '1M' - '-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)' -) +# +# aarch64 is the exception: Arch Linux ARM builds its kernel without +# CONFIG_SQUASHFS_ZSTD (xz, zlib, lz4 and lzo are all present), so a zstd root +# produces an ISO whose own kernel cannot mount it -- the archiso hook fails +# with `Filesystem uses "zstd" compression. This is not supported.` and drops to +# a recovery shell. xz costs decompression speed but is what that kernel reads. +if [[ $arch == "aarch64" ]]; then + airootfs_image_tool_options=( + '-comp' 'xz' + '-Xdict-size' '1M' + '-b' '1M' + '-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)' + ) +else + airootfs_image_tool_options=( + '-comp' 'zstd' + '-Xcompression-level' '19' + '-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/docs/aarch64-platforms.md b/docs/aarch64-platforms.md new file mode 100644 index 00000000..50658e03 --- /dev/null +++ b/docs/aarch64-platforms.md @@ -0,0 +1,273 @@ +# AArch64 platform support + +`configs/aarch64/platforms.json` is the source of truth for ARM machines that +need more than the generic `linux-aarch64` boot path. It keeps hardware support +in one ISO: the builder stages the union of the declared resources, and the +installer applies only the entry matching the target machine. + +An entry in the manifest is not, by itself, a claim that a machine has been +tested. See [Current coverage](#current-coverage) for the validation state of +the entries shipped today. + +## The support model + +AArch64 support has three layers: + +1. `linux-aarch64` is a multi-platform kernel. A separate kernel or ISO is not + normally required for each board. +2. A SoC family shares most kernel modules, firmware packages, and boot + arguments. For example, Snapdragon X Elite laptops based on `x1e80100` can + start from the same family requirements. +3. Each board still needs its own hardware description and identity. A DTB is + exact board data, not a generic driver bundle; it describes regulators, + GPIOs, panels, input devices, buses, and power domains. Never substitute a + DTB from a similar product. + +The kernel package currently carries hundreds of Qualcomm DTBs, including the +upstream laptop DTBs. They are available inside the live root after Linux has +booted. A board whose firmware does not give Linux a usable hardware +description needs its selected DTB copied outside the squashfs so GRUB can load +it *before* the kernel. Declaring `boot.hardware_description` as +`dtb-override` is what asks the builder to make that second copy. + +Staging the supported Snapdragon laptop DTB catalog is safe and inexpensive; +choosing the wrong board description is not. Do not turn the kernel's entire +Qualcomm tree (which also contains phones, routers, development boards, and +revision-specific variants) into an undifferentiated boot menu. Add one product +entry per board, with its exact DTB and SMBIOS selector. A manual live-boot +choice may be added while identity data is being collected, but automatic and +installed-system selection require a reliable match. + +The same rule applies to initramfs contents. Drivers present in the universal +kernel are not necessarily included by `mkinitcpio`, and firmware filenames +constructed at runtime are not necessarily visible to `modinfo`. Everything +needed between kernel entry and encrypted-root unlock must be made explicit. + +## Current coverage + +| Platform | Hardware description | Declared coverage | Physical validation | +| --- | --- | --- | --- | +| Lenovo Yoga Slim 7x (83ED) | Explicit `x1e80100` DTB | DTB, Qualcomm package, pre-LUKS display/input/watchdog/retimer modules, dynamic GPU firmware | Full encrypted installation validated: the corrected initramfs renders the Plymouth LUKS prompt, Limine finalization completes, and the installed OS boots successfully | +| NVIDIA DGX Spark | Firmware | NVIDIA runtime and DKMS packages only | Early-boot dependency audit and physical installation are not complete | +| ASUS Ascent GX10 | Firmware | NVIDIA runtime and DKMS packages only | Early-boot dependency audit and physical installation are not complete | + +Package-only entries for Spark and GX10 are intentional bring-up entries. They +must not be described as complete platform support until their storage, +display, input, watchdog, firmware, and encrypted-boot paths have been tested. + +## Manifest reference + +The document has a `schema_version`, a human-readable `description`, and a +`platforms` array. Each platform contains the following fields. + +### Identity + +- `id`: stable lowercase identifier used by tests and logs. +- `name`: human-readable product name. +- `match`: one or more SMBIOS selectors. Selectors are ORed; all fields inside + one selector are ANDed. Matching is exact and case-sensitive after leading + and trailing whitespace is removed. Supported fields are `sys_vendor`, + `product_name`, and `product_version` from `/sys/class/dmi/id/`. + +Use the smallest selector that uniquely identifies the board. Do not guess a +marketing name or match only a broad vendor string. Multiple selectors are +appropriate for confirmed firmware revisions that report different identities. + +### Boot description + +- `boot.hardware_description`: `firmware` when UEFI/ACPI supplies Linux with a + usable description; `dtb-override` when the bootloader must supply one. +- `boot.dtb`: required only for `dtb-override`. It is the path relative to + `/boot/dtbs` in the `linux-aarch64` package, such as + `qcom/x1e80100-lenovo-yoga-slim7x.dtb`. +- `boot.kernel_cmdline`: persistent, platform-specific arguments appended to + installed Limine entries. Each array item is one argument with no whitespace. + +Kernel arguments must have a demonstrated requirement. Diagnostic arguments +such as extra logging, removed quiet mode, or a temporary timeout belong in a +test boot entry until hardware results justify making them permanent. + +### Kernel + +- `kernel.package` identifies the package used by the platform. +- `kernel.availability` is `iso` when the configured repositories can place it + in the offline mirror, or `vendor-required` when support cannot be shipped in + the ISO yet. + +The existence of an upstream DTB does not prove the selected kernel has every +required driver enabled. Confirm the DTB exists in the exact kernel package +being built and verify the resulting machine, rather than relying on a newer +upstream source tree. + +### Initramfs + +- `initramfs.modules`: modules that must be present before root unlock. Use + module names, not `.ko` paths. +- `initramfs.files`: absolute firmware paths under `/usr/lib/firmware` that + must be copied even when automatic discovery misses them. +- `initramfs.omit_hooks`: exceptional removal of an inherited mkinitcpio hook. + This is a diagnostic or last-resort compatibility mechanism; explain and + test every use. + +The installer writes these values to +`/etc/mkinitcpio.conf.d/zz-omarchy-aarch64-platform.conf`. The drop-in persists +across kernel upgrades. Do not solve a missing-firmware failure by embedding an +entire firmware tree in every initramfs. + +### Packages + +`packages` contains target packages needed only by the matched platform. The +builder downloads the union of all platform packages into the ISO's offline +repository; the installer installs only the matched entry's list. Every package +must exist for AArch64 in the configured repositories. + +Package installation and initramfs inclusion are separate concerns. Installing +`linux-firmware-qcom` or `nvidia-utils` into the target does not make firmware +available before an encrypted root has been opened. Add early firmware to +`initramfs.files` when automatic inclusion cannot be proven. + +## Adding a platform + +### 1. Record exact identity + +Collect the values on the physical machine; do not infer them from a product +page: + +```sh +for field in sys_vendor product_name product_version; do + printf '%s: ' "$field" + cat "/sys/class/dmi/id/$field" +done +``` + +Record the firmware version and the exact product/SKU used for testing in the +pull request. If the installer cannot boot yet, obtain the same data from the +factory OS or a working ARM live environment. + +### 2. Determine the hardware-description path + +Establish whether the kernel successfully consumes ACPI or a DTB supplied by +firmware, or instead requires a bootloader DTB override. For an override: + +- Select the exact upstream board DTB, including revision or display variants. +- Confirm it exists and is non-empty in the built `linux-aarch64` package. +- Add a distinct live GRUB entry so generic ARM systems never receive it. +- Verify the installed Limine entry receives the same DTB. + +An upstream DTB makes a board a good support candidate, not automatically a +supported system. We still need its boot identity and pre-root dependency set. + +### 3. Find the pre-root dependency closure + +For an encrypted installation, inventory everything required before the LUKS +prompt can be displayed and operated: + +- boot storage and its bus/controller; +- display controller, GPU, clocks, resets, PHYs, and panel/output path; +- built-in keyboard or the USB/I2C path used for input; +- watchdog and power-domain drivers that can reset or disable the board; +- firmware requested by any of those drivers. + +Use the working system's journal and driver bindings as evidence. `lsmod`, +`modinfo -F firmware`, `/sys/bus/*/devices/*/driver`, and kernel logs are useful, +but none is complete alone. Search driver source or trace firmware requests +when filenames are selected from DT properties, SMBIOS identity, or chip IDs. + +Start a same-SoC board from the established family list, then verify it. Copying +the X Elite baseline is reasonable for another `x1e80100` laptop; treating it +as proven without checking board-specific firmware and input/display paths is +not. A later SoC generation such as Kaanapali/X2 needs a separate family +baseline even though it uses the same multi-platform kernel. + +### 4. Separate diagnosis from the permanent fix + +Change one boot boundary at a time. Useful diagnostic images include a full +no-`autodetect` image and a delayed-KMS image. A black screen with a responsive +keyboard and successful blind passphrase is evidence of a display handoff +failure, not a stalled kernel or broken encryption. + +Do not commit a huge diagnostic initramfs, permanently disable Plymouth, or +remove early KMS merely because it masks the symptom. Translate the result into +the smallest explicit module and firmware set that preserves the branded +unlock flow. + +### 5. Validate the installed lifecycle + +A platform is ready to be marked physically validated only after checking: + +1. The live ISO boots using the intended entry. +2. Display and input work in the installer. +3. Installation completes from the offline repository. +4. The normal encrypted Limine entry shows a usable LUKS prompt. +5. The installed desktop starts and the expected hardware is functional. +6. `limine-update` preserves the selected DTB and arguments. +7. Regenerating the initramfs, including after a kernel update, preserves the + declared modules and firmware. +8. A cold boot succeeds; a warm reboot alone is insufficient for firmware and + watchdog validation. + +In the pull request, state which checks were performed and retain logs for any +behavior that motivated a platform-specific setting. + +### 6. Run repository checks + +At minimum: + +```sh +python -m unittest \ + test.unit.test_aarch64_platforms \ + test.unit.test_protected_esp_mount -q +git diff --check +``` + +Add or update matching tests for every new SMBIOS identity, DTB, required +package, and exceptional initramfs rule. The ISO build also fails when a +declared DTB is absent from the exact kernel package used for that image. + +## Build and persistence boundaries + +- `builder/build-iso.sh` copies the manifest into the live root, downloads the + union of platform packages, and stages declared DTB overrides outside the + squashfs. +- `_current_aarch64_platform()` matches the physical machine's SMBIOS identity. +- `_runtime_package_list()` installs packages only for the matched entry. +- `_configure_aarch64_platform_boot()` writes persistent mkinitcpio and Limine + drop-ins into the target. +- The installed Limine post-hook reinserts the selected DTB into every generated + Linux entry because `limine-update` rewrites `limine.conf`. +- `test/unit/test_aarch64_platforms.py` checks manifest safety, matching, + persistent DTB injection, and early-boot configuration. + +## Lenovo Yoga Slim 7x (83ED) findings + +Observed on physical hardware on 2026-09-03: + +1. The generic installed Limine entry supplied no DTB. The kernel did not + initialize `sbsa_gwdt`, and firmware's reported 10-second watchdog reset the + machine. Supplying `x1e80100-lenovo-yoga-slim7x.dtb` changed this from a boot + loop to a continuing kernel boot. +2. The live ISO contained `linux-firmware-qcom`, but the target package set did + not. The installed root therefore had no `/usr/lib/firmware/qcom`. The Yoga + entry now selects that package from the offline mirror. +3. The target initramfs was about 20 MiB versus about 191 MiB for the working + live image. The working unencrypted system's journal showed EFI framebuffer + and NVMe at about 1.8 seconds, root mount at 2.86 seconds, and the Qualcomm + firmware, I2C keyboard, and MSM display takeover at about 5 seconds. The + encrypted target runs `kms` before root unlock. +4. A 159 MiB no-`autodetect` diagnostic image included 573 modules but omitted + DT-selected firmware and reproduced the black screen. A 17 MiB delayed-KMS + image booted after a blind passphrase, proving storage, encryption, watchdog, + keyboard, kernel, and installed userspace were healthy. +5. An EFI-backed pre-unlock trace proved that loading the initial MSM module set + was insufficient: from 2 through 17 seconds the only framebuffer remained + `EFI VGA`, no DRM connectors appeared, and all three external DisplayPort + controllers remained in deferred probe. Their `aux_bridge` instances could + not acquire the downstream DRM bridges supplied by the Yoga's three Parade + PS8830 USB-C retimers. The `ps883x` module appeared only after switch-root; + it must therefore be explicit in the initramfs even though the internal + panel is eDP. +6. The production entry retains early KMS and the branded Plymouth unlock. It + explicitly includes the MSM display stack, PS8830 retimer bridge, I2C + keyboard, SBSA watchdog, and the three dynamically selected GPU firmware + files. On 2026-09-05, a full encrypted installation rendered the unlock + prompt, completed Limine finalization, and booted successfully. diff --git a/docs/alarm-iso.md b/docs/alarm-iso.md new file mode 100644 index 00000000..95ce79bc --- /dev/null +++ b/docs/alarm-iso.md @@ -0,0 +1,272 @@ +# Building the Omarchy ISO for aarch64 / Arch Linux ARM + +Working notes for the `aarch64-support` branch. Companion to +`plans/aarch64-support.md` — that document is upstream's plan; this one records +what actually happened when it was implemented, including the places the plan is +wrong. + +Status: **the ISO builds and passes structural verification. It has never been +booted or installed.** Treat "builds", "boots", and "installs a working system" +as three separate claims; only the first is demonstrated. Everything below +marked "verified" was checked directly on this machine. + +`test/unit/iso-structure-test.sh` asserts the invariants that separate an ISO +which merely built from one that can boot. It runs against `release/*.iso` when +one is present and skips otherwise, so `test/all` stays fast and VM-free. + +--- + +## Build command + +```bash +cd ~/Projects/omarchy-iso +OMARCHY_PKGS_MIRROR='https://snapdragon-omarchy.mattgilg.com/aarch64' \ +OMARCHY_EXTRA_PKGBUILDS='tzupdate tensaku hyprland-preview-share-picker' \ + ./bin/omarchy-iso-make --arch aarch64 --keep-pkg-cache --no-boot-offer \ + --local-source ../omarchy ../omarchy-pkgs +``` + +The tracked aarch64 configs use a global HTTPS mirror set: a CDN first, then +independent mirrors in Denmark, Germany, California, and Florida. The ALARM +GeoIP hostname is deliberately absent because its certificate does not cover +`mirror.archlinuxarm.org`. Set `OMARCHY_BASE_MIRROR` only when a build must pin +every base repository to one server; single-quote its value so pacman's `$arch` +/ `$repo` placeholders reach the config unexpanded. + +`--keep-pkg-cache` is not optional in practice. Without it, `omarchy-iso-make` +runs `sudo rm -rf /var/cache/pacman/pkg/*` against the **host**, which (a) needs +an interactive password so unattended runs die immediately, and (b) wipes the +local package cache — worth keeping on a hand-assembled ALARM box. + +`--no-boot-offer` skips the post-build QEMU offer, which cannot work yet (see +Remaining work). + +### Prerequisites on the package host + +The bucket must serve a database whose filename matches the pacman section name. +The tracked configs declare `[omarchy]`, so the bucket needs `omarchy.db` — +pacman derives the database name from the section name, and a mismatch 404s every +sync. `snapdragon-omarchy.db` alone is not enough. + +This was solved by adding an `omarchy.db` copy alongside the existing one rather +than by renaming anything, so the local `/etc/pacman.conf` `[snapdragon-omarchy]` +section keeps working. That copy goes stale on republish, and the failure is +quiet — the build just resolves an older package set. Make it a step in whatever +publishes the repo. + +--- + +## Environment facts (verified) + +| Fact | Value | +|---|---| +| Kernel package | `linux-aarch64` — there is **no** `linux` package on ALARM | +| Package compression | `.pkg.tar.xz` (Arch uses `.pkg.tar.zst`) | +| `archiso` package | **not packaged for ALARM** | +| `mkinitcpio-archiso` | `extra 73-1` — present, and required | +| `limine` | `extra 12.6.1-1` | +| `archinstall` | `extra 4.4-1` | +| `edk2-armvirt` | **not in ALARM** — blocks QEMU boot testing | +| Repo layout | `$arch/$repo`, e.g. `/aarch64/core/core.db` | + +ALARM's layout is **not** Arch's `$repo/os/$arch`, and has no `/os/` component: + +``` +https://fl.us.mirror.archlinuxarm.org/aarch64/core/core.db 200 +https://fl.us.mirror.archlinuxarm.org/aarch64/alarm/alarm.db 200 +https://mirror.archlinuxarm.org/aarch64/core/core.db TLS hostname failure +https://mirror.archlinuxarm.org/core/os/aarch64/core.db wrong layout +https://pkgs.omarchy.org/stable/aarch64/omarchy.db 404 +``` + +ALARM also carries a third base repo, `[alarm]`, alongside `core` and `extra`. +There is no `[multilib]` (32-bit x86) and no `[arch-mact2]` (Apple T2, x86-only). + +--- + +## Package availability + +Omarchy's install lists are written for x86_64. Populating the offline mirror on +aarch64 surfaced **41 unresolvable packages**, and pacman aborts the entire +`-Syw` transaction on the first one — so every single one has to be dealt with +before a build completes. + +They split three ways: + +**38 excluded** — `builder/aarch64-excludes.packages`. Mostly hardware that +cannot exist on ARM: Apple T2 (`linux-t2`, `t2fanrd`, `apple-bcm-firmware`), +Intel (`thermald`, `intel-media-driver`, `vpl-gpu-rt`, `linux-ptl`), NVIDIA and +32-bit x86, x86 laptop drivers (`tuxedo-drivers`, `yt6801-dkms`, Dell XPS), and +x86 VM guest tooling. + +The last group in that file is different in kind and worth revisiting: `obsidian`, +`obs-studio`, `pinta`, `dotnet-runtime`, `reflector`, `yay-debug` are not ARM +impossibilities, just absent from ALARM. Excluding them is a real reduction in +what the installed system offers. Build them for aarch64 and drop them from the +exclude list if any matter. + +**3 built locally** — via `OMARCHY_EXTRA_PKGBUILDS`: + +| Package | Why it cannot be excluded | +|---|---| +| `tzupdate` | listed in `arch_packages`, so the **live ISO itself** installs it | +| `tensaku` | Omarchy runtime dependency | +| `hyprland-preview-share-picker` | Hyprland screen-share picker | + +All three have PKGBUILDs in `omarchy-pkgs` but no aarch64 build published. They +do **not** need publishing to the bucket first: `profiledef.sh` sets +`pacman_conf="pacman-offline.conf"`, so the live ISO pacstraps out of the offline +mirror, and a locally built package lands there directly. Building them properly +into the repo is still the better long-term answer. + +The remaining 12 with PKGBUILDs (`linux-ptl`, `macbook12-spi-driver-dkms`, +`nvidia-580xx-utils`, `intel-ipu7-camera`, `asusctl`, `qmk-hid`, the Dell ones, +…) are x86 hardware support — correct to exclude, not to build. + +--- + +## Errors in `plans/aarch64-support.md` + +Each of these would break a build or produce a broken artifact. + +| § | Plan says | Reality | +|---|---|---| +| 3, 6 | "use plain `linux`" | ALARM has only `linux-aarch64`; releng's `linux` entry must be **remapped**, not dropped | +| 6 | "releng's own `linux.preset` covers the stock kernel" | Presets are keyed by **pkgbase**. `linux.preset` is sourced by nothing on ARM, so the package's stock preset wins and builds an initramfs with **no archiso hook** — ISO builds clean, then won't boot | +| 7 | one `custom_servers` block at lines 422–424 | **Two** blocks (now 861 and 1248). Fixing one leaves the installer broken depending on which wizard path the user walks | +| 7 | T2 kernel probe needs "no change strictly required" | `detect_kernel()`'s `else` returns `linux`, which does not exist on ALARM — archinstall would install a system with no kernel | +| 8 | `$repo/os/$arch` "adapts automatically" | False for ALARM; the `Server` template must be rewritten, not re-pointed | +| — | not mentioned | `archiso` is not packaged for ALARM. `mkarchiso` must come from the pinned submodule, and `mkinitcpio-archiso` must be installed separately for the hooks | +| — | not mentioned | Package compression differs (xz vs zstd). Four hardcoded `.pkg.tar.zst` globs; the `repo-add` one **silently indexes nothing** rather than erroring | +| — | not mentioned | The offline-repo build cache is keyed only on channel, so parallel dual-arch builds poison each other with wrong-arch packages | +| — | mentions only microcode in `archinstall.packages` | Omarchy's own install lists carry **41** packages unavailable on ARM. pacman aborts the whole transaction on the first, so all 41 must be resolved | +| — | not mentioned | ALARM installs its kernel as `/boot/Image`; archiso hard-globs `/boot/vmlinuz-*` in four places and finds nothing | +| — | not mentioned | `linux-aarch64` **owns** `/etc/mkinitcpio.d/linux-aarch64.preset`, so the profile cannot ship one, and its stock preset builds against `/etc/mkinitcpio.conf` — which `omarchy-settings` replaces with the *target* system's hooks. The live initramfs then has no `archiso` hook and the ISO panics on boot, after building perfectly | +| — | not mentioned | archiso's `grub-mkstandalone` module list is x86-derived; 7 modules have no arm64-efi equivalent and it aborts on the first missing one | +| — | not mentioned | Excluding `syslinux` (x86 BIOS) also removes `memdiskfind`, which archiso's `memdisk` initramfs hook needs. The hook must be dropped from `HOOKS` too | + +### Where the plan is too pessimistic + +- **§2 BCJ filter** — already resolved upstream. The airootfs moved from xz to + zstd, which has no BCJ concept. Nothing to change. +- **Risk 1, "mkarchiso on aarch64 is less-trodden ground"** — overstated. + archiso already ships `uefi_arch['aarch64']='AA64'` and emits `BOOTAA64.EFI` + natively. +- **Build speed** — the plan budgets 30–60 min under QEMU binfmt from an x86 + host. This machine is native aarch64, so no emulation. + +### The pattern + +The plan covers *conditional logic* thoroughly and assumes the ARM toolchain is +otherwise interchangeable with Arch's. The real failures live in that assumption: +missing packages, different compression, different repo layout. Five of the seven +gaps produce a silently-broken artifact rather than a build error. + +--- + +## Changes made + +### `bin/omarchy-iso-make` +- `--arch x86_64|aarch64` flag with validation, default `x86_64`. +- Per-arch container: `menci/archlinuxarm:base-devel` + `--platform linux/arm64`. +- Passes `OMARCHY_ARCH`, `OMARCHY_PKGS_MIRROR`, `OMARCHY_BASE_MIRROR` through. +- Offline-repo cache key now includes the arch. + +### `configs/profiledef.sh` +- `arch` reads `OMARCHY_ARCH`; `bootmodes` drops `bios.syslinux` on ARM (no BIOS). +- x86_64 output is byte-identical to `quattro` — verified. + +### `builder/build-iso.sh` +- Derives `packages.aarch64` from releng: prunes x86-only entries, **remaps + `linux` → `linux-aarch64`**. +- Installs `mkinitcpio-archiso` + mkarchiso's callouts instead of `archiso`; runs + the vendored `/archiso/archiso/mkarchiso`. +- Node.js `linux-arm64` tarball. +- No `linux-t2` on ARM; guards the T2-pruning block. +- Filters microcode from `archinstall.packages` and from the live initramfs + `HOOKS`. +- Prunes the wrong-arch efiboot entry, points `loader.conf` at the right one, + removes the syslinux tree, rewrites GRUB kernel filenames, drops the Intel + `xe.enable_panel_replay` parameter. +- Selects `aarch64/pacman-online-.conf`; applies mirror overrides to a + writable copy (`/configs` is mounted read-only). +- Accepts xz **or** zstd packages; `repo-add` now errors on an empty mirror + instead of silently indexing nothing. + +### `builder/build-omarchy-packages.sh` +- Accepts whichever `PKGEXT` the container produced. +- `OMARCHY_EXTRA_PKGBUILDS` builds additional pkgbuilds from `omarchy-pkgs` + alongside the Omarchy packages, for packages with no build published for this + architecture. `build-iso.sh` strips them from the `-Syw` list the same way it + strips the locally built Omarchy packages. + +### `builder/aarch64-excludes.packages` (new) +- The 38 packages dropped from the offline mirror on aarch64, grouped by reason. + +### `configs/airootfs/root/configurator` +- `detect_kernel()` returns `linux-aarch64` on ARM. +- `MIRROR_SERVERS_JSON` branches on `uname -m`; both `custom_servers` blocks now + reference it. x86_64 output unchanged — verified. + +- Generates a `customize_airootfs.sh` that runs in the chroot after pacstrap: + aliases `/boot/Image` to `/boot/vmlinuz-linux-aarch64` for archiso's globs, and + rebuilds the live initramfs with + `mkinitcpio -c /etc/mkinitcpio.conf.d/archiso.conf`. The kernel version comes + from the package preset's `ALL_kver`, which is authoritative; the preset itself + is left alone. It then asserts `archiso` and `archiso_loop_mnt` are present and + the initramfs kernel matches, failing the build rather than shipping an ISO + that panics. mkarchiso runs this under `set -e`, which a post-transaction + pacman hook could not match (`AbortOnFail` is pre-transaction only). +- Patches a copy of the vendored `mkarchiso` to drop the 7 GRUB modules with no + arm64-efi equivalent. The submodule itself stays pristine. This is the most + fragile change here: if archiso reformats that list, the sed silently stops + matching and the original error returns. + +### New files +- `configs/efiboot/loader/entries/01-archiso-aarch64-linux.conf` +- `configs/aarch64/pacman-online-{stable,rc,edge}.conf` +- `builder/aarch64-excludes.packages` +- `test/unit/iso-structure-test.sh` + +--- + +## Design note: mirrors stay out of tracked files + +The tracked configs name **public** defaults: `pkgs.omarchy.org` plus a global +set of ALARM-compatible HTTPS mirrors. The GeoIP redirector is not included: +ALARM publishes it as HTTP, while forcing HTTPS fails hostname validation. The +private package bucket is supplied per build through `OMARCHY_PKGS_MIRROR`. + +This means no tracked file names a personal mirror, so nothing has to be stripped +before upstreaming, and this branch works for anyone else the moment +`pkgs.omarchy.org` publishes an aarch64 tree. `OMARCHY_BASE_MIRROR` remains an +escape hatch for pinning a build to a single ALARM mirror. + +`efiboot/loader/entries/` is worth understanding before editing: the +`uefi.grub` bootmode **validates** that the directory exists and holds at least +one `.conf`, but the code that installs those entries lives in the +`uefi.systemd-boot` path. So the files must exist, but `grub/grub.cfg` is what +actually boots. The load-bearing fix for ARM was the kernel filename in +`grub.cfg`, not the loader entry. + +--- + +## Remaining work + +- **§9** — `bin/omarchy-iso-boot`, `bin/omarchy-vm`: swap in + `qemu-system-aarch64 -machine virt -cpu max`. Blocked on UEFI firmware: + `qemu-system-aarch64` is in ALARM (`extra 11.1.1-1`) but `edk2-armvirt` is not, + so AAVMF has to come from somewhere else before the ISO can be boot-tested + locally. +- **§10** — `bin/omarchy-iso-release`: the ISO glob is hardcoded to `*x86_64-*`. +- **§11** — CI matrix. +- **End-to-end install** has never been run. Until it is, treat "the ISO builds" + and "the ISO installs a working system" as separate claims. +- **The ISO has never been booted.** Bootability is currently inferred from the + initramfs carrying the archiso hooks, not demonstrated. +- Three `mkinitcpio` errors during pacstrap are expected noise: `failed to detect + root filesystem`, `Hook 'btrfs-overlayfs' cannot be found`, `module not found: + 'thunderbolt'`. They come from the stock preset building against the target + system's config; that image is discarded and rebuilt by `customize_airootfs.sh`, + and pacman treats hook failures as non-fatal. `btrfs-overlayfs` would only + matter on the installed system, where the hook is present. diff --git a/test/integration b/test/integration index 3954354c..1b195df6 100755 --- a/test/integration +++ b/test/integration @@ -14,6 +14,7 @@ # --memory MB VM memory (default 8192) # --timeout SECS Install timeout (default 2400) # --no-preview Do not open the collected screenshots in imv +# --install-only Install, reboot, and save the verified base; skip scenarios set -euo pipefail @@ -21,6 +22,7 @@ ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) ISO="" REUSE_BASE=false +INSTALL_ONLY=false SCENARIOS=() export OMARCHY_INTEGRATION_SSH_PORT=2322 @@ -31,6 +33,7 @@ export OMARCHY_INTEGRATION_NO_PREVIEW=false while (($#)); do case "$1" in --reuse-base) REUSE_BASE=true ;; + --install-only) INSTALL_ONLY=true ;; --no-preview) OMARCHY_INTEGRATION_NO_PREVIEW=true ;; --port) OMARCHY_INTEGRATION_SSH_PORT="$2"; shift ;; --memory) OMARCHY_INTEGRATION_MEMORY="$2"; shift ;; @@ -56,7 +59,7 @@ if [[ ! -f ${ISO:-} ]]; then exit 1 fi -if (( ${#SCENARIOS[@]} == 0 )); then +if ! $INSTALL_ONLY && (( ${#SCENARIOS[@]} == 0 )); then SCENARIOS=("$ROOT"/test/integration.d/*-test.sh) else for scenario in "${SCENARIOS[@]}"; do @@ -67,7 +70,18 @@ else done fi -omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng mtools +case "$(basename "$ISO")" in + *aarch64*) + omarchy-pkg-add qemu-system-aarch64 socat imagemagick tesseract tesseract-data-eng mtools dosfstools + ;; + *x86_64*) + omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng mtools dosfstools + ;; + *) + echo "Cannot determine ISO architecture from $(basename "$ISO")" >&2 + exit 1 + ;; +esac export OMARCHY_INTEGRATION_ISO OMARCHY_INTEGRATION_ISO=$(realpath "$ISO") @@ -83,6 +97,8 @@ else install_phase fi +$INSTALL_ONLY && exit 0 + status=0 for scenario in "${SCENARIOS[@]}"; do [[ $(basename "$scenario") == "base-test.sh" ]] && continue diff --git a/test/integration.d/base-test.sh b/test/integration.d/base-test.sh index 10d4d2e2..500da752 100644 --- a/test/integration.d/base-test.sh +++ b/test/integration.d/base-test.sh @@ -23,8 +23,41 @@ GUEST_HOSTNAME="omarchy-test" SCENARIO="${SCENARIO:-$(basename "${0%-test.sh}")}" -OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd" -OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd" +case "$(basename "$ISO")" in +*aarch64*) + GUEST_ARCH=aarch64 + QEMU=qemu-system-aarch64 + FIRMWARE_DIR="${OMARCHY_AAVMF_DIR:-$HOME/.local/share/aavmf}" + OVMF_CODE="$FIRMWARE_DIR/AAVMF_CODE.no-secboot.fd" + OVMF_VARS_TEMPLATE="$FIRMWARE_DIR/AAVMF_VARS.fd" + GUEST_KERNEL=linux-aarch64 + GUEST_EFI_BINARY=limine_aa64.efi + MIRROR_SERVERS_JSON=' {"url": "https://cdnmirror.com/archlinuxarm/$arch/$repo"}, + {"url": "https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo"}, + {"url": "https://de3.mirror.archlinuxarm.org/$arch/$repo"}, + {"url": "https://ca.us.mirror.archlinuxarm.org/$arch/$repo"}, + {"url": "https://fl.us.mirror.archlinuxarm.org/$arch/$repo"}' + ;; +*x86_64*) + GUEST_ARCH=x86_64 + QEMU=qemu-system-x86_64 + OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd" + OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd" + GUEST_KERNEL=linux + GUEST_EFI_BINARY=limine_x64.efi + MIRROR_SERVERS_JSON=' {"url": "https://mirror.omarchy.org/$repo/os/$arch"}, + {"url": "https://mirror.rackspace.com/archlinux/$repo/os/$arch"}, + {"url": "https://geo.mirror.pkgbuild.com/$repo/os/$arch"}' + ;; +*) + echo "Cannot determine ISO architecture from $(basename "$ISO")" >&2 + return 1 2>/dev/null || exit 1 + ;; +esac + +command -v "$QEMU" >/dev/null || { echo "$QEMU is not installed" >&2; return 1 2>/dev/null || exit 1; } +[[ -f $OVMF_CODE ]] || { echo "UEFI firmware missing: $OVMF_CODE" >&2; return 1 2>/dev/null || exit 1; } +[[ -f $OVMF_VARS_TEMPLATE ]] || { echo "UEFI vars template missing: $OVMF_VARS_TEMPLATE" >&2; return 1 2>/dev/null || exit 1; } BASE_DIR="$ROOT/test-runs/$(basename "$ISO" .iso)-integration" RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)-$SCENARIO" @@ -160,17 +193,29 @@ start_vm() { local disk="$1" serial="$2" shift 2 - qemu-system-x86_64 \ - -cpu host -enable-kvm -machine q35,accel=kvm \ - -smp "$(nproc)" \ + local -a machine_args display_args + if [[ $GUEST_ARCH == aarch64 ]]; then + # Snapdragon systems commonly leave Linux at EL1 under Gunyah, so KVM is + # unavailable even on an ARM host. Keep this portable by using TCG. + machine_args=(-machine virt -cpu max -smp 4) + # This minimal qemu-system-aarch64 build exposes ramfb rather than the + # modular virtio GPU devices. A USB keyboard keeps QMP send-key usable for + # the reboot prompt while ramfb supports QMP screendump/OCR. + display_args=(-device ramfb -device nec-usb-xhci,id=xhci -device usb-kbd) + else + machine_args=(-cpu host -enable-kvm -machine q35,accel=kvm -smp "$(nproc)") + display_args=(-device virtio-vga -usb -device usb-tablet) + fi + + "$QEMU" \ + "${machine_args[@]}" \ -m "$MEMORY" \ -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \ -drive if=pflash,format=raw,file="$ACTIVE_OVMF" \ -drive file="$disk",format=qcow2,if=none,id=drive0 \ -device virtio-blk-pci,drive=drive0,bootindex=1 \ - -device virtio-vga \ + "${display_args[@]}" \ -display none \ - -usb -device usb-tablet \ -netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \ -device virtio-net-pci,netdev=net0 \ -qmp "unix:$QMP_SOCK,server,nowait" \ @@ -400,10 +445,10 @@ EOF "boot": { "esp_mount": "/boot", "esp_path": "/EFI/limine", - "efi_binary": "limine_x64.efi", + "efi_binary": "$GUEST_EFI_BINARY", "enable_fallback": true }, - "storage": { "kernel": "linux" } + "storage": { "kernel": "$GUEST_KERNEL" } }, "disk_config": { "config_type": "default_layout", @@ -448,7 +493,7 @@ EOF ] }, "hostname": "$GUEST_HOSTNAME", - "kernels": [ "linux" ], + "kernels": [ "$GUEST_KERNEL" ], "network_config": { "type": "iso" }, "ntp": true, "parallel_downloads": 8, @@ -460,9 +505,7 @@ EOF "mirror_config": { "custom_repositories": [], "custom_servers": [ - {"url": "https://mirror.omarchy.org/\$repo/os/\$arch"}, - {"url": "https://mirror.rackspace.com/archlinux/\$repo/os/\$arch"}, - {"url": "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"} +$MIRROR_SERVERS_JSON ], "mirror_regions": {}, "optional_repositories": [] @@ -514,11 +557,21 @@ install_phase() { cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF" ACTIVE_OVMF="$BASE_OVMF" - start_vm "$BASE_DISK.building" "$RUN_DIR/install-serial.log" \ - -drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \ - -device ide-cd,drive=cdrom0,bootindex=2 \ - -drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \ - -device usb-storage,drive=cidata + if [[ $GUEST_ARCH == aarch64 ]]; then + # The hybrid ARM ISO presents like the USB installer does on hardware. + # virt has no IDE controller, so both auxiliary images are virtio disks. + start_vm "$BASE_DISK.building" "$RUN_DIR/install-serial.log" \ + -drive "file=$ISO,if=none,format=raw,readonly=on,id=installiso" \ + -device virtio-blk-pci,drive=installiso,bootindex=2 \ + -drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \ + -device virtio-blk-pci,drive=cidata + else + start_vm "$BASE_DISK.building" "$RUN_DIR/install-serial.log" \ + -drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \ + -device ide-cd,drive=cdrom0,bootindex=2 \ + -drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \ + -device usb-storage,drive=cidata + fi log "Waiting for the unattended install to finish (timeout ${INSTALL_TIMEOUT}s)" local waited=0 text progress_name diff --git a/test/qemu-boot b/test/qemu-boot new file mode 100755 index 00000000..686cdaed --- /dev/null +++ b/test/qemu-boot @@ -0,0 +1,159 @@ +#!/bin/bash +# +# Boot smoke test: does the ISO actually reach a live environment? +# +# Sits between test/unit (fast, VM-free) and test/integration (installs to a +# disk and drives scenarios over SSH). This boots the ISO headless, reads the +# serial console, and asserts userspace comes up off the squashfs. Nothing is +# installed and no disk is touched. +# +# It exists because an ISO can build cleanly, pass every structural check, and +# still fail to boot. Both of these shipped during the aarch64 port: +# +# * an initramfs with no archiso hook, which panics with no root +# * a zstd-compressed root that the ISO's own kernel cannot decompress, +# because Arch Linux ARM builds without CONFIG_SQUASHFS_ZSTD +# +# Neither is visible from outside the image; both are obvious two seconds into +# a boot. +# +# Usage: test/qemu-boot [release/omarchy.iso] [--timeout SECS] [--keep-log] +# +# aarch64 needs UEFI firmware that Arch Linux ARM does not package. Stage it +# from Debian's architecture-independent qemu-efi-aarch64 into +# ~/.local/share/aavmf (override with OMARCHY_AAVMF_DIR). See docs/alarm-iso.md. + +set -uo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +ISO="" +TIMEOUT=300 +KEEP_LOG=false + +while (($#)); do + case "$1" in + --timeout) TIMEOUT="$2"; shift ;; + --keep-log) KEEP_LOG=true ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) ISO="$1" ;; + esac + shift +done + +pass() { printf 'ok - %s\n' "$1"; } +fail() { + printf 'not ok - %s\n' "$1" >&2 + [[ -n ${2:-} ]] && printf '%s\n' "$2" >&2 + printf '\nserial log: %s\n' "$LOG" >&2 + exit 1 +} + +[[ -n $ISO ]] || ISO=$(\ls -t "$ROOT"/release/*.iso 2>/dev/null | head -n1 || true) +[[ -n $ISO && -f $ISO ]] || { echo "No ISO found; pass one explicitly." >&2; exit 1; } + +case "${ISO##*/}" in +*aarch64*) + QEMU=qemu-system-aarch64 + FW_DIR="${OMARCHY_AAVMF_DIR:-$HOME/.local/share/aavmf}" + CODE="$FW_DIR/AAVMF_CODE.no-secboot.fd" + VARS_TEMPLATE="$FW_DIR/AAVMF_VARS.fd" + MACHINE=(-machine virt -cpu max) + ;; +*x86_64*) + QEMU=qemu-system-x86_64 + FW_DIR="${OMARCHY_OVMF_DIR:-/usr/share/edk2/x64}" + CODE="$FW_DIR/OVMF_CODE.4m.fd" + VARS_TEMPLATE="$FW_DIR/OVMF_VARS.4m.fd" + MACHINE=(-machine q35 -cpu max) + # Same-arch guests can use KVM when the host exposes it. + [[ -w /dev/kvm ]] && MACHINE+=(-enable-kvm) + ;; +*) echo "Cannot determine architecture from ${ISO##*/}" >&2; exit 1 ;; +esac + +command -v "$QEMU" >/dev/null || { echo "$QEMU not installed" >&2; exit 1; } +[[ -f $CODE ]] || { echo "UEFI firmware missing: $CODE" >&2; exit 1; } + +# aarch64 on an ARM host still runs under TCG wherever EL2 is taken by another +# hypervisor (Snapdragon's Gunyah, for one), so the default timeout is generous. +[[ -w /dev/kvm ]] || echo "note: no KVM; running under emulation, this is slow" + +LOG=$(mktemp /tmp/omarchy-qemu-boot-XXXXXX.log) +VARS=$(mktemp /tmp/omarchy-qemu-vars-XXXXXX.fd) +cp -f "$VARS_TEMPLATE" "$VARS" + +cleanup() { + rm -f "$VARS" + $KEEP_LOG || rm -f "$LOG" +} +trap cleanup EXIT + +echo "==> booting ${ISO##*/} (timeout ${TIMEOUT}s)" + +timeout "$TIMEOUT" "$QEMU" \ + "${MACHINE[@]}" \ + -m 4096 -smp 4 \ + -drive "if=pflash,format=raw,unit=0,file=$CODE,readonly=on" \ + -drive "if=pflash,format=raw,unit=1,file=$VARS" \ + -drive "if=none,id=iso,format=raw,readonly=on,file=$ISO" \ + -device virtio-blk-pci,drive=iso,bootindex=0 \ + -device virtio-rng-pci \ + -nographic "$LOG" 2>&1 & +qemu_pid=$! + +# Watch the log as it fills rather than waiting the full timeout: a failure +# marker is conclusive the moment it appears. +FATAL='Kernel panic|Failed to mount|Falling back to interactive prompt|not supported|Attempted to kill init|No working init' +READY='Reached target|Welcome to|omarchy-iso|configurator|login:' + +deadline=$((SECONDS + TIMEOUT)) +outcome=timeout +while ((SECONDS < deadline)); do + kill -0 "$qemu_pid" 2>/dev/null || { outcome=exited; break; } + if grep -qE "$FATAL" "$LOG" 2>/dev/null; then outcome=fatal; break; fi + if grep -qE "$READY" "$LOG" 2>/dev/null; then outcome=ready; break; fi + sleep 2 +done + +kill "$qemu_pid" 2>/dev/null +wait "$qemu_pid" 2>/dev/null + +# --- assertions ------------------------------------------------------------ + +grep -q 'BdsDxe: starting' "$LOG" 2>/dev/null || + fail "firmware started a boot entry" "$(tail -20 "$LOG")" +pass "firmware started a boot entry" + +grep -qiE 'GRUB|grub_|Booting a command list|Loading Linux' "$LOG" 2>/dev/null || + fail "bootloader ran" "$(tail -20 "$LOG")" +pass "bootloader ran" + +# The kernel boots with `quiet splash`, so its own messages never reach the +# serial console. Output from the initramfs is the evidence that it ran at all. +grep -qE 'Linux version|Booting Linux|/run/archiso|archiso|Falling back to interactive' "$LOG" 2>/dev/null || + fail "kernel started" "no kernel or initramfs output; the bootloader may not have handed off: +$(tail -20 "$LOG")" +pass "kernel started" + +# Checked before the generic failure scan: it names the cause exactly, and the +# generic scan would otherwise report it as an unexplained mount failure. +if grep -qE 'Filesystem uses "[a-z0-9]+" compression' "$LOG" 2>/dev/null; then + comp=$(grep -oE 'Filesystem uses "[a-z0-9]+"' "$LOG" | head -1 | grep -oE '"[a-z0-9]+"') + fail "root filesystem is mountable by this ISO's own kernel" \ + "squashfs is compressed with ${comp:-?}, which this kernel cannot decompress. +Set airootfs_image_tool_options in configs/profiledef.sh to a supported +compressor for this architecture (check CONFIG_SQUASHFS_* in the kernel)." +fi + +if [[ $outcome == fatal ]]; then + fail "reached a live environment" "$(grep -nE "$FATAL" "$LOG" | head -5)" +fi +pass "root filesystem mounted" + +[[ $outcome == ready ]] || + fail "reached a live environment" "no readiness marker within ${TIMEOUT}s; last lines: +$(tail -20 "$LOG")" +pass "reached a live environment" + +echo "==> ok" diff --git a/test/unit/iso-structure-test.sh b/test/unit/iso-structure-test.sh new file mode 100755 index 00000000..13489cd6 --- /dev/null +++ b/test/unit/iso-structure-test.sh @@ -0,0 +1,243 @@ +#!/bin/bash +# +# Structural checks on a built ISO. These are the invariants that distinguish an +# ISO which merely built from one that can actually boot -- every failure mode +# here has been observed producing a clean, successful build: +# +# * an initramfs with no archiso hook, which panics with no root filesystem +# * boot configs naming a kernel or initramfs the ISO does not contain +# * the wrong architecture's EFI bootloader +# +# Runs against release/*.iso when one exists and skips otherwise, so test/all +# stays fast and VM-free. Pass a specific image as $1 to check that one. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + local description="$1" + local detail="${2:-}" + + [[ -n $detail ]] && printf '%s\n' "$detail" >&2 + printf 'not ok - %s\n' "$description" >&2 + exit 1 +} + +skip() { + printf 'ok - %s # SKIP %s\n' "$1" "${2:-}" +} + +iso="${1:-}" +if [[ -z $iso ]]; then + iso=$(\ls -t "$ROOT"/release/*.iso 2>/dev/null | head -n1 || true) +fi + +if [[ -z $iso || ! -f $iso ]]; then + skip "iso structure" "no ISO in release/; build one first" + exit 0 +fi + +for tool in bsdtar unsquashfs; do + command -v "$tool" >/dev/null || fail "$tool is required" +done + +# The ISO filename carries the architecture archiso built it for. +case "${iso##*/}" in +*aarch64*) arch=aarch64 efi=BOOTAA64.EFI ;; +*x86_64*) arch=x86_64 efi=BOOTX64.EFI ;; +*) fail "cannot determine architecture from ${iso##*/}" ;; +esac + +listing=$(bsdtar -tf "$iso" 2>/dev/null) || fail "cannot read $iso as an archive" + +has() { + printf '%s\n' "$listing" | grep -qxF "$1" +} + +# --- the ISO is bootable at all ------------------------------------------- + +file "$iso" | grep -q 'ISO 9660' || + fail "$arch: not an ISO 9660 image" +pass "$arch: ISO 9660 image" + +has "EFI/BOOT/$efi" || + fail "$arch: missing EFI/BOOT/$efi" "$(printf '%s\n' "$listing" | grep -i '\.efi$' || true)" +pass "$arch: EFI/BOOT/$efi present" + +# The other architecture's loader must NOT be there: its presence means the +# profile arch and the build arch disagreed somewhere. +case $arch in +aarch64) other=EFI/BOOT/BOOTX64.EFI ;; +*) other=EFI/BOOT/BOOTAA64.EFI ;; +esac +if has "$other"; then + fail "$arch: unexpected $other in an $arch image" +fi +pass "$arch: no foreign EFI loader" + +has "arch/$arch/airootfs.sfs" || + fail "$arch: missing arch/$arch/airootfs.sfs" +pass "$arch: squashfs root present" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# --- installer and installed-package compatibility ------------------------ +# +# A successful ISO build does not exercise the installer against the package +# scripts embedded in its own live root. limine-mkinitcpio-hook 1.38 replaced +# its pkgbase ownership check with modules.builtin discovery; an installer that +# only knows the old anchor boots normally, installs the whole OS, then fails at +# final bootloader setup. Check the actual pair shipped in the artifact. + +bsdtar -xOf "$iso" "arch/$arch/airootfs.sfs" >"$work/airootfs.sfs" 2>/dev/null || + fail "$arch: cannot extract live root from image" + +# The releng profile normally blocks sysinit.target on NTP. On machines that +# boot before networking is usable this presents as a static cursor for roughly +# two minutes before tty1 and the configurator appear. Time synchronization +# itself must remain enabled; only its synchronous boot gate is removed. +unsquashfs -ll "$work/airootfs.sfs" >"$work/live-root.list" 2>/dev/null || + fail "$arch: cannot list live root" + +if grep -Fq '/etc/systemd/system/sysinit.target.wants/systemd-time-wait-sync.service' \ + "$work/live-root.list"; then + fail "$arch: live boot still blocks on systemd-time-wait-sync" +fi +pass "$arch: live boot does not block on NTP" + +grep -Fq '/etc/systemd/system/sysinit.target.wants/systemd-timesyncd.service' \ + "$work/live-root.list" || + fail "$arch: asynchronous systemd-timesyncd enablement is missing" +pass "$arch: asynchronous clock synchronization remains enabled" + +installer_impl=$(unsquashfs -cat "$work/airootfs.sfs" \ + usr/share/omarchy-iso/orchestrator/phases_impl.py 2>/dev/null) || + fail "$arch: installer implementation missing from live root" + +limine_package=$(awk '$NF ~ /\/limine-mkinitcpio-hook-[^/]+\.pkg\.tar\.(zst|xz)$/ { + package = $NF + } + END { + sub(/^squashfs-root\//, "", package) + print package + }' "$work/live-root.list") +[[ -n $limine_package ]] || + fail "$arch: Limine mkinitcpio package missing from offline mirror" +unsquashfs -cat "$work/airootfs.sfs" "$limine_package" \ + >"$work/limine-mkinitcpio-hook.pkg.tar" 2>/dev/null || + fail "$arch: cannot extract Limine mkinitcpio package from live root" +limine_hook=$(bsdtar -xOf "$work/limine-mkinitcpio-hook.pkg.tar" \ + usr/share/libalpm/scripts/limine-mkinitcpio-install 2>/dev/null) || + fail "$arch: Limine mkinitcpio hook missing from offline package" + +if printf '%s\n' "$limine_hook" | grep -qF 'kernel_dir}/modules.builtin'; then + printf '%s\n' "$installer_impl" | grep -qF 'modules_builtin_marker' || + fail "$arch: installer does not recognize the embedded Limine modules.builtin hook" +elif printf '%s\n' "$limine_hook" | grep -qF 'pacman -Qqo "$pkgbase_file"'; then + printf '%s\n' "$installer_impl" | grep -qF 'pkgbase_file' || + fail "$arch: installer does not recognize the embedded Limine pkgbase hook" +else + fail "$arch: embedded Limine kernel discovery mechanism is unknown" +fi +pass "$arch: installer recognizes the embedded Limine kernel hook" + +# --- boot configs point at files that exist ------------------------------- +# +# grub.cfg is generated by substituting %ARCH% and, on aarch64, rewriting the +# kernel filenames. A mismatch here is invisible until the bootloader runs. + +grub_cfg=$(bsdtar -xOf "$iso" boot/grub/grub.cfg 2>/dev/null) || + fail "$arch: no boot/grub/grub.cfg in image" + +referenced=$(printf '%s\n' "$grub_cfg" | + grep -oE '/arch/boot/[^ ]*(vmlinuz|initramfs)[^ ]*' | sort -u) + +[[ -n $referenced ]] || + fail "$arch: grub.cfg references no kernel or initramfs" + +while read -r path; do + [[ -n $path ]] || continue + has "${path#/}" || + fail "$arch: grub.cfg references $path, absent from the image" \ + "$(printf '%s\n' "$listing" | grep "^arch/boot/" || true)" +done <<<"$referenced" +pass "$arch: every kernel/initramfs grub.cfg names exists in the image" + +# --- device trees and Snapdragon boot arguments --------------------------- +# +# aarch64 only. Qualcomm's UEFI is ACPI-only and Linux has no ACPI support for +# x1e80100, so a Snapdragon X machine boots to a silent black screen without a +# device tree. builder/build-iso.sh stages one into ISO 9660 out of the same +# linux-aarch64 package the live root is pacstrapped from -- but grub.cfg guards +# the entry with -f, so a staging failure is invisible at boot: the menu entry +# simply is not there. Catch it here instead. + +dtbs=$(printf '%s\n' "$grub_cfg" | + grep -oE '^[[:space:]]*devicetree[[:space:]]+[^[:space:]]+' | awk '{print $2}' | sort -u || true) + +if [[ $arch == aarch64 ]]; then + [[ -n $dtbs ]] || + fail "aarch64: grub.cfg loads no devicetree; Snapdragon X machines cannot boot this ISO" + + while read -r path; do + [[ -n $path ]] || continue + has "${path#/}" || + fail "aarch64: grub.cfg loads $path, absent from the image" \ + "$(printf '%s\n' "$listing" | grep '^boot/grub/' || true)" + # A truncated or zero-length blob passes an existence check and still + # black-screens. 0xd00dfeed is the flattened-device-tree magic. + # od -N4 exits after four bytes, so bsdtar takes SIGPIPE; under pipefail + # that would abort the script rather than report anything. + magic=$(bsdtar -xOf "$iso" "${path#/}" 2>/dev/null | od -An -tx1 -N4 | tr -d ' ' || true) + [[ $magic == d00dfeed ]] || + fail "aarch64: $path is not a device tree blob (magic $magic, want d00dfeed)" + done <<<"$dtbs" + pass "aarch64: every devicetree grub.cfg names is present and is a valid FDT" + + # The device tree alone is not enough: without these the clock and + # power-domain drivers gate the panel and the USB/PCIe links before their + # consumers bind, and the machine has no serial port to report it on. + for required in rd.udev.event_timeout=5 clk_ignore_unused pd_ignore_unused efi=noruntime console=tty0; do + printf '%s\n' "$grub_cfg" | grep -qF -- "$required" || + fail "aarch64: grub.cfg names no '$required'; the Snapdragon entry would not boot" + done + pass "aarch64: Snapdragon kernel arguments present" +elif [[ -n $dtbs ]]; then + fail "x86_64: grub.cfg loads a devicetree, which x86 firmware has no use for" +fi + +# --- the live initramfs can actually mount the medium --------------------- +# +# The archiso hook is what finds and mounts airootfs.sfs off the boot medium. +# Without it the kernel boots and panics. This has happened: linux-aarch64 owns +# its own mkinitcpio preset, which builds against /etc/mkinitcpio.conf -- and +# omarchy-settings replaces that file with the *target* system's hooks. + +initramfs_path=$(printf '%s\n' "$referenced" | grep 'initramfs' | head -n1) +initramfs_path="${initramfs_path#/}" + +if ! command -v lsinitcpio >/dev/null; then + skip "$arch: initramfs carries the archiso hook" "lsinitcpio not installed" + exit 0 +fi + +bsdtar -xOf "$iso" "$initramfs_path" >"$work/initramfs.img" 2>/dev/null || + fail "$arch: cannot extract $initramfs_path" + +hooks=$(lsinitcpio "$work/initramfs.img" 2>/dev/null | grep -oE 'hooks/[a-z_0-9-]+$' | sed 's|hooks/||' | sort -u) + +printf '%s\n' "$hooks" | grep -qx archiso || + fail "$arch: initramfs has no archiso hook; this ISO would panic on boot" \ + "hooks present: $(printf '%s' "$hooks" | tr '\n' ' ')" +pass "$arch: initramfs carries the archiso hook" + +printf '%s\n' "$hooks" | grep -qx archiso_loop_mnt || + fail "$arch: initramfs missing archiso_loop_mnt" \ + "hooks present: $(printf '%s' "$hooks" | tr '\n' ' ')" +pass "$arch: initramfs carries archiso_loop_mnt" diff --git a/test/unit/test_aarch64_pacman.py b/test/unit/test_aarch64_pacman.py new file mode 100644 index 00000000..b447301b --- /dev/null +++ b/test/unit/test_aarch64_pacman.py @@ -0,0 +1,203 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +BUILDER = ROOT / "builder/build-iso.sh" +TARGET_CONF = ROOT / "configs/aarch64/pacman-target.conf" +TARGET_MIRRORLIST = ROOT / "configs/aarch64/mirrorlist-target" +ONLINE_CONFIGS = [ + ROOT / f"configs/aarch64/pacman-online-{channel}.conf" + for channel in ("stable", "rc", "edge") +] + +sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso")) +sys.modules.setdefault( + "orchestrator.archinstall_adapter", + types.ModuleType("orchestrator.archinstall_adapter"), +) + +from orchestrator import phases_impl # noqa: E402 + + +class Aarch64PacmanTests(unittest.TestCase): + def test_target_uses_alarm_without_multilib(self): + config = TARGET_CONF.read_text() + self.assertIn("[core]", config) + self.assertIn("[extra]", config) + self.assertIn("[alarm]", config) + self.assertNotIn("[multilib]", config) + self.assertIn("Server = @@OMARCHY_PKGS_MIRROR@@", config) + + def test_omarchy_overlay_precedes_alarm_repositories(self): + for path in [TARGET_CONF, *ONLINE_CONFIGS]: + with self.subTest(path=path.name): + sections = [ + line + for line in path.read_text().splitlines() + if line.startswith("[") and line.endswith("]") + ] + self.assertLess(sections.index("[omarchy]"), sections.index("[core]")) + self.assertLess(sections.index("[omarchy]"), sections.index("[extra]")) + self.assertLess(sections.index("[omarchy]"), sections.index("[alarm]")) + + def test_builder_prepends_overlay_for_makepkg_dependency_resolution(self): + builder = BUILDER.read_text() + self.assertIn("cat /etc/pacman.conf", builder) + self.assertIn(">/tmp/pacman.conf.with-omarchy", builder) + self.assertNotIn(">> /etc/pacman.conf", builder) + + def test_builder_reads_architecture_specific_configs_from_aarch64_directory(self): + builder = BUILDER.read_text() + self.assertIn( + 'pacman_online_conf="/configs/aarch64/pacman-online-${OMARCHY_MIRROR}.conf"', + builder, + ) + self.assertIn("/configs/aarch64/pacman-target.conf", builder) + self.assertIn("/configs/aarch64/mirrorlist-target", builder) + + def test_target_uses_global_https_mirror_set(self): + servers = [ + line + for line in TARGET_MIRRORLIST.read_text().splitlines() + if line.startswith("Server = ") + ] + self.assertEqual( + servers, + [ + "Server = https://cdnmirror.com/archlinuxarm/$arch/$repo", + "Server = https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo", + "Server = https://de3.mirror.archlinuxarm.org/$arch/$repo", + "Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo", + "Server = https://fl.us.mirror.archlinuxarm.org/$arch/$repo", + ], + ) + self.assertTrue(all(server.startswith("Server = https://") for server in servers)) + self.assertFalse(any("//mirror.archlinuxarm.org/" in server for server in servers)) + + def test_build_configs_use_global_https_set_for_every_alarm_repo(self): + expected = [ + "https://cdnmirror.com/archlinuxarm/$arch/$repo", + "https://mirrors.dotsrc.org/archlinuxarm/$arch/$repo", + "https://de3.mirror.archlinuxarm.org/$arch/$repo", + "https://ca.us.mirror.archlinuxarm.org/$arch/$repo", + "https://fl.us.mirror.archlinuxarm.org/$arch/$repo", + ] + for path in ONLINE_CONFIGS: + with self.subTest(path=path.name): + sections = {} + section = None + for line in path.read_text().splitlines(): + if line.startswith("["): + section = line.strip("[]") + elif line.startswith("Server = "): + sections.setdefault(section, []).append(line.removeprefix("Server = ")) + for repository in ("core", "extra", "alarm"): + self.assertEqual(sections[repository], expected) + + def test_offline_config_is_reasserted_between_finalizers(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + target = root / "target" + (target / "etc").mkdir(parents=True) + live_conf = root / "pacman-offline.conf" + target_marker = root / "pacman-target.conf" + live_conf.write_text("[offline]\n") + target_marker.touch() + (target / "etc/pacman.conf").write_text("[multilib]\n") + ctx = types.SimpleNamespace(target=target) + + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl, "LIVE_PACMAN_CONF", live_conf), + mock.patch.object( + phases_impl, "AARCH64_TARGET_PACMAN_CONF", target_marker + ), + ): + phases_impl._restore_aarch64_offline_pacman(ctx) + + self.assertEqual((target / "etc/pacman.conf").read_text(), "[offline]\n") + + def test_final_target_receives_network_configuration(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + target = root / "target" + target_conf = root / "pacman-target.conf" + target_mirrorlist = root / "mirrorlist-target" + target_conf.write_text("[alarm]\nServer = snapdragon\n") + target_mirrorlist.write_text("Server = geoip\nServer = florida\n") + ctx = types.SimpleNamespace(target=target) + + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object( + phases_impl, "AARCH64_TARGET_PACMAN_CONF", target_conf + ), + mock.patch.object( + phases_impl, "AARCH64_TARGET_MIRRORLIST", target_mirrorlist + ), + ): + phases_impl.configure_package_repositories(ctx) + + self.assertEqual( + (target / "etc/pacman.conf").read_text(), target_conf.read_text() + ) + self.assertEqual( + (target / "etc/pacman.d/mirrorlist").read_text(), + target_mirrorlist.read_text(), + ) + + def test_non_aarch64_target_is_unchanged(self): + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "target" + ctx = types.SimpleNamespace(target=target) + with mock.patch.object( + phases_impl.platform, "machine", return_value="x86_64" + ): + phases_impl.configure_package_repositories(ctx) + self.assertFalse(target.exists()) + + def test_aarch64_bootstrap_installs_and_populates_alarm_keyring(self): + target = Path("/mnt") + installer = mock.Mock(target=target) + + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl.subprocess, "run") as run, + ): + packages = phases_impl._early_bootstrap_packages() + phases_impl._install_early_packages(installer) + + self.assertIn("archlinuxarm-keyring", packages) + self.assertIn( + "archlinuxarm-keyring", installer.add_additional_packages.call_args_list[0].args[0] + ) + run.assert_any_call( + ["arch-chroot", "/mnt", "pacman-key", "--init"], check=True + ) + run.assert_any_call( + [ + "arch-chroot", + "/mnt", + "pacman-key", + "--populate", + "archlinux", + "archlinuxarm", + "omarchy", + ], + check=True, + ) + + def test_x86_bootstrap_does_not_install_alarm_keyring(self): + with mock.patch.object(phases_impl.platform, "machine", return_value="x86_64"): + self.assertNotIn( + "archlinuxarm-keyring", phases_impl._early_bootstrap_packages() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit/test_aarch64_platforms.py b/test/unit/test_aarch64_platforms.py new file mode 100644 index 00000000..5a606cae --- /dev/null +++ b/test/unit/test_aarch64_platforms.py @@ -0,0 +1,266 @@ +import json +import os +import subprocess +import sys +import tempfile +import types +import unittest +from pathlib import Path, PurePosixPath +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "configs/aarch64/platforms.json" + +sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso")) +sys.modules.setdefault( + "orchestrator.archinstall_adapter", + types.ModuleType("orchestrator.archinstall_adapter"), +) + +from orchestrator import phases_impl # noqa: E402 + + +class Aarch64PlatformManifestTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.document = json.loads(MANIFEST.read_text()) + cls.platforms = cls.document["platforms"] + + def test_schema_version_is_supported(self): + self.assertEqual(self.document["schema_version"], 1) + + def test_platform_ids_and_dtbs_are_unique(self): + ids = [platform["id"] for platform in self.platforms] + dtbs = [ + platform["boot"]["dtb"] + for platform in self.platforms + if "dtb" in platform["boot"] + ] + self.assertEqual(len(ids), len(set(ids))) + self.assertEqual(len(dtbs), len(set(dtbs))) + + def test_each_platform_has_safe_boot_data(self): + self.assertTrue(self.platforms) + for platform in self.platforms: + with self.subTest(platform=platform.get("id")): + self.assertRegex(platform["id"], r"^[a-z0-9][a-z0-9-]*$") + self.assertTrue(platform["name"].strip()) + self.assertTrue(platform["match"]) + + for selector in platform["match"]: + self.assertTrue(selector) + self.assertLessEqual( + set(selector), {"sys_vendor", "product_name", "product_version"} + ) + self.assertTrue(all(value.strip() for value in selector.values())) + + boot = platform["boot"] + self.assertIn( + boot["hardware_description"], {"firmware", "dtb-override"} + ) + if boot["hardware_description"] == "dtb-override": + dtb = PurePosixPath(boot["dtb"]) + self.assertFalse(dtb.is_absolute()) + self.assertNotIn("..", dtb.parts) + self.assertEqual(dtb.suffix, ".dtb") + else: + self.assertNotIn("dtb", boot) + + for argument in boot["kernel_cmdline"]: + self.assertTrue(argument) + self.assertNotRegex(argument, r"\s") + + kernel = platform["kernel"] + self.assertIn(kernel["availability"], {"iso", "vendor-required"}) + self.assertTrue(kernel.get("package") or kernel.get("family")) + + modules = platform.get("initramfs", {}).get("modules", []) + self.assertEqual(len(modules), len(set(modules))) + for module in modules: + self.assertRegex(module, r"^[A-Za-z0-9][A-Za-z0-9_-]*$") + + files = platform.get("initramfs", {}).get("files", []) + self.assertEqual(len(files), len(set(files))) + for file in files: + path = PurePosixPath(file) + self.assertTrue(path.is_absolute()) + self.assertNotIn("..", path.parts) + self.assertTrue(str(path).startswith("/usr/lib/firmware/")) + + omitted_hooks = platform.get("initramfs", {}).get("omit_hooks", []) + self.assertEqual(len(omitted_hooks), len(set(omitted_hooks))) + for hook in omitted_hooks: + self.assertRegex(hook, r"^[a-z0-9][a-z0-9_-]*$") + + packages = platform.get("packages", []) + self.assertEqual(len(packages), len(set(packages))) + for package in packages: + self.assertRegex(package, r"^[a-z0-9][a-z0-9@._+-]*$") + + def test_yoga_initramfs_includes_typec_display_graph(self): + """The MSM component graph needs the full Type-C stack before LUKS.""" + yoga = next( + platform + for platform in self.platforms + if platform["id"] == "lenovo-yoga-slim7x" + ) + + required_modules = { + "ps883x", + "qrtr", + "pmic_glink", + "pmic_glink_altmode", + "ucsi_glink", + } + self.assertLessEqual(required_modules, set(yoga["initramfs"]["modules"])) + + +class Aarch64PlatformMatchingTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dmi = Path(self.tmp.name) / "dmi" + self.dmi.mkdir() + + def write_dmi(self, vendor, product): + (self.dmi / "sys_vendor").write_text(vendor + "\n") + (self.dmi / "product_name").write_text(product + "\n") + + def matched(self): + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl, "AARCH64_PLATFORM_MANIFEST", MANIFEST), + mock.patch.object(phases_impl, "DMI_ID_ROOT", self.dmi), + ): + return phases_impl._current_aarch64_platform() + + def test_matches_yoga_by_vendor_and_product(self): + self.write_dmi("LENOVO", "83ED") + self.assertEqual(self.matched()["id"], "lenovo-yoga-slim7x") + + def test_matches_dgx_spark(self): + self.write_dmi("NVIDIA", "NVIDIA_DGX_Spark") + matched = self.matched() + self.assertEqual(matched["id"], "nvidia-dgx-spark") + self.assertNotIn("dtb", matched["boot"]) + self.assertIn("nvidia-open-dkms", matched["packages"]) + + def test_matches_asus_gx10(self): + self.write_dmi("ASUSTeK COMPUTER INC.", "GX10") + matched = self.matched() + self.assertEqual(matched["id"], "asus-ascent-gx10") + self.assertNotIn("dtb", matched["boot"]) + self.assertIn("nvidia-open-dkms", matched["packages"]) + + def test_does_not_guess_from_product_name_alone(self): + self.write_dmi("NOT NVIDIA", "NVIDIA_DGX_Spark") + self.assertIsNone(self.matched()) + + +class Aarch64LiminePlatformHookTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) / "target" + self.esp = self.target / "boot" + self.dtb = "qcom/x1e80100-lenovo-yoga-slim7x.dtb" + (self.esp / "dtbs" / Path(self.dtb).parent).mkdir(parents=True) + (self.esp / "dtbs" / self.dtb).write_bytes(b"\xd0\x0d\xfe\xedtest") + self.ctx = types.SimpleNamespace( + target=self.target, + omarchy_install={"boot": {"esp_mount": "/boot"}}, + is_protected=False, + ) + self.yoga = next( + platform + for platform in json.loads(MANIFEST.read_text())["platforms"] + if platform["id"] == "lenovo-yoga-slim7x" + ) + + def configure(self): + with ( + mock.patch.object( + phases_impl, "_current_aarch64_platform", return_value=self.yoga + ), + mock.patch.object(phases_impl, "AARCH64_PLATFORM_MANIFEST", MANIFEST), + mock.patch.object(phases_impl, "info"), + ): + return phases_impl._configure_aarch64_platform_boot(self.ctx) + + def test_installs_persistent_cmdline_and_dtb_hook(self): + matched = self.configure() + self.assertEqual(matched["id"], "lenovo-yoga-slim7x") + + dropin = self.target / "etc/limine-entry-tool.d/80-omarchy-aarch64-platform.conf" + self.assertIn("clk_ignore_unused", dropin.read_text()) + self.assertIn("efi=noruntime", dropin.read_text()) + + initramfs_dropin = ( + self.target + / "etc/mkinitcpio.conf.d/zz-omarchy-aarch64-platform.conf" + ) + initramfs_text = initramfs_dropin.read_text() + self.assertIn("sbsa_gwdt", initramfs_text) + self.assertIn("msm", initramfs_text) + self.assertIn("ps883x", initramfs_text) + self.assertIn("qrtr", initramfs_text) + self.assertIn("pmic_glink", initramfs_text) + self.assertIn("pmic_glink_altmode", initramfs_text) + self.assertIn("ucsi_glink", initramfs_text) + self.assertIn("i2c_hid_of", initramfs_text) + self.assertIn("qcdxkmsuc8380.mbn", initramfs_text) + result = subprocess.run( + [ + "bash", + "-c", + 'HOOKS=(base udev plymouth kms block encrypt); MODULES=(); source "$1"; ' + 'printf "hooks=%s\\n" "${HOOKS[*]}"; ' + 'printf "modules=%s\\n" "${MODULES[*]}"; ' + 'printf "files=%s\\n" "${FILES[*]}"', + "bash", + str(initramfs_dropin), + ], + check=True, + capture_output=True, + text=True, + ) + self.assertIn("hooks=base udev plymouth kms block encrypt", result.stdout) + self.assertIn("modules=sbsa_gwdt msm", result.stdout) + self.assertIn("ps883x", result.stdout) + self.assertIn("qrtr", result.stdout) + self.assertIn("pmic_glink", result.stdout) + self.assertIn("pmic_glink_altmode", result.stdout) + self.assertIn("ucsi_glink", result.stdout) + self.assertIn("files=/usr/lib/firmware/qcom/gen70500_sqe.fw", result.stdout) + + hook = self.target / "etc/boot/hooks/post.d/80-omarchy-aarch64-platform" + self.assertTrue(hook.stat().st_mode & 0o111) + subprocess.run(["bash", "-n", str(hook)], check=True) + + def test_hook_adds_dtb_to_every_linux_entry_idempotently(self): + self.configure() + config = self.esp / "limine.conf" + config.write_text( + "/+Omarchy\n" + " //linux-aarch64\n" + " protocol: linux\n" + " path: boot():/vmlinuz\n" + " //fallback\n" + " protocol: linux\n" + " path: boot():/fallback\n" + ) + hook = self.target / "etc/boot/hooks/post.d/80-omarchy-aarch64-platform" + env = {**os.environ, "OMARCHY_LIMINE_ESP": str(self.esp)} + subprocess.run([str(hook)], check=True, env=env) + subprocess.run([str(hook)], check=True, env=env) + + text = config.read_text() + expected = f"dtb_path: boot():/dtbs/{self.dtb}" + self.assertEqual(text.count(expected), 2) + self.assertIn(f" {expected}", text) + self.assertIn(f" {expected}", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit/test_protected_esp_mount.py b/test/unit/test_protected_esp_mount.py new file mode 100644 index 00000000..f8c4f1c4 --- /dev/null +++ b/test/unit/test_protected_esp_mount.py @@ -0,0 +1,353 @@ +#!/usr/bin/python + +"""Regression coverage for the protected/pre-mounted ESP handoff.""" + +import sys +import subprocess +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso")) + +# The live ISO supplies archinstall; unit tests only need the adapter symbol. +sys.modules.setdefault( + "orchestrator.archinstall_adapter", + types.ModuleType("orchestrator.archinstall_adapter"), +) + +from orchestrator import phases_impl # noqa: E402 + + +class ProtectedEspMountTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) / "mnt" + self.target.mkdir() + self.esp_device = Path(self.tmp.name) / "esp-device" + self.esp_device.touch() + self.ctx = types.SimpleNamespace( + target=self.target, + is_protected=True, + omarchy_install={ + "mode": "protected", + "boot": {"esp_mount": "/boot"}, + "storage": { + "esp_device": str(self.esp_device), + "root_device": str(self.target), + "kernel": "linux-aarch64", + }, + }, + ) + + def test_prepare_target_mounts_an_absent_esp_before_install(self): + esp_mount = self.target / "boot" + mounted = False + + def is_mountpoint(path): + if path == self.target: + return True + if path == esp_mount: + return mounted + return False + + def run(command, **kwargs): + nonlocal mounted + self.assertEqual(command, ["mount", str(self.esp_device), str(esp_mount)]) + self.assertTrue(kwargs["check"]) + mounted = True + + with ( + mock.patch.object(phases_impl, "_is_mountpoint", side_effect=is_mountpoint), + mock.patch.object(phases_impl.subprocess, "run", side_effect=run) as run_mock, + mock.patch.object(phases_impl, "info"), + ): + phases_impl.prepare_install_target(self.ctx) + + self.assertTrue(mounted) + self.assertTrue(esp_mount.is_dir()) + run_mock.assert_called_once() + + def test_prepare_target_stops_if_mount_did_not_take_effect(self): + def is_mountpoint(path): + return path == self.target + + with ( + mock.patch.object(phases_impl, "_is_mountpoint", side_effect=is_mountpoint), + mock.patch.object(phases_impl.subprocess, "run"), + mock.patch.object(phases_impl, "info"), + self.assertRaisesRegex(RuntimeError, "mount reported success"), + ): + phases_impl.prepare_install_target(self.ctx) + + def test_pre_mounted_arch_config_rechecks_esp_even_without_protected_mode(self): + # disk_config.config_type is what tells archinstall not to mount a + # layout. The ESP safeguard must follow that fact rather than rely + # solely on the separate Omarchy mode label agreeing with it. + config = object() + ctx = types.SimpleNamespace( + target=self.target, + is_protected=False, + state={ + "arch_config_handler": types.SimpleNamespace(config=config), + "mirror_handler": object(), + }, + ) + + with ( + mock.patch.object( + phases_impl.arch, "is_pre_mount", return_value=True, create=True + ), + mock.patch.object(phases_impl, "verify_protected_mounts") as verify, + mock.patch.object( + phases_impl.arch, + "open_installer", + side_effect=RuntimeError("stop after mount boundary"), + create=True, + ), + mock.patch.object(phases_impl, "info"), + self.assertRaisesRegex(RuntimeError, "stop after mount boundary"), + ): + phases_impl.arch_install_system(ctx) + + verify.assert_called_once_with(ctx) + + +class Aarch64LimineKernelLayoutTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) / "mnt" + (self.target / "etc/mkinitcpio.d").mkdir(parents=True) + (self.target / "boot").mkdir() + self.kver = "7.2.2-2-aarch64-ARCH" + (self.target / "usr/lib/modules" / self.kver).mkdir(parents=True) + hook = self.target / "usr/share/libalpm/scripts/limine-mkinitcpio-install" + hook.parent.mkdir(parents=True) + hook.write_text( + 'process_kernel() {\n' + ' pacman -Qqo "$pkgbase_file" &>/dev/null || return 0\n' + '}\n' + ) + (self.target / "etc/mkinitcpio.d/linux-aarch64.preset").write_text( + f'ALL_kver="{self.kver}"\n' + ) + (self.target / "boot/Image").write_bytes(b"arm64-kernel-image") + self.ctx = types.SimpleNamespace(target=self.target) + + def test_bridges_alarm_kernel_layout_for_limine_discovery(self): + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl, "info"), + ): + phases_impl._prepare_aarch64_limine_kernel_layout(self.ctx) + + modules = self.target / "usr/lib/modules" / self.kver + self.assertEqual((modules / "pkgbase").read_text(), "linux-aarch64\n") + self.assertEqual( + (modules / "vmlinuz").read_bytes(), + (self.target / "boot/Image").read_bytes(), + ) + self.assertEqual(phases_impl._installed_kernels(self.ctx), ["linux-aarch64"]) + hook = self.target / "usr/share/libalpm/scripts/limine-mkinitcpio-install" + hook_text = hook.read_text() + self.assertIn('$(<"$pkgbase_file") == "linux-aarch64"', hook_text) + self.assertIn("pacman -Q linux-aarch64", hook_text) + self.assertNotIn( + 'pacman -Qqo "$pkgbase_file" &>/dev/null || return 0', hook_text + ) + subprocess.run(["bash", "-n", str(hook)], check=True) + + compat = self.target / "etc/mkinitcpio.conf.d/zz-omarchy-module-compat.conf" + self.assertTrue(compat.exists()) + compat_text = compat.read_text() + self.assertIn('modinfo -k "$KERNELVERSION" thunderbolt', compat_text) + self.assertIn('[[ $_omarchy_module == thunderbolt ]]', compat_text) + subprocess.run(["bash", "-n", str(compat)], check=True) + + def test_current_limine_modules_builtin_discovery_needs_no_patch(self): + hook = self.target / "usr/share/libalpm/scripts/limine-mkinitcpio-install" + current_hook = ( + 'process_kernel() {\n' + ' local kernel_dir="$1" kernel_name=""\n' + ' [[ -f "${kernel_dir}/modules.builtin" ]] || return 0\n' + ' kernel_name="$(pacman -Qqo "${kernel_dir}/modules.builtin" ' + '2>/dev/null)" || return 0\n' + ' set_kernel_context "$kernel_dir" "$kernel_name" || return 0\n' + '}\n' + ) + hook.write_text(current_hook) + modules = self.target / "usr/lib/modules" / self.kver + (modules / "modules.builtin").write_text("kernel/drivers/test.ko\n") + + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl, "info"), + ): + phases_impl._prepare_aarch64_limine_kernel_layout(self.ctx) + + self.assertEqual(hook.read_text(), current_hook) + self.assertEqual((modules / "pkgbase").read_text(), "linux-aarch64\n") + self.assertEqual((modules / "vmlinuz").read_bytes(), b"arm64-kernel-image") + subprocess.run(["bash", "-n", str(hook)], check=True) + + def test_missing_alarm_image_fails_before_limine_update(self): + (self.target / "boot/Image").unlink() + + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + self.assertRaisesRegex(RuntimeError, "kernel image missing or empty"), + ): + phases_impl._prepare_aarch64_limine_kernel_layout(self.ctx) + + def test_compat_dropin_filters_only_a_module_missing_from_selected_kernel(self): + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl, "info"), + ): + phases_impl._prepare_aarch64_limine_kernel_layout(self.ctx) + + compat = self.target / "etc/mkinitcpio.conf.d/zz-omarchy-module-compat.conf" + command = r''' +MODULES=(nvme thunderbolt qcom_q6v5_pas) +KERNELVERSION=test-kernel +modinfo() { return 1; } +source "$1" +printf '%s\n' "${MODULES[@]}" +''' + result = subprocess.run( + ["bash", "-c", command, "bash", str(compat)], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(result.stdout.splitlines(), ["nvme", "qcom_q6v5_pas"]) + + def test_compat_dropin_runs_after_thunderbolt_default(self): + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl, "info"), + ): + phases_impl._prepare_aarch64_limine_kernel_layout(self.ctx) + + conf_dir = self.target / "etc/mkinitcpio.conf.d" + (conf_dir / "thunderbolt_module.conf").write_text( + "MODULES+=(thunderbolt)\n" + ) + names = [path.name for path in conf_dir.glob("*.conf")] + ordered = subprocess.run( + ["sort", "-V"], + input="\n".join(names) + "\n", + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + self.assertLess( + ordered.index("thunderbolt_module.conf"), + ordered.index("zz-omarchy-module-compat.conf"), + ) + + command = r''' +MODULES=(nvme) +KERNELVERSION=test-kernel +modinfo() { return 1; } +for config in "$@"; do source "$config"; done +printf '%s\n' "${MODULES[@]}" +''' + result = subprocess.run( + ["bash", "-c", command, "bash", *[str(conf_dir / name) for name in ordered]], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(result.stdout.splitlines(), ["nvme"]) + + def test_compat_dropin_keeps_thunderbolt_when_selected_kernel_has_it(self): + with ( + mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), + mock.patch.object(phases_impl, "info"), + ): + phases_impl._prepare_aarch64_limine_kernel_layout(self.ctx) + + compat = self.target / "etc/mkinitcpio.conf.d/zz-omarchy-module-compat.conf" + command = r''' +MODULES=(nvme thunderbolt) +KERNELVERSION=test-kernel +modinfo() { return 0; } +source "$1" +printf '%s\n' "${MODULES[@]}" +''' + result = subprocess.run( + ["bash", "-c", command, "bash", str(compat)], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(result.stdout.splitlines(), ["nvme", "thunderbolt"]) + + def test_non_arm_target_is_unchanged(self): + with mock.patch.object(phases_impl.platform, "machine", return_value="x86_64"): + phases_impl._prepare_aarch64_limine_kernel_layout(self.ctx) + + modules = self.target / "usr/lib/modules" / self.kver + self.assertFalse((modules / "pkgbase").exists()) + self.assertFalse((modules / "vmlinuz").exists()) + self.assertFalse( + (self.target / "etc/mkinitcpio.conf.d/zz-omarchy-module-compat.conf").exists() + ) + + +class LimineUkiConfigurationTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) / "mnt" + (self.target / "usr/share/omarchy/default/limine").mkdir(parents=True) + (self.target / "usr/share/omarchy/default/limine/default.conf").write_text( + 'ESP_PATH="/boot"\nKERNEL_CMDLINE[default]+="@@CMDLINE@@"\n' + ) + (self.target / "usr/share/omarchy/default/limine/limine.conf").write_text( + "Omarchy\n" + ) + self.ctx = types.SimpleNamespace( + target=self.target, + omarchy_path=Path(self.tmp.name) / "unused", + ) + + def test_explicit_uki_false_overrides_installed_omarchy_default(self): + config = types.SimpleNamespace( + bootloader_config=types.SimpleNamespace(uki=False) + ) + with mock.patch.object(phases_impl.arch, "has_uefi", return_value=True, create=True): + phases_impl._write_limine_defaults( + self.ctx, + "root=/dev/mapper/omarchy_root", + esp_mount="/boot", + enable_uki=phases_impl._configured_uki(config), + ) + + defaults = (self.target / "etc/default/limine").read_text() + self.assertIn("ENABLE_UKI=no", defaults) + + def test_unspecified_uki_does_not_override_package_default(self): + config = types.SimpleNamespace( + bootloader_config=types.SimpleNamespace(uki=None) + ) + with mock.patch.object(phases_impl.arch, "has_uefi", return_value=True, create=True): + phases_impl._write_limine_defaults( + self.ctx, + "root=/dev/mapper/omarchy_root", + esp_mount="/boot", + enable_uki=phases_impl._configured_uki(config), + ) + + defaults = (self.target / "etc/default/limine").read_text() + self.assertNotIn("ENABLE_UKI=", defaults) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit/test_provisioning_state.py b/test/unit/test_provisioning_state.py index 7386f001..30c0ec34 100644 --- a/test/unit/test_provisioning_state.py +++ b/test/unit/test_provisioning_state.py @@ -8,6 +8,7 @@ import json import os +import platform import sys import tempfile import types @@ -184,10 +185,15 @@ def setUp(self): info_patch.start() self.addCleanup(info_patch.stop) - # A fake bundled Node tarball on the "live ISO". + # A fake bundled Node tarball on the "live ISO". Named for the running + # architecture, because that is what _stage_node_tarball globs for -- + # an x64 name here would make this suite pass only on x86_64. self.packages = Path(self.tmp.name) / "opt-packages" self.packages.mkdir() - (self.packages / "node-v24.0.0-linux-x64.tar.gz").write_bytes(b"node") + self.node_tarball = ( + f"node-v24.0.0-linux-{phases_impl.NODE_ARCH_TOKENS[platform.machine()]}.tar.gz" + ) + (self.packages / self.node_tarball).write_bytes(b"node") node_patch = mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", self.packages) node_patch.start() self.addCleanup(node_patch.stop) @@ -207,7 +213,7 @@ def test_normal_install_stages_only_the_node_tarball(self): ctx = make_ctx(self.target, defer_provisioning=False) phases_impl.stage_provisioning_state(ctx) - self.assertTrue((self.provisioning_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists()) + self.assertTrue((self.provisioning_dir() / "packages" / self.node_tarball).exists()) self.assertFalse((self.provisioning_dir() / "pending").exists()) self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())