diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d576b6d194c..75a13101b12 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -97,7 +97,7 @@ jobs: - name: Install test dependencies run: | sudo apt-get update - sudo apt-get install -y python3-yaml jq lua5.4 imagemagick libxkbcommon-tools ripgrep desktop-file-utils pacman-package-manager + sudo apt-get install -y python3-yaml jq lua5.4 imagemagick libxkbcommon-tools ripgrep desktop-file-utils pacman-package-manager libarchive-tools sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua if ! command -v magick >/dev/null; then sudo ln -sf "$(command -v convert)" /usr/local/bin/magick diff --git a/README.md b/README.md index 3960e3846e4..855d84a0163 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,20 @@ in one command, full-disk encryption included. Already running Omarchy 3.x? This page is the fresh install — to upgrade in place, see [docs/upgrade-to-quattro.md](docs/upgrade-to-quattro.md). +## Moving to Omarchy's official packages + +Omarchy's own packages now support Apple Silicon, so this fork's quattro line ends. Macs move onto the official packages of the channel they follow (on edge: `omarchy-dev`, `omarchy-mac`, `omarchy-mac-boot` and the Aurora kernel) with `omarchy-mac-migrate`, which keeps the encryption, snapshots and data, changes nothing until a check passes, and resumes if it is cut short. + +- **Macs installed from this repository:** nothing to do. One update marks the Mac; the next `omarchy update` moves it and asks for a reboot. A channel without a Mac release yet (stable and rc today) keeps updating as before until it has one. +- **Test images and other Macs:** run this on the Mac, then reboot when it says so: + + +```bash +d=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v1/omarchy-mac-migrate && echo "07c01b67cc55e44576e1981da0b9ffd574f8f0d484cb9367f5dd9134fb43c25c $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run +``` + +`sudo omarchy-mac-migrate check` says what a run would do without changing anything, and `sudo omarchy-mac-migrate status` where a run stands. How it works: [migrate/README.md](migrate/README.md). + --- ## Before you begin diff --git a/bin/omarchy-mac-migrate b/bin/omarchy-mac-migrate new file mode 100755 index 00000000000..c2691a09fc8 --- /dev/null +++ b/bin/omarchy-mac-migrate @@ -0,0 +1,3539 @@ +#!/bin/bash -p + +# omarchy:summary=Move this Mac onto Omarchy's official packages through a journaled, resumable migration +# omarchy:args=status | check | run [--target FILE] | verify +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# GENERATED from migrate/src by migrate/build: edit the sources there, then run +# migrate/build. One self-contained file, so it runs the same from a quattro +# checkout, from omarchy-mx-mac's final release and as a downloaded release +# asset, and needs no migration code in any package. +# +# It moves an Apple Silicon Mac running an Omarchy fork (omarchy-mac quattro, +# omarchy-mx-mac, a quattro-upstream test image) onto the official packages of +# the channel it follows: the Omarchy runtime pair from pkgs.omarchy.org (the +# omarchy-dev pair on edge), omarchy-mac and omarchy-mac-boot, and the Aurora +# boot chain, under the core Apple Silicon pacman configuration. A channel +# whose repository has no qualified Mac packages yet defers (75) with nothing +# changed; so does anything preflight refuses. +# +# status what this Mac's migration is doing +# check preflight only: says what run would do, changes nothing +# run migrate, or resume a migration cut short +# verify after the reboot: verify the new boot chain and finish +# +# Exit 0: migrated (or waiting for its reboot), or nothing to migrate. Exit 75: +# deferred, nothing changed. Any other status: a step failed part way; running +# it again resumes. +# +# Root starts over in an empty environment with a fixed PATH and reads only the +# live system. Unprivileged tests name a fixture root in +# OMARCHY_MAC_MIGRATE_ROOT. + +if (( EUID == 0 )) && [[ ${1:-} != "--clean-environment" ]]; then + exec /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/bin HOME=/root /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@" +fi +[[ ${1:-} != "--clean-environment" ]] || shift + +set -euo pipefail + +# shellcheck disable=SC2034 # R, fixture and self are the engine's inputs +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin + R="" + fixture=0 +else + R=${OMARCHY_MAC_MIGRATE_ROOT:-} + if [[ $R != /?* ]]; then + echo "omarchy-mac-migrate: run it as root: sudo omarchy-mac-migrate ${*:-status}" >&2 + exit 1 + fi + R=${R%/} + fixture=1 +fi +self=$(realpath -- "${BASH_SOURCE[0]}") + +# --- engine.sh ------------------------------------------------------------ + +# The journaled migration engine. +# +# It moves a Mac onto its target in thirteen ordered steps. Every step records +# its start and its end in an append-only journal synced to disk, so a power +# loss or a kill resumes at the first step that did not finish, and every step +# can run again from its start. Preflight changes nothing and freezes the plan +# the later steps follow. A cohort adapter (cohort-.sh) decides what +# its machines need: the package targets, the packages the transaction may +# remove and the compatibility state to retire. The engine owns the order, the +# journal and every change to the system. +# +# The caller sets R (the fixture root, empty on a live system), fixture (1 when +# unprivileged tests drive it) and self (this file). Adapters read the +# target_* values. +# +# Exit status: 0 when the Mac is migrated, waits for its reboot or has nothing +# to migrate; 75 (EX_TEMPFAIL) when it stopped before anything changed (a +# preflight refusal, or any failure before the journal exists); 1 when a step +# failed, and running again resumes it. +# shellcheck disable=SC2034,SC2154 + +# Raised with every change to what the tool does; the journal format only when +# a journal one version writes cannot be resumed by another. +tool_version=1 +journal_format=2 + +migrate_steps=(preflight backup keyring prefetch repositories transaction boot-chain loader defaults verify unpin reboot retire) + +# pacman's download user reads the work, cache and candidate directories. +umask 022 + +state=$R/var/lib/omarchy-mac/migration +journal=$state/journal +plan=$state/plan +cache=$state/cache +backup=$state/backup +expected=$state/expected +start=$state/start +interrupted_marker=$state/transaction-interrupted +set_copy=$state/set +complete=$state/complete +reboot_pending=$state/reboot-pending +user_pending=$state/user-pending +tool_copy=$state/tool/omarchy-mac-migrate +lock_file=$R/run/lock/omarchy-mac-migrate.lock +pacman_conf=$R/etc/pacman.conf +pacman_db=$R/var/lib/pacman +pacman_cache=$R/var/cache/pacman/pkg +pacman_gpg=$R/etc/pacman.d/gnupg +esp=/boot/efi +limine_gate=$R/var/lib/omarchy/limine.enabled +limine_default=$R/etc/default/limine +verify_unit=omarchy-mac-migrate-verify.service +verify_unit_file=$R/etc/systemd/system/$verify_unit +first_boot_marker=$R/var/lib/omarchy/mac-first-boot/pending +legacy_first_boot_marker=$R/var/lib/omarchy/first-boot/pending +# The user units a fresh install's first run enables +# (install/user/first-run/enable-user-units.sh). +fresh_user_units="bt-agent.service owed.service omarchy-recover-internal-monitor.service omarchy-sleep-lock.service omarchy-migrate-notify.service omarchy-fcitx5.service omarchy-crash-watch.service omarchy-brightness-keyboard-auto.service" + +current_step="" +check_only=0 +original_args=() +target_file="" +payload_dir="" +restarted=0 +restarts=0 +work="" +gpgdir="" + +say() { + printf '%s\n' "$*" +} + +die() { + echo "omarchy-mac-migrate: $*" >&2 + if [[ -n $current_step && -f $journal ]]; then + journal_write "$current_step" "fail" "$*" + fi + # With nothing journaled, nothing has changed: deferred, like a refusal. + [[ -f $journal ]] || exit 75 + # Before the repository switch the system still runs as it did (only the + # official key was trusted): the attempt is set aside and the next run starts + # over from preflight. + if before_boundary; then + abort_migration "$*" + exit 75 + fi + exit 1 +} + +# The repository switch is the first change that cannot be left in place: from +# the boundary its step records just before it writes, the migration only +# goes forward. +before_boundary() { + [[ -f $journal ]] && ! awk '$3 == "boundary" { found = 1 } END { exit !found }' "$journal" +} + +abort_migration() { + local destination + destination=$state/history/aborted-$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$state/format" "$destination/" 2>/dev/null + rm -rf "$cache" "$backup" "$set_copy" "$start" "$expected" "$state/installed.now" "$state/tool" + if [[ -f $verify_unit_file ]]; then + systemctl disable "$verify_unit" >/dev/null 2>&1 || true + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$1" | durable_write "$state/deferred" 2>/dev/null || true + say "Nothing on this Mac changed; the next run starts the migration over." >&2 +} + +on_exit() { + local status=$? + if (( status != 0 )) && [[ -n $current_step && -f $journal && $(step_state "$current_step") == "begin" ]]; then + journal_write "$current_step" "fail" "exit $status" + fi + [[ -z $work ]] || rm -rf "$work" +} + +# --- Journal ----------------------------------------------------------------- + +journal_write() { + local detail=${3:-} + printf '%s %s %s%s\n' "$(date +%s)" "$1" "$2" "${detail:+ ${detail//$'\n'/ }}" >>"$journal" + sync "$journal" +} + +# The last event recorded for a step: begin, done, fail, or nothing. +step_state() { + [[ -f $journal ]] || return 0 + awk -v step="$1" '$2 == step { event = $3 } END { print event }' "$journal" +} + +next_step() { + local step + for step in "${migrate_steps[@]}"; do + if [[ $(step_state "$step") != "done" ]]; then + printf '%s\n' "$step" + return + fi + done +} + +# Unprivileged tests kill the engine with SIGKILL part way through a step's +# work (mid), once the work is done (during) or once its end is recorded +# (after). Root never reads these. +interrupt_for_test() { + (( fixture )) || return 0 + if [[ $1 == "mid" && ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "$2" ]] || + [[ $1 == "during" && ${OMARCHY_MAC_MIGRATE_KILL_DURING:-} == "$2" ]] || + [[ $1 == "after" && ${OMARCHY_MAC_MIGRATE_KILL_AFTER:-} == "$2" ]]; then + kill -9 $$ + fi +} + +run_step() { + local step=$1 + current_step=$step + restarted=0 + journal_write "$step" "begin" + "step_${step//-/_}" + if (( restarted )); then + current_step="" + return 0 + fi + interrupt_for_test during "$step" + journal_write "$step" "done" + interrupt_for_test after "$step" + current_step="" +} + +# Replace a file whole: written beside it, synced, then renamed over it. +durable_write() { + local file=$1 mode=${2:-644} tmp + tmp=$(mktemp "$file.XXXXXX") || return 1 + if cat >"$tmp" && chmod "$mode" "$tmp" && sync "$tmp" && mv -f "$tmp" "$file"; then + sync "$(dirname "$file")" + else + rm -f "$tmp" + return 1 + fi +} + +# --- Helpers ------------------------------------------------------------------- + +# A root-owned (in a fixture, caller-owned) regular file or directory, not a +# symlink and not writable by group or others. Target files and sets decide +# what is installed as root. +trusted() { + local owner mode + [[ -e $1 && ! -L $1 ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1 + (( owner == EUID && (8#$mode & 8#022) == 0 )) +} + +pacman_run() { + env OMARCHY_UPDATE_PACMAN=1 LC_ALL=C pacman --gpgdir "${gpgdir:-$pacman_gpg}" "$@" +} + +installed_packages() { + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Q +} + +installed_version() { + awk -v name="$1" '$1 == name { print $2; exit }' "$2" +} + +# The upstream detector where the runtime has it; else the device tree, as +# Asahi's own tools read it (a quattro or mx-mac runtime predates the +# detector). +hardware_platform() { + if command -v omarchy-hw-platform >/dev/null; then + omarchy-hw-platform + elif (( ! fixture )) && [[ -r /proc/device-tree/compatible ]] && tr '\0' '\n' /dev/null | awk -F: '$1 == "pub" { print $2; exit }') + [[ $validity == "f" || $validity == "u" ]] +} + +key_present() { + gpg --homedir "${2:-$pacman_gpg}" --batch --no-auto-check-trustdb --with-colons --list-keys "$1" >/dev/null 2>&1 +} + +# Official trust in the keyring at HOME: the keyrings installed are populated, +# and a missing Omarchy key comes from the keyserver by its full fingerprint and +# is signed locally. Fails when the key is not trusted after it. +trust_official_key() { + local home=$1 keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$home" --populate "${keyrings[@]}" >/dev/null || return 1 + fi + if ! key_trusted "$target_keyring" "$home"; then + pacman-key --gpgdir "$home" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$home" --lsign-key "$target_keyring" >/dev/null || return 1 + fi + key_trusted "$target_keyring" "$home" +} + +sha256_of() { + sha256sum "$1" | cut -d' ' -f1 +} + +repositories_in() { + awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); if (name != "options") print name }' "$1" +} + +# The configuration the transaction runs with: the future one, with the +# verified candidate set as a local repository ahead of everything. It is never +# installed as /etc/pacman.conf, so candidates stay invisible afterwards. +transaction_conf() { + local conf=$1 candidate_dir=$2 + if [[ -z $candidate_dir ]]; then + cat "$conf" + return + fi + awk -v repo="$candidate_repo" -v server="file://$candidate_dir" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ && !inserted && $0 !~ /\[options\]/ { + print "[" repo "]"; print "SigLevel = Optional"; print "Server = " server; print ""; inserted = 1 + } + { print } + ' "$conf" +} + +# --- Candidate sets --------------------------------------------------------- + +# Prints the key that made a detached signature, or fails. A revoked or expired +# key or signature does not count. gpgv reads the set's keyring file and needs +# no agent, so nothing depends on where a gpg-agent socket could live. +signer_of() { + local home=$1 file=$2 signature=$3 status + status=$(gpgv --homedir "$home" --keyring "$home/key.gpg" --status-fd 1 "$signature" "$file" 2>/dev/null) || return 1 + awk '$1 != "[GNUPG:]" { next } + $2 ~ /^(BADSIG|ERRSIG|EXPSIG|EXPKEYSIG|REVKEYSIG|KEYEXPIRED|KEYREVOKED)$/ { bad = 1 } + $2 == "GOODSIG" { good = 1 } + $2 == "VALIDSIG" { primary = $NF; valid++ } + END { if (!good || valid != 1 || bad) exit 1; print primary }' <<<"$status" +} + +# Verifies a candidate set as tools/release/candidate-set verify does, trusting +# only the target's fingerprint. Prints why it fails. +verify_candidate_set() { + local dir=$1 home=$2 manifest=$1/manifest.json receipt=$1/signing.json name sha digest + rm -rf "$home" + mkdir -m 700 "$home" + if ! gpg --batch --homedir "$home" --dearmor <"$dir/candidate-signing-key.asc" >"$home/key.gpg" 2>/dev/null || + ! gpg --batch --homedir "$home" --with-colons --show-keys "$home/key.gpg" 2>/dev/null | awk -F: '$1 == "fpr" { print $10 }' | grep -qx "$target_fingerprint"; then + echo "its key is not $target_fingerprint" + return 1 + fi + [[ -f $receipt && -f $receipt.sig && $(signer_of "$home" "$receipt" "$receipt.sig") == "$target_fingerprint" ]] || + { echo "signing.json is not signed by $target_fingerprint"; return 1; } + [[ $(jq -r '.signer.fingerprint' "$receipt") == "$target_fingerprint" && + $(jq -r '.manifest_sha256' "$receipt") == "$(sha256_of "$manifest")" && + $(jq -r '.set_sha256' "$receipt") == "$(jq -r '.set_sha256' "$manifest")" ]] || + { echo "signing.json does not bind this manifest"; return 1; } + digest=$(jq -r '.packages[] | "\(.name) \(.version) \(.filename) \(.sha256)"' "$manifest" | LC_ALL=C sort | sha256sum | cut -d' ' -f1) + [[ $digest == "$(jq -r '.set_sha256' "$manifest")" ]] || { echo "the manifest's set digest does not match its packages"; return 1; } + [[ $(jq -r '[.signatures[].file] | sort | join(" ")' "$receipt") == "$(jq -r '[.packages[].filename] | sort | join(" ")' "$manifest")" ]] || + { echo "signing.json does not cover exactly the manifest's packages"; return 1; } + while IFS=$'\t' read -r name sha; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ -f $dir/$name && $(sha256_of "$dir/$name") == "$sha" ]] || { echo "$name is missing or changed"; return 1; } + [[ -f $dir/$name.sig && $(signer_of "$home" "$dir/$name" "$dir/$name.sig") == "$target_fingerprint" ]] || + { echo "$name is not signed by $target_fingerprint"; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$manifest") +} + +# Copies a set into a directory only root can write, so nothing can change it +# between its verification and its use; everything later reads the copy. +copy_candidate_set() { + local source=$1 destination=$2 name + rm -rf "$destination" + install -d -m 700 "$destination" || return 1 + for name in manifest.json signing.json signing.json.sig candidate-signing-key.asc; do + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + done + [[ -f $destination/manifest.json ]] || { echo "the set has no manifest.json"; return 1; } + while read -r name; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + [[ ! -f $source/$name.sig ]] || cp "$source/$name.sig" "$destination/$name.sig" || return 1 + done < <(jq -r '.packages[].filename' "$destination/manifest.json") || { echo "cannot read its manifest"; return 1; } +} + +# Verifies the frozen set again, then builds a local repository of copies whose +# digests are checked again, so what pacman reads is what was verified. +# Signatures stay out of it: pacman's keyring never trusts the candidate key. +stage_candidate_repo() { + local destination=$1 home=$2 reason name sha + reason=$(verify_candidate_set "$target_set" "$home") || { echo "$reason" >&2; return 1; } + rm -rf "$destination" + install -d -m 755 "$destination" || return 1 + while IFS=$'\t' read -r name sha; do + install -m 644 "$target_set/$name" "$destination/$name" || return 1 + [[ $(sha256_of "$destination/$name") == "$sha" ]] || { echo "the copy of $name changed" >&2; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$target_set/manifest.json") + index_candidate_repo "$destination" +} + +# Indexes the manifest's packages, and nothing else, in DIR (already holding +# copies, or given links to the set with "link"). repo-add embeds a signature +# lying beside a package, so the set's own signatures are never in DIR. +index_candidate_repo() { + local destination=$1 mode=${2:-} files=() name + mapfile -t files < <(jq -r '.packages[].filename' "$target_set/manifest.json") + if [[ $mode == "link" ]]; then + for name in "${files[@]}"; do + ln -sfn "$target_set/$name" "$destination/$name" || return 1 + done + fi + (cd "$destination" && repo-add -q "$candidate_repo.db.tar.gz" "${files[@]}") >/dev/null || return 1 + chmod -R go+rX "$destination" +} + +target_version() { + jq -r --arg name "$1" '.packages[] | select(.name == $name) | .version' "$target_set/manifest.json" +} + +# --- Preflight ----------------------------------------------------------------- + +# The cohort an Apple Silicon Mac belongs to, from what is installed. Each +# cohort needs an adapter defining _plan and _retire; it may +# also define _preflight, _prefetch, _prepare and _restore, which the +# steps of those names call, and _stage and _unstage, which the loader step of +# a GRUB Mac calls before Limine is activated and after a failed activation. +# Only a cohort with a stage may have its ESP mounted at /boot or its root +# unlocked by busybox encrypt: the stage moves both. A legacy omarchy-mac +# install runs Omarchy from a checkout, trusts the rc4 fork keyring or carries +# the quattro tree, whose 3.x upgrade command quattro-upstream never had. A +# Mac on the omarchy-dev pair without the mx-mac fork's updaters, records or a +# test image's pin already runs Omarchy's own dev packages. +detect_cohort() { + local list=$1 + if grep -Eq '^omarchy(-settings)?-dev ' "$list"; then + if mx_mac_fork; then + echo mx-mac + elif [[ -n $(test_pin_block "$pacman_conf") ]]; then + # A test image built from a dev pair candidate keeps it pinned. + echo tester + else + echo official-dev + fi + elif ! grep -Eq '^omarchy ' "$list" || grep -Eq '^omarchy-mac-keyring ' "$list" || + [[ -e $R/usr/share/omarchy/bin/omarchy-upgrade-to-quattro-mac ]]; then + echo legacy + else + echo tester + fi +} + +cohort_refusal() { + echo "no adapter handles the $1 cohort" +} + +# What stops a Mac running omarchy-dev from counting as a Mac on Omarchy's own +# dev channel: a retired repository or key, a repository trusted without +# signatures, or an [omarchy] served from anywhere but pkgs.omarchy.org. +official_trust_problems() { + local conf repos repo fpr + conf=$(cat "$1") + repos=$(repositories_in <(printf '%s\n' "$conf")) + for repo in "${retired_repos[@]}"; do + ! grep -Fxq "$repo" <<<"$repos" || echo "[$repo]" + done + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || echo "the key $fpr" + done + printf '%s\n' "$conf" | awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "omarchy" && /^[[:space:]]*Server[[:space:]]*=/ && $0 !~ /=[[:space:]]*https:\/\/pkgs\.omarchy\.org\// { print "an [omarchy] server other than pkgs.omarchy.org" } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { print "[" name "] without signature checks" }' | sort -u +} + +# Runs the cohort's optional hook for a step. +adapter_hook() { + local hook=${cohort//-/_}_$1 + shift + if declare -F "$hook" >/dev/null; then + "$hook" "$@" + fi +} + +# The LUKS partition beneath /, or nothing when / is not encrypted; fails when +# it cannot tell (as omarchy-drive-password decides it). +root_luks_device() { + local source ancestry device + source=$(findmnt -no SOURCE "$R/") && [[ -n $source ]] || return 1 + ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${source%%[*}") || return 1 + device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry") + if [[ -n $device ]]; then + printf '%s\n' "$device" + elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then + return 1 + fi +} + +free_bytes() { + df -B1 --output=avail "$1" 2>/dev/null | tail -n 1 | tr -d ' ' +} + +bytes_used() { + local bytes + bytes=$(du -sxb "$1" 2>/dev/null | cut -f1) + printf '%s\n' "${bytes:-0}" +} + +# Running on battery below 30% is refused: the transaction and the boot switch +# must not lose power. +low_battery() { + local supply capacity on_battery=0 low=0 + for supply in "$R"/sys/class/power_supply/*; do + [[ -f $supply/type ]] || continue + case $(<"$supply/type") in + Battery) + capacity=$(<"$supply/capacity") 2>/dev/null || capacity=100 + [[ $capacity =~ ^[0-9]+$ ]] && (( capacity < 30 )) && low=1 + on_battery=1 + ;; + Mains | USB | USB_C | USB_PD) + [[ $(cat "$supply/online" 2>/dev/null) == "1" ]] && return 1 + ;; + esac + done + (( on_battery && low )) +} + +# The configuration pacman reads: FILE with each Include replaced by the files +# it names, three levels deep. +pacman_conf_flat() { + local file=$1 depth=${2:-0} line included + while IFS= read -r line || [[ -n $line ]]; do + if (( depth < 3 )) && [[ $line =~ ^[[:space:]]*Include[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$ ]]; then + # shellcheck disable=SC2086 # Include takes a glob + for included in $R${BASH_REMATCH[1]}; do + [[ ! -f $included ]] || pacman_conf_flat "$included" $(( depth + 1 )) + done + else + printf '%s\n' "$line" + fi + done <"$file" +} + +# The administrator's repositories the switch keeps must not accept untrusted +# packages: the core configuration requires signatures. +pacman_trust_problems() { + admin_repositories "$1" | awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { + value = $0; sub(/^[^=]*=[[:space:]]*/, "", value) + print "[" name "] accepts untrusted packages (SigLevel = " value "); remove it or sign it first" + }' +} + +preflight() { + local reasons=() installed boot_state kernels hooks="" check_output luks="" need esp_mount="" staged=0 channel + local future transaction targets_file resolved name version problem official_problems="" names saved_path problems=() + work=$(mktemp -d "$R/var/tmp/omarchy-mac-migrate.XXXXXX") || die "cannot create a work directory" + chmod 755 "$work" + installed=$work/installed + installed_packages >"$installed" || die "cannot list the installed packages" + pacman_conf_flat "$pacman_conf" >"$work/flat.conf" || die "cannot read $pacman_conf" + + [[ -d $R/run/systemd/system ]] || reasons+=("this is not a booted system (an image build or a chroot)") + [[ ! -e $pacman_db/db.lck ]] || reasons+=("pacman is busy or was interrupted ($pacman_db/db.lck exists)") + + cohort=$(detect_cohort "$installed") + # A Mac following Omarchy's own dev channel already runs official packages. + # One that still trusts what the switch retires, or that an administrator + # points at a target, is moved like a tester: its packages are named. + if [[ $cohort == "official-dev" ]]; then + official_problems=$(official_trust_problems "$work/flat.conf" | paste -sd, | sed 's/,/, /g') + if [[ -z $official_problems && -z ${target_file:-} ]]; then + say "This Mac runs Omarchy's own packages (omarchy-dev from pkgs.omarchy.org): nothing to migrate." + exit 0 + fi + cohort=tester + fi + declare -F "${cohort//-/_}_plan" >/dev/null || reasons+=("$(cohort_refusal "$cohort")") + ! declare -F "${cohort//-/_}_stage" >/dev/null || staged=1 + + kernels=$(awk '$1 == "linux-asahi" || $1 == "linux-aurora" { print $1 }' "$installed" | xargs) + [[ $kernels == "linux-asahi" || $kernels == "linux-aurora" ]] || + reasons+=("expected one Apple kernel (linux-asahi or linux-aurora), found: ${kernels:-none}") + + if limine_mac; then + boot_state=limine + elif [[ -f $R/boot/grub/grub.cfg ]]; then + boot_state=grub + else + boot_state=unknown + reasons+=("cannot tell whether this Mac boots GRUB or Limine") + fi + # The Limine setup derives the kernel command line from GRUB's defaults. + [[ -f $R/etc/default/grub ]] || reasons+=("there is no /etc/default/grub, which the Limine setup reads the kernel command line from") + [[ -f $R/usr/share/pacman/keyrings/asahi-alarm.gpg ]] || reasons+=("asahi-alarm-keyring is not installed, so Asahi ALARM's packages cannot be verified") + if ! luks=$(root_luks_device); then + reasons+=("cannot tell whether the root filesystem is encrypted") + fi + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + reasons+=("first boot has not finished on this Mac") + fi + if low_battery; then + reasons+=("the battery is below 30% and no charger is connected") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(pacman_trust_problems "$work/flat.conf"; unsupported_options "$pacman_conf") + # The switch writes pacman.conf whole: a repository only an Include file + # defines would be lost or doubled. + for problem in $(comm -13 <(repositories_in "$pacman_conf" | LC_ALL=C sort -u) <(repositories_in "$work/flat.conf" | LC_ALL=C sort -u)); do + reasons+=("[$problem] is configured through an Include, which the repository switch cannot rewrite; move it into $pacman_conf first") + done + + # The target: the administrator's, else the channel this Mac follows. + if [[ -z ${target_file:-} ]]; then + if channel=$(detect_channel "$cohort" "$work/flat.conf"); then + write_channel_target "$channel" "$work/target" + target_file=$work/target + else + reasons+=("cannot tell which Omarchy channel this Mac follows (stable, rc or edge); set one in $admin_target") + fi + fi + [[ -z ${target_file:-} ]] || load_target "$target_file" + if [[ ${target_type:-} == "candidate-set" ]] && ! command -v gpgv >/dev/null; then + reasons+=("gpgv is not installed (gnupg), so the candidate set's signatures cannot be checked") + fi + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + + # The target, read in isolation: a copy of the local database and the future + # configuration, never the live sync databases. Signatures are checked + # against a copy of the keyring that trusts the target's key, so preflight + # never changes the live one. + future=$work/pacman.conf + future_pacman_conf "$pacman_conf" >"$future" || die "cannot compute the new pacman configuration" + mkdir -p "$work/db" + cp -a "$pacman_db/local" "$work/db/local" || die "cannot copy the package database" + install -d -m 700 "$work/pacman-gnupg" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$work/pacman-gnupg" -xf - || die "cannot copy the pacman keyring" + gpgdir=$work/pacman-gnupg + trust_official_key "$gpgdir" || refuse "cannot fetch and trust the Omarchy packaging key $target_keyring" + # The trust the switch leaves: no retired fork key verifies anything from here. + for name in "${retired_keys[@]}"; do + if key_present "$name" "$gpgdir"; then + pacman-key --gpgdir "$gpgdir" --delete "$name" >/dev/null 2>&1 || die "cannot drop $name from the keyring copy" + fi + done + if [[ $target_type == "candidate-set" ]]; then + install -d -m 755 "$work/candidate" + if ! problem=$(copy_candidate_set "$target_set" "$work/set") || ! problem=$(verify_candidate_set "$work/set" "$work/gnupg"); then + refuse "the candidate set does not verify: $problem" + fi + local loaded_id=$target_id + target_set=$work/set + candidate_identity "$target_set" || die "cannot read the candidate manifest" + [[ $target_id == "$loaded_id" ]] || refuse "the candidate set changed while it was read" + index_candidate_repo "$work/candidate" link || die "cannot index the candidate set" + fi + transaction=$work/transaction.conf + transaction_conf "$future" "${target_set:+$work/candidate}" >"$transaction" + pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sy --noconfirm >"$work/sync.log" 2>&1 || + refuse "cannot read the target repositories: $(tail -n 1 "$work/sync.log")" + mapfile -t problems < <(presence_problems "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "The $target_channel channel has no Mac release yet; this Mac stays as it is until it has one." + refuse "${problems[@]}" + fi + + targets_file=$work/targets + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + { + cat "$work/adapter-targets" + for name in $keyring_packages; do + sed 's|^.*/||' "$work/adapter-targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + } >"$targets_file" + names=$(sed 's|^.*/||' "$targets_file" | xargs) + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$pacman_conf holds back $problem, which the migration changes; remove it from IgnorePkg or IgnoreGroup first") + done < <(pinned_targets "$pacman_conf" "$names $(xargs <"$work/allowed-removals")" "$work/db" "$transaction") + (( ${#reasons[@]} == 0 )) || refuse "${reasons[@]}" + resolved=$work/resolved + # shellcheck disable=SC2046 + if ! pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sup --noconfirm --ask 4 \ + --print-format '%r/%n %v' $(plan_ignores "$work") $(cat "$targets_file") >"$resolved" 2>"$work/resolve.log"; then + refuse "the target set does not resolve on this Mac: $(tail -n 1 "$work/resolve.log")" + fi + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + grep -Fxq "$name $version" "$resolved" || refuse "${name#*/} does not resolve to the candidate's $version" + done <"$targets_file" + fi + + mapfile -t problems < <(archive_problems "$resolved" "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "This Mac cannot move to the $target_channel channel's packages yet; it stays as it is." + refuse "${problems[@]}" + fi + + # The boot tools of the omarchy-mac-boot the transaction installs judge the + # Mac from here on. + payload_dir=$work/payload + version=$(fetch_payload "$resolved" "$transaction" "$work/db" "$payload_dir") || + refuse "cannot take the boot tools from the target's omarchy-mac-boot: $version" + saved_path=$PATH + if (( fixture )); then + PATH=$PATH:$payload_dir/usr/bin + else + PATH=$payload_dir/usr/bin:$PATH + fi + # The busybox encrypt hook matters only where it unlocks the root: legacy + # omarchy-mac sets it on every Mac, and on an unencrypted one it does nothing. + # Only a cohort whose stage moves that unlock (legacy) may carry it. + if ! hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null); then + reasons+=("cannot read the initramfs HOOKS") + elif [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( ! staged )); then + reasons+=("the root unlocks through busybox encrypt, which only the legacy omarchy-mac migration moves") + fi + # Until the loader step moves the unlock, the image GRUB boots is built from + # the new packages' HOOKS drop-ins: they must keep busybox encrypt. + if [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( staged )); then + if ! problem=$(future_hooks "$resolved" "$transaction" "$work/db"); then + reasons+=("cannot tell the HOOKS the new packages give: $problem") + elif [[ " $problem " != *" encrypt "* ]]; then + reasons+=("the new packages' HOOKS drop-ins would drop busybox encrypt before the boot switch moves the unlock: $problem") + fi + fi + # Installed boot files, not the running kernel: an update that just replaced + # the kernel leaves a reboot pending, and the migration replaces it anyway. + if ! check_output=$(boot_check_pending 2>&1); then + reasons+=("the boot files are not coherent; repair them first: $(tail -n 1 <<<"$check_output")") + fi + # Limine and its UKI live on the ESP U-Boot boots, mounted at /boot/efi. A + # cohort with a stage moves an ESP mounted at /boot there first. + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" ]] && (( staged )); then + : + elif [[ $esp_mount != "$esp" ]]; then + reasons+=("the system ESP is not mounted at $esp") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(adapter_hook preflight "$installed" "$luks" "$hooks") + need=$(( 4 * 1024 * 1024 * 1024 + $(bytes_used "$R/etc") + $(bytes_used "$R/boot") )) + # An ESP mounted at /boot is also /boot: its kernel and initramfs move onto + # the root filesystem. + [[ $esp_mount != "/boot" ]] || need=$(( need + 512 * 1024 * 1024 )) + (( $(free_bytes "$R/var/lib") >= need )) || reasons+=("the root filesystem needs $(( need / 1024 / 1024 )) MiB free for backups and downloads") + (( $(free_bytes "$R${esp_mount:-$esp}") >= 64 * 1024 * 1024 )) || reasons+=("the ESP needs 64 MiB free") + [[ $esp_mount == "/boot" ]] || (( $(free_bytes "$R/boot") >= 128 * 1024 * 1024 )) || reasons+=("/boot needs 128 MiB free") + PATH=$saved_path + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + # The adapter's plan records the unlock its stage moves, now that the HOOKS + # are known. + if [[ $cohort == "legacy" ]]; then + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "$hooks" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + fi + + gpgconf --homedir "$gpgdir" --kill all >/dev/null 2>&1 || true + gpgdir="" + if already_on_target "$installed" "$resolved" "$targets_file" "$future"; then + say "This Mac already runs the target set ($target_id): nothing to migrate." + exit 0 + fi + if (( check_only )); then + say "Ready: run moves this Mac ($cohort, $boot_state boot${luks:+, encrypted}) onto $target_id ($target_channel)." + say "It installs: $names" + [[ ! -s $work/allowed-removals ]] || say "It may remove: $(xargs <"$work/allowed-removals")" + exit 0 + fi + + # Passed: freeze the plan. Nothing on the system has changed yet. + install -d -m 755 "$(dirname "$state")" "$state" + : >"$journal" + printf 'journal_format=%s\n' "$journal_format" >"$state/format" + sync "$journal" "$state/format" + current_step=preflight + journal_write preflight "begin" "$target_id" + rm -rf "$plan.new" + install -d -m 755 "$plan.new" + cp "$installed" "$plan.new/installed" + cp "$future" "$plan.new/pacman.conf" + cp -a "$work/db/sync" "$plan.new/sync" + guarded_pacman_conf "$future" "$pacman_conf" "$(printf '%s\n' $names $(xargs <"$work/allowed-removals") $guarded_boot | awk '!seen[$0]++' | xargs)" >"$plan.new/pacman.guarded.conf" + cp "$targets_file" "$plan.new/targets" + cp "$work/allowed-removals" "$plan.new/allowed-removals" + cp "$work/kept" "$plan.new/kept" 2>/dev/null || : >"$plan.new/kept" + cp "$work/removals" "$plan.new/removals" 2>/dev/null || : >"$plan.new/removals" + [[ ! -d $work/adapter ]] || cp -r "$work/adapter" "$plan.new/adapter" + cp "$target_file" "$plan.new/target" + printf '%s\n' "$target_id" >"$plan.new/target-id" + printf '%s\n' "$target_packages" >"$plan.new/target-packages" + printf '%s\n' "$cohort" >"$plan.new/cohort" + printf '%s\n' "$boot_state" >"$plan.new/boot" + printf '%s\n' "$luks" >"$plan.new/luks" + printf '%s\n' "$esp_mount" >"$plan.new/esp" + for name in $fresh_user_units; do + [[ ! -f $R/usr/lib/systemd/user/$name ]] || printf '%s\n' "$name" + done >"$plan.new/user-units" + find "$plan.new" -type f -exec sync {} + + if [[ $target_type == "candidate-set" ]]; then + rm -rf "$set_copy" + mv "$work/set" "$set_copy" || die "cannot keep the verified candidate set" + sync "$set_copy"/* + target_set=$set_copy + fi + rm -rf "$plan" + mv "$plan.new" "$plan" + keep_tool || die "cannot keep a copy of this tool for the migration's resume" + sync "$state" + interrupt_for_test during preflight + journal_write preflight "done" + interrupt_for_test after preflight + current_step="" +} + +refuse() { + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + die "$*" + fi + say "The migration was refused before anything changed:" >&2 + printf ' - %s\n' "$@" >&2 + install -d -m 755 "$state" 2>/dev/null && + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$*" | durable_write "$state/deferred" 2>/dev/null || true + exit 75 +} + +# Every target is installed at the version the target resolves to, the +# configuration is already the future one and no retired key is trusted. +# Ordinary upgrades of other packages are omarchy update's business. +already_on_target() { + local installed=$1 resolved=$2 targets=$3 future=$4 target name version fpr + cmp -s "$future" "$pacman_conf" || return 1 + while read -r target; do + name=${target#*/} + version=$(awk -v name="$name" '{ sub(/^[^\/]*\//, "", $1) } $1 == name { print $2; exit }' "$resolved") + [[ -n $version && $(installed_version "$name" "$installed") == "$version" ]] || return 1 + done <"$targets" + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 1 + done +} + +# --- Steps ------------------------------------------------------------------- + +# The frozen plan: the target as preflight read it, the candidate set as it +# verified it. Nothing is read from the original set again. +load_plan() { + target_file=$plan/target + load_target "$target_file" frozen + target_id=$(<"$plan/target-id") + target_packages=$(<"$plan/target-packages") + cohort=$(<"$plan/cohort") +} + +plan_targets() { + cat "$plan/targets" +} + +# Where the ESP was mounted at preflight: /boot/efi, or /boot where the +# cohort's stage moves it. +plan_esp() { + if [[ -s $plan/esp ]]; then + cat "$plan/esp" + else + printf '%s\n' "$esp" + fi +} + +# Unqualified names of every package the transaction replaces or may remove. +plan_package_names() { + { sed 's|^.*/||' "$plan/targets"; cat "$plan/allowed-removals"; } | sort -u +} + +step_backup() { + local partial=$state/backup.partial name version file found luks esp_mount + esp_mount=$(plan_esp) + rm -rf "$partial" + install -d -m 700 "$partial" "$partial/packages" + cp "$plan/installed" "$partial/installed" + : >"$partial/packages.missing" + while read -r name; do + version=$(installed_version "$name" "$plan/installed") + [[ -n $version ]] || continue + found=0 + for file in "$pacman_cache/$name-$version"-*.pkg.tar.*; do + [[ -f $file ]] || continue + cp -p "$file" "$partial/packages/" || die "cannot copy $file into the backup" + found=1 + done + (( found )) || printf '%s %s\n' "$name" "$version" >>"$partial/packages.missing" + done < <(plan_package_names) + tar -C "$R/" --xattrs --acls -cpf "$partial/etc.tar" etc 2>"$partial/etc.log" || die "cannot back up /etc" + interrupt_for_test mid backup + # An ESP mounted at /boot is /boot: esp.tar holds it. + if [[ $esp_mount != "/boot" ]]; then + tar -C "$R/boot" --one-file-system -cpf "$partial/boot.tar" . || die "cannot back up /boot" + fi + tar -C "$R$esp_mount" -cpf "$partial/esp.tar" . || die "cannot back up the ESP" + luks=$(<"$plan/luks") + if [[ -n $luks ]]; then + cryptsetup luksHeaderBackup "$luks" --header-backup-file "$partial/luks-header.img" || + die "cannot back up the LUKS header of $luks" + fi + (cd "$partial" && find . -type f ! -name SHA256SUMS -print0 | LC_ALL=C sort -z | xargs -0 sha256sum >SHA256SUMS) || + die "cannot record the backup's digests" + find "$partial" -type f -exec sync {} + || die "cannot sync the backup" + rm -rf "$backup" + mv "$partial" "$backup" || die "cannot finish the backup" + sync "$state" + if [[ -s $backup/packages.missing ]]; then + say "Not in the package cache, so not backed up: $(awk '{ print $1 }' "$backup/packages.missing" | xargs)" + fi +} + +# Official trust, bootstrapped without any repository the switch retires: the +# keyrings already installed are populated, and a missing Omarchy key comes +# from the keyserver by its full fingerprint and is signed locally. A candidate +# set's key never enters pacman's keyring. +step_keyring() { + local keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$pacman_gpg" --populate "${keyrings[@]}" >/dev/null || die "cannot populate the keyrings: ${keyrings[*]}" + fi + interrupt_for_test mid keyring + if ! key_trusted "$target_keyring"; then + pacman-key --gpgdir "$pacman_gpg" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$pacman_gpg" --lsign-key "$target_keyring" >/dev/null || + die "cannot fetch and trust the Omarchy key $target_keyring" + fi + key_trusted "$target_keyring" || die "the Omarchy key $target_keyring is not trusted after the bootstrap" +} + +# What the transaction may remove stays out of the upgrade: an official build +# of the same name that conflicts with a target (stock omarchy, which the +# omarchy-dev pair replaces on edge) would otherwise join the transaction, and +# pacman drops one of the two. Left alone, it leaves through the target's +# conflict, or by name after the transaction. +plan_ignores() { + local dir=${1:-$plan} names + names=$(cat "$dir/removals" "$dir/allowed-removals" 2>/dev/null | awk 'NF' | LC_ALL=C sort -u | paste -sd,) + [[ -z $names ]] || printf -- '--ignore=%s\n' "$names" + return 0 +} + +# Packages the transaction removes by name once it has installed the targets, +# as far as DB still has them. By exact name: pacman -Q NAME also answers with +# a package that provides NAME (mise-bin for mise), which pacman -R refuses. +plan_removals() { + local db=$1 name installed + [[ -f $plan/removals ]] || return 0 + installed=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Qq) || return 1 + while read -r name; do + [[ -n $name ]] && grep -Fxq -- "$name" <<<"$installed" && printf '%s\n' "$name" + done <"$plan/removals" + return 0 +} + +# Paths the installed package NAME owns in DB that another package there owns +# too. pacman -R deletes every file of the package it removes, whoever else +# owns it, so a planned removal that hands files over (omarchy-dev's commands +# to omarchy-mac-boot) must leave inside the transaction, through the conflict +# of the package that replaces it, never in the removals after it. +shared_files() { + local db=$1 name=$2 + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Ql 2>/dev/null | awk -v name="$name" ' + { owner = $1; path = $0; sub(/^[^ ]+ /, "", path) } + path ~ /\/$/ { next } + owner == name { mine[path] = 1; next } + { other[path] = 1 } + END { for (path in mine) if (path in other) print path }' | LC_ALL=C sort +} + +# Downloads and verifies every package the transaction needs, then rehearses the +# transaction on a copy of the package database (--dbonly: no files, scripts or +# hooks) to learn exactly what it installs and removes. Signatures are checked +# against the trust the switch leaves, a copy of the keyring without the +# retired keys, so nothing that needs a fork key gets this far. +step_prefetch() { + local db=$cache/db rehearsal=$cache/rehearsal conf=$cache/transaction.conf removed name version bad=() fpr removals shared + install -d -m 755 "$cache" "$cache/pkg" + rm -rf "$db" "$rehearsal" "$cache/candidate" "$cache/trust" + mkdir -p "$db" + cp -a "$pacman_db/local" "$db/local" || die "cannot copy the package database" + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Q >"$cache/start" || die "cannot read the package database copy" + if [[ $target_type == "candidate-set" ]]; then + stage_candidate_repo "$cache/candidate" "$cache/gnupg" || die "the candidate set does not verify" + fi + install -d -m 700 "$cache/trust" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$cache/trust" -xf - || die "cannot copy the pacman keyring" + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$cache/trust" --delete "$fpr" >/dev/null 2>&1 || die "cannot drop $fpr from the keyring copy" + fi + done + gpgdir=$cache/trust + transaction_conf "$plan/pacman.conf" "${target_set:+$cache/candidate}" >"$conf" + # The databases preflight qualified, never a newer sync: the transaction + # installs exactly the set preflight checked. + cp -a "$plan/sync" "$db/sync" || die "cannot copy the frozen package databases" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + -Suw --noconfirm --ask 4 $(plan_ignores) $(plan_targets) || die "cannot download and verify the target set" + interrupt_for_test mid prefetch + cp -a "$db" "$rehearsal" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$rehearsal" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + --dbonly -Su --noconfirm --ask 4 $(plan_ignores) $(plan_targets) >"$cache/rehearsal.log" 2>&1 || + die "the rehearsed transaction failed: $(tail -n 1 "$cache/rehearsal.log")" + removals=$(plan_removals "$rehearsal" | xargs) + for name in $removals; do + shared=$(shared_files "$rehearsal" "$name" | head -n 3 | xargs) + [[ -z $shared ]] || + die "the transaction would leave $name to be removed after it, but the packages it installs also own $shared; nothing was changed" + done + if [[ -n $removals ]]; then + # shellcheck disable=SC2086 + pacman_run --config "$conf" --dbpath "$rehearsal" --logfile "$cache/pacman.log" --dbonly -R --noconfirm $removals \ + >>"$cache/rehearsal.log" 2>&1 || die "the rehearsed removal of $removals failed: $(tail -n 1 "$cache/rehearsal.log")" + fi + gpgdir="" + gpgconf --homedir "$cache/trust" --kill all >/dev/null 2>&1 || true + LC_ALL=C pacman --config "$conf" --dbpath "$rehearsal" -Q >"$cache/expected" || die "cannot read the rehearsed result" + removed=$(comm -23 <(awk '{ print $1 }' "$cache/start" | LC_ALL=C sort) <(awk '{ print $1 }' "$cache/expected" | LC_ALL=C sort)) + for name in $removed; do + grep -Fxq "$name" "$plan/allowed-removals" || bad+=("$name") + done + (( ${#bad[@]} == 0 )) || die "the transaction would also remove ${bad[*]}; nothing was changed" + # pacman can drop a named target that conflicts with another package of the + # transaction; every target must end installed. + while read -r name; do + [[ -n $(installed_version "${name#*/}" "$cache/expected") ]] || + die "the rehearsed transaction would not install ${name#*/}; nothing was changed" + done < <(plan_targets) + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + [[ $(installed_version "${name#*/}" "$cache/expected") == "$version" ]] || die "${name#*/} would not end at the candidate's $version" + done < <(plan_targets) + fi + adapter_hook prefetch || die "the $cohort adapter refused the rehearsed transaction; nothing was changed" + durable_write "$start" <"$cache/start" && durable_write "$expected" <"$cache/expected" || + die "cannot record the rehearsed transaction" +} + +# The installed packages no longer match what the rehearsal started from: an +# omarchy update ran in between, or a transaction was cut short. The +# transaction is rehearsed again from what is installed now. +system_moved() { + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + ! cmp -s "$state/installed.now" "$start" +} + +restart_from_prefetch() { + local step + (( ++restarts <= 3 )) || die "the system keeps changing under the migration ($1); run it again when nothing else updates" + say "The system changed since the transaction was rehearsed ($1); rehearsing it again" + for step in prefetch repositories transaction; do + journal_write "$step" "reset" "$1" + done + restarted=1 +} + +# The sync databases the rehearsal resolved against, over any a later sync left. +# A signature the rehearsal has none of belongs to the database it replaces +# (a fork's signed [omarchy]), and pacman rejects a database beside a +# signature that does not match it. +install_rehearsed_databases() { + local repo extension + for repo in $(repositories_in "$cache/transaction.conf"); do + for extension in db db.sig; do + if [[ ! -f $cache/db/sync/$repo.$extension ]]; then + [[ $extension != "db.sig" || ! -f $cache/db/sync/$repo.db ]] || rm -f "$pacman_db/sync/$repo.db.sig" + continue + fi + cmp -s "$cache/db/sync/$repo.$extension" "$pacman_db/sync/$repo.$extension" && continue + durable_write "$pacman_db/sync/$repo.$extension" 644 <"$cache/db/sync/$repo.$extension" || + die "cannot install the $repo database" + done + done +} + +# The process holding pacman's lock: libalpm keeps it open while it works. +lock_holder() { + local fd + for fd in "$R"/proc/[0-9]*/fd/*; do + if [[ $(readlink "$fd" 2>/dev/null) == "$pacman_db/db.lck" ]]; then + fd=${fd#"$R/proc/"} + printf '%s\n' "${fd%%/*}" + return 0 + fi + done + return 1 +} + +# Official repository precedence and no legacy trust: the frozen +# configuration, the sync databases the rehearsal used, no retired database or +# fork key. Until the transaction is done the configuration carries the +# migration's guard (and a test image keeps its pin), so a plain pacman -Syu in +# between moves none of the packages the transaction is about to change. +step_repositories() { + local repo extension fpr + if system_moved; then + restart_from_prefetch "before the repository switch" + return 0 + fi + # From here on the fork's own update may be gone with its packages: a boot + # resumes whatever is left. + keep_tool && write_verify_unit && systemctl enable "$verify_unit" >/dev/null 2>&1 || + die "cannot install $verify_unit, which resumes the migration at boot" + # The boundary: recorded before the first change that is not set aside. + before_boundary && journal_write repositories "boundary" + if ! cmp -s "$plan/pacman.guarded.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.guarded.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid repositories + install_rehearsed_databases + for repo in "${retired_repos[@]}"; do + rm -f "$pacman_db/sync/$repo".{db,db.sig,files,files.sig} + done + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$pacman_gpg" --delete "$fpr" >/dev/null || die "cannot remove the retired key $fpr" + fi + done +} + +# Something rewrote pacman.conf or trusted a retired key again since the +# switch: on an mx-mac Mac, the fork's own omarchy update, whose channel +# updaters stay until the transaction removes them. +switch_undone() { + local fpr + cmp -s "$plan/pacman.guarded.conf" "$pacman_conf" || return 0 + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 0 + done + return 1 +} + +# The installed packages with the planned removals left out. +without_removals() { + awk 'NR == FNR { drop[$1]; next } !($1 in drop)' "$plan/removals" "$1" +} + +# The targets are installed and only the planned removals are left. +removals_pending() { + [[ -f $plan/removals ]] && ! cmp -s "$state/installed.now" "$expected" && + cmp -s <(without_removals "$state/installed.now") "$expected" +} + +# A path as a pacman --overwrite glob that matches only itself. +overwrite_glob() { + sed 's/[][*?\\]/\\&/g' <<<"$1" +} + +# One transaction from the prefetched cache and databases, without a new sync: +# it installs exactly what was verified and rehearsed. Same-name packages are +# named explicitly, so a higher installed version is replaced too. A lock left +# by a transaction that was killed means its hooks may not have run, so the +# transaction runs again even when the packages are all in place. The adapter +# prepares the system for it first and may list, in $state/overwrite, files no +# package owns that it may replace; when pacman fails, the adapter restores +# what it prepared. Planned removals run after it, by name. +step_transaction() { + local holder interrupted=0 overwrite=() remove=() path removal shared + if [[ -e $pacman_db/db.lck ]]; then + if holder=$(lock_holder); then + die "pacman is running (process $holder); run the migration again when it has finished" + fi + say "Removing the pacman lock an interrupted transaction left behind" + : | durable_write "$interrupted_marker" || die "cannot record the interrupted transaction" + rm -f "$pacman_db/db.lck" + fi + # Kept across a new rehearsal until a transaction has run to its end. + [[ ! -e $interrupted_marker ]] || interrupted=1 + if switch_undone; then + restart_from_prefetch "pacman.conf or a retired key came back after the repository switch" + return 0 + fi + if system_moved && ! cmp -s "$state/installed.now" "$expected" && ! removals_pending; then + restart_from_prefetch "before the transaction" + return 0 + fi + if (( interrupted )) || ! cmp -s "$state/installed.now" "$expected"; then + install_rehearsed_databases + if (( interrupted )) || ! removals_pending; then + rm -f "$state/overwrite" + if ! adapter_hook prepare; then + adapter_hook restore + die "the $cohort adapter could not prepare the transaction" + fi + if [[ -f $state/overwrite ]]; then + while IFS= read -r path; do + [[ -z $path ]] || overwrite+=(--overwrite "$(overwrite_glob "$path")") + done <"$state/overwrite" + fi + # shellcheck disable=SC2046 + if ! pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" \ + -Su --noconfirm --ask 4 "${overwrite[@]}" $(plan_ignores) $(plan_targets); then + adapter_hook restore + die "the package transaction failed" + fi + fi + interrupt_for_test mid removals + mapfile -t remove < <(plan_removals "$pacman_db") + for removal in "${remove[@]}"; do + shared=$(shared_files "$pacman_db" "$removal" | head -n 3 | xargs) + [[ -z $shared ]] || die "cannot remove $removal: other packages also own $shared, which pacman -R would delete" + done + if (( ${#remove[@]} )); then + pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" -R --noconfirm "${remove[@]}" || + die "cannot remove ${remove[*]}" + fi + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + cmp -s "$state/installed.now" "$expected" || + die "the installed packages differ from the rehearsed transaction: $(diff "$expected" "$state/installed.now" | grep '^[<>]' | head -n 3 | xargs)" + rm -f "$interrupted_marker" + fi + rm -f "$pacman_db/sync/$candidate_repo".{db,db.sig} + # Fresh-image provisioning is never armed on an existing machine (preflight + # refuses one whose first boot is unfinished). + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + say "Disarming the first-boot setup the transaction left on this installed Mac" + rm -f "$first_boot_marker" "$legacy_first_boot_marker" + fi +} + +# The new packages are installed, set up and verified: the configuration loses +# the migration's guard and a test image's pin, and is the core one from here +# on. +step_unpin() { + if ! cmp -s "$plan/pacman.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid unpin + pacman-key --gpgdir "$pacman_gpg" --populate $(installed_keyrings) >/dev/null || die "cannot populate the installed keyrings" +} + +# The keyrings pacman-key can populate from what is installed now. +installed_keyrings() { + local name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || printf '%s\n' "$name" + done +} + +# Aurora, m1n1 and U-Boot came with the transaction; their stage-two image +# (m1n1, the device trees and U-Boot) and the kernel's menu are rebuilt and +# checked. The loader U-Boot starts is not touched here. +step_boot_chain() { + local output + update-m1n1 >/dev/null || die "update-m1n1 could not rebuild m1n1, the device trees and U-Boot" + interrupt_for_test mid boot-chain + if [[ $(<"$plan/boot") == "limine" ]]; then + omarchy-mac-limine-cmdline && limine-update >/dev/null || die "cannot rebuild the Limine menu and UKI" + else + update-grub >/dev/null || die "cannot rebuild the GRUB menu" + fi + output=$(boot_check_pending linux-aurora 2>&1) || die "the rebuilt boot files do not check: $(tail -n 1 <<<"$output")" +} + +# The installed omarchy-mac-boot's setup-boot, through the new runtime's +# dispatcher, activates Limine (or refreshes it on a Limine Mac): it stages and +# verifies Limine before it takes U-Boot's EFI slot and restores every file it +# touched when anything fails, so a failed activation leaves the previous +# loader booting. On a GRUB Mac the cohort's stage runs first, while GRUB still +# boots the Mac, and is undone when it or the activation fails. A switch cut +# short is run again from its start. +step_loader() { + local output uki=$R$esp/EFI/Linux/omarchy_linux-aurora.efi + if [[ $(<"$plan/boot") == "limine" ]]; then + interrupt_for_test mid loader + omarchy-lifecycle-dispatch setup-boot >/dev/null || die "setup-boot could not refresh Limine; the previous loader stays" + else + if ! adapter_hook stage; then + adapter_hook unstage || die "the $cohort adapter could not stage the boot switch, nor undo it; GRUB is still the loader" + die "the $cohort adapter could not stage the boot switch; GRUB is still the loader" + fi + install -D -m 644 /dev/null "$limine_gate" || die "cannot mark this Mac for Limine" + interrupt_for_test mid loader + if ! omarchy-lifecycle-dispatch setup-boot >/dev/null; then + rm -f "$limine_gate" + adapter_hook unstage || die "Limine could not be activated, and the $cohort adapter could not undo its stage; GRUB is still the loader" + die "Limine could not be activated; GRUB is still the loader" + fi + fi + [[ -s $uki ]] && grep -Fq "boot():/EFI/Linux/omarchy_linux-aurora.efi" "$R$esp/limine.conf" || + die "Limine has no linux-aurora UKI entry after setup-boot" + cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || die "the ESP loader is not the packaged Limine" + output=$(boot_check_pending linux-aurora 2>&1) || die "the staged boot chain does not check: $(tail -n 1 <<<"$output")" +} + +# Runs a command as USER, whose home is HOME, in a clean environment, so +# nothing root does follows a link the user controls. +as_user() { + local user=$1 home=$2 + shift 2 + if (( fixture )); then + env HOME="$home" "$@" + else + runuser -u "$user" -- env -i HOME="$home" USER="$user" LOGNAME="$user" PATH="$PATH" "$@" + fi +} + +# Accounts that have used Omarchy: a regular UID and Omarchy's state in the home. +omarchy_users() { + local user home + awk -F: '$3 >= 1000 && $3 < 60000 { print $1, $6 }' "$R/etc/passwd" 2>/dev/null | + while read -r user home; do + [[ ! -d $R$home/.local/state/omarchy ]] || printf '%s %s\n' "$user" "$home" + done +} + +# Enables a user unit by the links its [Install] WantedBy names, as +# systemctl --user enable writes them for these units. A mask, an override or any enablement, the user's +# or the administrator's, stays as it is. +enable_user_unit() { + local user=$1 home=$2 unit=$3 config=$R$2/.config/systemd/user target path + [[ -f $R/usr/lib/systemd/user/$unit ]] || return 0 + for path in "$config/$unit" "$R/etc/systemd/user/$unit" "$config"/*.wants/"$unit" "$R/etc/systemd/user"/*.wants/"$unit"; do + [[ ! -e $path && ! -L $path ]] || return 0 + done + for target in $(awk -F= '/^\[/ { install = ($0 == "[Install]") } install && $1 == "WantedBy" { print $2 }' "$R/usr/lib/systemd/user/$unit"); do + as_user "$user" "$R$home" mkdir -p "$config/$target.wants" && + as_user "$user" "$R$home" ln -s "/usr/lib/systemd/user/$unit" "$config/$target.wants/$unit" || return 1 + done +} + +# What omarchy update checks before it offers a reboot, through the new +# runtime's dispatcher: the boot files the next boot reads. +update_verify() { + local output + output=$(omarchy-lifecycle-dispatch update-verify 2>&1) || die "update-verify does not pass: $(grep -v '^[[:space:]]*$' <<<"$output" | head -n 2 | xargs)" +} + +step_verify() { + update_verify +} + +# The unit that finishes the migration after its reboot runs the copy of this +# tool the migration keeps, so it works whatever else is installed. +write_verify_unit() { + local unit + unit="[Unit] +Description=Finish the Omarchy Mac migration after its reboot +# Either condition starts it: a migration past its repository switch and not +# complete, or user setup a migration left pending, retried at every boot until +# it succeeds. +ConditionPathExists=|/var/lib/omarchy-mac/migration/journal +ConditionPathExists=|/var/lib/omarchy-mac/migration/user-pending +Wants=network-online.target +After=local-fs.target network-online.target + +[Service] +Type=oneshot +ExecStart=${tool_copy#"$R"} verify + +[Install] +WantedBy=multi-user.target" + [[ -f $verify_unit_file && $(<"$verify_unit_file") == "$unit" ]] && return 0 + install -d -m 755 "$(dirname "$verify_unit_file")" && + printf '%s\n' "$unit" | durable_write "$verify_unit_file" 644 && + { systemctl daemon-reload >/dev/null 2>&1 || true; } +} + +# Waits for a reboot; after it, the new chain must have booted Aurora through +# Limine with every boot file coherent. The boot waited for stays recorded +# until retire, so a verification cut short is repeated on the same boot. +step_reboot() { + local staged current release output + current=$(boot_id) + if [[ ! -s $reboot_pending ]]; then + printf '%s\n' "$current" | durable_write "$reboot_pending" || die "cannot record the boot to wait for" + fi + interrupt_for_test mid reboot + keep_tool && write_verify_unit || die "cannot install $verify_unit, which verifies the next boot" + systemctl enable "$verify_unit" >/dev/null 2>&1 || die "cannot enable $verify_unit, which verifies the next boot" + staged=$(<"$reboot_pending") + if [[ $current == "$staged" ]]; then + say "Reboot to finish the migration to $target_id. The next boot verifies the new boot chain." + current_step="" + exit 0 + fi + release=$(kernel_release linux-aurora) || die "linux-aurora has no module tree" + [[ $(<"$R/proc/sys/kernel/osrelease") == "$release" ]] || + die "this boot runs $(<"$R/proc/sys/kernel/osrelease"), not linux-aurora $release; the backups are in $backup" + limine_mac && cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || + die "this Mac did not boot through the packaged Limine" + output=$(omarchy-apple-silicon-boot-check --boot-chain linux-aurora 2>&1) || die "the boot check failed after the reboot: $(tail -n 1 <<<"$output")" + update_verify +} + +# The completion record comes first: what is left after it is only cleanup, +# which every later run repeats until it is done. +step_retire() { + "${cohort//-/_}_retire" || die "the $cohort adapter could not retire its compatibility state" + printf 'target=%s\ncompleted=%s\n' "$target_id" "$(date +%Y-%m-%dT%H:%M:%S%z)" | durable_write "$complete" || + die "cannot record the completed migration" + interrupt_for_test mid retire + tidy_completed +} + +# The post-reboot unit is released once the working state is gone and no user +# setup is pending; while some is, it stays enabled to retry at every boot. +tidy_completed() { + local working=0 release=0 + if [[ -e $reboot_pending || -d $cache || -d $set_copy ]]; then + working=1 + release=1 + fi + if [[ -s $user_pending ]]; then + if retry_user_pending; then + release=1 + else + release=0 + fi + fi + if (( release )); then + release_verify_unit + fi + if (( working )); then + rm -rf "$cache" "$set_copy" "$state/installed.now" "$state/overwrite" + rm -f "$reboot_pending" + fi +} + +# The post-reboot unit goes once nothing is left for it to do, and with it, +# outside a migration in progress, the copy of the tool it runs. +release_verify_unit() { + systemctl disable "$verify_unit" >/dev/null 2>&1 || say "Could not disable $verify_unit; it does nothing from now on." + if [[ -f $verify_unit_file ]]; then + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi + if [[ -f $complete || ! -f $journal ]]; then + rm -rf "$state/tool" + fi +} + +# A copy of this tool beside the journal: the post-reboot unit runs it, and a +# migration in progress resumes with it (see hand_over). +keep_tool() { + [[ $self != "$tool_copy" ]] || return 0 + cmp -s "$self" "$tool_copy" && return 0 + install -d -m 755 "$(dirname "$tool_copy")" && + install -m 755 "$self" "$tool_copy.new" && sync "$tool_copy.new" && mv -f "$tool_copy.new" "$tool_copy" +} + +# The tool_version and journal_format a copy of the tool declares. +tool_field() { + sed -n "s/^$1=\([0-9][0-9]*\)$/\1/p" "$2" | head -n 1 +} + +# A migration in progress continues with the tool that started it, unless this +# one resumes the same journal format and is at least as new: then this one +# takes over and becomes the kept copy. +hand_over() { + local version format + [[ -f $tool_copy && $self != "$tool_copy" ]] || return 0 + version=$(tool_field tool_version "$tool_copy") + format=$(tool_field journal_format "$tool_copy") + if [[ $format == "$journal_format" ]] && (( tool_version >= ${version:-0} )); then + keep_tool || die "cannot update the kept copy of this tool" + return 0 + fi + say "Resuming with the tool this migration started with (version ${version:-unknown})" + exec "$tool_copy" "$@" +} + +# --- Commands ---------------------------------------------------------------- + +# Another run holding the lock owns the migration's state: this one leaves it +# alone, failing once the migration is past its switch and deferring before. +# A copy of the tool handed the migration keeps the lock it inherited. +take_lock() { + install -d -m 755 "$(dirname "$lock_file")" + if [[ $(readlink "/proc/$$/fd/9" 2>/dev/null) != "$(realpath -m "$lock_file")" ]]; then + exec 9>"$lock_file" + fi + if ! flock -n 9; then + echo "omarchy-mac-migrate: another migration run is in progress" >&2 + if past_boundary; then + exit 1 + fi + exit 75 + fi +} + +resume_steps() { + local step event + while step=$(next_step) && [[ -n $step ]]; do + [[ $step != "preflight" ]] || die "the migration has no finished preflight" + event=$(step_state "$step") + [[ -z $event || $event == "reset" || $step == "reboot" ]] || say "Resuming the migration at $step" + run_step "$step" + done + say "This Mac now runs $target_id. Backups stay in $backup." +} + +migrate_run() { + local target_arg="" candidate + while (( $# )); do + case $1 in + --target) target_arg=${2:?--target needs a file}; shift 2 ;; + *) usage; exit 2 ;; + esac + done + + # A migration past its switch is this Mac's, whatever a detector says now. + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + if [[ $platform != "apple-silicon" ]]; then + say "Not an Apple Silicon Mac: nothing to migrate." + return 0 + fi + fi + take_lock + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + + if [[ -f $complete && -f $plan/target-id ]]; then + tidy_completed + candidate=$(find_target "$target_arg") + if [[ -n $candidate ]]; then + load_target "$candidate" + fi + if [[ -z $candidate || $target_id == "$(<"$plan/target-id")" ]]; then + say "Already migrated to $(<"$plan/target-id")." + return 0 + fi + (( check_only )) || archive_state + else + # User setup an earlier migration left pending runs first, whatever this + # run does next. + (( check_only )) || retry_user_pending_now + fi + + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + if [[ -n $target_arg ]] && ! cmp -s "$target_arg" "$plan/target"; then + die "a migration to $(<"$plan/target-id") is in progress; finish it before choosing another target" + fi + if (( check_only )); then + migrate_status + return 0 + fi + load_plan + resume_steps + return 0 + fi + + target_file=$(find_target "$target_arg") + preflight + resume_steps +} + +# Keeps a finished migration's record and backups beside the next one. +archive_state() { + local destination + destination=$state/history/$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$start" "$expected" "$complete" "$destination/" 2>/dev/null + [[ ! -f $repaired ]] || mv "$repaired" "$destination/" + [[ ! -d $backup ]] || mv "$backup" "$destination/" +} + +# Run by omarchy-mac-migrate-verify.service at boot: continues a migration that +# is waiting for, or past, its reboot, and does nothing otherwise. +migrate_verify() { + past_boundary || [[ -s $user_pending ]] || return 0 + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + [[ $platform == "apple-silicon" ]] || return 0 + fi + take_lock + if past_boundary; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + if [[ -f $complete ]]; then + tidy_completed + return 0 + fi + retry_user_pending_now + past_boundary || return 0 + load_plan + resume_steps +} + +# A migration that has started its repository switch and is not complete. +past_boundary() { + [[ -f $journal && ! -f $complete ]] && ! before_boundary +} + +migrate_status() { + local step event + if [[ -s $user_pending ]]; then + say "User setup pending, retried at the next run or boot: $(pending_summary)" + fi + if [[ ! -f $journal || $(step_state preflight) != "done" ]]; then + if [[ -s $state/deferred ]]; then + say "No migration has started on this Mac. The last run deferred: $(cut -d' ' -f2- "$state/deferred")" + else + say "No migration has started on this Mac." + fi + return 0 + fi + say "Target: $(<"$plan/target-id")" + if [[ -f $complete ]]; then + say "State: complete ($(awk -F= '$1 == "completed" { print $2 }' "$complete"))" + return 0 + fi + step=$(next_step) + event=$(step_state "$step") + if [[ $step == "reboot" && -s $reboot_pending && $event == "begin" ]]; then + if [[ $(boot_id) == "$(<"$reboot_pending")" ]]; then + say "State: waiting for a reboot" + else + say "State: rebooted; the new boot chain is not verified yet (sudo omarchy-mac-migrate verify)" + fi + elif [[ $event == "fail" ]]; then + say "State: failed at $step: $(awk -v step="$step" '$2 == step && $3 == "fail" { $1 = $2 = $3 = ""; line = $0 } END { sub(/^ +/, "", line); print line }' "$journal")" + else + say "State: in progress, next step $step" + fi +} + +usage() { + cat >&2 <<'USAGE' +Usage: omarchy-mac-migrate status + omarchy-mac-migrate check [--target FILE] + omarchy-mac-migrate run [--target FILE] + omarchy-mac-migrate verify + omarchy-mac-migrate version +USAGE +} + +migrate_main() { + local command=${1:-status} + original_args=("$@") + (( $# == 0 )) || shift + case $command in + status) migrate_status ;; + check) check_only=1; migrate_run "$@" ;; + run) migrate_run "$@" ;; + verify) migrate_verify ;; + version) say "omarchy-mac-migrate $tool_version (journal format $journal_format)" ;; + -h | --help | help) usage ;; + *) usage; exit 2 ;; + esac +} + +# --- target.sh ------------------------------------------------------------ + +# The target: which channel this Mac moves to, the packages and pacman +# configuration it ends with, and whether that channel is ready for Macs. +# shellcheck disable=SC2034,SC2154 + +# The Omarchy packaging key omarchy-keyring carries. +official_key=40DFB630FF42BCFFB047046CF0134EE680CAC571 +# Trust the converged system never keeps: the forks' repositories and keys +# (omarchy-mac's rc4 channel key and mx-mac's). Adapters add their own. +retired_repos=(omarchy-aarch64) +retired_keys=(FBD6874D423C418DDB6D143EECE19CDDE306DBD2 C81AC3E2A99556F9B21D5FEA3DD49BC9F8360BDC) +# The repositories the core configuration defines; any other one is the +# administrator's and is kept. +core_repos="omarchy asahi-alarm core extra alarm aur" +candidate_repo=omarchy-mac-candidate +admin_target=$R/etc/omarchy-mac/migration-target +# The image builder's test-image pin (omarchy-mac-installer +# image-builder/builder/test_image_pin.py): its first line, a reason line and +# the IgnorePkg line. +test_pin_mark="# Test image only (omarchy-mac-installer image-builder)" +guard_mark="# omarchy-mac-migrate: a migration to Omarchy's packages is in progress." + +# The runtime pair a channel's Macs run: Omarchy's edge builds its runtime from +# the development branch as omarchy-dev. +channel_pair() { + if [[ $1 == "edge" ]]; then + echo "omarchy-dev omarchy-settings-dev" + else + echo "omarchy omarchy-settings" + fi +} + +# Every package a migrated Mac takes from its channel's [omarchy]. +channel_packages() { + echo "$(channel_pair "$1") omarchy-mac omarchy-mac-boot linux-aurora linux-aurora-headers m1n1-aurora uboot-asahi limine-mkinitcpio-hook" +} + +# Installed or refreshed in the same transaction, from whichever repository +# carries them: the keyrings official trust comes from. +keyring_packages="asahi-alarm-keyring omarchy-keyring" + +# Held back with the transaction's packages while the migration is in +# progress: the rest of the boot chain they build on. +guarded_boot="limine limine-snapper-sync asahi-scripts mkinitcpio" + +# key=value lines, comments and blank lines ignored, anything else refused. +# format=1 +# type=repository | candidate-set +# channel=stable | rc | edge the [omarchy] channel after the switch +# server=URL optional; https://pkgs.omarchy.org//$arch +# keyring=FINGERPRINT optional; the Omarchy packaging key +# packages=NAME... repository only; optional +# set=DIR candidate-set: the set's files, manifest.json and signing.json +# fingerprint=FINGERPRINT candidate-set: the only key its signatures may carry +load_target() { + local file=$1 frozen=${2:-} line key value format="" packages="" + target_type="" target_channel="" target_server="" target_keyring=$official_key + target_set="" target_fingerprint="" target_repo=omarchy + trusted "$file" || refuse "refusing the target $file: it must be a regular file owned by root and writable only by root" + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + [[ $line == *=* ]] || refuse "the target $file is malformed: $line" + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + type) target_type=$value ;; + channel) target_channel=$value ;; + server) target_server=$value ;; + keyring) target_keyring=${value^^} ;; + packages) packages=$value ;; + set) target_set=${value%/} ;; + fingerprint) target_fingerprint=${value^^} ;; + *) refuse "the target $file has an unknown key: $key" ;; + esac + done <"$file" + [[ $format == "1" ]] || refuse "the target $file is not format=1" + [[ $target_channel =~ ^(stable|rc|edge)$ ]] || refuse "the target $file names no channel (stable, rc or edge)" + if [[ -z $target_server ]]; then + target_server="https://pkgs.omarchy.org/$target_channel/\$arch" + # Unprivileged tests serve the channels themselves. + if (( fixture )) && [[ -n ${OMARCHY_MAC_MIGRATE_SERVER:-} ]]; then + target_server=${OMARCHY_MAC_MIGRATE_SERVER//@channel@/$target_channel} + fi + fi + [[ $target_server =~ ^(https|file):// ]] || refuse "the target server must be https:// or file://: $target_server" + [[ $target_keyring =~ ^[0-9A-F]{40}$ ]] || refuse "the target keyring must be a 40-digit fingerprint" + target_packages=${packages:-$(channel_packages "$target_channel")} + case $target_type in + repository) + target_id="repository $target_server" + ;; + candidate-set) + [[ $target_fingerprint =~ ^[0-9A-F]{40}$ ]] || refuse "a candidate-set target needs its signer's 40-digit fingerprint" + target_repo=$candidate_repo + if [[ -n $frozen ]]; then + # After preflight only the verified copy counts; the original may be gone. + target_set=$set_copy + else + [[ $target_set == /* ]] && trusted "$target_set" || refuse "the candidate set $target_set must be a root-owned directory writable only by root" + [[ -f $target_set/manifest.json ]] || refuse "the candidate set has no manifest.json" + fi + candidate_identity "$target_set" || refuse "cannot read the candidate manifest" + ;; + *) + refuse "the target $file has no type (repository or candidate-set)" + ;; + esac +} + +# A candidate set's packages join the channel's: what the set carries comes +# from it, the rest of the Mac set from the channel's [omarchy]. +candidate_identity() { + local names + names=$(jq -r '.packages[].name' "$1/manifest.json") || return 1 + target_packages=$( { printf '%s\n' $(channel_packages "$target_channel"); printf '%s\n' "$names"; } | awk '!seen[$0]++' | xargs) && + target_id="candidate-set $(jq -r '.set' "$1/manifest.json") $(jq -r '.set_sha256' "$1/manifest.json")" +} + +# How the transaction names NAME: from the candidate set when it carries it, +# else from [omarchy]. +target_spec() { + if [[ $target_type == "candidate-set" ]] && jq -e --arg name "$1" '.packages[] | select(.name == $name)' "$target_set/manifest.json" >/dev/null; then + printf '%s/%s\n' "$candidate_repo" "$1" + else + printf 'omarchy/%s\n' "$1" + fi +} + +# --target, else the administrator's target. +find_target() { + local candidate + for candidate in "$@" "$admin_target"; do + if [[ -n $candidate && -e $candidate ]]; then + printf '%s\n' "$candidate" + return + fi + done +} + +# The channel this Mac's own configuration follows: an omarchy-mac lane +# ([omarchy-aarch64] on omarchy-mac/omarchy-pkgs-aarch64's releases, which +# quattro names after the channel), else an official [omarchy]. Anything else +# is unknown. +config_channel() { + local conf=$1 lane official + lane=$(section_servers "$conf" omarchy-aarch64 | sed -nE 's#^https://github\.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/(stable|rc|edge)/?$#\1#p' | sort -u) + official=$(section_servers "$conf" omarchy | sed -nE 's#^https://pkgs\.omarchy\.org/(stable|rc|edge)/(\$arch|aarch64)/?$#\1#p' | sort -u) + if [[ -n $(section_servers "$conf" omarchy-aarch64) ]]; then + [[ -n $lane && $lane != *$'\n'* ]] && printf '%s\n' "$lane" + elif [[ -n $official && $official != *$'\n'* ]]; then + printf '%s\n' "$official" + else + return 1 + fi +} + +# The Server values of SECTION in CONF. +section_servers() { + awk -v want="$2" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == want && /^[[:space:]]*Server[[:space:]]*=/ { value = $0; sub(/^[^=]*=[[:space:]]*/, "", value); sub(/[[:space:]]+$/, "", value); print value }' "$1" +} + +# The channel this Mac follows, or nothing when it cannot be told. An mx-mac +# Mac follows the fork's channel record; the rest follow their configuration. +detect_channel() { + local cohort=$1 conf=$2 channel="" + if [[ $cohort == "mx-mac" ]]; then + if command -v omarchy-apple-silicon-channel >/dev/null; then + channel=$(omarchy-apple-silicon-channel current 2>/dev/null) || channel="" + fi + else + channel=$(config_channel "$conf") || channel="" + fi + [[ $channel =~ ^(stable|rc|edge)$ ]] && printf '%s\n' "$channel" +} + +# A repository target for CHANNEL, written to FILE: what a Mac with no +# administrator's target moves to. +write_channel_target() { + printf 'format=1\ntype=repository\nchannel=%s\n' "$1" >"$2" + chmod 644 "$2" +} + +# The core Apple Silicon configuration (omacom/omarchy #13362, +# default/pacman/apple-silicon/pacman-edge.conf), with SERVER for [omarchy]. +# Unprivileged tests name their own Asahi ALARM server. +core_pacman_conf() { + local asahi=https://github.com/asahi-alarm/asahi-alarm/releases/download/aarch64 + (( ! fixture )) || asahi=${OMARCHY_MAC_MIGRATE_ASAHI_SERVER:-$asahi} + cat < 0 && /^#/ { skip--; next } + skip > 0 && /^[[:space:]]*IgnorePkg[[:space:]]*=/ { skip = 0; next } + { skip = 0 } + /^[[:space:]]*(#|$)/ { next } + { key = $0; sub(/^[[:space:]]*/, "", key); sub(/[[:space:]]*=.*$/, "", key); sub(/[[:space:]]+$/, "", key); if (!(key in core)) print }' "$1" +} + +# The test-image pin block of CONF, as it is written. +test_pin_block() { + awk -v pin="$test_pin_mark" ' + index($0, pin) == 1 { keep = 3 } + keep > 0 { print; keep-- }' "$1" +} + +# CONF's repositories that are neither the core ones nor retired, whole. +admin_repositories() { + local drop + drop="$core_repos ${retired_repos[*]} $candidate_repo" + awk -v drop="$drop" ' + BEGIN { n = split(drop, list, " "); for (i = 1; i <= n; i++) skip[list[i]] = 1; skip["options"] = 1 } + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); keep = !(name in skip) } + keep { print }' "$1" +} + +# The configuration after the switch: the core one for the target, with the +# administrator's own options and repositories kept. Applying it to its own +# output changes nothing. +future_pacman_conf() { + local conf=$1 options repositories + options=$(admin_options "$conf") + repositories=$(admin_repositories "$conf") + core_pacman_conf "$target_server" | awk -v options="$options" ' + { print } + /^LocalFileSigLevel/ && options != "" { print ""; print "# Kept from this Mac'"'"'s configuration"; print options }' + if [[ -n $repositories ]]; then + printf '\n%s\n' "$repositories" + fi +} + +# CONF with this migration's guard as the first lines of [options], and the +# test-image pin of OLD kept below it: until the package transaction is done, +# a plain pacman -Syu leaves every package the migration changes alone. +guarded_pacman_conf() { + local conf=$1 old=$2 names=$3 pin + pin=$(test_pin_block "$old") + awk -v guard="$guard_mark" -v names="$names" -v pin="$pin" ' + { print } + /^\[options\][[:space:]]*$/ && !done { + print guard " It removes these lines when its package transaction is done; sudo omarchy-mac-migrate run finishes it." + print "IgnorePkg = " names + if (pin != "") print pin + done = 1 + }' "$conf" +} + +# Administrator IgnorePkg entries (globs, as pacman reads them) matching a +# package this migration installs or removes, and IgnoreGroup entries holding +# one, one per line. +pinned_targets() { + local conf=$1 names=$2 db=$3 pattern name group member + for pattern in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnorePkg[[:space:]]*=//p'); do + for name in $names; do + # shellcheck disable=SC2053 # IgnorePkg takes globs + [[ $name != $pattern ]] || printf '%s\n' "$name" + done + done + for group in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnoreGroup[[:space:]]*=//p'); do + for member in $(LC_ALL=C pacman --config "$4" --dbpath "$db" -Sgq "$group" 2>/dev/null); do + [[ " $names " != *" $member "* ]] || printf '%s (group %s)\n' "$member" "$group" + done + done +} + +# An Include in [options] or an option the switch cannot keep as it is. +unsupported_options() { + awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "options" && /^[[:space:]]*Include[[:space:]]*=/ { print "an Include in [options] (" $0 ")" }' "$1" +} + +# --- Whether the channel is ready for Macs ----------------------------------- +# +# A channel takes Macs once its [omarchy] carries the Mac packages built from +# omacom/omarchy-mac-pkgs with a runtime that drives them. That is read from +# the signed archives the transaction would install, not from repository +# metadata: the runtime ships the lifecycle dispatcher, and omarchy-mac-boot +# ships its setup-boot and update-verify operations and no migration engine of +# its own. Until then every Mac on the channel defers. + +# The Mac packages the channel's repositories lack, one reason a line. +presence_problems() { + local listing name + listing=$(LC_ALL=C pacman --config "$1" --dbpath "$2" -Sl 2>/dev/null | awk '{ print $2 }' | LC_ALL=C sort -u) + for name in $(channel_pair "$target_channel") omarchy-mac omarchy-mac-boot linux-aurora m1n1-aurora uboot-asahi; do + grep -Fxq "$name" <<<"$listing" || echo "the $target_channel channel has no $name for Apple Silicon yet" + done +} + +# What the verified archives of the resolved runtime and omarchy-mac-boot +# lack, one reason a line. +archive_problems() { + local resolved=$1 conf=$2 db=$3 runtime listing + runtime=$(channel_pair "$target_channel") + runtime=${runtime%% *} + if listing=$(fetch_archive "$resolved" "$runtime" "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/bin/omarchy-lifecycle-dispatch' <<<"$listing" || + echo "the $target_channel channel's $runtime has no omarchy-lifecycle-dispatch to drive the Mac packages yet" + excluded_files "$listing" + else + echo "$listing" + fi + if listing=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/lib/omarchy/mac-boot/setup-boot' <<<"$listing" && grep -qx 'usr/lib/omarchy/mac-boot/update-verify' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot has no setup-boot and update-verify operations yet" + ! grep -qx 'usr/lib/omarchy-mac/boot/migrate-engine.sh' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot is not built from omacom/omarchy-mac-pkgs yet" + excluded_files "$listing" + else + echo "$listing" + fi +} + +# The administrator's NoExtract and NoUpgrade globs that keep a file of the +# migration's runtime or boot package (LISTING) from being installed as built. +excluded_files() { + local listing=$1 pattern path + while read -r pattern; do + [[ -n $pattern && $pattern != !* ]] || continue + while IFS= read -r path; do + [[ -n $path && $path != */ ]] || continue + # shellcheck disable=SC2053 # NoExtract and NoUpgrade take globs + if [[ $path == $pattern ]]; then + echo "NoExtract or NoUpgrade ($pattern) in $pacman_conf keeps $path from the packages the migration installs; remove it first" + break + fi + done <<<"$listing" + done < <(admin_options "$pacman_conf" | sed -nE 's/^[[:space:]]*(NoExtract|NoUpgrade)[[:space:]]*=[[:space:]]*//p' | tr ' ' '\n') +} + +# --- payload.sh ------------------------------------------------------------ + +# The boot tools preflight judges this Mac with: the boot check, the ESP +# finder and the HOOKS composer of the omarchy-mac-boot the transaction will +# install. A Mac on a fork has none it can trust, or older ones, so preflight +# takes them from that package's verified archive, unpacked where only root can +# write, and puts them first on its PATH. Nothing of the package is installed +# here and nothing in it runs but those read-only checks; after the +# transaction the installed package's own commands are used. +# shellcheck disable=SC2154 + +# The verified archive of NAME as the target resolves it, downloaded once into +# the work cache: a candidate set's from the verified copy of the set, a +# repository's by pacman, which checks its signature against the keyring copy +# that trusts the target's key (and no retired one). Prints its path, or why +# there is none. +fetch_archive() { + local resolved=$1 wanted=$2 conf=$3 db=$4 name version file archive="" + read -r name version < <(awk -v wanted="$wanted" '{ n = $1; sub(/^[^\/]*\//, "", n) } n == wanted { print $1, $2; exit }' "$resolved") + [[ -n $name ]] || { echo "the target installs no $wanted"; return 1; } + if [[ $name == "$candidate_repo/"* ]]; then + file=$(jq -r --arg name "$wanted" --arg version "$version" '.packages[] | select(.name == $name and .version == $version) | .filename' "$target_set/manifest.json") + [[ -n $file && -f $target_set/$file ]] && archive=$target_set/$file + else + install -d -m 755 "$work/archives" + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + if [[ -z $archive ]]; then + if ! pacman_run --config "$conf" --dbpath "$db" --cachedir "$work/archives" --logfile "$work/pacman.log" \ + -Swdd --noconfirm --ask 4 "$name" >"$work/download.log" 2>&1; then + echo "cannot download and verify $wanted $version: $(tail -n 1 "$work/download.log")" + return 1 + fi + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + fi + fi + [[ -n $archive ]] || { echo "the archive of $wanted $version is missing"; return 1; } + printf '%s\n' "$archive" +} + +# Unpacks the verified archive of the resolved omarchy-mac-boot into DIR, where +# only root can write, and prints its version. +fetch_payload() { + local resolved=$1 conf=$2 db=$3 dir=$4 archive version + archive=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db") || { echo "$archive"; return 1; } + version=$(awk '{ n = $1; sub(/^[^\/]*\//, "", n) } n == "omarchy-mac-boot" { print $2; exit }' "$resolved") + rm -rf "$dir" + install -d -m 700 "$dir" + bsdtar -xpf "$archive" -C "$dir" 2>/dev/null || { echo "cannot unpack omarchy-mac-boot $version"; return 1; } + [[ $(sed -n 's/^pkgname = //p' "$dir/.PKGINFO") == "omarchy-mac-boot" && $(sed -n 's/^pkgver = //p' "$dir/.PKGINFO") == "$version" ]] || + { echo "the archive is not omarchy-mac-boot $version"; return 1; } + [[ -z $(find "$dir" ! -type l \( ! -uid "$EUID" -o -perm /022 \) -print -quit) ]] || + { echo "the unpacked omarchy-mac-boot is writable by others"; return 1; } + # Its commands, and nothing a link could point elsewhere, run first. + [[ -z $(find "$dir/usr/bin" -type l -print -quit 2>/dev/null) ]] || { echo "omarchy-mac-boot's commands include a link"; return 1; } + for name in $payload_commands; do + [[ -f $dir/usr/bin/$name && ! -L $dir/usr/bin/$name && -x $dir/usr/bin/$name ]] || + { echo "omarchy-mac-boot $version has no $name"; return 1; } + done + printf '%s\n' "$version" +} + +# What preflight runs from the target's omarchy-mac-boot. +payload_commands="omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-esp omarchy-mac-kernel" + +# The HOOKS the Mac's mkinitcpio configuration gives once the transaction has +# put the new settings and boot packages' drop-ins in place. +future_hooks() { + local resolved=$1 conf=$2 db=$3 dir=$work/future-conf.d name archive pair + rm -rf "$dir" + install -d -m 700 "$dir" + [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } + # The drop-ins of the packages the transaction replaces go with them. + for name in omarchy omarchy-settings omarchy-dev omarchy-settings-dev omarchy-mac-boot; do + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null || true + done | sed -n 's|^/etc/mkinitcpio.conf.d/\([^/][^/]*\)$|\1|p' | while IFS= read -r name; do + rm -f -- "$dir/$name" + done + pair=$(channel_pair "$target_channel") + for name in "${pair#* }" omarchy-mac-boot; do + archive=$(fetch_archive "$resolved" "$name" "$conf" "$db") || { echo "$archive"; return 1; } + install -d -m 700 "$work/future-root-$name" + # A package without drop-ins extracts nothing. + bsdtar -xpf "$archive" -C "$work/future-root-$name" --include 'etc/mkinitcpio.conf.d/*' 2>/dev/null || true + [[ ! -d $work/future-root-$name/etc/mkinitcpio.conf.d ]] || cp -a "$work/future-root-$name/etc/mkinitcpio.conf.d/." "$dir/" + done + OMARCHY_MKINITCPIO_CONF_DIR=$dir omarchy-mac-initramfs-hooks 2>/dev/null || { echo "the HOOKS composer failed"; return 1; } +} + +# --- repairs.sh ------------------------------------------------------------ + +# Official migrations a migrated Mac records as done, and the repairs a fresh +# image does not need. +# shellcheck disable=SC2154 + +# Official migrations a migrated Mac records as done instead of running them, +# as a fresh Mac image has them (reviewed for ticket 53): initramfs and +# boot-chain repairs for the x86 Limine, T2, NVIDIA and linux-omarchy paths, +# whose Mac counterparts are this package's; the Intel Mac Broadcom quirk, which breaks +# Apple Silicon Wi-Fi; and systemd-oomd, which stays off on Macs. Every other official +# migration still pending runs on the next omarchy update, as on any install +# that upgraded. An adapter adds what its cohort already applied, and each +# repair below adds the Mac migration whose work it did. +settled_migrations="1784476564 1784917531 1785273276 1785424256 1785944594 1786137597 1786391100 1786482992 1786605598 1789325478 1789444024" +repaired=$state/repaired +repaired_migrations=() + +# The migrations USER records as done: the common ones, the repairs this +# migration made and the cohort's, comma-separated. +settled_for() { + local dir=$R$2/.local/state/omarchy/migrations + { printf '%s\n' $settled_migrations; cat "$repaired" 2>/dev/null; adapter_hook settled "$dir"; } | awk 'NF' | paste -sd, +} + +# Records the migrations NAMES lists (comma-separated) as done for USER, as +# the user, where they are not recorded yet. +settle_migrations() { + local user=$1 home=$2 names=$3 dir=$R$2/.local/state/omarchy/migrations name + as_user "$user" "$R$home" mkdir -p "$dir" || return 1 + for name in ${names//,/ }; do + [[ -e $dir/$name.sh ]] || as_user "$user" "$R$home" touch "$dir/$name.sh" || return 1 + done +} + +# --- Repairs a fresh image does not need ---------------------------------------- +# +# Macs set up before the runtime or its images carried a fix got it from a +# migration of the runtime they ran. Upstream Omarchy carries none of those +# migrations, so the engine does their work here, as root, for every cohort. +# Each repair can run again from its start and fails the step when it cannot +# finish; a later run repeats it. One that did its work, or found none to do, +# records its migration as done for every user. The target's runtime carries +# the leaves they run (install/config/snapper.sh and locale.sh) and its +# omarchy-mac the keyboard handover; a target +# without one is reported, and that migration is left to the runtime. + +runtime_leaf_present() { + [[ -f $R/usr/share/omarchy/$1 ]] +} + +# A runtime leaf, run whole in a strict shell as the runtime's migrations run them. +run_runtime_leaf() { + local leaf=$R/usr/share/omarchy/$1 + shift + env OMARCHY_PATH="$R/usr/share/omarchy" "$@" bash -euo pipefail "$leaf" +} + +# Snapper's root configuration (migration 1789148088): the asahi-overlay +# install skipped it. The leaf skips a root that is not btrfs; 3 means it +# found a layout it will not touch, left for manual repair, which is final. +repair_snapper() { + local status=0 + if ! runtime_leaf_present install/config/snapper.sh; then + say "This Omarchy has no Snapper setup leaf: the root's Snapper configuration was not checked" + return 0 + fi + run_runtime_leaf install/config/snapper.sh >/dev/null || status=$? + case $status in + 0) repaired_migrations+=(1789148088) ;; + 3) + say "The existing Snapper configuration was left for manual repair" + repaired_migrations+=(1789148088) + ;; + *) die "cannot set up Snapper for the root filesystem" ;; + esac +} + +# Asahi ALARM's bootstrap administrator (migration 1789158179): polkit asks +# for alarm's password while it stays in wheel. It leaves wheel only when +# another existing account is in wheel. Where alarm is itself an Omarchy user, +# the engine leaves the decision to that migration, which skips only alarm's +# own run. +repair_bootstrap_admin() { + local members member others=0 + members=$(awk -F: '$1 == "wheel" { print $4 }' "$R/etc/group" 2>/dev/null) || members="" + if omarchy_users | awk '{ print $1 }' | grep -Fxq alarm; then + say "alarm uses Omarchy here: its wheel membership is left to the runtime's migration" + else + if [[ ,$members, == *,alarm,* ]]; then + IFS=, read -ra members <<<"$members" + for member in "${members[@]}"; do + if [[ -n $member && $member != "alarm" ]] && awk -F: -v user="$member" '$1 == user { found = 1 } END { exit !found }' "$R/etc/passwd"; then + others=1 + fi + done + if (( others )); then + say "Removing Asahi's bootstrap account alarm from wheel" + gpasswd -d alarm wheel >/dev/null || die "cannot remove alarm from wheel" + fi + fi + repaired_migrations+=(1789158179) + fi +} + +# The Intel Mac Broadcom quirk (migration 1789172112): an older runtime wrote +# it on Apple Silicon too, where it breaks the WPA handshake. Only the exact +# block it wrote goes, and what the file held before it stays. The migration +# also required the Wi-Fi chip's PCI ID; on Apple Silicon the block does harm +# whichever chip carries it, so the engine does not. The rebuild it owes is +# recorded first, under the migration's own marker, so an interrupted run of +# either finishes it. +repair_broadcom_block() { + local conf=$R/etc/modprobe.d/brcmfmac.conf pending=$R/var/lib/omarchy/migrations/1789172112-initramfs-pending + local block content rest file + block="# Broadcom's firmware supplicant and authenticator fail the WPA four-way +# handshake on Apple hardware, which surfaces as a rejected password. Disable +# both so wpa_supplicant performs the handshake instead. +options brcmfmac feature_disable=0x82000" + if [[ -f $conf ]]; then + content=$(<"$conf") + if [[ $content == "$block" || $content == *$'\n'"$block" ]]; then + say "Removing the Intel Mac Broadcom quirk from $conf" + install -D -m 644 /dev/null "$pending" && sync "$pending" "$(dirname "$pending")" || + die "cannot record the initramfs rebuild the Broadcom repair needs" + interrupt_for_test mid broadcom + rest=${content%"$block"} + rest=${rest%$'\n'} + if [[ -z $rest && ! -L $conf ]]; then + rm -f -- "$conf" && sync "$(dirname "$conf")" + else + # A link keeps pointing where it did: its target is rewritten. + file=$(readlink -f -- "$conf") || die "cannot resolve $conf" + if [[ -n $rest ]]; then + printf '%s\n' "$rest" + fi | durable_write "$file" + fi || die "cannot remove the Broadcom quirk from $conf" + fi + fi + interrupt_for_test mid broadcom-rebuild + if [[ -f $pending ]]; then + omarchy-mac-boot-update >/dev/null || die "cannot rebuild the boot image without the Broadcom quirk" + rm -f "$pending" + fi + repaired_migrations+=(1789172112) +} + +# A UTF-8 locale (migration 1789146110): Asahi ALARM ships LANG=C. The leaf +# changes only an unset LANG, C or POSIX. +repair_locale() { + if ! runtime_leaf_present install/config/locale.sh; then + say "This Omarchy has no locale setup leaf: the locale was not checked" + return 0 + fi + run_runtime_leaf install/config/locale.sh OMARCHY_LOCALE_CONF="$R/etc/locale.conf" OMARCHY_LOCALE_GEN="$R/etc/locale.gen" >/dev/null || + die "cannot set up the UTF-8 locale" + repaired_migrations+=(1789146110) +} + +# The keyboard's function-key mode (migration 1790327324), handed to +# omarchy-mac. The line Omarchy generated here depends on the fork the Mac +# came from: fnmode=2 from the install leaf, replaced once by mx-mac +# (1790305681, fnmode=3) or quattro-upstream (1789132067, fnmode=1), as any +# of its users' migration records say, mx-mac first as in that migration. +# omarchy-mac-setup-keyboard decides once, and a fork rebuild still owed +# overrides this. +repair_keyboard_mode() { + local generated=2 user home dir + if ! command -v omarchy-mac-setup-keyboard >/dev/null; then + say "This omarchy-mac has no omarchy-mac-setup-keyboard: the keyboard mode was not handed over" + return 0 + fi + while read -r user home; do + [[ -n $user ]] || continue + dir=$R$home/.local/state/omarchy/migrations + if [[ -f $dir/1790305681.sh ]]; then + generated=3 + elif [[ -f $dir/1789132067.sh && $generated == 2 ]]; then + generated=1 + fi + done < <(omarchy_users) + env OMARCHY_MAC_FIXTURE_ROOT="$R" omarchy-mac-setup-keyboard "$generated" >/dev/null || + die "cannot hand the keyboard's function-key mode to omarchy-mac" + repaired_migrations+=(1790327324) +} + +repair_system() { + local output + repaired_migrations=() + repair_snapper + repair_bootstrap_admin + repair_broadcom_block + repair_locale + repair_keyboard_mode + # The Broadcom and keyboard repairs can rebuild the UKI. + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the repairs: $(tail -n 1 <<<"$output")" + printf '%s\n' "${repaired_migrations[@]}" | durable_write "$repaired" || die "cannot record the repairs made" +} + +# --- Fork leftovers -------------------------------------------------------------- +# +# Earlier Apple installs and omarchy-mx-mac wrote these files, which the Mac +# packages now ship as vendor defaults (omarchy-mac retired them itself until +# omacom/omarchy-mac-pkgs da8279b handed that to this migration). A copy that +# is byte for byte the one they wrote goes, kept beside itself as +# NAME.omarchy-mac-retired; an edited copy, a link (a mask included) or a +# different backup stays as it is. + +# The bytes a fork wrote as NAME. +leftover() { + case $1 in + wifi_backend.conf) + cat <<'LEFTOVER' +[device] +wifi.backend=iwd +LEFTOVER + ;; + asahi-notch.conf) + cat <<'LEFTOVER' +options appledrm show_notch=1 +LEFTOVER + ;; + omarchy-wifi-resume-fix.service) + cat <<'LEFTOVER' +[Unit] +Description=Reload brcmfmac if Wi-Fi does not return after resume +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +After=NetworkManager.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-wifi-resume-fix +TimeoutStartSec=120 + +[Install] +WantedBy=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +LEFTOVER + ;; + asahi-headset-mic.conf) + cat <<'LEFTOVER' +# The 3.5mm headset mic stays in the source list with nothing plugged in. +# Apps often pick it over the built-in array because it advertises a MONO map. +monitor.alsa.rules = [ + { + matches = [ + { node.name = "alsa_input.platform-sound.HiFi__Headset__source" } + ] + actions = { + update-props = { + priority.session = 1 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend-overlay.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## The companion software-dsp.lua overlay also stops the asahi-audio convolver +## graph from pausing when a client (Chromium, mpv, ...) closes its stream. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + *) return 1 ;; + esac +} + +# Writes every leftover into DIR, readable by every user. +write_leftovers() { + local dir=$1 name + install -d -m 755 "$dir" || return 1 + for name in wifi_backend.conf asahi-notch.conf omarchy-wifi-resume-fix.service asahi-headset-mic.conf asahi-audio-no-suspend.conf asahi-audio-no-suspend-overlay.conf; do + leftover "$name" >"$dir/$name" && chmod 644 "$dir/$name" || return 1 + done +} + +# FILE goes when it is the regular file ORIGINAL holds byte for byte. Fails +# when a backup that differs is in the way. +retire_copy() { + local file=$1 original=$2 + [[ -f $file && ! -L $file ]] && cmp -s "$file" "$original" || return 0 + if [[ -e $file.omarchy-mac-retired || -L $file.omarchy-mac-retired ]]; then + if ! cmp -s "$file" "$file.omarchy-mac-retired"; then + echo "$file.omarchy-mac-retired differs from $file; move it aside and run the migration again" >&2 + return 1 + fi + rm -- "$file" + else + mv -- "$file" "$file.omarchy-mac-retired" + fi +} + +# The machine's leftovers: the Wi-Fi backend and notch settings, and the Wi-Fi +# resume unit, whose enablement links into /etc are pointed at the vendor unit +# omarchy-mac ships. +retire_system_leftovers() { + local dir=$state/leftovers unit=omarchy-wifi-resume-fix.service target link + write_leftovers "$dir" || die "cannot stage the fork's leftover files" + retire_copy "$R/etc/NetworkManager/conf.d/wifi_backend.conf" "$dir/wifi_backend.conf" && + retire_copy "$R/etc/modprobe.d/asahi-notch.conf" "$dir/asahi-notch.conf" && + retire_copy "$R/etc/systemd/system/$unit" "$dir/$unit" || die "cannot retire the fork's leftover files" + if [[ ! -e $R/etc/systemd/system/$unit && ! -L $R/etc/systemd/system/$unit ]] && + cmp -s "$R/etc/systemd/system/$unit.omarchy-mac-retired" "$dir/$unit"; then + for target in suspend hibernate hybrid-sleep suspend-then-hibernate; do + link=$R/etc/systemd/system/$target.target.wants/$unit + if [[ -L $link && $(readlink "$link") == "/etc/systemd/system/$unit" ]]; then + ln -sfn "/usr/lib/systemd/system/$unit" "$link" || die "cannot point $link at the vendor unit" + fi + done + fi +} + +# A user's leftovers: the WirePlumber policies the fork copied into each +# user's configuration, retired as that user. +retire_user_leftovers() { + local user=$1 home=$2 dir=$state/leftovers policies=$R$2/.config/wireplumber/wireplumber.conf.d + [[ -d $policies && ! -L $policies ]] || return 0 + [[ -d $dir ]] || write_leftovers "$dir" || return 1 + # shellcheck disable=SC2016 # expanded by the user's shell + as_user "$user" "$R$home" bash -c "$(declare -f retire_copy)"' + retire_copy "$1/asahi-headset-mic.conf" "$2/asahi-headset-mic.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend-overlay.conf"' _ "$policies" "$dir" +} + +# --- users.sh ------------------------------------------------------------ + +# What a fresh install sets up, done for a migrated Mac: its default packages, +# the Mac services, the repairs and, for every Omarchy user, the settled +# migrations, the units first run enables and the user setup. +# shellcheck disable=SC2154 + +# The default packages the aarch64 and Apple lists add, where they are missing +# and a repository carries them (the base list's applications stay the owner's +# choice). Firmware among them rebuilds the initramfs and the UKI through +# pacman's hooks, so the boot files are checked again. +install_defaults() { + local generic apple available name missing=() absent=() output + if ! command -v omarchy-pkg-defaults >/dev/null; then + say "This Omarchy has no omarchy-pkg-defaults: the default packages were not checked" + return 0 + fi + generic=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults generic) && + apple=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults apple-silicon) || + die "cannot read the Apple Silicon default packages" + available=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl | awk '{ print $2 }') || + die "cannot read the repositories' packages" + while read -r name; do + [[ -n $name ]] && ! grep -Fxq -- "$name" <<<"$generic" || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qq "$name" >/dev/null 2>&1 && continue + if grep -Fxq -- "$name" <<<"$available"; then + missing+=("$name") + else + absent+=("$name") + fi + done <<<"$apple" + (( ${#absent[@]} == 0 )) || say "No repository carries these default packages, so they stay missing: ${absent[*]}" + (( ${#missing[@]} )) || return 0 + say "Installing the default packages a fresh install has: ${missing[*]}" + pacman_run --config "$pacman_conf" --dbpath "$pacman_db" -S --noconfirm "${missing[@]}" || + die "cannot install the default packages: ${missing[*]}" + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the default packages: $(tail -n 1 <<<"$output")" +} + +# --- User setup ------------------------------------------------------------------ +# +# Each Omarchy user gets the settled migrations, the units first run enables +# and the Mac user setup. What fails for one user (a broken home, a setup that +# exits nonzero) never stops the migration: it is kept in user-pending, a +# "user item" line each, and runs again at every later run and boot until it +# succeeds. The post-reboot unit stays enabled for that. + +# One item of a user's setup: settle:NAMES, a unit first run enables, +# retire-leftovers or setup-user. A pending settle keeps the names it was +# given, so a retry after the plan moved on records the same ones. +apply_user_item() { + local user=$1 home=$2 item=$3 + case $item in + settle:*) settle_migrations "$user" "$home" "${item#settle:}" ;; + retire-leftovers) retire_user_leftovers "$user" "$home" ;; + setup-user) as_user "$user" "$R$home" omarchy-lifecycle-dispatch setup-user >/dev/null ;; + *) enable_user_unit "$user" "$home" "$item" ;; + esac +} + +# The user's setup; prints what failed, one item a line. +setup_user() { + local user=$1 home=$2 item items=("settle:$(settled_for "$user" "$home")") + if [[ -f $plan/user-units ]]; then + for item in $fresh_user_units; do + grep -Fxq "$item" "$plan/user-units" || items+=("$item") + done + fi + for item in "${items[@]}" retire-leftovers setup-user; do + apply_user_item "$user" "$home" "$item" || printf '%s\n' "$item" + done +} + +# Replaces the pending record with FILE's lines, or removes it when FILE is empty. +record_user_pending() { + if [[ -s $1 ]]; then + LC_ALL=C sort -u "$1" | durable_write "$user_pending" || die "cannot record the pending user setup" + else + rm -f "$user_pending" + fi +} + +# "user item; ..." for messages, a settle item without its names. +pending_summary() { + awk '{ item = $2; sub(/:.*/, "", item); print $1 " " item }' "$user_pending" | paste -sd';' | sed 's/;/; /g' +} + +# Runs the pending items again, only those, so nothing a user turned off since +# comes back. An account that is gone or no longer uses Omarchy is dropped. +# Fails while any item is still pending. +retry_user_pending() { + local user home item left + [[ -s $user_pending ]] || return 0 + rm -f "$state"/user-pending.?????? + left=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + while read -r user item; do + home=$(omarchy_users | awk -v user="$user" '$1 == user { print $2; exit }') + [[ -n $home && -n $item ]] || continue + apply_user_item "$user" "$home" "$item" >"$left" + done <"$user_pending" + record_user_pending "$left" + rm -f "$left" + if [[ -s $user_pending ]]; then + say "User setup still pending, retried at the next run or boot: $(pending_summary)" + return 1 + fi + say "The pending user setup is done" +} + +# Outside a completed migration's cleanup: pending user setup runs again, and +# once none is left the post-reboot unit is released unless a migration is +# waiting for its reboot. +retry_user_pending_now() { + [[ -s $user_pending ]] || return 0 + # A migration still under way keeps the unit that resumes it. + if retry_user_pending && [[ ! -e $reboot_pending ]] && ! past_boundary; then + release_verify_unit + fi +} + +# A migrated Mac ends as a fresh install does: with its default packages, the +# Mac services the image's hardware setup enables, the repairs above and, for +# every Omarchy user, the migrations a fresh image records as done, the units +# first run enables and the Mac user setup. A unit the Mac already had before +# the migration is taken to be off by choice and stays off; a plan frozen +# before that was recorded enables none. The reboot that follows brings up +# what probes only at boot, such as the video decoder. +step_defaults() { + local user home item pending + install_defaults + interrupt_for_test mid defaults + retire_system_leftovers + omarchy-lifecycle-dispatch setup-system >/dev/null || die "setup-system (omarchy-lifecycle-dispatch) could not set up the Mac's services" + repair_system + interrupt_for_test mid user-setup + # What an earlier migration left pending stays pending until it succeeds. + retry_user_pending || : + rm -f "$state"/user-pending.?????? + pending=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + [[ ! -f $user_pending ]] || cat "$user_pending" >"$pending" + while read -r user home; do + [[ -n $user ]] || continue + while read -r item; do + [[ -n $item ]] || continue + say "Could not apply $item for $user; it runs again after the reboot" + printf '%s %s\n' "$user" "$item" >>"$pending" + done < <(setup_user "$user" "$home" /, so a higher +# installed version is replaced. Kernel headers come only where headers are +# installed. +# - A package installed from a retired repository, at the exact version that +# repository lists, is named by itself when an official repository carries +# it, so it moves to the official build even when that is older. One nothing +# official carries stays installed and is listed in WORK/kept. +tester_plan() { + local installed=$1 work=$2 name retired official + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + target_spec "$name" + done + + official=$work/official + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + : >"$work/kept" + for retired in "${retired_repos[@]}"; do + [[ -f $pacman_db/sync/$retired.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$retired" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") == "$version" ]] || continue + [[ " $target_packages $(replaced_pair) " != *" $name "* ]] || continue + if grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + fi + done + done + + : >"$work/allowed-removals" + for name in $tester_replaced $(replaced_pair); do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The runtime pair the target's pair replaces: omarchy and omarchy-settings on +# edge, where the omarchy-dev pair takes their place. +replaced_pair() { + [[ $target_channel != "edge" ]] || echo "omarchy omarchy-settings" +} + +# The collaboration repository's pending-sync marker outlives its repository. +tester_retire() { + rm -f "$R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending" + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} + +# --- cohort-legacy.sh ------------------------------------------------------------ + +# The legacy omarchy-mac adapter (omarchy-mac quattro): trust and packages, +# then the boot switch. +# +# A legacy Mac runs omarchy-mac's quattro fork in one of three layouts: +# - a 3.x checkout upgraded to Quattro (omarchy-upgrade-to-quattro-mac): no +# omarchy package; /usr/share/omarchy links to ~/.local/share/omarchy, +# /usr/bin/omarchy-* link into it and /etc/omarchy.conf points OMARCHY_PATH +# at it, and the setup it ran left the files a package would own unowned; +# - a guided install (omarchy-mac-setup, install.sh): omarchy and +# omarchy-settings, and the keyrings and font beside them, built from that +# checkout and installed with pacman -U; +# - a channel install: the pair from an [omarchy-aarch64] lane. +# All of them trust [omarchy-aarch64] (Optional TrustAll, TrustedOnly from rc5 +# on) and, since rc4, omarchy-mac-keyring, whose populate trusts the fork key +# FBD6874D…. The engine drops the repository and the key; this adapter plans +# the packages as the tester adapter does, and adds: +# - the packages the checkout built move to their official builds; +# - omarchy-mac-keyring is removed once nothing needs it, so no populate +# trusts the fork key again; +# - before the transaction, the files no package owns that the new packages +# bring are backed up and overwritten (pacman keeps a changed configuration +# file and writes .pacnew), then the checkout is unwired, and all of it is +# restored if pacman fails; a file another package keeps owning stops the +# migration. +# Nothing here changes the checkout itself. +# +# The boot switch is the loader step's stage, run while GRUB still boots the +# Mac and undone when it or the Limine activation fails: +# - an ESP mounted at /boot (the quattro guided installer's encrypted layout, +# omarchy-system-boot-to-esp) moves to /boot/efi, where Limine and its UKI +# live; /boot becomes the root filesystem's again and gets the kernel and +# its initramfs. GRUB's own files stay on the ESP, untouched, so the GRUB +# chain boots as before until Limine takes U-Boot's slot; +# - a root unlocked by busybox encrypt and cryptdevice= keeps its LUKS header, +# keyslots and passphrase and moves to the converged unlock: crypttab's root +# and rd.luks.name= on the kernel line, and the systemd initramfs the Apple +# boot package composes (sd-encrypt), which is checked before Limine is. +# The package transaction before it still builds the busybox image GRUB +# boots: preflight requires encrypt in mkinitcpio.conf's own HOOKS, which +# keeps the HOOKS baseline off such a line. An unencrypted Mac keeps its +# HOOKS and stays unencrypted. +# Retire removes the kernels and GRUB the moved ESP still carries. +# shellcheck disable=SC2154 # the engine and the tester adapter define the shared state + +# Built beside the pair by the checkout's build-packages.sh. +legacy_built="omarchy-keyring ttf-jetbrains-mono-nerd-basic" +legacy_keyring=omarchy-mac-keyring +# The kernel the boot switch puts on the root's /boot. +legacy_kernel=linux-aurora +legacy_channel_stages=$R/var/cache/omarchy/channels +legacy_packaged_path='export OMARCHY_PATH="/usr/share/omarchy"' + +# The checkout /usr/share/omarchy links to, or nothing on a packaged install. +legacy_checkout() { + [[ ! -L $R/usr/share/omarchy ]] || readlink "$R/usr/share/omarchy" +} + +# legacy_preflight INSTALLED LUKS HOOKS: prints the states this adapter refuses. +legacy_preflight() { + local installed=$1 luks=${2:-} hooks=${3:-} stage fpr esp_mount + if ! grep -Eq '^omarchy ' "$installed" && [[ ! -L $R/usr/share/omarchy ]]; then + echo "Omarchy is neither a package nor a Quattro checkout here: upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" + fi + if grep -Eq '^[[:space:]]*IgnorePkg[[:space:]]*=.*#[[:space:]]*omarchy-install-pair' "$pacman_conf"; then + echo "an interrupted omarchy-mac channel install left its package pin in $pacman_conf (# omarchy-install-pair); finish that install or remove the line" + fi + for stage in "$legacy_channel_stages"/transaction.*; do + [[ ! -e $stage/restore-sync ]] || + echo "an interrupted omarchy-mac channel switch still owes its sync databases a restore (${stage#"$R"}); finish it first" + done + if [[ -f $R/usr/share/pacman/keyrings/omarchy-mac-trusted ]]; then + while IFS=: read -r fpr _; do + [[ -z $fpr || " ${retired_keys[*]} " == *" $fpr "* ]] || + echo "omarchy-mac-keyring trusts $fpr, a key this migration does not remove" + done <"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" + fi + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" || ( -n $luks && " $hooks " == *" encrypt "* ) ]] && limine_mac; then + echo "this Mac boots Limine with its ESP at /boot or its root unlocked by busybox encrypt; the boot switch moves those only on a GRUB Mac" + fi + if [[ $esp_mount == "/boot" ]]; then + legacy_esp_space + legacy_fstab_esp >/dev/null || + echo "the ESP is mounted at /boot, but /etc/fstab has no single vfat line mounting it there to move to /boot/efi" + ! findmnt --mountpoint "$R/boot/efi" >/dev/null 2>&1 || + echo "the ESP is mounted at /boot and something else at /boot/efi, where the ESP moves" + fi + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + legacy_busybox_problems "$luks" "$esp_mount" + fi +} + +# An ESP at /boot holds GRUB's kernel and image until retire, the transaction's +# Aurora ones beside them and Limine's UKI of both: it needs room for another +# kernel and image on top of the engine's 64 MiB. +legacy_esp_space() { + local used=0 file + for file in "$R"/boot/vmlinuz-linux-* "$R"/boot/initramfs-linux-*.img; do + [[ -f $file && $file != *-fallback.img ]] && used=$(( used + $(stat -c %s "$file") )) + done + (( $(free_bytes "$R/boot") >= used + 64 * 1024 * 1024 )) || + echo "the ESP at /boot needs $(( (used + 64 * 1024 * 1024) / 1024 / 1024 )) MiB free for the Aurora kernel and Limine's UKI beside GRUB's" +} + +# The device of the one vfat line in fstab mounting the ESP at /boot. +legacy_fstab_esp() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { device = $1; found++ } END { if (found != 1) exit 1; print device }' "$R/etc/fstab" 2>/dev/null +} + +# fstab (stdin) with the ESP's /boot line mounting it at /boot/efi instead. +legacy_esp_fstab() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { $2 = "/boot/efi" } { print }' +} + +# GRUB's value of a defaults variable, as omarchy-mac-limine-cmdline reads it. +legacy_grub_value() { + sed -n "s/^$1=//p" "$R/etc/default/grub" 2>/dev/null | tail -n 1 | sed -E "s/^\"(.*)\"$/\1/; s/^'(.*)'$/\1/" +} + +# The busybox encrypt words GRUB's defaults pass, one per line. +legacy_crypt_words() { + local words=() word + read -ra words <<<"$(legacy_grub_value GRUB_CMDLINE_LINUX) $(legacy_grub_value GRUB_CMDLINE_LINUX_DEFAULT)" + for word in "${words[@]}"; do + [[ $word != cryptdevice=* && $word != cryptkey=* ]] || printf '%s\n' "$word" + done +} + +# A root busybox encrypt unlocks moves only from the layout the quattro guided +# installer made: one cryptdevice=UUID=:root, the kernels +# on the ESP at /boot, encrypt in mkinitcpio.conf's own HOOKS (which keeps the +# transaction's image unlocking until the switch) and no other root in crypttab. +legacy_busybox_problems() { + local luks=$1 esp_mount=$2 uuid words=() source spec + uuid=$(cryptsetup luksUUID "$luks" 2>/dev/null) || uuid="" + mapfile -t words < <(legacy_crypt_words) + if (( ${#words[@]} != 1 )) || [[ ! ${words[0]} =~ ^cryptdevice=UUID=([0-9A-Fa-f-]+):root(:allow-discards)?$ ]] || + [[ -z $uuid || ${BASH_REMATCH[1],,} != "${uuid,,}" ]]; then + echo "the root unlocks through busybox encrypt, but GRUB's defaults do not pass the one cryptdevice=UUID=${uuid:-}:root[:allow-discards] this migration moves (found: ${words[*]:-none})" + fi + source=$(findmnt -no SOURCE "$R/" 2>/dev/null) || source="" + [[ ${source%%[*} == "/dev/mapper/root" ]] || echo "the encrypted root is not mounted from /dev/mapper/root, the mapping cryptdevice= opens" + grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R/etc/mkinitcpio.conf" 2>/dev/null || + echo "busybox encrypt is not in /etc/mkinitcpio.conf's own HOOKS, so the package transaction could drop the unlock GRUB boots with; add it there first" + [[ $esp_mount == "/boot" ]] || + echo "the encrypted root's kernels are not on the ESP mounted at /boot (the quattro guided installer's layout); the ESP is at ${esp_mount:-no mountpoint}" + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ -z $spec || ${spec,,} == "uuid=${uuid,,}" ]] || echo "/etc/crypttab names another root ($spec)" + # The switch rewrites these lines double-quoted. + ! grep -Eq '^GRUB_CMDLINE_LINUX(_DEFAULT)?=.*[$`\\]' "$R/etc/default/grub" 2>/dev/null || + echo "GRUB_CMDLINE_LINUX in /etc/default/grub uses shell expansion, which the switch cannot rewrite; write the words out" +} + +# legacy_plan INSTALLED WORK LUKS HOOKS: the tester plan, plus the checkout's +# own builds where an official repository carries them, and the fork keyring's +# removal. The boot switch's unlock is recorded: "busybox UUID DISCARD" for a +# root busybox encrypt unlocks, nothing otherwise. +legacy_plan() { + local installed=$1 work=$2 luks=${3:-} hooks=${4:-} targets name word uuid + targets=$(tester_plan "$installed" "$work") || return 1 + printf '%s\n' "$targets" + for name in $legacy_built; do + [[ -n $(installed_version "$name" "$installed") ]] && grep -Fxq "$name" "$work/official" || continue + sed 's|^.*/||' <<<"$targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + : >"$work/removals" + if [[ -n $(installed_version "$legacy_keyring" "$installed") ]]; then + printf '%s\n' "$legacy_keyring" | tee -a "$work/allowed-removals" >"$work/removals" + sed -i "/^$legacy_keyring /d" "$work/kept" + fi + install -d -m 755 "$work/adapter" + legacy_checkout >"$work/adapter/checkout" + : >"$work/adapter/unlock" + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + word=$(legacy_crypt_words) + uuid=${word#cryptdevice=UUID=} + printf 'busybox %s %s\n' "${uuid%%:*}" "$([[ $word == *:allow-discards ]] && echo 1 || echo 0)" >"$work/adapter/unlock" + fi +} + +# The archives of what AFTER adds or replaces: the targets and every new name. +legacy_archives() { + local before=$1 after=$2 name version dir archive path + while read -r name version; do + [[ -z $(installed_version "$name" "$before") ]] || sed 's|^.*/||' "$plan/targets" | grep -Fxq "$name" || continue + archive="" + for dir in "$pacman_cache" "$cache/candidate" "$cache/pkg"; do + for path in "$dir/$name-$version"-*.pkg.tar.*; do + [[ -f $path && $path != *.sig ]] && archive=$path + done + done + [[ -n $archive ]] || die "the archive of $name $version is not in the cache" + printf '%s\n' "$archive" + done < <(comm -13 <(LC_ALL=C sort "$before") <(LC_ALL=C sort "$after")) +} + +# legacy_conflicts BEFORE AFTER: the files those archives would write over. A +# path no package owns is printed: the transaction may overwrite it. A path a +# package keeps owning stops the migration, before anything is written. While +# the checkout is still linked in, the paths its links reach are left out: +# the links go before pacman runs. +legacy_conflicts() { + local before=$1 after=$2 checkout archive path name paths=$state/conflicts + checkout=$(<"$plan/adapter/checkout") + : >"$paths.new" + legacy_archives "$before" "$after" >"$paths.archives" + while read -r archive; do + LC_ALL=C pacman -Qlpq "$archive" >>"$paths.new" || die "cannot list the files of $archive" + done <"$paths.archives" + LC_ALL=C sort -u "$paths.new" | while IFS= read -r path; do + [[ $path == */ ]] && continue + if [[ -n $checkout ]]; then + [[ ! ( $path == /usr/share/omarchy/* && -L $R/usr/share/omarchy ) ]] || continue + [[ ! ( $path == /usr/bin/omarchy-* && -L $R$path && $(readlink "$R$path") == "$checkout"/* ) ]] || continue + fi + [[ -e $R$path || -L $R$path ]] && [[ ! -d $R$path || -L $R$path ]] && printf '%s\n' "$path" + done >"$paths" || true + rm -f "$paths.new" "$paths.archives" + [[ -s $paths ]] || { rm -f "$paths"; return 0; } + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Ql | + awk 'NR == FNR { wanted[$0]; next } { owner = $1; sub(/^[^ ]+ /, "") } $0 in wanted { print $0 "\t" owner }' "$paths" - >"$paths.owned" || + die "cannot read which packages own the conflicting files" + while IFS= read -r path; do + name=$(awk -F'\t' -v path="$path" '$1 == path { print $2; exit }' "$paths.owned") + if [[ -z $name ]]; then + [[ $path != *,* ]] || die "the new packages bring $path, which no package owns, and pacman cannot be told to overwrite a path with a comma; move it away first" + printf '%s\n' "$path" + elif [[ $(installed_version "$name" "$before") == "$(installed_version "$name" "$after")" ]]; then + die "the new packages would overwrite $path, which $name owns and keeps; nothing was changed" + fi + done <"$paths" + rm -f "$paths" "$paths.owned" +} + +# Once the rehearsal knows what the transaction installs: its conflicts are +# checked, and the archives the check reads are linked into the migration's own +# cache, so pruning pacman's cache cannot strand a resumed transaction. +legacy_prefetch() { + local archive + legacy_conflicts "$cache/start" "$cache/expected" >/dev/null + legacy_archives "$cache/start" "$cache/expected" >"$cache/archives" + while read -r archive; do + [[ $archive != "$cache"/* ]] || continue + ln -f "$archive" "$cache/pkg/" 2>/dev/null || cp -p "$archive" "$cache/pkg/" || die "cannot keep $archive for the transaction" + done <"$cache/archives" +} + +# Keeps the first copy of a file the conversion replaces or removes. +legacy_keep() { + local path=$1 kept=$backup/converted/files$1 + [[ -e $kept || -L $kept ]] && return 0 + install -d -m 700 "$(dirname "$kept")" && cp -a "$R$path" "$kept" +} + +# Lists the unowned files the transaction replaces and backs each up, then +# unwires the checkout: nothing changes until everything that can fail on the +# way has passed. Every run repeats it from the start. +legacy_prepare() { + local checkout link target converted=$backup/converted + checkout=$(<"$plan/adapter/checkout") + install -d -m 700 "$converted" + touch "$converted/links" + legacy_conflicts "$state/installed.now" "$expected" >"$state/overwrite.new" + while IFS= read -r target; do + legacy_keep "$target" || return 1 + done <"$state/overwrite.new" + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + legacy_keep /etc/omarchy.conf || return 1 + fi + if [[ -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + legacy_keep /etc/sudoers.d/omarchy-dev-path || return 1 + fi + sync "$converted" + if [[ -n $checkout ]]; then + for link in "$R"/usr/bin/omarchy-* "$R/usr/share/omarchy"; do + [[ -L $link ]] || continue + target=$(readlink "$link") + [[ $target == "$checkout" || $target == "$checkout"/* ]] || continue + grep -Fxq "${link#"$R"}"$'\t'"$target" "$converted/links" || + printf '%s\t%s\n' "${link#"$R"}" "$target" >>"$converted/links" || return 1 + rm -f "$link" || return 1 + interrupt_for_test mid unwire + done + fi + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + printf '%s\n' "$legacy_packaged_path" | durable_write "$R/etc/omarchy.conf" 644 || return 1 + fi + rm -f "$R/etc/sudoers.d/omarchy-dev-path" || return 1 + mv "$state/overwrite.new" "$state/overwrite" || return 1 + interrupt_for_test mid convert +} + +# The transaction could not run: the links pacman did not replace come back, +# and so do the checkout's OMARCHY_PATH and a dev link's sudo path, so the Mac +# runs as before until the transaction is run again. +legacy_restore() { + local path target kept=$backup/converted/files + if [[ -f $backup/converted/links ]]; then + while IFS=$'\t' read -r path target; do + [[ -e $R$path || -L $R$path ]] || ln -s "$target" "$R$path" + done <"$backup/converted/links" + fi + if [[ -f $kept/etc/omarchy.conf ]] && grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf" 2>/dev/null; then + cp -a "$kept/etc/omarchy.conf" "$R/etc/omarchy.conf" + fi + if [[ -f $kept/etc/sudoers.d/omarchy-dev-path && ! -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + cp -a "$kept/etc/sudoers.d/omarchy-dev-path" "$R/etc/sudoers.d/omarchy-dev-path" + fi + return 0 +} + +# --- The boot switch ----------------------------------------------------------- + +# Keeps the first copy of a file the boot switch changes, or a note that it did +# not exist, so the switch can be undone; written whole, never half. +legacy_stage_keep() { + local path=$1 kept=$backup/boot-switch + [[ -e $kept/files$path || -L $kept/files$path || -e $kept/absent$path ]] && return 0 + if [[ -e $R$path || -L $R$path ]]; then + install -d -m 700 "$(dirname "$kept/files$path")" && + cp -a "$R$path" "$kept/files$path.new" && sync "$kept/files$path.new" && mv "$kept/files$path.new" "$kept/files$path" + else + install -d -m 700 "$(dirname "$kept/absent$path")" && : >"$kept/absent$path" && sync "$kept/absent$path" + fi +} + +# Puts back what legacy_stage_keep kept of PATH. +legacy_stage_restore() { + local path=$1 kept=$backup/boot-switch + if [[ -e $kept/absent$path ]]; then + rm -f "$R$path" + elif [[ -e $kept/files$path || -L $kept/files$path ]]; then + cp -a "$kept/files$path" "$R$path.restore" && mv -f "$R$path.restore" "$R$path" + fi +} + +# The ESP moves from /boot to /boot/efi: fstab first, then the mounts. Each +# part is skipped once done, so a run cut short continues where it was. +legacy_move_esp() { + local fstab=$R/etc/fstab + if legacy_fstab_esp >/dev/null; then + legacy_stage_keep /etc/fstab || return 1 + legacy_esp_fstab <"$fstab" | durable_write "$fstab" 644 || return 1 + fi + interrupt_for_test mid esp-fstab + if [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]]; then + systemctl daemon-reload >/dev/null 2>&1 || echo "systemctl daemon-reload failed; the mounts move anyway" >&2 + umount "$R/boot" || { echo "cannot unmount the ESP from /boot" >&2; return 1; } + fi + interrupt_for_test mid esp-unmounted + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + install -d -m 755 "$R/boot/efi" && mount "$R/boot/efi" || { echo "cannot mount the ESP at /boot/efi" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot/efi" ]] || { echo "the ESP is not mounted at /boot/efi after the move" >&2; return 1; } + # Asahi's update-grub, which the Limine activation still runs, resolves its + # directory before creating it: without one it fails. + install -d -m 755 "$R/boot/grub" || { echo "cannot create /boot/grub" >&2; return 1; } +} + +# The ESP goes back to /boot. What the switch put on the root's /boot is +# removed only once no ESP covers it. +legacy_restore_esp() { + local where + where=$(omarchy-mac-esp 2>/dev/null) || where="" + if [[ $where == "/boot/efi" ]]; then + umount "$R/boot/efi" || { echo "cannot unmount the ESP from /boot/efi" >&2; return 1; } + fi + if ! findmnt --mountpoint "$R/boot" >/dev/null 2>&1; then + rm -f "$R/boot/vmlinuz-$legacy_kernel" "$R/boot/initramfs-$legacy_kernel.img" "$R/boot/initramfs-$legacy_kernel-fallback.img" + rmdir "$R/boot/efi" "$R/boot/grub" 2>/dev/null || true + fi + legacy_stage_restore /etc/fstab || return 1 + systemctl daemon-reload >/dev/null 2>&1 || true + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]]; then + mount "$R/boot" || { echo "cannot mount the ESP at /boot again" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]] || { echo "the ESP is not back at /boot" >&2; return 1; } +} + +# HOOKS lines (stdin) without busybox encrypt and asahi: the HOOKS baseline +# and the Apple boot package's drop-ins then compose the systemd image, the +# asahi hook back in its place with the firmware loader beside it. A HOOKS +# assignment that does not fit on one line cannot be edited: exit 2. +legacy_drop_hooks() { + awk ' + /^[[:space:]]*HOOKS\+?=\(/ { + if (!match($0, /\([^)]*\)/)) { bad = 1; print; next } + head = substr($0, 1, RSTART); tail = substr($0, RSTART + RLENGTH - 1) + n = split(substr($0, RSTART + 1, RLENGTH - 2), words, /[[:space:]]+/) + kept = "" + for (i = 1; i <= n; i++) if (words[i] != "" && words[i] != "encrypt" && words[i] != "asahi") kept = kept (kept == "" ? "" : " ") words[i] + print head kept tail + next + } + { print } + END { exit bad ? 2 : 0 } + ' +} + +# GRUB's defaults FILE with cryptdevice= gone and the root's rd.luks.name= +# (and rd.luks.options= for allow-discards) on the last GRUB_CMDLINE_LINUX, +# where the Apple encrypt flow keeps them and omarchy-mac-limine-cmdline reads +# them. Both variables are written double-quoted. +legacy_grub_unlock() { + local file=$1 uuid=$2 discard=$3 want + want="rd.luks.name=$uuid=root" + (( ! discard )) || want+=" rd.luks.options=$uuid=discard" + awk -v want="$want" ' + function strip(value, n, i, words, out) { + n = split(value, words, /[[:space:]]+/) + out = "" + for (i = 1; i <= n; i++) + if (words[i] != "" && words[i] !~ /^(cryptdevice|rd\.luks\.name|rd\.luks\.options)=/) out = out (out == "" ? "" : " ") words[i] + return out + } + NR == FNR { if ($0 ~ /^GRUB_CMDLINE_LINUX=/) last = FNR; next } + /^GRUB_CMDLINE_LINUX(_DEFAULT)?=/ { + key = $0; sub(/=.*/, "", key) + value = $0; sub(/^[^=]*=/, "", value) + if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) value = substr(value, 2, length(value) - 2) + value = strip(value) + if (FNR == last) value = value (value == "" ? "" : " ") want + print key "=\"" value "\"" + next + } + { print } + END { if (!last) print "GRUB_CMDLINE_LINUX=\"" want "\"" } + ' "$file" "$file" +} + +# The root's unlock moves from busybox encrypt to sd-encrypt. Every file is +# kept first and written whole; running it again changes nothing. +legacy_switch_unlock() { + local uuid=$1 discard=$2 options=luks file owned conf=$R/etc/mkinitcpio.conf grub=$R/etc/default/grub + (( ! discard )) || options+=,discard + legacy_stage_keep /etc/crypttab || return 1 + { [[ ! -f $R/etc/crypttab ]] || awk '$1 != "root"' "$R/etc/crypttab"; printf 'root UUID=%s none %s\n' "$uuid" "$options"; } | + durable_write "$R/etc/crypttab" 644 || return 1 + legacy_stage_keep /etc/default/grub || return 1 + legacy_grub_unlock "$grub" "$uuid" "$discard" | durable_write "$grub" 644 || return 1 + interrupt_for_test mid unlock + legacy_stage_keep /etc/mkinitcpio.conf || return 1 + legacy_drop_hooks <"$conf" >"$state/mkinitcpio.conf.new" || + { echo "cannot edit the HOOKS in /etc/mkinitcpio.conf (one HOOKS=(...) line each is expected)" >&2; return 1; } + durable_write "$conf" 644 <"$state/mkinitcpio.conf.new" || return 1 + rm -f "$state/mkinitcpio.conf.new" + # The fork's omarchy_hooks.conf sets the busybox line outright and sorts + # after the Apple drop-ins. Where no package took it over, it goes too. Any + # other drop-in is left alone; the check below names the HOOKS it gives. + file=/etc/mkinitcpio.conf.d/omarchy_hooks.conf + if [[ -f $R$file ]] && grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R$file"; then + owned=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq 2>/dev/null) || + { echo "cannot read which packages own $file" >&2; return 1; } + if ! grep -Fxq "$file" <<<"$owned"; then + legacy_stage_keep "$file" && rm -f "$R$file" || return 1 + fi + fi +} + +# The kernel on the root's /boot (as the kernel's own install hook copies it) +# and its initramfs. Never onto an ESP still mounted at /boot: GRUB boots that. +legacy_build_initramfs() { + local release image target=$R/boot/vmlinuz-$legacy_kernel + [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]] || { echo "the ESP is still mounted at /boot" >&2; return 1; } + release=$(kernel_release "$legacy_kernel") || { echo "$legacy_kernel has no module tree" >&2; return 1; } + image=$R/usr/lib/modules/$release/vmlinuz + if ! cmp -s "$image" "$target"; then + install -m 644 "$image" "$target.new" && sync "$target.new" && mv -f "$target.new" "$target" || + { echo "cannot put $legacy_kernel on /boot" >&2; return 1; } + fi + interrupt_for_test mid initramfs + mkinitcpio -p "$legacy_kernel" >"$state/mkinitcpio.log" 2>&1 || + { echo "mkinitcpio -p $legacy_kernel failed: $(tail -n 1 "$state/mkinitcpio.log")" >&2; return 1; } +} + +# What the next boot unlocks with, checked before Limine is activated: the +# HOOKS, the image built from them, crypttab and the kernel line's source. +legacy_check_unlock() { + local uuid=$1 hooks listing spec + hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null) || { echo "cannot read the initramfs HOOKS after the switch" >&2; return 1; } + if [[ " $hooks " == *" encrypt "* || " $hooks " != *" systemd "* || " $hooks " != *" sd-encrypt "* || " $hooks " != *" asahi "* ]]; then + echo "the initramfs HOOKS after the switch do not unlock the root through systemd (sd-encrypt, with asahi): $hooks" >&2 + return 1 + fi + listing=$(lsinitcpio -l "$R/boot/initramfs-$legacy_kernel.img" 2>/dev/null) || { echo "/boot/initramfs-$legacy_kernel.img cannot be listed" >&2; return 1; } + if ! grep -Eq '(^|/)usr/lib/systemd/system-generators/systemd-cryptsetup-generator$' <<<"$listing" || + ! grep -Eq '(^|/)usr/bin/systemd-cryptsetup$' <<<"$listing" || grep -Eq '(^|/)hooks/encrypt$' <<<"$listing"; then + echo "/boot/initramfs-$legacy_kernel.img does not unlock the root through sd-encrypt" >&2 + return 1 + fi + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ ${spec,,} == "uuid=${uuid,,}" ]] || { echo "/etc/crypttab does not name the root UUID=$uuid" >&2; return 1; } + if [[ -n $(legacy_crypt_words) || " $(legacy_grub_value GRUB_CMDLINE_LINUX) " != *" rd.luks.name=$uuid=root "* ]]; then + echo "GRUB's defaults, which Limine's kernel line comes from, do not unlock the root with rd.luks.name=$uuid=root alone" >&2 + return 1 + fi +} + +# The loader step's stage, while GRUB still boots the Mac: the ESP off /boot, +# the unlock off busybox encrypt, then the kernel and initramfs Limine's UKI is +# built from. An unencrypted Mac with its ESP at /boot/efi has nothing to do. +legacy_stage() { + local esp_mount unlock="" uuid="" discard=0 + esp_mount=$(plan_esp) + [[ ! -s $plan/adapter/unlock ]] || read -r unlock uuid discard <"$plan/adapter/unlock" + [[ $esp_mount == "/boot" || $unlock == "busybox" ]] || return 0 + if [[ $esp_mount == "/boot" ]]; then + legacy_move_esp || return 1 + fi + if [[ $unlock == "busybox" ]]; then + legacy_switch_unlock "$uuid" "$discard" || return 1 + fi + legacy_build_initramfs || return 1 + if [[ $unlock == "busybox" ]]; then + legacy_check_unlock "$uuid" || return 1 + fi +} + +# Undoes the stage in reverse: the unlock's files, then the ESP's mount. The +# busybox image GRUB boots stayed on the ESP throughout. +legacy_unstage() { + local kept=$backup/boot-switch path + [[ -d $kept ]] || return 0 + while IFS= read -r path; do + [[ $path == "/etc/fstab" ]] || legacy_stage_restore "$path" || return 1 + done < <(cd "$kept" && find files absent \( -type f -o -type l \) ! -name '*.new' 2>/dev/null | sed -E 's#^(files|absent)##' | LC_ALL=C sort -u) + if [[ $(plan_esp) == "/boot" ]]; then + legacy_restore_esp || return 1 + fi + # Everything is back: a later stage keeps what it finds then. + rm -rf "$kept" + echo "The boot switch was undone; GRUB boots this Mac as before." >&2 +} + +# The kernels, initramfs images and GRUB a moved ESP still carries at its top: +# Limine boots the UKI now, and the backup holds the ESP as it was. +legacy_retire_esp() { + [[ $(plan_esp) == "/boot" ]] || return 0 + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + say "The ESP is not mounted at /boot/efi; its old kernels and GRUB stay on it." + return 0 + fi + rm -f "$R"/boot/efi/vmlinuz-linux-* "$R"/boot/efi/initramfs-linux-*.img && rm -rf "$R/boot/efi/grub" +} + +# The fork's channel machinery goes with its repository, and the copies of +# pacman.conf its tools left beside it that still trust unsigned packages move +# into the backup, so none is restored by mistake. The checkout stays where it +# is, unused. An autologin on an unencrypted root stays too: quattro retired +# the boot lock's own long ago, so one there now is an administrator's opt-in. +legacy_retire() { + local checkout file + tester_retire + legacy_retire_esp || return 1 + rm -rf "$legacy_channel_stages"/transaction.* + for file in "$R"/etc/pacman.conf.*; do + [[ -f $file ]] && grep -Eq '^[[:space:]]*SigLevel[[:space:]]*=.*TrustAll' "$file" || continue + legacy_keep "${file#"$R"}" && rm -f "$file" || return 1 + done + checkout=$(<"$plan/adapter/checkout") + if [[ -n $checkout ]]; then + say "Omarchy now runs from its packages. The checkout at $checkout is no longer used; keep or remove it." + fi +} + +# --- cohort-mx-mac.sh ------------------------------------------------------------ + +# The mx-mac adapter. +# +# An mx-mac Mac runs the omarchy-mx-mac fork: the omarchy-dev and +# omarchy-settings-dev runtime pair with the rest of the fork's bundle, which +# omarchy-update-asahi-bundle installs from signed release assets with +# pacman -U, the fork's own [omarchy] release repository, and the Aurora kernel +# from [omarchy-aurora]. omarchy-update-asahi-repository and +# omarchy-update-aurora-repository keep those two sections on the fork's +# latest releases by rewriting pacman.conf. One transaction swaps the fork's +# pair for the channel's official one (on edge, Omarchy's own omarchy-dev pair, +# named explicitly because the fork's builds sort above it; elsewhere omarchy and +# omarchy-settings, which conflict with it), and moves every fork build an +# official repository carries to that build. The switch drops both +# fork sections and the fork's keys. The updaters leave with omarchy-dev, and +# retire moves the state they read into the backup, so nothing can point the +# Mac back at a fork release. Encryption, snapshots and Limine stay the +# engine's: nothing here touches them. +# shellcheck disable=SC2154 # the engine defines the shared state + +retired_repos+=(omarchy-aurora) +# The fork's release key, which signs the bundle's release pointers. +retired_keys+=(5983B1CA32CB778F4D74D24ECFF35022CA5B5959) + +# What omarchy-update-asahi-bundle installs, so no repository lists it. +mx_mac_bundle="omarchy-dev omarchy-settings-dev omarchy-keyring omarchy-nvim quickshell-git ttf-jetbrains-mono-nerd-basic" +# What the target's packages replace, as on a tester. +mx_mac_replaced="linux-asahi linux-asahi-headers m1n1 omarchy-apple-boot omarchy-first-boot omarchy-settings-asahi" +# The records the bundle and channel updaters keep in /var/lib/omarchy. +mx_mac_state="asahi-quattro-release asahi-quattro-release.pending asahi-package-repository aurora-target.descriptor apple-silicon-channel apple-silicon-aurora-lane" + +# The official name of a fork build. The official package conflicts with the +# fork one it replaces, so naming it removes the fork build in the same +# transaction. +mx_mac_counterpart() { + case $1 in + omarchy-dev | omarchy-settings-dev) + if [[ $target_channel == "edge" ]]; then + echo "$1" + elif [[ $1 == "omarchy-dev" ]]; then + echo omarchy + else + echo omarchy-settings + fi + ;; + quickshell-git) echo quickshell ;; + mise | dotnet-host | dotnet-runtime) echo "$1-bin" ;; + *) echo "$1" ;; + esac +} + +# omacom's repositories carry an omarchy-dev of their own, so the fork is told +# by its updaters or their records, not by the package name alone. The engine +# asks this before it picks the cohort. +mx_mac_fork() { + local marker + for marker in usr/share/omarchy/bin/omarchy-update-asahi-bundle usr/share/omarchy/bin/omarchy-update-asahi-repository \ + usr/share/omarchy/bin/omarchy-update-aurora-repository var/lib/omarchy/asahi-quattro-release var/lib/omarchy/asahi-package-repository; do + [[ ! -e $R/$marker ]] || return 0 + done + return 1 +} + +# Official migrations the fork's runner settled as handled, not run: its +# Quattro transition applied their effect (packages, theme and Hyprland state, +# the network and zram changes), so running them again could edit the user's +# configuration twice. The runner records that in .sh.skipped. +mx_mac_handled="1778623107 1780739888 1781043107 1781063758 1781158082 1781485962 1781793381 1782002156 1784401744 1784672586 1784914435 1784961000 1785013000" + +# mx_mac_settled DIR: the handled migrations DIR's records say the fork's +# runner settled, which the engine records as done. What it skipped instead +# runs on the new packages, apart from what the engine settles on every Mac. +mx_mac_settled() { + local dir=$1 name record disposition + for name in $mx_mac_handled; do + record=$dir/$name.sh.skipped + [[ -f $record && ! -L $record ]] || continue + IFS=$'\t' read -r _ disposition _ <"$record" || continue + [[ $disposition != "handled" ]] || printf '%s\n' "$name" + done +} + +# mx_mac_preflight INSTALLED LUKS: prints the states this adapter refuses. +mx_mac_preflight() { + local name + for name in $(channel_pair "$target_channel"); do + [[ " $target_packages " == *" $name "* ]] || echo "the target has no $name to replace the fork's runtime pair" + done +} + +# mx_mac_plan INSTALLED WORK: prints the transaction's targets, one per line, +# and writes WORK/allowed-removals, WORK/removals and WORK/kept. WORK/db holds +# the target's synced databases; the live ones are still the fork's. +# +# - A fork build is a bundle package, or a package installed at the exact +# version the fork's [omarchy] or [omarchy-aurora] lists. +# - The target's packages are named with their repository (target_spec), so a higher +# installed version is replaced: the Mac packages always, kernel headers only +# where headers are installed, and every other one where it is installed or +# replaces a fork build. +# - Every other fork build is named when an official repository carries it, +# by its own name or else by its official counterpart's, so it moves to the +# official build even when that is older. One nothing official carries stays +# installed and is listed in WORK/kept. +# - The fork builds whose official counterpart has another name are removed: +# by the counterpart's conflict where it has one, else by name after the +# install (a versioned conflict, such as dotnet-runtime-bin's, can miss the +# fork's build). The transaction may also remove what the target's packages +# replace. +mx_mac_plan() { + local installed=$1 work=$2 name version counterpart repo official=$2/official fork=$2/fork present=$2/present named=$2/named + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + { + for name in $mx_mac_bundle; do + version=$(installed_version "$name" "$installed") + [[ -z $version ]] || printf '%s %s\n' "$name" "$version" + done + for repo in omarchy omarchy-aurora; do + [[ -f $pacman_db/sync/$repo.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$repo" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") != "$version" ]] || printf '%s %s\n' "$name" "$version" + done + done + } | LC_ALL=C sort -u >"$fork" + { + awk '{ print $1 }' "$installed" + while read -r name _; do + mx_mac_counterpart "$name" + done <"$fork" + } | LC_ALL=C sort -u >"$present" + + : >"$named" + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + if [[ " $(channel_packages "$target_channel") " == *" $name "* ]] || grep -Fxq "$name" "$present"; then + target_spec "$name" + printf '%s\n' "$name" >>"$named" + fi + done + + : >"$work/kept" + : >"$work/allowed-removals" + : >"$work/removals" + while read -r name version; do + counterpart=$(mx_mac_counterpart "$name") + if grep -Fxq "$name" "$named"; then + continue + elif grep -Fxq "$counterpart" "$named"; then + : + elif grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + continue + elif grep -Fxq "$counterpart" "$official"; then + printf '%s\n' "$counterpart" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + continue + fi + printf '%s\n' "$name" | tee -a "$work/allowed-removals" >>"$work/removals" + done <"$fork" + for name in $mx_mac_replaced; do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The updaters left with omarchy-dev; what they read is kept with the backup, +# where no updater or check looks for it. +mx_mac_retire() { + local name moved=$backup/mx-mac-state + for name in $mx_mac_state; do + [[ -e $R/var/lib/omarchy/$name || -L $R/var/lib/omarchy/$name ]] || continue + install -d -m 700 "$moved" && mv -f "$R/var/lib/omarchy/$name" "$moved/$name" || return 1 + interrupt_for_test mid mx-mac-retire + done + if [[ -d $moved ]]; then + sync "$moved" "$R/var/lib/omarchy" || return 1 + fi + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} + +migrate_main "$@" diff --git a/bin/omarchy-update b/bin/omarchy-update index 14b044656cb..d764971576a 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -38,6 +38,28 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start + # omarchy-mac's last quattro release: once migration 1791080196 marked this + # Mac, the update moves it onto Omarchy's official packages before any fork + # step, and stops there; from the next boot on, omarchy update is Omarchy's + # own. A move that cannot start yet (75) leaves the Mac on the fork and updates + # it as before, and so does a run that found nothing to move; one that failed + # part way stops the update, and the next update or boot resumes it. + if [[ -e ${OMARCHY_MAC_MOVE_MARKER:-/var/lib/omarchy/migrations/1791080196} ]]; then + move_status=0 + sudo "$OMARCHY_PATH/bin/omarchy-mac-migrate" run || move_status=$? + move_state=${OMARCHY_MAC_MOVE_STATE:-/var/lib/omarchy-mac/migration} + if (( move_status == 0 )) && [[ -e $move_state/reboot-pending || -e $move_state/complete ]]; then + echo -e "\e[32m\nThis Mac now runs Omarchy's official packages. Reboot to finish the move.\e[0m" + omarchy-update-stay-awake stop + trap - EXIT + exit 0 + elif (( move_status == 75 )); then + echo "The move onto Omarchy's official packages cannot start yet; updating this Mac as before." >&2 + elif (( move_status != 0 )); then + (exit "$move_status") + fi + fi + if [[ -z ${OMARCHY_UPDATE_CHANNEL:-} ]]; then omarchy-update-dev omarchy-update-keyring diff --git a/migrate/README.md b/migrate/README.md new file mode 100644 index 00000000000..d0f0d09d26c --- /dev/null +++ b/migrate/README.md @@ -0,0 +1,37 @@ +# omarchy-mac-migrate + +Moves an Apple Silicon Mac running an Omarchy fork onto Omarchy's official packages for the channel it follows, as one journaled, resumable migration. It is a single self-contained script (`bin/omarchy-mac-migrate`) built from `migrate/src`, and no package carries migration code: omarchy-mac (this repository's `quattro`) and omarchy-mx-mac ship the script, and testers download the release asset. + +## What a migrated Mac runs + +On edge: `omarchy-dev` and `omarchy-settings-dev` from `https://pkgs.omarchy.org/edge/aarch64`, `omarchy-mac` and `omarchy-mac-boot` (built from omacom/omarchy-mac-pkgs), `linux-aurora`, `m1n1-aurora`, `uboot-asahi` and Limine; `asahi-alarm-keyring` and `omarchy-keyring`. `/etc/pacman.conf` is Omarchy's Apple Silicon configuration (`[omarchy]` first, `[asahi-alarm]`, then Arch Linux ARM), plus the administrator's own options and repositories. No fork package, repository, key or pin is left. On stable and rc the runtime pair is `omarchy` and `omarchy-settings`. + +A channel takes Macs once the signed archives its `[omarchy]` would install carry the Mac: the runtime ships `omarchy-lifecycle-dispatch`, and `omarchy-mac-boot` ships its `setup-boot` and `update-verify` operations and no migration engine of its own (that is, it is built from omacom/omarchy-mac-pkgs). Until then every Mac on that channel defers, with nothing changed. + +## Who it moves + +| Cohort | Told by | Channel | +| --- | --- | --- | +| omarchy-mac quattro (legacy): checkout, guided or channel install | no `omarchy` package, `omarchy-mac-keyring`, or quattro's `omarchy-upgrade-to-quattro-mac` | `[omarchy-aarch64]` lane `…/omarchy-pkgs-aarch64/releases/download/` | +| Test images and collaboration builds (tester) | `omarchy` installed; or the dev pair with the image builder's pin | `[omarchy-aarch64]` lane, else `[omarchy]` `pkgs.omarchy.org/` | +| omarchy-mx-mac | `omarchy-dev` with the fork's updaters or records | `omarchy-apple-silicon-channel current` | +| Omarchy's own dev pair | `omarchy-dev` without the above | nothing to migrate | + +An administrator's `/etc/omarchy-mac/migration-target` (or `--target FILE`, root-owned) overrides the channel or points at a mirror or a signed candidate set. A channel that cannot be told defers. + +## How it runs + +`status`, `check` (preflight only), `run`, `verify` (the boot unit's). Exit 0: migrated, waiting for its reboot, or nothing to migrate. 75: deferred, nothing changed. Anything else: a step failed after the repository switch; the next run, or the next boot, resumes it. + +Steps, each journaled in `/var/lib/omarchy-mac/migration/journal`: `preflight` (refusals, channel, isolated resolution against a copy of the package database and keyring, the target's verified archives, its boot tools for the checks; freezes the plan and the sync databases), `backup` (packages, `/etc`, `/boot`, the ESP, the LUKS header), `keyring`, `prefetch` (downloads and rehearses the one transaction on a database copy; refuses any removal the plan does not allow and any file a removed package would take from another), `repositories` (the core configuration with a guard that holds back every package the migration changes; arms the boot unit), `transaction` (one `pacman -Su` from the frozen databases and cache), `boot-chain`, `loader` (the legacy GRUB→Limine stage, then `omarchy-lifecycle-dispatch setup-boot`), `defaults` (default packages, `setup-system`, repairs, settled migrations, user units, `setup-user` per user), `verify` (`update-verify`), `unpin` (drops the guard and a test image's pin), `reboot` (waits; after it, the running Aurora kernel, the packaged Limine, the full boot check and `update-verify`), `retire`. + +Before the repository switch a failure sets the attempt aside and defers (the next run starts over); from it on, the migration only goes forward. The tool keeps a copy of itself beside the journal; a migration in progress resumes with that copy unless a newer tool of the same journal format takes over. + +## Build and release + +```bash +migrate/build # writes bin/omarchy-mac-migrate and the README one-liner's checksum +migrate/build --check # CI: both are current +``` + +Tests: `test/shell.d/mac-migrate-test.sh` (tester cohort, engine), `mac-migrate-legacy-test.sh`, `mac-migrate-mx-test.sh`, `mac-move-migration-test.sh` (delivery). Release: raise `tool_version` in `src/engine.sh` (and `journal_format` only when a journal can no longer be resumed across versions), build, merge, then publish `bin/omarchy-mac-migrate` as the asset of release `mac-migrate-v`. omarchy-mx-mac vendors the same file byte for byte. diff --git a/migrate/build b/migrate/build new file mode 100755 index 00000000000..1ee8b89b159 --- /dev/null +++ b/migrate/build @@ -0,0 +1,58 @@ +#!/bin/bash + +# Builds bin/omarchy-mac-migrate, one self-contained script, from migrate/src. +# --check fails when the committed script is not what the sources build. + +set -euo pipefail + +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +output=$here/../bin/omarchy-mac-migrate +modules=(main.sh engine.sh target.sh payload.sh repairs.sh users.sh cohort-tester.sh cohort-legacy.sh cohort-mx-mac.sh) + +build() { + local module + for module in "${modules[@]}"; do + if [[ $module != "main.sh" ]]; then + printf '\n# --- %s %s\n\n' "$module" "$(printf '%.0s-' {1..60})" + fi + cat "$here/src/$module" + done + printf '\nmigrate_main "$@"\n' +} + +readme=$here/../README.md +version=$(sed -n 's/^tool_version=\([0-9]*\)$/\1/p' "$here/src/engine.sh") + +# The README's one-liner names this version's release asset and its checksum. +readme_line() { + printf 'd=$(mktemp -d) && curl -fsSLo "$d/omarchy-mac-migrate" https://github.com/omacom/omarchy-mac/releases/download/mac-migrate-v%s/omarchy-mac-migrate && echo "%s $d/omarchy-mac-migrate" | sha256sum -c - && sudo bash "$d/omarchy-mac-migrate" run\n' \ + "$version" "$1" +} + +with_readme_line() { + awk -v line="$1" '/^d=\$\(mktemp -d\) && curl .*omarchy-mac-migrate/ { print line; next } { print }' "$readme" +} + +case ${1:-} in + --check) + if ! cmp -s <(build) "$output"; then + echo "bin/omarchy-mac-migrate is not built from migrate/src: run migrate/build" >&2 + exit 1 + fi + if ! cmp -s <(with_readme_line "$(readme_line "$(sha256sum "$output" | cut -d' ' -f1)")") "$readme"; then + echo "README.md's one-liner does not name this build: run migrate/build" >&2 + exit 1 + fi + ;; + "") + build >"$output.new" + chmod 755 "$output.new" + mv "$output.new" "$output" + with_readme_line "$(readme_line "$(sha256sum "$output" | cut -d' ' -f1)")" >"$readme.new" + mv "$readme.new" "$readme" + ;; + *) + echo "Usage: migrate/build [--check]" >&2 + exit 2 + ;; +esac diff --git a/migrate/src/cohort-legacy.sh b/migrate/src/cohort-legacy.sh new file mode 100644 index 00000000000..972f3b5118f --- /dev/null +++ b/migrate/src/cohort-legacy.sh @@ -0,0 +1,564 @@ +# The legacy omarchy-mac adapter (omarchy-mac quattro): trust and packages, +# then the boot switch. +# +# A legacy Mac runs omarchy-mac's quattro fork in one of three layouts: +# - a 3.x checkout upgraded to Quattro (omarchy-upgrade-to-quattro-mac): no +# omarchy package; /usr/share/omarchy links to ~/.local/share/omarchy, +# /usr/bin/omarchy-* link into it and /etc/omarchy.conf points OMARCHY_PATH +# at it, and the setup it ran left the files a package would own unowned; +# - a guided install (omarchy-mac-setup, install.sh): omarchy and +# omarchy-settings, and the keyrings and font beside them, built from that +# checkout and installed with pacman -U; +# - a channel install: the pair from an [omarchy-aarch64] lane. +# All of them trust [omarchy-aarch64] (Optional TrustAll, TrustedOnly from rc5 +# on) and, since rc4, omarchy-mac-keyring, whose populate trusts the fork key +# FBD6874D…. The engine drops the repository and the key; this adapter plans +# the packages as the tester adapter does, and adds: +# - the packages the checkout built move to their official builds; +# - omarchy-mac-keyring is removed once nothing needs it, so no populate +# trusts the fork key again; +# - before the transaction, the files no package owns that the new packages +# bring are backed up and overwritten (pacman keeps a changed configuration +# file and writes .pacnew), then the checkout is unwired, and all of it is +# restored if pacman fails; a file another package keeps owning stops the +# migration. +# Nothing here changes the checkout itself. +# +# The boot switch is the loader step's stage, run while GRUB still boots the +# Mac and undone when it or the Limine activation fails: +# - an ESP mounted at /boot (the quattro guided installer's encrypted layout, +# omarchy-system-boot-to-esp) moves to /boot/efi, where Limine and its UKI +# live; /boot becomes the root filesystem's again and gets the kernel and +# its initramfs. GRUB's own files stay on the ESP, untouched, so the GRUB +# chain boots as before until Limine takes U-Boot's slot; +# - a root unlocked by busybox encrypt and cryptdevice= keeps its LUKS header, +# keyslots and passphrase and moves to the converged unlock: crypttab's root +# and rd.luks.name= on the kernel line, and the systemd initramfs the Apple +# boot package composes (sd-encrypt), which is checked before Limine is. +# The package transaction before it still builds the busybox image GRUB +# boots: preflight requires encrypt in mkinitcpio.conf's own HOOKS, which +# keeps the HOOKS baseline off such a line. An unencrypted Mac keeps its +# HOOKS and stays unencrypted. +# Retire removes the kernels and GRUB the moved ESP still carries. +# shellcheck disable=SC2154 # the engine and the tester adapter define the shared state + +# Built beside the pair by the checkout's build-packages.sh. +legacy_built="omarchy-keyring ttf-jetbrains-mono-nerd-basic" +legacy_keyring=omarchy-mac-keyring +# The kernel the boot switch puts on the root's /boot. +legacy_kernel=linux-aurora +legacy_channel_stages=$R/var/cache/omarchy/channels +legacy_packaged_path='export OMARCHY_PATH="/usr/share/omarchy"' + +# The checkout /usr/share/omarchy links to, or nothing on a packaged install. +legacy_checkout() { + [[ ! -L $R/usr/share/omarchy ]] || readlink "$R/usr/share/omarchy" +} + +# legacy_preflight INSTALLED LUKS HOOKS: prints the states this adapter refuses. +legacy_preflight() { + local installed=$1 luks=${2:-} hooks=${3:-} stage fpr esp_mount + if ! grep -Eq '^omarchy ' "$installed" && [[ ! -L $R/usr/share/omarchy ]]; then + echo "Omarchy is neither a package nor a Quattro checkout here: upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" + fi + if grep -Eq '^[[:space:]]*IgnorePkg[[:space:]]*=.*#[[:space:]]*omarchy-install-pair' "$pacman_conf"; then + echo "an interrupted omarchy-mac channel install left its package pin in $pacman_conf (# omarchy-install-pair); finish that install or remove the line" + fi + for stage in "$legacy_channel_stages"/transaction.*; do + [[ ! -e $stage/restore-sync ]] || + echo "an interrupted omarchy-mac channel switch still owes its sync databases a restore (${stage#"$R"}); finish it first" + done + if [[ -f $R/usr/share/pacman/keyrings/omarchy-mac-trusted ]]; then + while IFS=: read -r fpr _; do + [[ -z $fpr || " ${retired_keys[*]} " == *" $fpr "* ]] || + echo "omarchy-mac-keyring trusts $fpr, a key this migration does not remove" + done <"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" + fi + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" || ( -n $luks && " $hooks " == *" encrypt "* ) ]] && limine_mac; then + echo "this Mac boots Limine with its ESP at /boot or its root unlocked by busybox encrypt; the boot switch moves those only on a GRUB Mac" + fi + if [[ $esp_mount == "/boot" ]]; then + legacy_esp_space + legacy_fstab_esp >/dev/null || + echo "the ESP is mounted at /boot, but /etc/fstab has no single vfat line mounting it there to move to /boot/efi" + ! findmnt --mountpoint "$R/boot/efi" >/dev/null 2>&1 || + echo "the ESP is mounted at /boot and something else at /boot/efi, where the ESP moves" + fi + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + legacy_busybox_problems "$luks" "$esp_mount" + fi +} + +# An ESP at /boot holds GRUB's kernel and image until retire, the transaction's +# Aurora ones beside them and Limine's UKI of both: it needs room for another +# kernel and image on top of the engine's 64 MiB. +legacy_esp_space() { + local used=0 file + for file in "$R"/boot/vmlinuz-linux-* "$R"/boot/initramfs-linux-*.img; do + [[ -f $file && $file != *-fallback.img ]] && used=$(( used + $(stat -c %s "$file") )) + done + (( $(free_bytes "$R/boot") >= used + 64 * 1024 * 1024 )) || + echo "the ESP at /boot needs $(( (used + 64 * 1024 * 1024) / 1024 / 1024 )) MiB free for the Aurora kernel and Limine's UKI beside GRUB's" +} + +# The device of the one vfat line in fstab mounting the ESP at /boot. +legacy_fstab_esp() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { device = $1; found++ } END { if (found != 1) exit 1; print device }' "$R/etc/fstab" 2>/dev/null +} + +# fstab (stdin) with the ESP's /boot line mounting it at /boot/efi instead. +legacy_esp_fstab() { + awk '$1 !~ /^#/ && $2 == "/boot" && $3 == "vfat" { $2 = "/boot/efi" } { print }' +} + +# GRUB's value of a defaults variable, as omarchy-mac-limine-cmdline reads it. +legacy_grub_value() { + sed -n "s/^$1=//p" "$R/etc/default/grub" 2>/dev/null | tail -n 1 | sed -E "s/^\"(.*)\"$/\1/; s/^'(.*)'$/\1/" +} + +# The busybox encrypt words GRUB's defaults pass, one per line. +legacy_crypt_words() { + local words=() word + read -ra words <<<"$(legacy_grub_value GRUB_CMDLINE_LINUX) $(legacy_grub_value GRUB_CMDLINE_LINUX_DEFAULT)" + for word in "${words[@]}"; do + [[ $word != cryptdevice=* && $word != cryptkey=* ]] || printf '%s\n' "$word" + done +} + +# A root busybox encrypt unlocks moves only from the layout the quattro guided +# installer made: one cryptdevice=UUID=:root, the kernels +# on the ESP at /boot, encrypt in mkinitcpio.conf's own HOOKS (which keeps the +# transaction's image unlocking until the switch) and no other root in crypttab. +legacy_busybox_problems() { + local luks=$1 esp_mount=$2 uuid words=() source spec + uuid=$(cryptsetup luksUUID "$luks" 2>/dev/null) || uuid="" + mapfile -t words < <(legacy_crypt_words) + if (( ${#words[@]} != 1 )) || [[ ! ${words[0]} =~ ^cryptdevice=UUID=([0-9A-Fa-f-]+):root(:allow-discards)?$ ]] || + [[ -z $uuid || ${BASH_REMATCH[1],,} != "${uuid,,}" ]]; then + echo "the root unlocks through busybox encrypt, but GRUB's defaults do not pass the one cryptdevice=UUID=${uuid:-}:root[:allow-discards] this migration moves (found: ${words[*]:-none})" + fi + source=$(findmnt -no SOURCE "$R/" 2>/dev/null) || source="" + [[ ${source%%[*} == "/dev/mapper/root" ]] || echo "the encrypted root is not mounted from /dev/mapper/root, the mapping cryptdevice= opens" + grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R/etc/mkinitcpio.conf" 2>/dev/null || + echo "busybox encrypt is not in /etc/mkinitcpio.conf's own HOOKS, so the package transaction could drop the unlock GRUB boots with; add it there first" + [[ $esp_mount == "/boot" ]] || + echo "the encrypted root's kernels are not on the ESP mounted at /boot (the quattro guided installer's layout); the ESP is at ${esp_mount:-no mountpoint}" + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ -z $spec || ${spec,,} == "uuid=${uuid,,}" ]] || echo "/etc/crypttab names another root ($spec)" + # The switch rewrites these lines double-quoted. + ! grep -Eq '^GRUB_CMDLINE_LINUX(_DEFAULT)?=.*[$`\\]' "$R/etc/default/grub" 2>/dev/null || + echo "GRUB_CMDLINE_LINUX in /etc/default/grub uses shell expansion, which the switch cannot rewrite; write the words out" +} + +# legacy_plan INSTALLED WORK LUKS HOOKS: the tester plan, plus the checkout's +# own builds where an official repository carries them, and the fork keyring's +# removal. The boot switch's unlock is recorded: "busybox UUID DISCARD" for a +# root busybox encrypt unlocks, nothing otherwise. +legacy_plan() { + local installed=$1 work=$2 luks=${3:-} hooks=${4:-} targets name word uuid + targets=$(tester_plan "$installed" "$work") || return 1 + printf '%s\n' "$targets" + for name in $legacy_built; do + [[ -n $(installed_version "$name" "$installed") ]] && grep -Fxq "$name" "$work/official" || continue + sed 's|^.*/||' <<<"$targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + : >"$work/removals" + if [[ -n $(installed_version "$legacy_keyring" "$installed") ]]; then + printf '%s\n' "$legacy_keyring" | tee -a "$work/allowed-removals" >"$work/removals" + sed -i "/^$legacy_keyring /d" "$work/kept" + fi + install -d -m 755 "$work/adapter" + legacy_checkout >"$work/adapter/checkout" + : >"$work/adapter/unlock" + if [[ -n $luks && " $hooks " == *" encrypt "* ]]; then + word=$(legacy_crypt_words) + uuid=${word#cryptdevice=UUID=} + printf 'busybox %s %s\n' "${uuid%%:*}" "$([[ $word == *:allow-discards ]] && echo 1 || echo 0)" >"$work/adapter/unlock" + fi +} + +# The archives of what AFTER adds or replaces: the targets and every new name. +legacy_archives() { + local before=$1 after=$2 name version dir archive path + while read -r name version; do + [[ -z $(installed_version "$name" "$before") ]] || sed 's|^.*/||' "$plan/targets" | grep -Fxq "$name" || continue + archive="" + for dir in "$pacman_cache" "$cache/candidate" "$cache/pkg"; do + for path in "$dir/$name-$version"-*.pkg.tar.*; do + [[ -f $path && $path != *.sig ]] && archive=$path + done + done + [[ -n $archive ]] || die "the archive of $name $version is not in the cache" + printf '%s\n' "$archive" + done < <(comm -13 <(LC_ALL=C sort "$before") <(LC_ALL=C sort "$after")) +} + +# legacy_conflicts BEFORE AFTER: the files those archives would write over. A +# path no package owns is printed: the transaction may overwrite it. A path a +# package keeps owning stops the migration, before anything is written. While +# the checkout is still linked in, the paths its links reach are left out: +# the links go before pacman runs. +legacy_conflicts() { + local before=$1 after=$2 checkout archive path name paths=$state/conflicts + checkout=$(<"$plan/adapter/checkout") + : >"$paths.new" + legacy_archives "$before" "$after" >"$paths.archives" + while read -r archive; do + LC_ALL=C pacman -Qlpq "$archive" >>"$paths.new" || die "cannot list the files of $archive" + done <"$paths.archives" + LC_ALL=C sort -u "$paths.new" | while IFS= read -r path; do + [[ $path == */ ]] && continue + if [[ -n $checkout ]]; then + [[ ! ( $path == /usr/share/omarchy/* && -L $R/usr/share/omarchy ) ]] || continue + [[ ! ( $path == /usr/bin/omarchy-* && -L $R$path && $(readlink "$R$path") == "$checkout"/* ) ]] || continue + fi + [[ -e $R$path || -L $R$path ]] && [[ ! -d $R$path || -L $R$path ]] && printf '%s\n' "$path" + done >"$paths" || true + rm -f "$paths.new" "$paths.archives" + [[ -s $paths ]] || { rm -f "$paths"; return 0; } + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Ql | + awk 'NR == FNR { wanted[$0]; next } { owner = $1; sub(/^[^ ]+ /, "") } $0 in wanted { print $0 "\t" owner }' "$paths" - >"$paths.owned" || + die "cannot read which packages own the conflicting files" + while IFS= read -r path; do + name=$(awk -F'\t' -v path="$path" '$1 == path { print $2; exit }' "$paths.owned") + if [[ -z $name ]]; then + [[ $path != *,* ]] || die "the new packages bring $path, which no package owns, and pacman cannot be told to overwrite a path with a comma; move it away first" + printf '%s\n' "$path" + elif [[ $(installed_version "$name" "$before") == "$(installed_version "$name" "$after")" ]]; then + die "the new packages would overwrite $path, which $name owns and keeps; nothing was changed" + fi + done <"$paths" + rm -f "$paths" "$paths.owned" +} + +# Once the rehearsal knows what the transaction installs: its conflicts are +# checked, and the archives the check reads are linked into the migration's own +# cache, so pruning pacman's cache cannot strand a resumed transaction. +legacy_prefetch() { + local archive + legacy_conflicts "$cache/start" "$cache/expected" >/dev/null + legacy_archives "$cache/start" "$cache/expected" >"$cache/archives" + while read -r archive; do + [[ $archive != "$cache"/* ]] || continue + ln -f "$archive" "$cache/pkg/" 2>/dev/null || cp -p "$archive" "$cache/pkg/" || die "cannot keep $archive for the transaction" + done <"$cache/archives" +} + +# Keeps the first copy of a file the conversion replaces or removes. +legacy_keep() { + local path=$1 kept=$backup/converted/files$1 + [[ -e $kept || -L $kept ]] && return 0 + install -d -m 700 "$(dirname "$kept")" && cp -a "$R$path" "$kept" +} + +# Lists the unowned files the transaction replaces and backs each up, then +# unwires the checkout: nothing changes until everything that can fail on the +# way has passed. Every run repeats it from the start. +legacy_prepare() { + local checkout link target converted=$backup/converted + checkout=$(<"$plan/adapter/checkout") + install -d -m 700 "$converted" + touch "$converted/links" + legacy_conflicts "$state/installed.now" "$expected" >"$state/overwrite.new" + while IFS= read -r target; do + legacy_keep "$target" || return 1 + done <"$state/overwrite.new" + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + legacy_keep /etc/omarchy.conf || return 1 + fi + if [[ -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + legacy_keep /etc/sudoers.d/omarchy-dev-path || return 1 + fi + sync "$converted" + if [[ -n $checkout ]]; then + for link in "$R"/usr/bin/omarchy-* "$R/usr/share/omarchy"; do + [[ -L $link ]] || continue + target=$(readlink "$link") + [[ $target == "$checkout" || $target == "$checkout"/* ]] || continue + grep -Fxq "${link#"$R"}"$'\t'"$target" "$converted/links" || + printf '%s\t%s\n' "${link#"$R"}" "$target" >>"$converted/links" || return 1 + rm -f "$link" || return 1 + interrupt_for_test mid unwire + done + fi + if [[ -f $R/etc/omarchy.conf ]] && ! grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf"; then + printf '%s\n' "$legacy_packaged_path" | durable_write "$R/etc/omarchy.conf" 644 || return 1 + fi + rm -f "$R/etc/sudoers.d/omarchy-dev-path" || return 1 + mv "$state/overwrite.new" "$state/overwrite" || return 1 + interrupt_for_test mid convert +} + +# The transaction could not run: the links pacman did not replace come back, +# and so do the checkout's OMARCHY_PATH and a dev link's sudo path, so the Mac +# runs as before until the transaction is run again. +legacy_restore() { + local path target kept=$backup/converted/files + if [[ -f $backup/converted/links ]]; then + while IFS=$'\t' read -r path target; do + [[ -e $R$path || -L $R$path ]] || ln -s "$target" "$R$path" + done <"$backup/converted/links" + fi + if [[ -f $kept/etc/omarchy.conf ]] && grep -Fxq "$legacy_packaged_path" "$R/etc/omarchy.conf" 2>/dev/null; then + cp -a "$kept/etc/omarchy.conf" "$R/etc/omarchy.conf" + fi + if [[ -f $kept/etc/sudoers.d/omarchy-dev-path && ! -e $R/etc/sudoers.d/omarchy-dev-path ]]; then + cp -a "$kept/etc/sudoers.d/omarchy-dev-path" "$R/etc/sudoers.d/omarchy-dev-path" + fi + return 0 +} + +# --- The boot switch ----------------------------------------------------------- + +# Keeps the first copy of a file the boot switch changes, or a note that it did +# not exist, so the switch can be undone; written whole, never half. +legacy_stage_keep() { + local path=$1 kept=$backup/boot-switch + [[ -e $kept/files$path || -L $kept/files$path || -e $kept/absent$path ]] && return 0 + if [[ -e $R$path || -L $R$path ]]; then + install -d -m 700 "$(dirname "$kept/files$path")" && + cp -a "$R$path" "$kept/files$path.new" && sync "$kept/files$path.new" && mv "$kept/files$path.new" "$kept/files$path" + else + install -d -m 700 "$(dirname "$kept/absent$path")" && : >"$kept/absent$path" && sync "$kept/absent$path" + fi +} + +# Puts back what legacy_stage_keep kept of PATH. +legacy_stage_restore() { + local path=$1 kept=$backup/boot-switch + if [[ -e $kept/absent$path ]]; then + rm -f "$R$path" + elif [[ -e $kept/files$path || -L $kept/files$path ]]; then + cp -a "$kept/files$path" "$R$path.restore" && mv -f "$R$path.restore" "$R$path" + fi +} + +# The ESP moves from /boot to /boot/efi: fstab first, then the mounts. Each +# part is skipped once done, so a run cut short continues where it was. +legacy_move_esp() { + local fstab=$R/etc/fstab + if legacy_fstab_esp >/dev/null; then + legacy_stage_keep /etc/fstab || return 1 + legacy_esp_fstab <"$fstab" | durable_write "$fstab" 644 || return 1 + fi + interrupt_for_test mid esp-fstab + if [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]]; then + systemctl daemon-reload >/dev/null 2>&1 || echo "systemctl daemon-reload failed; the mounts move anyway" >&2 + umount "$R/boot" || { echo "cannot unmount the ESP from /boot" >&2; return 1; } + fi + interrupt_for_test mid esp-unmounted + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + install -d -m 755 "$R/boot/efi" && mount "$R/boot/efi" || { echo "cannot mount the ESP at /boot/efi" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot/efi" ]] || { echo "the ESP is not mounted at /boot/efi after the move" >&2; return 1; } + # Asahi's update-grub, which the Limine activation still runs, resolves its + # directory before creating it: without one it fails. + install -d -m 755 "$R/boot/grub" || { echo "cannot create /boot/grub" >&2; return 1; } +} + +# The ESP goes back to /boot. What the switch put on the root's /boot is +# removed only once no ESP covers it. +legacy_restore_esp() { + local where + where=$(omarchy-mac-esp 2>/dev/null) || where="" + if [[ $where == "/boot/efi" ]]; then + umount "$R/boot/efi" || { echo "cannot unmount the ESP from /boot/efi" >&2; return 1; } + fi + if ! findmnt --mountpoint "$R/boot" >/dev/null 2>&1; then + rm -f "$R/boot/vmlinuz-$legacy_kernel" "$R/boot/initramfs-$legacy_kernel.img" "$R/boot/initramfs-$legacy_kernel-fallback.img" + rmdir "$R/boot/efi" "$R/boot/grub" 2>/dev/null || true + fi + legacy_stage_restore /etc/fstab || return 1 + systemctl daemon-reload >/dev/null 2>&1 || true + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]]; then + mount "$R/boot" || { echo "cannot mount the ESP at /boot again" >&2; return 1; } + fi + [[ $(omarchy-mac-esp 2>/dev/null) == "/boot" ]] || { echo "the ESP is not back at /boot" >&2; return 1; } +} + +# HOOKS lines (stdin) without busybox encrypt and asahi: the HOOKS baseline +# and the Apple boot package's drop-ins then compose the systemd image, the +# asahi hook back in its place with the firmware loader beside it. A HOOKS +# assignment that does not fit on one line cannot be edited: exit 2. +legacy_drop_hooks() { + awk ' + /^[[:space:]]*HOOKS\+?=\(/ { + if (!match($0, /\([^)]*\)/)) { bad = 1; print; next } + head = substr($0, 1, RSTART); tail = substr($0, RSTART + RLENGTH - 1) + n = split(substr($0, RSTART + 1, RLENGTH - 2), words, /[[:space:]]+/) + kept = "" + for (i = 1; i <= n; i++) if (words[i] != "" && words[i] != "encrypt" && words[i] != "asahi") kept = kept (kept == "" ? "" : " ") words[i] + print head kept tail + next + } + { print } + END { exit bad ? 2 : 0 } + ' +} + +# GRUB's defaults FILE with cryptdevice= gone and the root's rd.luks.name= +# (and rd.luks.options= for allow-discards) on the last GRUB_CMDLINE_LINUX, +# where the Apple encrypt flow keeps them and omarchy-mac-limine-cmdline reads +# them. Both variables are written double-quoted. +legacy_grub_unlock() { + local file=$1 uuid=$2 discard=$3 want + want="rd.luks.name=$uuid=root" + (( ! discard )) || want+=" rd.luks.options=$uuid=discard" + awk -v want="$want" ' + function strip(value, n, i, words, out) { + n = split(value, words, /[[:space:]]+/) + out = "" + for (i = 1; i <= n; i++) + if (words[i] != "" && words[i] !~ /^(cryptdevice|rd\.luks\.name|rd\.luks\.options)=/) out = out (out == "" ? "" : " ") words[i] + return out + } + NR == FNR { if ($0 ~ /^GRUB_CMDLINE_LINUX=/) last = FNR; next } + /^GRUB_CMDLINE_LINUX(_DEFAULT)?=/ { + key = $0; sub(/=.*/, "", key) + value = $0; sub(/^[^=]*=/, "", value) + if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) value = substr(value, 2, length(value) - 2) + value = strip(value) + if (FNR == last) value = value (value == "" ? "" : " ") want + print key "=\"" value "\"" + next + } + { print } + END { if (!last) print "GRUB_CMDLINE_LINUX=\"" want "\"" } + ' "$file" "$file" +} + +# The root's unlock moves from busybox encrypt to sd-encrypt. Every file is +# kept first and written whole; running it again changes nothing. +legacy_switch_unlock() { + local uuid=$1 discard=$2 options=luks file owned conf=$R/etc/mkinitcpio.conf grub=$R/etc/default/grub + (( ! discard )) || options+=,discard + legacy_stage_keep /etc/crypttab || return 1 + { [[ ! -f $R/etc/crypttab ]] || awk '$1 != "root"' "$R/etc/crypttab"; printf 'root UUID=%s none %s\n' "$uuid" "$options"; } | + durable_write "$R/etc/crypttab" 644 || return 1 + legacy_stage_keep /etc/default/grub || return 1 + legacy_grub_unlock "$grub" "$uuid" "$discard" | durable_write "$grub" 644 || return 1 + interrupt_for_test mid unlock + legacy_stage_keep /etc/mkinitcpio.conf || return 1 + legacy_drop_hooks <"$conf" >"$state/mkinitcpio.conf.new" || + { echo "cannot edit the HOOKS in /etc/mkinitcpio.conf (one HOOKS=(...) line each is expected)" >&2; return 1; } + durable_write "$conf" 644 <"$state/mkinitcpio.conf.new" || return 1 + rm -f "$state/mkinitcpio.conf.new" + # The fork's omarchy_hooks.conf sets the busybox line outright and sorts + # after the Apple drop-ins. Where no package took it over, it goes too. Any + # other drop-in is left alone; the check below names the HOOKS it gives. + file=/etc/mkinitcpio.conf.d/omarchy_hooks.conf + if [[ -f $R$file ]] && grep -Eq '^[[:space:]]*HOOKS=\(([^)#]*[[:space:]])?encrypt([[:space:]][^)#]*)?\)' "$R$file"; then + owned=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq 2>/dev/null) || + { echo "cannot read which packages own $file" >&2; return 1; } + if ! grep -Fxq "$file" <<<"$owned"; then + legacy_stage_keep "$file" && rm -f "$R$file" || return 1 + fi + fi +} + +# The kernel on the root's /boot (as the kernel's own install hook copies it) +# and its initramfs. Never onto an ESP still mounted at /boot: GRUB boots that. +legacy_build_initramfs() { + local release image target=$R/boot/vmlinuz-$legacy_kernel + [[ $(omarchy-mac-esp 2>/dev/null) != "/boot" ]] || { echo "the ESP is still mounted at /boot" >&2; return 1; } + release=$(kernel_release "$legacy_kernel") || { echo "$legacy_kernel has no module tree" >&2; return 1; } + image=$R/usr/lib/modules/$release/vmlinuz + if ! cmp -s "$image" "$target"; then + install -m 644 "$image" "$target.new" && sync "$target.new" && mv -f "$target.new" "$target" || + { echo "cannot put $legacy_kernel on /boot" >&2; return 1; } + fi + interrupt_for_test mid initramfs + mkinitcpio -p "$legacy_kernel" >"$state/mkinitcpio.log" 2>&1 || + { echo "mkinitcpio -p $legacy_kernel failed: $(tail -n 1 "$state/mkinitcpio.log")" >&2; return 1; } +} + +# What the next boot unlocks with, checked before Limine is activated: the +# HOOKS, the image built from them, crypttab and the kernel line's source. +legacy_check_unlock() { + local uuid=$1 hooks listing spec + hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null) || { echo "cannot read the initramfs HOOKS after the switch" >&2; return 1; } + if [[ " $hooks " == *" encrypt "* || " $hooks " != *" systemd "* || " $hooks " != *" sd-encrypt "* || " $hooks " != *" asahi "* ]]; then + echo "the initramfs HOOKS after the switch do not unlock the root through systemd (sd-encrypt, with asahi): $hooks" >&2 + return 1 + fi + listing=$(lsinitcpio -l "$R/boot/initramfs-$legacy_kernel.img" 2>/dev/null) || { echo "/boot/initramfs-$legacy_kernel.img cannot be listed" >&2; return 1; } + if ! grep -Eq '(^|/)usr/lib/systemd/system-generators/systemd-cryptsetup-generator$' <<<"$listing" || + ! grep -Eq '(^|/)usr/bin/systemd-cryptsetup$' <<<"$listing" || grep -Eq '(^|/)hooks/encrypt$' <<<"$listing"; then + echo "/boot/initramfs-$legacy_kernel.img does not unlock the root through sd-encrypt" >&2 + return 1 + fi + spec=$(awk '$1 == "root" { print $2; exit }' "$R/etc/crypttab" 2>/dev/null) || spec="" + [[ ${spec,,} == "uuid=${uuid,,}" ]] || { echo "/etc/crypttab does not name the root UUID=$uuid" >&2; return 1; } + if [[ -n $(legacy_crypt_words) || " $(legacy_grub_value GRUB_CMDLINE_LINUX) " != *" rd.luks.name=$uuid=root "* ]]; then + echo "GRUB's defaults, which Limine's kernel line comes from, do not unlock the root with rd.luks.name=$uuid=root alone" >&2 + return 1 + fi +} + +# The loader step's stage, while GRUB still boots the Mac: the ESP off /boot, +# the unlock off busybox encrypt, then the kernel and initramfs Limine's UKI is +# built from. An unencrypted Mac with its ESP at /boot/efi has nothing to do. +legacy_stage() { + local esp_mount unlock="" uuid="" discard=0 + esp_mount=$(plan_esp) + [[ ! -s $plan/adapter/unlock ]] || read -r unlock uuid discard <"$plan/adapter/unlock" + [[ $esp_mount == "/boot" || $unlock == "busybox" ]] || return 0 + if [[ $esp_mount == "/boot" ]]; then + legacy_move_esp || return 1 + fi + if [[ $unlock == "busybox" ]]; then + legacy_switch_unlock "$uuid" "$discard" || return 1 + fi + legacy_build_initramfs || return 1 + if [[ $unlock == "busybox" ]]; then + legacy_check_unlock "$uuid" || return 1 + fi +} + +# Undoes the stage in reverse: the unlock's files, then the ESP's mount. The +# busybox image GRUB boots stayed on the ESP throughout. +legacy_unstage() { + local kept=$backup/boot-switch path + [[ -d $kept ]] || return 0 + while IFS= read -r path; do + [[ $path == "/etc/fstab" ]] || legacy_stage_restore "$path" || return 1 + done < <(cd "$kept" && find files absent \( -type f -o -type l \) ! -name '*.new' 2>/dev/null | sed -E 's#^(files|absent)##' | LC_ALL=C sort -u) + if [[ $(plan_esp) == "/boot" ]]; then + legacy_restore_esp || return 1 + fi + # Everything is back: a later stage keeps what it finds then. + rm -rf "$kept" + echo "The boot switch was undone; GRUB boots this Mac as before." >&2 +} + +# The kernels, initramfs images and GRUB a moved ESP still carries at its top: +# Limine boots the UKI now, and the backup holds the ESP as it was. +legacy_retire_esp() { + [[ $(plan_esp) == "/boot" ]] || return 0 + if [[ $(omarchy-mac-esp 2>/dev/null) != "/boot/efi" ]]; then + say "The ESP is not mounted at /boot/efi; its old kernels and GRUB stay on it." + return 0 + fi + rm -f "$R"/boot/efi/vmlinuz-linux-* "$R"/boot/efi/initramfs-linux-*.img && rm -rf "$R/boot/efi/grub" +} + +# The fork's channel machinery goes with its repository, and the copies of +# pacman.conf its tools left beside it that still trust unsigned packages move +# into the backup, so none is restored by mistake. The checkout stays where it +# is, unused. An autologin on an unencrypted root stays too: quattro retired +# the boot lock's own long ago, so one there now is an administrator's opt-in. +legacy_retire() { + local checkout file + tester_retire + legacy_retire_esp || return 1 + rm -rf "$legacy_channel_stages"/transaction.* + for file in "$R"/etc/pacman.conf.*; do + [[ -f $file ]] && grep -Eq '^[[:space:]]*SigLevel[[:space:]]*=.*TrustAll' "$file" || continue + legacy_keep "${file#"$R"}" && rm -f "$file" || return 1 + done + checkout=$(<"$plan/adapter/checkout") + if [[ -n $checkout ]]; then + say "Omarchy now runs from its packages. The checkout at $checkout is no longer used; keep or remove it." + fi +} diff --git a/migrate/src/cohort-mx-mac.sh b/migrate/src/cohort-mx-mac.sh new file mode 100644 index 00000000000..16aa752eedc --- /dev/null +++ b/migrate/src/cohort-mx-mac.sh @@ -0,0 +1,184 @@ +# The mx-mac adapter. +# +# An mx-mac Mac runs the omarchy-mx-mac fork: the omarchy-dev and +# omarchy-settings-dev runtime pair with the rest of the fork's bundle, which +# omarchy-update-asahi-bundle installs from signed release assets with +# pacman -U, the fork's own [omarchy] release repository, and the Aurora kernel +# from [omarchy-aurora]. omarchy-update-asahi-repository and +# omarchy-update-aurora-repository keep those two sections on the fork's +# latest releases by rewriting pacman.conf. One transaction swaps the fork's +# pair for the channel's official one (on edge, Omarchy's own omarchy-dev pair, +# named explicitly because the fork's builds sort above it; elsewhere omarchy and +# omarchy-settings, which conflict with it), and moves every fork build an +# official repository carries to that build. The switch drops both +# fork sections and the fork's keys. The updaters leave with omarchy-dev, and +# retire moves the state they read into the backup, so nothing can point the +# Mac back at a fork release. Encryption, snapshots and Limine stay the +# engine's: nothing here touches them. +# shellcheck disable=SC2154 # the engine defines the shared state + +retired_repos+=(omarchy-aurora) +# The fork's release key, which signs the bundle's release pointers. +retired_keys+=(5983B1CA32CB778F4D74D24ECFF35022CA5B5959) + +# What omarchy-update-asahi-bundle installs, so no repository lists it. +mx_mac_bundle="omarchy-dev omarchy-settings-dev omarchy-keyring omarchy-nvim quickshell-git ttf-jetbrains-mono-nerd-basic" +# What the target's packages replace, as on a tester. +mx_mac_replaced="linux-asahi linux-asahi-headers m1n1 omarchy-apple-boot omarchy-first-boot omarchy-settings-asahi" +# The records the bundle and channel updaters keep in /var/lib/omarchy. +mx_mac_state="asahi-quattro-release asahi-quattro-release.pending asahi-package-repository aurora-target.descriptor apple-silicon-channel apple-silicon-aurora-lane" + +# The official name of a fork build. The official package conflicts with the +# fork one it replaces, so naming it removes the fork build in the same +# transaction. +mx_mac_counterpart() { + case $1 in + omarchy-dev | omarchy-settings-dev) + if [[ $target_channel == "edge" ]]; then + echo "$1" + elif [[ $1 == "omarchy-dev" ]]; then + echo omarchy + else + echo omarchy-settings + fi + ;; + quickshell-git) echo quickshell ;; + mise | dotnet-host | dotnet-runtime) echo "$1-bin" ;; + *) echo "$1" ;; + esac +} + +# omacom's repositories carry an omarchy-dev of their own, so the fork is told +# by its updaters or their records, not by the package name alone. The engine +# asks this before it picks the cohort. +mx_mac_fork() { + local marker + for marker in usr/share/omarchy/bin/omarchy-update-asahi-bundle usr/share/omarchy/bin/omarchy-update-asahi-repository \ + usr/share/omarchy/bin/omarchy-update-aurora-repository var/lib/omarchy/asahi-quattro-release var/lib/omarchy/asahi-package-repository; do + [[ ! -e $R/$marker ]] || return 0 + done + return 1 +} + +# Official migrations the fork's runner settled as handled, not run: its +# Quattro transition applied their effect (packages, theme and Hyprland state, +# the network and zram changes), so running them again could edit the user's +# configuration twice. The runner records that in .sh.skipped. +mx_mac_handled="1778623107 1780739888 1781043107 1781063758 1781158082 1781485962 1781793381 1782002156 1784401744 1784672586 1784914435 1784961000 1785013000" + +# mx_mac_settled DIR: the handled migrations DIR's records say the fork's +# runner settled, which the engine records as done. What it skipped instead +# runs on the new packages, apart from what the engine settles on every Mac. +mx_mac_settled() { + local dir=$1 name record disposition + for name in $mx_mac_handled; do + record=$dir/$name.sh.skipped + [[ -f $record && ! -L $record ]] || continue + IFS=$'\t' read -r _ disposition _ <"$record" || continue + [[ $disposition != "handled" ]] || printf '%s\n' "$name" + done +} + +# mx_mac_preflight INSTALLED LUKS: prints the states this adapter refuses. +mx_mac_preflight() { + local name + for name in $(channel_pair "$target_channel"); do + [[ " $target_packages " == *" $name "* ]] || echo "the target has no $name to replace the fork's runtime pair" + done +} + +# mx_mac_plan INSTALLED WORK: prints the transaction's targets, one per line, +# and writes WORK/allowed-removals, WORK/removals and WORK/kept. WORK/db holds +# the target's synced databases; the live ones are still the fork's. +# +# - A fork build is a bundle package, or a package installed at the exact +# version the fork's [omarchy] or [omarchy-aurora] lists. +# - The target's packages are named with their repository (target_spec), so a higher +# installed version is replaced: the Mac packages always, kernel headers only +# where headers are installed, and every other one where it is installed or +# replaces a fork build. +# - Every other fork build is named when an official repository carries it, +# by its own name or else by its official counterpart's, so it moves to the +# official build even when that is older. One nothing official carries stays +# installed and is listed in WORK/kept. +# - The fork builds whose official counterpart has another name are removed: +# by the counterpart's conflict where it has one, else by name after the +# install (a versioned conflict, such as dotnet-runtime-bin's, can miss the +# fork's build). The transaction may also remove what the target's packages +# replace. +mx_mac_plan() { + local installed=$1 work=$2 name version counterpart repo official=$2/official fork=$2/fork present=$2/present named=$2/named + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + { + for name in $mx_mac_bundle; do + version=$(installed_version "$name" "$installed") + [[ -z $version ]] || printf '%s %s\n' "$name" "$version" + done + for repo in omarchy omarchy-aurora; do + [[ -f $pacman_db/sync/$repo.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$repo" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") != "$version" ]] || printf '%s %s\n' "$name" "$version" + done + done + } | LC_ALL=C sort -u >"$fork" + { + awk '{ print $1 }' "$installed" + while read -r name _; do + mx_mac_counterpart "$name" + done <"$fork" + } | LC_ALL=C sort -u >"$present" + + : >"$named" + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + if [[ " $(channel_packages "$target_channel") " == *" $name "* ]] || grep -Fxq "$name" "$present"; then + target_spec "$name" + printf '%s\n' "$name" >>"$named" + fi + done + + : >"$work/kept" + : >"$work/allowed-removals" + : >"$work/removals" + while read -r name version; do + counterpart=$(mx_mac_counterpart "$name") + if grep -Fxq "$name" "$named"; then + continue + elif grep -Fxq "$counterpart" "$named"; then + : + elif grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + continue + elif grep -Fxq "$counterpart" "$official"; then + printf '%s\n' "$counterpart" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + continue + fi + printf '%s\n' "$name" | tee -a "$work/allowed-removals" >>"$work/removals" + done <"$fork" + for name in $mx_mac_replaced; do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The updaters left with omarchy-dev; what they read is kept with the backup, +# where no updater or check looks for it. +mx_mac_retire() { + local name moved=$backup/mx-mac-state + for name in $mx_mac_state; do + [[ -e $R/var/lib/omarchy/$name || -L $R/var/lib/omarchy/$name ]] || continue + install -d -m 700 "$moved" && mv -f "$R/var/lib/omarchy/$name" "$moved/$name" || return 1 + interrupt_for_test mid mx-mac-retire + done + if [[ -d $moved ]]; then + sync "$moved" "$R/var/lib/omarchy" || return 1 + fi + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} diff --git a/migrate/src/cohort-tester.sh b/migrate/src/cohort-tester.sh new file mode 100644 index 00000000000..2b82f63c789 --- /dev/null +++ b/migrate/src/cohort-tester.sh @@ -0,0 +1,75 @@ +# The tester adapter. +# +# A tester Mac runs omarchy, omarchy-settings and usually omarchy-mac built from +# quattro-upstream or a convergence branch: a pinned test image from a signed +# candidate set, the unsigned collaboration repository ([omarchy-aarch64]) or a +# pilot. Its same-name packages can be versioned above the target's (a +# candidate's pkgrel suffix, an rc runtime), so an upgrade would keep them: +# every one is named explicitly and replaced by the target's build. On edge the +# runtime pair becomes the omarchy-dev pair, which conflicts with it and +# replaces it in the same transaction. The Asahi kernel and m1n1 give way to +# their Aurora counterparts there too. +# shellcheck disable=SC2154 # the engine defines the shared state + +# Packages the tester transaction may remove: what the target's packages +# replace (the Asahi kernel, its headers and m1n1) and the boot and settings +# packages omarchy-mac and omarchy-mac-boot superseded. +tester_replaced="linux-asahi linux-asahi-headers m1n1 omarchy-apple-boot omarchy-first-boot omarchy-settings-asahi" + +# tester_plan INSTALLED WORK: prints the transaction's targets, one per line, +# and writes WORK/allowed-removals and WORK/kept. WORK/db holds the target's +# synced databases. +# +# - Each target package is named as /, so a higher +# installed version is replaced. Kernel headers come only where headers are +# installed. +# - A package installed from a retired repository, at the exact version that +# repository lists, is named by itself when an official repository carries +# it, so it moves to the official build even when that is older. One nothing +# official carries stays installed and is listed in WORK/kept. +tester_plan() { + local installed=$1 work=$2 name retired official + for name in $target_packages; do + if [[ $name == *-headers ]]; then + grep -Eq '^linux-(asahi|aurora)-headers ' "$installed" || continue + fi + target_spec "$name" + done + + official=$work/official + LC_ALL=C pacman --config "$work/transaction.conf" --dbpath "$work/db" -Sl 2>/dev/null | + awk -v candidate="$candidate_repo" '$1 != candidate { print $2 }' | LC_ALL=C sort -u >"$official" + : >"$work/kept" + for retired in "${retired_repos[@]}"; do + [[ -f $pacman_db/sync/$retired.db ]] || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl "$retired" 2>/dev/null | + while read -r _ name version _; do + [[ $(installed_version "$name" "$installed") == "$version" ]] || continue + [[ " $target_packages $(replaced_pair) " != *" $name "* ]] || continue + if grep -Fxq "$name" "$official"; then + printf '%s\n' "$name" + else + printf '%s %s\n' "$name" "$version" >>"$work/kept" + fi + done + done + + : >"$work/allowed-removals" + for name in $tester_replaced $(replaced_pair); do + [[ -z $(installed_version "$name" "$installed") ]] || printf '%s\n' "$name" >>"$work/allowed-removals" + done +} + +# The runtime pair the target's pair replaces: omarchy and omarchy-settings on +# edge, where the omarchy-dev pair takes their place. +replaced_pair() { + [[ $target_channel != "edge" ]] || echo "omarchy omarchy-settings" +} + +# The collaboration repository's pending-sync marker outlives its repository. +tester_retire() { + rm -f "$R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending" + if [[ -s $plan/kept ]]; then + say "Kept, with no official build: $(awk '{ print $1 }' "$plan/kept" | xargs)" + fi +} diff --git a/migrate/src/engine.sh b/migrate/src/engine.sh new file mode 100644 index 00000000000..8ad95c2b07e --- /dev/null +++ b/migrate/src/engine.sh @@ -0,0 +1,1638 @@ +# The journaled migration engine. +# +# It moves a Mac onto its target in thirteen ordered steps. Every step records +# its start and its end in an append-only journal synced to disk, so a power +# loss or a kill resumes at the first step that did not finish, and every step +# can run again from its start. Preflight changes nothing and freezes the plan +# the later steps follow. A cohort adapter (cohort-.sh) decides what +# its machines need: the package targets, the packages the transaction may +# remove and the compatibility state to retire. The engine owns the order, the +# journal and every change to the system. +# +# The caller sets R (the fixture root, empty on a live system), fixture (1 when +# unprivileged tests drive it) and self (this file). Adapters read the +# target_* values. +# +# Exit status: 0 when the Mac is migrated, waits for its reboot or has nothing +# to migrate; 75 (EX_TEMPFAIL) when it stopped before anything changed (a +# preflight refusal, or any failure before the journal exists); 1 when a step +# failed, and running again resumes it. +# shellcheck disable=SC2034,SC2154 + +# Raised with every change to what the tool does; the journal format only when +# a journal one version writes cannot be resumed by another. +tool_version=1 +journal_format=2 + +migrate_steps=(preflight backup keyring prefetch repositories transaction boot-chain loader defaults verify unpin reboot retire) + +# pacman's download user reads the work, cache and candidate directories. +umask 022 + +state=$R/var/lib/omarchy-mac/migration +journal=$state/journal +plan=$state/plan +cache=$state/cache +backup=$state/backup +expected=$state/expected +start=$state/start +interrupted_marker=$state/transaction-interrupted +set_copy=$state/set +complete=$state/complete +reboot_pending=$state/reboot-pending +user_pending=$state/user-pending +tool_copy=$state/tool/omarchy-mac-migrate +lock_file=$R/run/lock/omarchy-mac-migrate.lock +pacman_conf=$R/etc/pacman.conf +pacman_db=$R/var/lib/pacman +pacman_cache=$R/var/cache/pacman/pkg +pacman_gpg=$R/etc/pacman.d/gnupg +esp=/boot/efi +limine_gate=$R/var/lib/omarchy/limine.enabled +limine_default=$R/etc/default/limine +verify_unit=omarchy-mac-migrate-verify.service +verify_unit_file=$R/etc/systemd/system/$verify_unit +first_boot_marker=$R/var/lib/omarchy/mac-first-boot/pending +legacy_first_boot_marker=$R/var/lib/omarchy/first-boot/pending +# The user units a fresh install's first run enables +# (install/user/first-run/enable-user-units.sh). +fresh_user_units="bt-agent.service owed.service omarchy-recover-internal-monitor.service omarchy-sleep-lock.service omarchy-migrate-notify.service omarchy-fcitx5.service omarchy-crash-watch.service omarchy-brightness-keyboard-auto.service" + +current_step="" +check_only=0 +original_args=() +target_file="" +payload_dir="" +restarted=0 +restarts=0 +work="" +gpgdir="" + +say() { + printf '%s\n' "$*" +} + +die() { + echo "omarchy-mac-migrate: $*" >&2 + if [[ -n $current_step && -f $journal ]]; then + journal_write "$current_step" "fail" "$*" + fi + # With nothing journaled, nothing has changed: deferred, like a refusal. + [[ -f $journal ]] || exit 75 + # Before the repository switch the system still runs as it did (only the + # official key was trusted): the attempt is set aside and the next run starts + # over from preflight. + if before_boundary; then + abort_migration "$*" + exit 75 + fi + exit 1 +} + +# The repository switch is the first change that cannot be left in place: from +# the boundary its step records just before it writes, the migration only +# goes forward. +before_boundary() { + [[ -f $journal ]] && ! awk '$3 == "boundary" { found = 1 } END { exit !found }' "$journal" +} + +abort_migration() { + local destination + destination=$state/history/aborted-$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$state/format" "$destination/" 2>/dev/null + rm -rf "$cache" "$backup" "$set_copy" "$start" "$expected" "$state/installed.now" "$state/tool" + if [[ -f $verify_unit_file ]]; then + systemctl disable "$verify_unit" >/dev/null 2>&1 || true + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$1" | durable_write "$state/deferred" 2>/dev/null || true + say "Nothing on this Mac changed; the next run starts the migration over." >&2 +} + +on_exit() { + local status=$? + if (( status != 0 )) && [[ -n $current_step && -f $journal && $(step_state "$current_step") == "begin" ]]; then + journal_write "$current_step" "fail" "exit $status" + fi + [[ -z $work ]] || rm -rf "$work" +} + +# --- Journal ----------------------------------------------------------------- + +journal_write() { + local detail=${3:-} + printf '%s %s %s%s\n' "$(date +%s)" "$1" "$2" "${detail:+ ${detail//$'\n'/ }}" >>"$journal" + sync "$journal" +} + +# The last event recorded for a step: begin, done, fail, or nothing. +step_state() { + [[ -f $journal ]] || return 0 + awk -v step="$1" '$2 == step { event = $3 } END { print event }' "$journal" +} + +next_step() { + local step + for step in "${migrate_steps[@]}"; do + if [[ $(step_state "$step") != "done" ]]; then + printf '%s\n' "$step" + return + fi + done +} + +# Unprivileged tests kill the engine with SIGKILL part way through a step's +# work (mid), once the work is done (during) or once its end is recorded +# (after). Root never reads these. +interrupt_for_test() { + (( fixture )) || return 0 + if [[ $1 == "mid" && ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "$2" ]] || + [[ $1 == "during" && ${OMARCHY_MAC_MIGRATE_KILL_DURING:-} == "$2" ]] || + [[ $1 == "after" && ${OMARCHY_MAC_MIGRATE_KILL_AFTER:-} == "$2" ]]; then + kill -9 $$ + fi +} + +run_step() { + local step=$1 + current_step=$step + restarted=0 + journal_write "$step" "begin" + "step_${step//-/_}" + if (( restarted )); then + current_step="" + return 0 + fi + interrupt_for_test during "$step" + journal_write "$step" "done" + interrupt_for_test after "$step" + current_step="" +} + +# Replace a file whole: written beside it, synced, then renamed over it. +durable_write() { + local file=$1 mode=${2:-644} tmp + tmp=$(mktemp "$file.XXXXXX") || return 1 + if cat >"$tmp" && chmod "$mode" "$tmp" && sync "$tmp" && mv -f "$tmp" "$file"; then + sync "$(dirname "$file")" + else + rm -f "$tmp" + return 1 + fi +} + +# --- Helpers ------------------------------------------------------------------- + +# A root-owned (in a fixture, caller-owned) regular file or directory, not a +# symlink and not writable by group or others. Target files and sets decide +# what is installed as root. +trusted() { + local owner mode + [[ -e $1 && ! -L $1 ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1 + (( owner == EUID && (8#$mode & 8#022) == 0 )) +} + +pacman_run() { + env OMARCHY_UPDATE_PACMAN=1 LC_ALL=C pacman --gpgdir "${gpgdir:-$pacman_gpg}" "$@" +} + +installed_packages() { + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Q +} + +installed_version() { + awk -v name="$1" '$1 == name { print $2; exit }' "$2" +} + +# The upstream detector where the runtime has it; else the device tree, as +# Asahi's own tools read it (a quattro or mx-mac runtime predates the +# detector). +hardware_platform() { + if command -v omarchy-hw-platform >/dev/null; then + omarchy-hw-platform + elif (( ! fixture )) && [[ -r /proc/device-tree/compatible ]] && tr '\0' '\n' /dev/null | awk -F: '$1 == "pub" { print $2; exit }') + [[ $validity == "f" || $validity == "u" ]] +} + +key_present() { + gpg --homedir "${2:-$pacman_gpg}" --batch --no-auto-check-trustdb --with-colons --list-keys "$1" >/dev/null 2>&1 +} + +# Official trust in the keyring at HOME: the keyrings installed are populated, +# and a missing Omarchy key comes from the keyserver by its full fingerprint and +# is signed locally. Fails when the key is not trusted after it. +trust_official_key() { + local home=$1 keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$home" --populate "${keyrings[@]}" >/dev/null || return 1 + fi + if ! key_trusted "$target_keyring" "$home"; then + pacman-key --gpgdir "$home" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$home" --lsign-key "$target_keyring" >/dev/null || return 1 + fi + key_trusted "$target_keyring" "$home" +} + +sha256_of() { + sha256sum "$1" | cut -d' ' -f1 +} + +repositories_in() { + awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); if (name != "options") print name }' "$1" +} + +# The configuration the transaction runs with: the future one, with the +# verified candidate set as a local repository ahead of everything. It is never +# installed as /etc/pacman.conf, so candidates stay invisible afterwards. +transaction_conf() { + local conf=$1 candidate_dir=$2 + if [[ -z $candidate_dir ]]; then + cat "$conf" + return + fi + awk -v repo="$candidate_repo" -v server="file://$candidate_dir" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ && !inserted && $0 !~ /\[options\]/ { + print "[" repo "]"; print "SigLevel = Optional"; print "Server = " server; print ""; inserted = 1 + } + { print } + ' "$conf" +} + +# --- Candidate sets --------------------------------------------------------- + +# Prints the key that made a detached signature, or fails. A revoked or expired +# key or signature does not count. gpgv reads the set's keyring file and needs +# no agent, so nothing depends on where a gpg-agent socket could live. +signer_of() { + local home=$1 file=$2 signature=$3 status + status=$(gpgv --homedir "$home" --keyring "$home/key.gpg" --status-fd 1 "$signature" "$file" 2>/dev/null) || return 1 + awk '$1 != "[GNUPG:]" { next } + $2 ~ /^(BADSIG|ERRSIG|EXPSIG|EXPKEYSIG|REVKEYSIG|KEYEXPIRED|KEYREVOKED)$/ { bad = 1 } + $2 == "GOODSIG" { good = 1 } + $2 == "VALIDSIG" { primary = $NF; valid++ } + END { if (!good || valid != 1 || bad) exit 1; print primary }' <<<"$status" +} + +# Verifies a candidate set as tools/release/candidate-set verify does, trusting +# only the target's fingerprint. Prints why it fails. +verify_candidate_set() { + local dir=$1 home=$2 manifest=$1/manifest.json receipt=$1/signing.json name sha digest + rm -rf "$home" + mkdir -m 700 "$home" + if ! gpg --batch --homedir "$home" --dearmor <"$dir/candidate-signing-key.asc" >"$home/key.gpg" 2>/dev/null || + ! gpg --batch --homedir "$home" --with-colons --show-keys "$home/key.gpg" 2>/dev/null | awk -F: '$1 == "fpr" { print $10 }' | grep -qx "$target_fingerprint"; then + echo "its key is not $target_fingerprint" + return 1 + fi + [[ -f $receipt && -f $receipt.sig && $(signer_of "$home" "$receipt" "$receipt.sig") == "$target_fingerprint" ]] || + { echo "signing.json is not signed by $target_fingerprint"; return 1; } + [[ $(jq -r '.signer.fingerprint' "$receipt") == "$target_fingerprint" && + $(jq -r '.manifest_sha256' "$receipt") == "$(sha256_of "$manifest")" && + $(jq -r '.set_sha256' "$receipt") == "$(jq -r '.set_sha256' "$manifest")" ]] || + { echo "signing.json does not bind this manifest"; return 1; } + digest=$(jq -r '.packages[] | "\(.name) \(.version) \(.filename) \(.sha256)"' "$manifest" | LC_ALL=C sort | sha256sum | cut -d' ' -f1) + [[ $digest == "$(jq -r '.set_sha256' "$manifest")" ]] || { echo "the manifest's set digest does not match its packages"; return 1; } + [[ $(jq -r '[.signatures[].file] | sort | join(" ")' "$receipt") == "$(jq -r '[.packages[].filename] | sort | join(" ")' "$manifest")" ]] || + { echo "signing.json does not cover exactly the manifest's packages"; return 1; } + while IFS=$'\t' read -r name sha; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ -f $dir/$name && $(sha256_of "$dir/$name") == "$sha" ]] || { echo "$name is missing or changed"; return 1; } + [[ -f $dir/$name.sig && $(signer_of "$home" "$dir/$name" "$dir/$name.sig") == "$target_fingerprint" ]] || + { echo "$name is not signed by $target_fingerprint"; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$manifest") +} + +# Copies a set into a directory only root can write, so nothing can change it +# between its verification and its use; everything later reads the copy. +copy_candidate_set() { + local source=$1 destination=$2 name + rm -rf "$destination" + install -d -m 700 "$destination" || return 1 + for name in manifest.json signing.json signing.json.sig candidate-signing-key.asc; do + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + done + [[ -f $destination/manifest.json ]] || { echo "the set has no manifest.json"; return 1; } + while read -r name; do + [[ $name =~ ^[A-Za-z0-9@._+:-]+$ && $name != .* ]] || { echo "unsafe filename $name"; return 1; } + [[ ! -f $source/$name ]] || cp "$source/$name" "$destination/$name" || return 1 + [[ ! -f $source/$name.sig ]] || cp "$source/$name.sig" "$destination/$name.sig" || return 1 + done < <(jq -r '.packages[].filename' "$destination/manifest.json") || { echo "cannot read its manifest"; return 1; } +} + +# Verifies the frozen set again, then builds a local repository of copies whose +# digests are checked again, so what pacman reads is what was verified. +# Signatures stay out of it: pacman's keyring never trusts the candidate key. +stage_candidate_repo() { + local destination=$1 home=$2 reason name sha + reason=$(verify_candidate_set "$target_set" "$home") || { echo "$reason" >&2; return 1; } + rm -rf "$destination" + install -d -m 755 "$destination" || return 1 + while IFS=$'\t' read -r name sha; do + install -m 644 "$target_set/$name" "$destination/$name" || return 1 + [[ $(sha256_of "$destination/$name") == "$sha" ]] || { echo "the copy of $name changed" >&2; return 1; } + done < <(jq -r '.packages[] | "\(.filename)\t\(.sha256)"' "$target_set/manifest.json") + index_candidate_repo "$destination" +} + +# Indexes the manifest's packages, and nothing else, in DIR (already holding +# copies, or given links to the set with "link"). repo-add embeds a signature +# lying beside a package, so the set's own signatures are never in DIR. +index_candidate_repo() { + local destination=$1 mode=${2:-} files=() name + mapfile -t files < <(jq -r '.packages[].filename' "$target_set/manifest.json") + if [[ $mode == "link" ]]; then + for name in "${files[@]}"; do + ln -sfn "$target_set/$name" "$destination/$name" || return 1 + done + fi + (cd "$destination" && repo-add -q "$candidate_repo.db.tar.gz" "${files[@]}") >/dev/null || return 1 + chmod -R go+rX "$destination" +} + +target_version() { + jq -r --arg name "$1" '.packages[] | select(.name == $name) | .version' "$target_set/manifest.json" +} + +# --- Preflight ----------------------------------------------------------------- + +# The cohort an Apple Silicon Mac belongs to, from what is installed. Each +# cohort needs an adapter defining _plan and _retire; it may +# also define _preflight, _prefetch, _prepare and _restore, which the +# steps of those names call, and _stage and _unstage, which the loader step of +# a GRUB Mac calls before Limine is activated and after a failed activation. +# Only a cohort with a stage may have its ESP mounted at /boot or its root +# unlocked by busybox encrypt: the stage moves both. A legacy omarchy-mac +# install runs Omarchy from a checkout, trusts the rc4 fork keyring or carries +# the quattro tree, whose 3.x upgrade command quattro-upstream never had. A +# Mac on the omarchy-dev pair without the mx-mac fork's updaters, records or a +# test image's pin already runs Omarchy's own dev packages. +detect_cohort() { + local list=$1 + if grep -Eq '^omarchy(-settings)?-dev ' "$list"; then + if mx_mac_fork; then + echo mx-mac + elif [[ -n $(test_pin_block "$pacman_conf") ]]; then + # A test image built from a dev pair candidate keeps it pinned. + echo tester + else + echo official-dev + fi + elif ! grep -Eq '^omarchy ' "$list" || grep -Eq '^omarchy-mac-keyring ' "$list" || + [[ -e $R/usr/share/omarchy/bin/omarchy-upgrade-to-quattro-mac ]]; then + echo legacy + else + echo tester + fi +} + +cohort_refusal() { + echo "no adapter handles the $1 cohort" +} + +# What stops a Mac running omarchy-dev from counting as a Mac on Omarchy's own +# dev channel: a retired repository or key, a repository trusted without +# signatures, or an [omarchy] served from anywhere but pkgs.omarchy.org. +official_trust_problems() { + local conf repos repo fpr + conf=$(cat "$1") + repos=$(repositories_in <(printf '%s\n' "$conf")) + for repo in "${retired_repos[@]}"; do + ! grep -Fxq "$repo" <<<"$repos" || echo "[$repo]" + done + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || echo "the key $fpr" + done + printf '%s\n' "$conf" | awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "omarchy" && /^[[:space:]]*Server[[:space:]]*=/ && $0 !~ /=[[:space:]]*https:\/\/pkgs\.omarchy\.org\// { print "an [omarchy] server other than pkgs.omarchy.org" } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { print "[" name "] without signature checks" }' | sort -u +} + +# Runs the cohort's optional hook for a step. +adapter_hook() { + local hook=${cohort//-/_}_$1 + shift + if declare -F "$hook" >/dev/null; then + "$hook" "$@" + fi +} + +# The LUKS partition beneath /, or nothing when / is not encrypted; fails when +# it cannot tell (as omarchy-drive-password decides it). +root_luks_device() { + local source ancestry device + source=$(findmnt -no SOURCE "$R/") && [[ -n $source ]] || return 1 + ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${source%%[*}") || return 1 + device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry") + if [[ -n $device ]]; then + printf '%s\n' "$device" + elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then + return 1 + fi +} + +free_bytes() { + df -B1 --output=avail "$1" 2>/dev/null | tail -n 1 | tr -d ' ' +} + +bytes_used() { + local bytes + bytes=$(du -sxb "$1" 2>/dev/null | cut -f1) + printf '%s\n' "${bytes:-0}" +} + +# Running on battery below 30% is refused: the transaction and the boot switch +# must not lose power. +low_battery() { + local supply capacity on_battery=0 low=0 + for supply in "$R"/sys/class/power_supply/*; do + [[ -f $supply/type ]] || continue + case $(<"$supply/type") in + Battery) + capacity=$(<"$supply/capacity") 2>/dev/null || capacity=100 + [[ $capacity =~ ^[0-9]+$ ]] && (( capacity < 30 )) && low=1 + on_battery=1 + ;; + Mains | USB | USB_C | USB_PD) + [[ $(cat "$supply/online" 2>/dev/null) == "1" ]] && return 1 + ;; + esac + done + (( on_battery && low )) +} + +# The configuration pacman reads: FILE with each Include replaced by the files +# it names, three levels deep. +pacman_conf_flat() { + local file=$1 depth=${2:-0} line included + while IFS= read -r line || [[ -n $line ]]; do + if (( depth < 3 )) && [[ $line =~ ^[[:space:]]*Include[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$ ]]; then + # shellcheck disable=SC2086 # Include takes a glob + for included in $R${BASH_REMATCH[1]}; do + [[ ! -f $included ]] || pacman_conf_flat "$included" $(( depth + 1 )) + done + else + printf '%s\n' "$line" + fi + done <"$file" +} + +# The administrator's repositories the switch keeps must not accept untrusted +# packages: the core configuration requires signatures. +pacman_trust_problems() { + admin_repositories "$1" | awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + /^[[:space:]]*SigLevel[[:space:]]*=/ && /TrustAll|Never/ { + value = $0; sub(/^[^=]*=[[:space:]]*/, "", value) + print "[" name "] accepts untrusted packages (SigLevel = " value "); remove it or sign it first" + }' +} + +preflight() { + local reasons=() installed boot_state kernels hooks="" check_output luks="" need esp_mount="" staged=0 channel + local future transaction targets_file resolved name version problem official_problems="" names saved_path problems=() + work=$(mktemp -d "$R/var/tmp/omarchy-mac-migrate.XXXXXX") || die "cannot create a work directory" + chmod 755 "$work" + installed=$work/installed + installed_packages >"$installed" || die "cannot list the installed packages" + pacman_conf_flat "$pacman_conf" >"$work/flat.conf" || die "cannot read $pacman_conf" + + [[ -d $R/run/systemd/system ]] || reasons+=("this is not a booted system (an image build or a chroot)") + [[ ! -e $pacman_db/db.lck ]] || reasons+=("pacman is busy or was interrupted ($pacman_db/db.lck exists)") + + cohort=$(detect_cohort "$installed") + # A Mac following Omarchy's own dev channel already runs official packages. + # One that still trusts what the switch retires, or that an administrator + # points at a target, is moved like a tester: its packages are named. + if [[ $cohort == "official-dev" ]]; then + official_problems=$(official_trust_problems "$work/flat.conf" | paste -sd, | sed 's/,/, /g') + if [[ -z $official_problems && -z ${target_file:-} ]]; then + say "This Mac runs Omarchy's own packages (omarchy-dev from pkgs.omarchy.org): nothing to migrate." + exit 0 + fi + cohort=tester + fi + declare -F "${cohort//-/_}_plan" >/dev/null || reasons+=("$(cohort_refusal "$cohort")") + ! declare -F "${cohort//-/_}_stage" >/dev/null || staged=1 + + kernels=$(awk '$1 == "linux-asahi" || $1 == "linux-aurora" { print $1 }' "$installed" | xargs) + [[ $kernels == "linux-asahi" || $kernels == "linux-aurora" ]] || + reasons+=("expected one Apple kernel (linux-asahi or linux-aurora), found: ${kernels:-none}") + + if limine_mac; then + boot_state=limine + elif [[ -f $R/boot/grub/grub.cfg ]]; then + boot_state=grub + else + boot_state=unknown + reasons+=("cannot tell whether this Mac boots GRUB or Limine") + fi + # The Limine setup derives the kernel command line from GRUB's defaults. + [[ -f $R/etc/default/grub ]] || reasons+=("there is no /etc/default/grub, which the Limine setup reads the kernel command line from") + [[ -f $R/usr/share/pacman/keyrings/asahi-alarm.gpg ]] || reasons+=("asahi-alarm-keyring is not installed, so Asahi ALARM's packages cannot be verified") + if ! luks=$(root_luks_device); then + reasons+=("cannot tell whether the root filesystem is encrypted") + fi + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + reasons+=("first boot has not finished on this Mac") + fi + if low_battery; then + reasons+=("the battery is below 30% and no charger is connected") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(pacman_trust_problems "$work/flat.conf"; unsupported_options "$pacman_conf") + # The switch writes pacman.conf whole: a repository only an Include file + # defines would be lost or doubled. + for problem in $(comm -13 <(repositories_in "$pacman_conf" | LC_ALL=C sort -u) <(repositories_in "$work/flat.conf" | LC_ALL=C sort -u)); do + reasons+=("[$problem] is configured through an Include, which the repository switch cannot rewrite; move it into $pacman_conf first") + done + + # The target: the administrator's, else the channel this Mac follows. + if [[ -z ${target_file:-} ]]; then + if channel=$(detect_channel "$cohort" "$work/flat.conf"); then + write_channel_target "$channel" "$work/target" + target_file=$work/target + else + reasons+=("cannot tell which Omarchy channel this Mac follows (stable, rc or edge); set one in $admin_target") + fi + fi + [[ -z ${target_file:-} ]] || load_target "$target_file" + if [[ ${target_type:-} == "candidate-set" ]] && ! command -v gpgv >/dev/null; then + reasons+=("gpgv is not installed (gnupg), so the candidate set's signatures cannot be checked") + fi + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + + # The target, read in isolation: a copy of the local database and the future + # configuration, never the live sync databases. Signatures are checked + # against a copy of the keyring that trusts the target's key, so preflight + # never changes the live one. + future=$work/pacman.conf + future_pacman_conf "$pacman_conf" >"$future" || die "cannot compute the new pacman configuration" + mkdir -p "$work/db" + cp -a "$pacman_db/local" "$work/db/local" || die "cannot copy the package database" + install -d -m 700 "$work/pacman-gnupg" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$work/pacman-gnupg" -xf - || die "cannot copy the pacman keyring" + gpgdir=$work/pacman-gnupg + trust_official_key "$gpgdir" || refuse "cannot fetch and trust the Omarchy packaging key $target_keyring" + # The trust the switch leaves: no retired fork key verifies anything from here. + for name in "${retired_keys[@]}"; do + if key_present "$name" "$gpgdir"; then + pacman-key --gpgdir "$gpgdir" --delete "$name" >/dev/null 2>&1 || die "cannot drop $name from the keyring copy" + fi + done + if [[ $target_type == "candidate-set" ]]; then + install -d -m 755 "$work/candidate" + if ! problem=$(copy_candidate_set "$target_set" "$work/set") || ! problem=$(verify_candidate_set "$work/set" "$work/gnupg"); then + refuse "the candidate set does not verify: $problem" + fi + local loaded_id=$target_id + target_set=$work/set + candidate_identity "$target_set" || die "cannot read the candidate manifest" + [[ $target_id == "$loaded_id" ]] || refuse "the candidate set changed while it was read" + index_candidate_repo "$work/candidate" link || die "cannot index the candidate set" + fi + transaction=$work/transaction.conf + transaction_conf "$future" "${target_set:+$work/candidate}" >"$transaction" + pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sy --noconfirm >"$work/sync.log" 2>&1 || + refuse "cannot read the target repositories: $(tail -n 1 "$work/sync.log")" + mapfile -t problems < <(presence_problems "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "The $target_channel channel has no Mac release yet; this Mac stays as it is until it has one." + refuse "${problems[@]}" + fi + + targets_file=$work/targets + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + { + cat "$work/adapter-targets" + for name in $keyring_packages; do + sed 's|^.*/||' "$work/adapter-targets" | grep -Fxq "$name" || printf '%s\n' "$name" + done + } >"$targets_file" + names=$(sed 's|^.*/||' "$targets_file" | xargs) + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$pacman_conf holds back $problem, which the migration changes; remove it from IgnorePkg or IgnoreGroup first") + done < <(pinned_targets "$pacman_conf" "$names $(xargs <"$work/allowed-removals")" "$work/db" "$transaction") + (( ${#reasons[@]} == 0 )) || refuse "${reasons[@]}" + resolved=$work/resolved + # shellcheck disable=SC2046 + if ! pacman_run --config "$transaction" --dbpath "$work/db" --logfile "$work/pacman.log" -Sup --noconfirm --ask 4 \ + --print-format '%r/%n %v' $(plan_ignores "$work") $(cat "$targets_file") >"$resolved" 2>"$work/resolve.log"; then + refuse "the target set does not resolve on this Mac: $(tail -n 1 "$work/resolve.log")" + fi + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + grep -Fxq "$name $version" "$resolved" || refuse "${name#*/} does not resolve to the candidate's $version" + done <"$targets_file" + fi + + mapfile -t problems < <(archive_problems "$resolved" "$transaction" "$work/db") + if (( ${#problems[@]} )); then + say "This Mac cannot move to the $target_channel channel's packages yet; it stays as it is." + refuse "${problems[@]}" + fi + + # The boot tools of the omarchy-mac-boot the transaction installs judge the + # Mac from here on. + payload_dir=$work/payload + version=$(fetch_payload "$resolved" "$transaction" "$work/db" "$payload_dir") || + refuse "cannot take the boot tools from the target's omarchy-mac-boot: $version" + saved_path=$PATH + if (( fixture )); then + PATH=$PATH:$payload_dir/usr/bin + else + PATH=$payload_dir/usr/bin:$PATH + fi + # The busybox encrypt hook matters only where it unlocks the root: legacy + # omarchy-mac sets it on every Mac, and on an unencrypted one it does nothing. + # Only a cohort whose stage moves that unlock (legacy) may carry it. + if ! hooks=$(omarchy-mac-initramfs-hooks 2>/dev/null); then + reasons+=("cannot read the initramfs HOOKS") + elif [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( ! staged )); then + reasons+=("the root unlocks through busybox encrypt, which only the legacy omarchy-mac migration moves") + fi + # Until the loader step moves the unlock, the image GRUB boots is built from + # the new packages' HOOKS drop-ins: they must keep busybox encrypt. + if [[ -n $luks && " $hooks " == *" encrypt "* ]] && (( staged )); then + if ! problem=$(future_hooks "$resolved" "$transaction" "$work/db"); then + reasons+=("cannot tell the HOOKS the new packages give: $problem") + elif [[ " $problem " != *" encrypt "* ]]; then + reasons+=("the new packages' HOOKS drop-ins would drop busybox encrypt before the boot switch moves the unlock: $problem") + fi + fi + # Installed boot files, not the running kernel: an update that just replaced + # the kernel leaves a reboot pending, and the migration replaces it anyway. + if ! check_output=$(boot_check_pending 2>&1); then + reasons+=("the boot files are not coherent; repair them first: $(tail -n 1 <<<"$check_output")") + fi + # Limine and its UKI live on the ESP U-Boot boots, mounted at /boot/efi. A + # cohort with a stage moves an ESP mounted at /boot there first. + esp_mount=$(omarchy-mac-esp 2>/dev/null) || esp_mount="" + if [[ $esp_mount == "/boot" ]] && (( staged )); then + : + elif [[ $esp_mount != "$esp" ]]; then + reasons+=("the system ESP is not mounted at $esp") + fi + while IFS= read -r problem; do + [[ -z $problem ]] || reasons+=("$problem") + done < <(adapter_hook preflight "$installed" "$luks" "$hooks") + need=$(( 4 * 1024 * 1024 * 1024 + $(bytes_used "$R/etc") + $(bytes_used "$R/boot") )) + # An ESP mounted at /boot is also /boot: its kernel and initramfs move onto + # the root filesystem. + [[ $esp_mount != "/boot" ]] || need=$(( need + 512 * 1024 * 1024 )) + (( $(free_bytes "$R/var/lib") >= need )) || reasons+=("the root filesystem needs $(( need / 1024 / 1024 )) MiB free for backups and downloads") + (( $(free_bytes "$R${esp_mount:-$esp}") >= 64 * 1024 * 1024 )) || reasons+=("the ESP needs 64 MiB free") + [[ $esp_mount == "/boot" ]] || (( $(free_bytes "$R/boot") >= 128 * 1024 * 1024 )) || reasons+=("/boot needs 128 MiB free") + PATH=$saved_path + if (( ${#reasons[@]} )); then + refuse "${reasons[@]}" + fi + # The adapter's plan records the unlock its stage moves, now that the HOOKS + # are known. + if [[ $cohort == "legacy" ]]; then + "${cohort//-/_}_plan" "$installed" "$work" "$luks" "$hooks" >"$work/adapter-targets" || die "the $cohort adapter could not plan this Mac" + fi + + gpgconf --homedir "$gpgdir" --kill all >/dev/null 2>&1 || true + gpgdir="" + if already_on_target "$installed" "$resolved" "$targets_file" "$future"; then + say "This Mac already runs the target set ($target_id): nothing to migrate." + exit 0 + fi + if (( check_only )); then + say "Ready: run moves this Mac ($cohort, $boot_state boot${luks:+, encrypted}) onto $target_id ($target_channel)." + say "It installs: $names" + [[ ! -s $work/allowed-removals ]] || say "It may remove: $(xargs <"$work/allowed-removals")" + exit 0 + fi + + # Passed: freeze the plan. Nothing on the system has changed yet. + install -d -m 755 "$(dirname "$state")" "$state" + : >"$journal" + printf 'journal_format=%s\n' "$journal_format" >"$state/format" + sync "$journal" "$state/format" + current_step=preflight + journal_write preflight "begin" "$target_id" + rm -rf "$plan.new" + install -d -m 755 "$plan.new" + cp "$installed" "$plan.new/installed" + cp "$future" "$plan.new/pacman.conf" + cp -a "$work/db/sync" "$plan.new/sync" + guarded_pacman_conf "$future" "$pacman_conf" "$(printf '%s\n' $names $(xargs <"$work/allowed-removals") $guarded_boot | awk '!seen[$0]++' | xargs)" >"$plan.new/pacman.guarded.conf" + cp "$targets_file" "$plan.new/targets" + cp "$work/allowed-removals" "$plan.new/allowed-removals" + cp "$work/kept" "$plan.new/kept" 2>/dev/null || : >"$plan.new/kept" + cp "$work/removals" "$plan.new/removals" 2>/dev/null || : >"$plan.new/removals" + [[ ! -d $work/adapter ]] || cp -r "$work/adapter" "$plan.new/adapter" + cp "$target_file" "$plan.new/target" + printf '%s\n' "$target_id" >"$plan.new/target-id" + printf '%s\n' "$target_packages" >"$plan.new/target-packages" + printf '%s\n' "$cohort" >"$plan.new/cohort" + printf '%s\n' "$boot_state" >"$plan.new/boot" + printf '%s\n' "$luks" >"$plan.new/luks" + printf '%s\n' "$esp_mount" >"$plan.new/esp" + for name in $fresh_user_units; do + [[ ! -f $R/usr/lib/systemd/user/$name ]] || printf '%s\n' "$name" + done >"$plan.new/user-units" + find "$plan.new" -type f -exec sync {} + + if [[ $target_type == "candidate-set" ]]; then + rm -rf "$set_copy" + mv "$work/set" "$set_copy" || die "cannot keep the verified candidate set" + sync "$set_copy"/* + target_set=$set_copy + fi + rm -rf "$plan" + mv "$plan.new" "$plan" + keep_tool || die "cannot keep a copy of this tool for the migration's resume" + sync "$state" + interrupt_for_test during preflight + journal_write preflight "done" + interrupt_for_test after preflight + current_step="" +} + +refuse() { + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + die "$*" + fi + say "The migration was refused before anything changed:" >&2 + printf ' - %s\n' "$@" >&2 + install -d -m 755 "$state" 2>/dev/null && + printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$*" | durable_write "$state/deferred" 2>/dev/null || true + exit 75 +} + +# Every target is installed at the version the target resolves to, the +# configuration is already the future one and no retired key is trusted. +# Ordinary upgrades of other packages are omarchy update's business. +already_on_target() { + local installed=$1 resolved=$2 targets=$3 future=$4 target name version fpr + cmp -s "$future" "$pacman_conf" || return 1 + while read -r target; do + name=${target#*/} + version=$(awk -v name="$name" '{ sub(/^[^\/]*\//, "", $1) } $1 == name { print $2; exit }' "$resolved") + [[ -n $version && $(installed_version "$name" "$installed") == "$version" ]] || return 1 + done <"$targets" + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 1 + done +} + +# --- Steps ------------------------------------------------------------------- + +# The frozen plan: the target as preflight read it, the candidate set as it +# verified it. Nothing is read from the original set again. +load_plan() { + target_file=$plan/target + load_target "$target_file" frozen + target_id=$(<"$plan/target-id") + target_packages=$(<"$plan/target-packages") + cohort=$(<"$plan/cohort") +} + +plan_targets() { + cat "$plan/targets" +} + +# Where the ESP was mounted at preflight: /boot/efi, or /boot where the +# cohort's stage moves it. +plan_esp() { + if [[ -s $plan/esp ]]; then + cat "$plan/esp" + else + printf '%s\n' "$esp" + fi +} + +# Unqualified names of every package the transaction replaces or may remove. +plan_package_names() { + { sed 's|^.*/||' "$plan/targets"; cat "$plan/allowed-removals"; } | sort -u +} + +step_backup() { + local partial=$state/backup.partial name version file found luks esp_mount + esp_mount=$(plan_esp) + rm -rf "$partial" + install -d -m 700 "$partial" "$partial/packages" + cp "$plan/installed" "$partial/installed" + : >"$partial/packages.missing" + while read -r name; do + version=$(installed_version "$name" "$plan/installed") + [[ -n $version ]] || continue + found=0 + for file in "$pacman_cache/$name-$version"-*.pkg.tar.*; do + [[ -f $file ]] || continue + cp -p "$file" "$partial/packages/" || die "cannot copy $file into the backup" + found=1 + done + (( found )) || printf '%s %s\n' "$name" "$version" >>"$partial/packages.missing" + done < <(plan_package_names) + tar -C "$R/" --xattrs --acls -cpf "$partial/etc.tar" etc 2>"$partial/etc.log" || die "cannot back up /etc" + interrupt_for_test mid backup + # An ESP mounted at /boot is /boot: esp.tar holds it. + if [[ $esp_mount != "/boot" ]]; then + tar -C "$R/boot" --one-file-system -cpf "$partial/boot.tar" . || die "cannot back up /boot" + fi + tar -C "$R$esp_mount" -cpf "$partial/esp.tar" . || die "cannot back up the ESP" + luks=$(<"$plan/luks") + if [[ -n $luks ]]; then + cryptsetup luksHeaderBackup "$luks" --header-backup-file "$partial/luks-header.img" || + die "cannot back up the LUKS header of $luks" + fi + (cd "$partial" && find . -type f ! -name SHA256SUMS -print0 | LC_ALL=C sort -z | xargs -0 sha256sum >SHA256SUMS) || + die "cannot record the backup's digests" + find "$partial" -type f -exec sync {} + || die "cannot sync the backup" + rm -rf "$backup" + mv "$partial" "$backup" || die "cannot finish the backup" + sync "$state" + if [[ -s $backup/packages.missing ]]; then + say "Not in the package cache, so not backed up: $(awk '{ print $1 }' "$backup/packages.missing" | xargs)" + fi +} + +# Official trust, bootstrapped without any repository the switch retires: the +# keyrings already installed are populated, and a missing Omarchy key comes +# from the keyserver by its full fingerprint and is signed locally. A candidate +# set's key never enters pacman's keyring. +step_keyring() { + local keyrings=() name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || keyrings+=("$name") + done + if (( ${#keyrings[@]} )); then + pacman-key --gpgdir "$pacman_gpg" --populate "${keyrings[@]}" >/dev/null || die "cannot populate the keyrings: ${keyrings[*]}" + fi + interrupt_for_test mid keyring + if ! key_trusted "$target_keyring"; then + pacman-key --gpgdir "$pacman_gpg" --keyserver hkps://keys.openpgp.org --recv-keys "$target_keyring" >/dev/null && + pacman-key --gpgdir "$pacman_gpg" --lsign-key "$target_keyring" >/dev/null || + die "cannot fetch and trust the Omarchy key $target_keyring" + fi + key_trusted "$target_keyring" || die "the Omarchy key $target_keyring is not trusted after the bootstrap" +} + +# What the transaction may remove stays out of the upgrade: an official build +# of the same name that conflicts with a target (stock omarchy, which the +# omarchy-dev pair replaces on edge) would otherwise join the transaction, and +# pacman drops one of the two. Left alone, it leaves through the target's +# conflict, or by name after the transaction. +plan_ignores() { + local dir=${1:-$plan} names + names=$(cat "$dir/removals" "$dir/allowed-removals" 2>/dev/null | awk 'NF' | LC_ALL=C sort -u | paste -sd,) + [[ -z $names ]] || printf -- '--ignore=%s\n' "$names" + return 0 +} + +# Packages the transaction removes by name once it has installed the targets, +# as far as DB still has them. By exact name: pacman -Q NAME also answers with +# a package that provides NAME (mise-bin for mise), which pacman -R refuses. +plan_removals() { + local db=$1 name installed + [[ -f $plan/removals ]] || return 0 + installed=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Qq) || return 1 + while read -r name; do + [[ -n $name ]] && grep -Fxq -- "$name" <<<"$installed" && printf '%s\n' "$name" + done <"$plan/removals" + return 0 +} + +# Paths the installed package NAME owns in DB that another package there owns +# too. pacman -R deletes every file of the package it removes, whoever else +# owns it, so a planned removal that hands files over (omarchy-dev's commands +# to omarchy-mac-boot) must leave inside the transaction, through the conflict +# of the package that replaces it, never in the removals after it. +shared_files() { + local db=$1 name=$2 + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Ql 2>/dev/null | awk -v name="$name" ' + { owner = $1; path = $0; sub(/^[^ ]+ /, "", path) } + path ~ /\/$/ { next } + owner == name { mine[path] = 1; next } + { other[path] = 1 } + END { for (path in mine) if (path in other) print path }' | LC_ALL=C sort +} + +# Downloads and verifies every package the transaction needs, then rehearses the +# transaction on a copy of the package database (--dbonly: no files, scripts or +# hooks) to learn exactly what it installs and removes. Signatures are checked +# against the trust the switch leaves, a copy of the keyring without the +# retired keys, so nothing that needs a fork key gets this far. +step_prefetch() { + local db=$cache/db rehearsal=$cache/rehearsal conf=$cache/transaction.conf removed name version bad=() fpr removals shared + install -d -m 755 "$cache" "$cache/pkg" + rm -rf "$db" "$rehearsal" "$cache/candidate" "$cache/trust" + mkdir -p "$db" + cp -a "$pacman_db/local" "$db/local" || die "cannot copy the package database" + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$db" -Q >"$cache/start" || die "cannot read the package database copy" + if [[ $target_type == "candidate-set" ]]; then + stage_candidate_repo "$cache/candidate" "$cache/gnupg" || die "the candidate set does not verify" + fi + install -d -m 700 "$cache/trust" + tar -C "$pacman_gpg" --exclude='S.*' -cf - . | tar -C "$cache/trust" -xf - || die "cannot copy the pacman keyring" + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$cache/trust" --delete "$fpr" >/dev/null 2>&1 || die "cannot drop $fpr from the keyring copy" + fi + done + gpgdir=$cache/trust + transaction_conf "$plan/pacman.conf" "${target_set:+$cache/candidate}" >"$conf" + # The databases preflight qualified, never a newer sync: the transaction + # installs exactly the set preflight checked. + cp -a "$plan/sync" "$db/sync" || die "cannot copy the frozen package databases" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + -Suw --noconfirm --ask 4 $(plan_ignores) $(plan_targets) || die "cannot download and verify the target set" + interrupt_for_test mid prefetch + cp -a "$db" "$rehearsal" + # shellcheck disable=SC2046 + pacman_run --config "$conf" --dbpath "$rehearsal" --cachedir "$cache/pkg" --cachedir "$pacman_cache" --logfile "$cache/pacman.log" \ + --dbonly -Su --noconfirm --ask 4 $(plan_ignores) $(plan_targets) >"$cache/rehearsal.log" 2>&1 || + die "the rehearsed transaction failed: $(tail -n 1 "$cache/rehearsal.log")" + removals=$(plan_removals "$rehearsal" | xargs) + for name in $removals; do + shared=$(shared_files "$rehearsal" "$name" | head -n 3 | xargs) + [[ -z $shared ]] || + die "the transaction would leave $name to be removed after it, but the packages it installs also own $shared; nothing was changed" + done + if [[ -n $removals ]]; then + # shellcheck disable=SC2086 + pacman_run --config "$conf" --dbpath "$rehearsal" --logfile "$cache/pacman.log" --dbonly -R --noconfirm $removals \ + >>"$cache/rehearsal.log" 2>&1 || die "the rehearsed removal of $removals failed: $(tail -n 1 "$cache/rehearsal.log")" + fi + gpgdir="" + gpgconf --homedir "$cache/trust" --kill all >/dev/null 2>&1 || true + LC_ALL=C pacman --config "$conf" --dbpath "$rehearsal" -Q >"$cache/expected" || die "cannot read the rehearsed result" + removed=$(comm -23 <(awk '{ print $1 }' "$cache/start" | LC_ALL=C sort) <(awk '{ print $1 }' "$cache/expected" | LC_ALL=C sort)) + for name in $removed; do + grep -Fxq "$name" "$plan/allowed-removals" || bad+=("$name") + done + (( ${#bad[@]} == 0 )) || die "the transaction would also remove ${bad[*]}; nothing was changed" + # pacman can drop a named target that conflicts with another package of the + # transaction; every target must end installed. + while read -r name; do + [[ -n $(installed_version "${name#*/}" "$cache/expected") ]] || + die "the rehearsed transaction would not install ${name#*/}; nothing was changed" + done < <(plan_targets) + if [[ $target_type == "candidate-set" ]]; then + while read -r name; do + [[ $name == "$candidate_repo/"* ]] || continue + version=$(target_version "${name#*/}") + [[ $(installed_version "${name#*/}" "$cache/expected") == "$version" ]] || die "${name#*/} would not end at the candidate's $version" + done < <(plan_targets) + fi + adapter_hook prefetch || die "the $cohort adapter refused the rehearsed transaction; nothing was changed" + durable_write "$start" <"$cache/start" && durable_write "$expected" <"$cache/expected" || + die "cannot record the rehearsed transaction" +} + +# The installed packages no longer match what the rehearsal started from: an +# omarchy update ran in between, or a transaction was cut short. The +# transaction is rehearsed again from what is installed now. +system_moved() { + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + ! cmp -s "$state/installed.now" "$start" +} + +restart_from_prefetch() { + local step + (( ++restarts <= 3 )) || die "the system keeps changing under the migration ($1); run it again when nothing else updates" + say "The system changed since the transaction was rehearsed ($1); rehearsing it again" + for step in prefetch repositories transaction; do + journal_write "$step" "reset" "$1" + done + restarted=1 +} + +# The sync databases the rehearsal resolved against, over any a later sync left. +# A signature the rehearsal has none of belongs to the database it replaces +# (a fork's signed [omarchy]), and pacman rejects a database beside a +# signature that does not match it. +install_rehearsed_databases() { + local repo extension + for repo in $(repositories_in "$cache/transaction.conf"); do + for extension in db db.sig; do + if [[ ! -f $cache/db/sync/$repo.$extension ]]; then + [[ $extension != "db.sig" || ! -f $cache/db/sync/$repo.db ]] || rm -f "$pacman_db/sync/$repo.db.sig" + continue + fi + cmp -s "$cache/db/sync/$repo.$extension" "$pacman_db/sync/$repo.$extension" && continue + durable_write "$pacman_db/sync/$repo.$extension" 644 <"$cache/db/sync/$repo.$extension" || + die "cannot install the $repo database" + done + done +} + +# The process holding pacman's lock: libalpm keeps it open while it works. +lock_holder() { + local fd + for fd in "$R"/proc/[0-9]*/fd/*; do + if [[ $(readlink "$fd" 2>/dev/null) == "$pacman_db/db.lck" ]]; then + fd=${fd#"$R/proc/"} + printf '%s\n' "${fd%%/*}" + return 0 + fi + done + return 1 +} + +# Official repository precedence and no legacy trust: the frozen +# configuration, the sync databases the rehearsal used, no retired database or +# fork key. Until the transaction is done the configuration carries the +# migration's guard (and a test image keeps its pin), so a plain pacman -Syu in +# between moves none of the packages the transaction is about to change. +step_repositories() { + local repo extension fpr + if system_moved; then + restart_from_prefetch "before the repository switch" + return 0 + fi + # From here on the fork's own update may be gone with its packages: a boot + # resumes whatever is left. + keep_tool && write_verify_unit && systemctl enable "$verify_unit" >/dev/null 2>&1 || + die "cannot install $verify_unit, which resumes the migration at boot" + # The boundary: recorded before the first change that is not set aside. + before_boundary && journal_write repositories "boundary" + if ! cmp -s "$plan/pacman.guarded.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.guarded.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid repositories + install_rehearsed_databases + for repo in "${retired_repos[@]}"; do + rm -f "$pacman_db/sync/$repo".{db,db.sig,files,files.sig} + done + for fpr in "${retired_keys[@]}"; do + if key_present "$fpr"; then + pacman-key --gpgdir "$pacman_gpg" --delete "$fpr" >/dev/null || die "cannot remove the retired key $fpr" + fi + done +} + +# Something rewrote pacman.conf or trusted a retired key again since the +# switch: on an mx-mac Mac, the fork's own omarchy update, whose channel +# updaters stay until the transaction removes them. +switch_undone() { + local fpr + cmp -s "$plan/pacman.guarded.conf" "$pacman_conf" || return 0 + for fpr in "${retired_keys[@]}"; do + ! key_present "$fpr" || return 0 + done + return 1 +} + +# The installed packages with the planned removals left out. +without_removals() { + awk 'NR == FNR { drop[$1]; next } !($1 in drop)' "$plan/removals" "$1" +} + +# The targets are installed and only the planned removals are left. +removals_pending() { + [[ -f $plan/removals ]] && ! cmp -s "$state/installed.now" "$expected" && + cmp -s <(without_removals "$state/installed.now") "$expected" +} + +# A path as a pacman --overwrite glob that matches only itself. +overwrite_glob() { + sed 's/[][*?\\]/\\&/g' <<<"$1" +} + +# One transaction from the prefetched cache and databases, without a new sync: +# it installs exactly what was verified and rehearsed. Same-name packages are +# named explicitly, so a higher installed version is replaced too. A lock left +# by a transaction that was killed means its hooks may not have run, so the +# transaction runs again even when the packages are all in place. The adapter +# prepares the system for it first and may list, in $state/overwrite, files no +# package owns that it may replace; when pacman fails, the adapter restores +# what it prepared. Planned removals run after it, by name. +step_transaction() { + local holder interrupted=0 overwrite=() remove=() path removal shared + if [[ -e $pacman_db/db.lck ]]; then + if holder=$(lock_holder); then + die "pacman is running (process $holder); run the migration again when it has finished" + fi + say "Removing the pacman lock an interrupted transaction left behind" + : | durable_write "$interrupted_marker" || die "cannot record the interrupted transaction" + rm -f "$pacman_db/db.lck" + fi + # Kept across a new rehearsal until a transaction has run to its end. + [[ ! -e $interrupted_marker ]] || interrupted=1 + if switch_undone; then + restart_from_prefetch "pacman.conf or a retired key came back after the repository switch" + return 0 + fi + if system_moved && ! cmp -s "$state/installed.now" "$expected" && ! removals_pending; then + restart_from_prefetch "before the transaction" + return 0 + fi + if (( interrupted )) || ! cmp -s "$state/installed.now" "$expected"; then + install_rehearsed_databases + if (( interrupted )) || ! removals_pending; then + rm -f "$state/overwrite" + if ! adapter_hook prepare; then + adapter_hook restore + die "the $cohort adapter could not prepare the transaction" + fi + if [[ -f $state/overwrite ]]; then + while IFS= read -r path; do + [[ -z $path ]] || overwrite+=(--overwrite "$(overwrite_glob "$path")") + done <"$state/overwrite" + fi + # shellcheck disable=SC2046 + if ! pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" --cachedir "$cache/pkg" --cachedir "$pacman_cache" \ + -Su --noconfirm --ask 4 "${overwrite[@]}" $(plan_ignores) $(plan_targets); then + adapter_hook restore + die "the package transaction failed" + fi + fi + interrupt_for_test mid removals + mapfile -t remove < <(plan_removals "$pacman_db") + for removal in "${remove[@]}"; do + shared=$(shared_files "$pacman_db" "$removal" | head -n 3 | xargs) + [[ -z $shared ]] || die "cannot remove $removal: other packages also own $shared, which pacman -R would delete" + done + if (( ${#remove[@]} )); then + pacman_run --config "$cache/transaction.conf" --dbpath "$pacman_db" -R --noconfirm "${remove[@]}" || + die "cannot remove ${remove[*]}" + fi + installed_packages >"$state/installed.now" || die "cannot list the installed packages" + cmp -s "$state/installed.now" "$expected" || + die "the installed packages differ from the rehearsed transaction: $(diff "$expected" "$state/installed.now" | grep '^[<>]' | head -n 3 | xargs)" + rm -f "$interrupted_marker" + fi + rm -f "$pacman_db/sync/$candidate_repo".{db,db.sig} + # Fresh-image provisioning is never armed on an existing machine (preflight + # refuses one whose first boot is unfinished). + if [[ -e $first_boot_marker || -e $legacy_first_boot_marker ]]; then + say "Disarming the first-boot setup the transaction left on this installed Mac" + rm -f "$first_boot_marker" "$legacy_first_boot_marker" + fi +} + +# The new packages are installed, set up and verified: the configuration loses +# the migration's guard and a test image's pin, and is the core one from here +# on. +step_unpin() { + if ! cmp -s "$plan/pacman.conf" "$pacman_conf"; then + durable_write "$pacman_conf" 644 <"$plan/pacman.conf" || die "cannot write $pacman_conf" + fi + interrupt_for_test mid unpin + pacman-key --gpgdir "$pacman_gpg" --populate $(installed_keyrings) >/dev/null || die "cannot populate the installed keyrings" +} + +# The keyrings pacman-key can populate from what is installed now. +installed_keyrings() { + local name + for name in archlinuxarm asahi-alarm omarchy; do + [[ ! -f $R/usr/share/pacman/keyrings/$name.gpg ]] || printf '%s\n' "$name" + done +} + +# Aurora, m1n1 and U-Boot came with the transaction; their stage-two image +# (m1n1, the device trees and U-Boot) and the kernel's menu are rebuilt and +# checked. The loader U-Boot starts is not touched here. +step_boot_chain() { + local output + update-m1n1 >/dev/null || die "update-m1n1 could not rebuild m1n1, the device trees and U-Boot" + interrupt_for_test mid boot-chain + if [[ $(<"$plan/boot") == "limine" ]]; then + omarchy-mac-limine-cmdline && limine-update >/dev/null || die "cannot rebuild the Limine menu and UKI" + else + update-grub >/dev/null || die "cannot rebuild the GRUB menu" + fi + output=$(boot_check_pending linux-aurora 2>&1) || die "the rebuilt boot files do not check: $(tail -n 1 <<<"$output")" +} + +# The installed omarchy-mac-boot's setup-boot, through the new runtime's +# dispatcher, activates Limine (or refreshes it on a Limine Mac): it stages and +# verifies Limine before it takes U-Boot's EFI slot and restores every file it +# touched when anything fails, so a failed activation leaves the previous +# loader booting. On a GRUB Mac the cohort's stage runs first, while GRUB still +# boots the Mac, and is undone when it or the activation fails. A switch cut +# short is run again from its start. +step_loader() { + local output uki=$R$esp/EFI/Linux/omarchy_linux-aurora.efi + if [[ $(<"$plan/boot") == "limine" ]]; then + interrupt_for_test mid loader + omarchy-lifecycle-dispatch setup-boot >/dev/null || die "setup-boot could not refresh Limine; the previous loader stays" + else + if ! adapter_hook stage; then + adapter_hook unstage || die "the $cohort adapter could not stage the boot switch, nor undo it; GRUB is still the loader" + die "the $cohort adapter could not stage the boot switch; GRUB is still the loader" + fi + install -D -m 644 /dev/null "$limine_gate" || die "cannot mark this Mac for Limine" + interrupt_for_test mid loader + if ! omarchy-lifecycle-dispatch setup-boot >/dev/null; then + rm -f "$limine_gate" + adapter_hook unstage || die "Limine could not be activated, and the $cohort adapter could not undo its stage; GRUB is still the loader" + die "Limine could not be activated; GRUB is still the loader" + fi + fi + [[ -s $uki ]] && grep -Fq "boot():/EFI/Linux/omarchy_linux-aurora.efi" "$R$esp/limine.conf" || + die "Limine has no linux-aurora UKI entry after setup-boot" + cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || die "the ESP loader is not the packaged Limine" + output=$(boot_check_pending linux-aurora 2>&1) || die "the staged boot chain does not check: $(tail -n 1 <<<"$output")" +} + +# Runs a command as USER, whose home is HOME, in a clean environment, so +# nothing root does follows a link the user controls. +as_user() { + local user=$1 home=$2 + shift 2 + if (( fixture )); then + env HOME="$home" "$@" + else + runuser -u "$user" -- env -i HOME="$home" USER="$user" LOGNAME="$user" PATH="$PATH" "$@" + fi +} + +# Accounts that have used Omarchy: a regular UID and Omarchy's state in the home. +omarchy_users() { + local user home + awk -F: '$3 >= 1000 && $3 < 60000 { print $1, $6 }' "$R/etc/passwd" 2>/dev/null | + while read -r user home; do + [[ ! -d $R$home/.local/state/omarchy ]] || printf '%s %s\n' "$user" "$home" + done +} + +# Enables a user unit by the links its [Install] WantedBy names, as +# systemctl --user enable writes them for these units. A mask, an override or any enablement, the user's +# or the administrator's, stays as it is. +enable_user_unit() { + local user=$1 home=$2 unit=$3 config=$R$2/.config/systemd/user target path + [[ -f $R/usr/lib/systemd/user/$unit ]] || return 0 + for path in "$config/$unit" "$R/etc/systemd/user/$unit" "$config"/*.wants/"$unit" "$R/etc/systemd/user"/*.wants/"$unit"; do + [[ ! -e $path && ! -L $path ]] || return 0 + done + for target in $(awk -F= '/^\[/ { install = ($0 == "[Install]") } install && $1 == "WantedBy" { print $2 }' "$R/usr/lib/systemd/user/$unit"); do + as_user "$user" "$R$home" mkdir -p "$config/$target.wants" && + as_user "$user" "$R$home" ln -s "/usr/lib/systemd/user/$unit" "$config/$target.wants/$unit" || return 1 + done +} + +# What omarchy update checks before it offers a reboot, through the new +# runtime's dispatcher: the boot files the next boot reads. +update_verify() { + local output + output=$(omarchy-lifecycle-dispatch update-verify 2>&1) || die "update-verify does not pass: $(grep -v '^[[:space:]]*$' <<<"$output" | head -n 2 | xargs)" +} + +step_verify() { + update_verify +} + +# The unit that finishes the migration after its reboot runs the copy of this +# tool the migration keeps, so it works whatever else is installed. +write_verify_unit() { + local unit + unit="[Unit] +Description=Finish the Omarchy Mac migration after its reboot +# Either condition starts it: a migration past its repository switch and not +# complete, or user setup a migration left pending, retried at every boot until +# it succeeds. +ConditionPathExists=|/var/lib/omarchy-mac/migration/journal +ConditionPathExists=|/var/lib/omarchy-mac/migration/user-pending +Wants=network-online.target +After=local-fs.target network-online.target + +[Service] +Type=oneshot +ExecStart=${tool_copy#"$R"} verify + +[Install] +WantedBy=multi-user.target" + [[ -f $verify_unit_file && $(<"$verify_unit_file") == "$unit" ]] && return 0 + install -d -m 755 "$(dirname "$verify_unit_file")" && + printf '%s\n' "$unit" | durable_write "$verify_unit_file" 644 && + { systemctl daemon-reload >/dev/null 2>&1 || true; } +} + +# Waits for a reboot; after it, the new chain must have booted Aurora through +# Limine with every boot file coherent. The boot waited for stays recorded +# until retire, so a verification cut short is repeated on the same boot. +step_reboot() { + local staged current release output + current=$(boot_id) + if [[ ! -s $reboot_pending ]]; then + printf '%s\n' "$current" | durable_write "$reboot_pending" || die "cannot record the boot to wait for" + fi + interrupt_for_test mid reboot + keep_tool && write_verify_unit || die "cannot install $verify_unit, which verifies the next boot" + systemctl enable "$verify_unit" >/dev/null 2>&1 || die "cannot enable $verify_unit, which verifies the next boot" + staged=$(<"$reboot_pending") + if [[ $current == "$staged" ]]; then + say "Reboot to finish the migration to $target_id. The next boot verifies the new boot chain." + current_step="" + exit 0 + fi + release=$(kernel_release linux-aurora) || die "linux-aurora has no module tree" + [[ $(<"$R/proc/sys/kernel/osrelease") == "$release" ]] || + die "this boot runs $(<"$R/proc/sys/kernel/osrelease"), not linux-aurora $release; the backups are in $backup" + limine_mac && cmp -s "$R/usr/share/limine/BOOTAA64.EFI" "$R$esp/EFI/BOOT/BOOTAA64.EFI" || + die "this Mac did not boot through the packaged Limine" + output=$(omarchy-apple-silicon-boot-check --boot-chain linux-aurora 2>&1) || die "the boot check failed after the reboot: $(tail -n 1 <<<"$output")" + update_verify +} + +# The completion record comes first: what is left after it is only cleanup, +# which every later run repeats until it is done. +step_retire() { + "${cohort//-/_}_retire" || die "the $cohort adapter could not retire its compatibility state" + printf 'target=%s\ncompleted=%s\n' "$target_id" "$(date +%Y-%m-%dT%H:%M:%S%z)" | durable_write "$complete" || + die "cannot record the completed migration" + interrupt_for_test mid retire + tidy_completed +} + +# The post-reboot unit is released once the working state is gone and no user +# setup is pending; while some is, it stays enabled to retry at every boot. +tidy_completed() { + local working=0 release=0 + if [[ -e $reboot_pending || -d $cache || -d $set_copy ]]; then + working=1 + release=1 + fi + if [[ -s $user_pending ]]; then + if retry_user_pending; then + release=1 + else + release=0 + fi + fi + if (( release )); then + release_verify_unit + fi + if (( working )); then + rm -rf "$cache" "$set_copy" "$state/installed.now" "$state/overwrite" + rm -f "$reboot_pending" + fi +} + +# The post-reboot unit goes once nothing is left for it to do, and with it, +# outside a migration in progress, the copy of the tool it runs. +release_verify_unit() { + systemctl disable "$verify_unit" >/dev/null 2>&1 || say "Could not disable $verify_unit; it does nothing from now on." + if [[ -f $verify_unit_file ]]; then + rm -f "$verify_unit_file" + systemctl daemon-reload >/dev/null 2>&1 || true + fi + if [[ -f $complete || ! -f $journal ]]; then + rm -rf "$state/tool" + fi +} + +# A copy of this tool beside the journal: the post-reboot unit runs it, and a +# migration in progress resumes with it (see hand_over). +keep_tool() { + [[ $self != "$tool_copy" ]] || return 0 + cmp -s "$self" "$tool_copy" && return 0 + install -d -m 755 "$(dirname "$tool_copy")" && + install -m 755 "$self" "$tool_copy.new" && sync "$tool_copy.new" && mv -f "$tool_copy.new" "$tool_copy" +} + +# The tool_version and journal_format a copy of the tool declares. +tool_field() { + sed -n "s/^$1=\([0-9][0-9]*\)$/\1/p" "$2" | head -n 1 +} + +# A migration in progress continues with the tool that started it, unless this +# one resumes the same journal format and is at least as new: then this one +# takes over and becomes the kept copy. +hand_over() { + local version format + [[ -f $tool_copy && $self != "$tool_copy" ]] || return 0 + version=$(tool_field tool_version "$tool_copy") + format=$(tool_field journal_format "$tool_copy") + if [[ $format == "$journal_format" ]] && (( tool_version >= ${version:-0} )); then + keep_tool || die "cannot update the kept copy of this tool" + return 0 + fi + say "Resuming with the tool this migration started with (version ${version:-unknown})" + exec "$tool_copy" "$@" +} + +# --- Commands ---------------------------------------------------------------- + +# Another run holding the lock owns the migration's state: this one leaves it +# alone, failing once the migration is past its switch and deferring before. +# A copy of the tool handed the migration keeps the lock it inherited. +take_lock() { + install -d -m 755 "$(dirname "$lock_file")" + if [[ $(readlink "/proc/$$/fd/9" 2>/dev/null) != "$(realpath -m "$lock_file")" ]]; then + exec 9>"$lock_file" + fi + if ! flock -n 9; then + echo "omarchy-mac-migrate: another migration run is in progress" >&2 + if past_boundary; then + exit 1 + fi + exit 75 + fi +} + +resume_steps() { + local step event + while step=$(next_step) && [[ -n $step ]]; do + [[ $step != "preflight" ]] || die "the migration has no finished preflight" + event=$(step_state "$step") + [[ -z $event || $event == "reset" || $step == "reboot" ]] || say "Resuming the migration at $step" + run_step "$step" + done + say "This Mac now runs $target_id. Backups stay in $backup." +} + +migrate_run() { + local target_arg="" candidate + while (( $# )); do + case $1 in + --target) target_arg=${2:?--target needs a file}; shift 2 ;; + *) usage; exit 2 ;; + esac + done + + # A migration past its switch is this Mac's, whatever a detector says now. + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + if [[ $platform != "apple-silicon" ]]; then + say "Not an Apple Silicon Mac: nothing to migrate." + return 0 + fi + fi + take_lock + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + + if [[ -f $complete && -f $plan/target-id ]]; then + tidy_completed + candidate=$(find_target "$target_arg") + if [[ -n $candidate ]]; then + load_target "$candidate" + fi + if [[ -z $candidate || $target_id == "$(<"$plan/target-id")" ]]; then + say "Already migrated to $(<"$plan/target-id")." + return 0 + fi + (( check_only )) || archive_state + else + # User setup an earlier migration left pending runs first, whatever this + # run does next. + (( check_only )) || retry_user_pending_now + fi + + if [[ -f $journal && $(step_state preflight) == "done" && ! -f $complete ]]; then + if [[ -n $target_arg ]] && ! cmp -s "$target_arg" "$plan/target"; then + die "a migration to $(<"$plan/target-id") is in progress; finish it before choosing another target" + fi + if (( check_only )); then + migrate_status + return 0 + fi + load_plan + resume_steps + return 0 + fi + + target_file=$(find_target "$target_arg") + preflight + resume_steps +} + +# Keeps a finished migration's record and backups beside the next one. +archive_state() { + local destination + destination=$state/history/$(date +%s) + install -d -m 700 "$destination" + mv "$journal" "$plan" "$start" "$expected" "$complete" "$destination/" 2>/dev/null + [[ ! -f $repaired ]] || mv "$repaired" "$destination/" + [[ ! -d $backup ]] || mv "$backup" "$destination/" +} + +# Run by omarchy-mac-migrate-verify.service at boot: continues a migration that +# is waiting for, or past, its reboot, and does nothing otherwise. +migrate_verify() { + past_boundary || [[ -s $user_pending ]] || return 0 + if ! past_boundary; then + platform=$(hardware_platform) || die "cannot determine the hardware platform" + [[ $platform == "apple-silicon" ]] || return 0 + fi + take_lock + if past_boundary; then + hand_over "${original_args[@]}" + fi + trap on_exit EXIT + if [[ -f $complete ]]; then + tidy_completed + return 0 + fi + retry_user_pending_now + past_boundary || return 0 + load_plan + resume_steps +} + +# A migration that has started its repository switch and is not complete. +past_boundary() { + [[ -f $journal && ! -f $complete ]] && ! before_boundary +} + +migrate_status() { + local step event + if [[ -s $user_pending ]]; then + say "User setup pending, retried at the next run or boot: $(pending_summary)" + fi + if [[ ! -f $journal || $(step_state preflight) != "done" ]]; then + if [[ -s $state/deferred ]]; then + say "No migration has started on this Mac. The last run deferred: $(cut -d' ' -f2- "$state/deferred")" + else + say "No migration has started on this Mac." + fi + return 0 + fi + say "Target: $(<"$plan/target-id")" + if [[ -f $complete ]]; then + say "State: complete ($(awk -F= '$1 == "completed" { print $2 }' "$complete"))" + return 0 + fi + step=$(next_step) + event=$(step_state "$step") + if [[ $step == "reboot" && -s $reboot_pending && $event == "begin" ]]; then + if [[ $(boot_id) == "$(<"$reboot_pending")" ]]; then + say "State: waiting for a reboot" + else + say "State: rebooted; the new boot chain is not verified yet (sudo omarchy-mac-migrate verify)" + fi + elif [[ $event == "fail" ]]; then + say "State: failed at $step: $(awk -v step="$step" '$2 == step && $3 == "fail" { $1 = $2 = $3 = ""; line = $0 } END { sub(/^ +/, "", line); print line }' "$journal")" + else + say "State: in progress, next step $step" + fi +} + +usage() { + cat >&2 <<'USAGE' +Usage: omarchy-mac-migrate status + omarchy-mac-migrate check [--target FILE] + omarchy-mac-migrate run [--target FILE] + omarchy-mac-migrate verify + omarchy-mac-migrate version +USAGE +} + +migrate_main() { + local command=${1:-status} + original_args=("$@") + (( $# == 0 )) || shift + case $command in + status) migrate_status ;; + check) check_only=1; migrate_run "$@" ;; + run) migrate_run "$@" ;; + verify) migrate_verify ;; + version) say "omarchy-mac-migrate $tool_version (journal format $journal_format)" ;; + -h | --help | help) usage ;; + *) usage; exit 2 ;; + esac +} diff --git a/migrate/src/main.sh b/migrate/src/main.sh new file mode 100644 index 00000000000..9e1ebbb6277 --- /dev/null +++ b/migrate/src/main.sh @@ -0,0 +1,55 @@ +#!/bin/bash -p + +# omarchy:summary=Move this Mac onto Omarchy's official packages through a journaled, resumable migration +# omarchy:args=status | check | run [--target FILE] | verify +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# GENERATED from migrate/src by migrate/build: edit the sources there, then run +# migrate/build. One self-contained file, so it runs the same from a quattro +# checkout, from omarchy-mx-mac's final release and as a downloaded release +# asset, and needs no migration code in any package. +# +# It moves an Apple Silicon Mac running an Omarchy fork (omarchy-mac quattro, +# omarchy-mx-mac, a quattro-upstream test image) onto the official packages of +# the channel it follows: the Omarchy runtime pair from pkgs.omarchy.org (the +# omarchy-dev pair on edge), omarchy-mac and omarchy-mac-boot, and the Aurora +# boot chain, under the core Apple Silicon pacman configuration. A channel +# whose repository has no qualified Mac packages yet defers (75) with nothing +# changed; so does anything preflight refuses. +# +# status what this Mac's migration is doing +# check preflight only: says what run would do, changes nothing +# run migrate, or resume a migration cut short +# verify after the reboot: verify the new boot chain and finish +# +# Exit 0: migrated (or waiting for its reboot), or nothing to migrate. Exit 75: +# deferred, nothing changed. Any other status: a step failed part way; running +# it again resumes. +# +# Root starts over in an empty environment with a fixed PATH and reads only the +# live system. Unprivileged tests name a fixture root in +# OMARCHY_MAC_MIGRATE_ROOT. + +if (( EUID == 0 )) && [[ ${1:-} != "--clean-environment" ]]; then + exec /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/bin HOME=/root /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@" +fi +[[ ${1:-} != "--clean-environment" ]] || shift + +set -euo pipefail + +# shellcheck disable=SC2034 # R, fixture and self are the engine's inputs +if (( EUID == 0 )); then + export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin + R="" + fixture=0 +else + R=${OMARCHY_MAC_MIGRATE_ROOT:-} + if [[ $R != /?* ]]; then + echo "omarchy-mac-migrate: run it as root: sudo omarchy-mac-migrate ${*:-status}" >&2 + exit 1 + fi + R=${R%/} + fixture=1 +fi +self=$(realpath -- "${BASH_SOURCE[0]}") diff --git a/migrate/src/payload.sh b/migrate/src/payload.sh new file mode 100644 index 00000000000..94f474c0825 --- /dev/null +++ b/migrate/src/payload.sh @@ -0,0 +1,89 @@ +# The boot tools preflight judges this Mac with: the boot check, the ESP +# finder and the HOOKS composer of the omarchy-mac-boot the transaction will +# install. A Mac on a fork has none it can trust, or older ones, so preflight +# takes them from that package's verified archive, unpacked where only root can +# write, and puts them first on its PATH. Nothing of the package is installed +# here and nothing in it runs but those read-only checks; after the +# transaction the installed package's own commands are used. +# shellcheck disable=SC2154 + +# The verified archive of NAME as the target resolves it, downloaded once into +# the work cache: a candidate set's from the verified copy of the set, a +# repository's by pacman, which checks its signature against the keyring copy +# that trusts the target's key (and no retired one). Prints its path, or why +# there is none. +fetch_archive() { + local resolved=$1 wanted=$2 conf=$3 db=$4 name version file archive="" + read -r name version < <(awk -v wanted="$wanted" '{ n = $1; sub(/^[^\/]*\//, "", n) } n == wanted { print $1, $2; exit }' "$resolved") + [[ -n $name ]] || { echo "the target installs no $wanted"; return 1; } + if [[ $name == "$candidate_repo/"* ]]; then + file=$(jq -r --arg name "$wanted" --arg version "$version" '.packages[] | select(.name == $name and .version == $version) | .filename' "$target_set/manifest.json") + [[ -n $file && -f $target_set/$file ]] && archive=$target_set/$file + else + install -d -m 755 "$work/archives" + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + if [[ -z $archive ]]; then + if ! pacman_run --config "$conf" --dbpath "$db" --cachedir "$work/archives" --logfile "$work/pacman.log" \ + -Swdd --noconfirm --ask 4 "$name" >"$work/download.log" 2>&1; then + echo "cannot download and verify $wanted $version: $(tail -n 1 "$work/download.log")" + return 1 + fi + for file in "$work/archives/$wanted-$version"-*.pkg.tar.*; do + [[ $file == *.sig || ! -f $file ]] || archive=$file + done + fi + fi + [[ -n $archive ]] || { echo "the archive of $wanted $version is missing"; return 1; } + printf '%s\n' "$archive" +} + +# Unpacks the verified archive of the resolved omarchy-mac-boot into DIR, where +# only root can write, and prints its version. +fetch_payload() { + local resolved=$1 conf=$2 db=$3 dir=$4 archive version + archive=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db") || { echo "$archive"; return 1; } + version=$(awk '{ n = $1; sub(/^[^\/]*\//, "", n) } n == "omarchy-mac-boot" { print $2; exit }' "$resolved") + rm -rf "$dir" + install -d -m 700 "$dir" + bsdtar -xpf "$archive" -C "$dir" 2>/dev/null || { echo "cannot unpack omarchy-mac-boot $version"; return 1; } + [[ $(sed -n 's/^pkgname = //p' "$dir/.PKGINFO") == "omarchy-mac-boot" && $(sed -n 's/^pkgver = //p' "$dir/.PKGINFO") == "$version" ]] || + { echo "the archive is not omarchy-mac-boot $version"; return 1; } + [[ -z $(find "$dir" ! -type l \( ! -uid "$EUID" -o -perm /022 \) -print -quit) ]] || + { echo "the unpacked omarchy-mac-boot is writable by others"; return 1; } + # Its commands, and nothing a link could point elsewhere, run first. + [[ -z $(find "$dir/usr/bin" -type l -print -quit 2>/dev/null) ]] || { echo "omarchy-mac-boot's commands include a link"; return 1; } + for name in $payload_commands; do + [[ -f $dir/usr/bin/$name && ! -L $dir/usr/bin/$name && -x $dir/usr/bin/$name ]] || + { echo "omarchy-mac-boot $version has no $name"; return 1; } + done + printf '%s\n' "$version" +} + +# What preflight runs from the target's omarchy-mac-boot. +payload_commands="omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-esp omarchy-mac-kernel" + +# The HOOKS the Mac's mkinitcpio configuration gives once the transaction has +# put the new settings and boot packages' drop-ins in place. +future_hooks() { + local resolved=$1 conf=$2 db=$3 dir=$work/future-conf.d name archive pair + rm -rf "$dir" + install -d -m 700 "$dir" + [[ ! -d $R/etc/mkinitcpio.conf.d ]] || cp -a "$R/etc/mkinitcpio.conf.d/." "$dir/" || { echo "cannot copy the drop-ins"; return 1; } + # The drop-ins of the packages the transaction replaces go with them. + for name in omarchy omarchy-settings omarchy-dev omarchy-settings-dev omarchy-mac-boot; do + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qlq "$name" 2>/dev/null || true + done | sed -n 's|^/etc/mkinitcpio.conf.d/\([^/][^/]*\)$|\1|p' | while IFS= read -r name; do + rm -f -- "$dir/$name" + done + pair=$(channel_pair "$target_channel") + for name in "${pair#* }" omarchy-mac-boot; do + archive=$(fetch_archive "$resolved" "$name" "$conf" "$db") || { echo "$archive"; return 1; } + install -d -m 700 "$work/future-root-$name" + # A package without drop-ins extracts nothing. + bsdtar -xpf "$archive" -C "$work/future-root-$name" --include 'etc/mkinitcpio.conf.d/*' 2>/dev/null || true + [[ ! -d $work/future-root-$name/etc/mkinitcpio.conf.d ]] || cp -a "$work/future-root-$name/etc/mkinitcpio.conf.d/." "$dir/" + done + OMARCHY_MKINITCPIO_CONF_DIR=$dir omarchy-mac-initramfs-hooks 2>/dev/null || { echo "the HOOKS composer failed"; return 1; } +} diff --git a/migrate/src/repairs.sh b/migrate/src/repairs.sh new file mode 100644 index 00000000000..e7b3e1ac79b --- /dev/null +++ b/migrate/src/repairs.sh @@ -0,0 +1,379 @@ +# Official migrations a migrated Mac records as done, and the repairs a fresh +# image does not need. +# shellcheck disable=SC2154 + +# Official migrations a migrated Mac records as done instead of running them, +# as a fresh Mac image has them (reviewed for ticket 53): initramfs and +# boot-chain repairs for the x86 Limine, T2, NVIDIA and linux-omarchy paths, +# whose Mac counterparts are this package's; the Intel Mac Broadcom quirk, which breaks +# Apple Silicon Wi-Fi; and systemd-oomd, which stays off on Macs. Every other official +# migration still pending runs on the next omarchy update, as on any install +# that upgraded. An adapter adds what its cohort already applied, and each +# repair below adds the Mac migration whose work it did. +settled_migrations="1784476564 1784917531 1785273276 1785424256 1785944594 1786137597 1786391100 1786482992 1786605598 1789325478 1789444024" +repaired=$state/repaired +repaired_migrations=() + +# The migrations USER records as done: the common ones, the repairs this +# migration made and the cohort's, comma-separated. +settled_for() { + local dir=$R$2/.local/state/omarchy/migrations + { printf '%s\n' $settled_migrations; cat "$repaired" 2>/dev/null; adapter_hook settled "$dir"; } | awk 'NF' | paste -sd, +} + +# Records the migrations NAMES lists (comma-separated) as done for USER, as +# the user, where they are not recorded yet. +settle_migrations() { + local user=$1 home=$2 names=$3 dir=$R$2/.local/state/omarchy/migrations name + as_user "$user" "$R$home" mkdir -p "$dir" || return 1 + for name in ${names//,/ }; do + [[ -e $dir/$name.sh ]] || as_user "$user" "$R$home" touch "$dir/$name.sh" || return 1 + done +} + +# --- Repairs a fresh image does not need ---------------------------------------- +# +# Macs set up before the runtime or its images carried a fix got it from a +# migration of the runtime they ran. Upstream Omarchy carries none of those +# migrations, so the engine does their work here, as root, for every cohort. +# Each repair can run again from its start and fails the step when it cannot +# finish; a later run repeats it. One that did its work, or found none to do, +# records its migration as done for every user. The target's runtime carries +# the leaves they run (install/config/snapper.sh and locale.sh) and its +# omarchy-mac the keyboard handover; a target +# without one is reported, and that migration is left to the runtime. + +runtime_leaf_present() { + [[ -f $R/usr/share/omarchy/$1 ]] +} + +# A runtime leaf, run whole in a strict shell as the runtime's migrations run them. +run_runtime_leaf() { + local leaf=$R/usr/share/omarchy/$1 + shift + env OMARCHY_PATH="$R/usr/share/omarchy" "$@" bash -euo pipefail "$leaf" +} + +# Snapper's root configuration (migration 1789148088): the asahi-overlay +# install skipped it. The leaf skips a root that is not btrfs; 3 means it +# found a layout it will not touch, left for manual repair, which is final. +repair_snapper() { + local status=0 + if ! runtime_leaf_present install/config/snapper.sh; then + say "This Omarchy has no Snapper setup leaf: the root's Snapper configuration was not checked" + return 0 + fi + run_runtime_leaf install/config/snapper.sh >/dev/null || status=$? + case $status in + 0) repaired_migrations+=(1789148088) ;; + 3) + say "The existing Snapper configuration was left for manual repair" + repaired_migrations+=(1789148088) + ;; + *) die "cannot set up Snapper for the root filesystem" ;; + esac +} + +# Asahi ALARM's bootstrap administrator (migration 1789158179): polkit asks +# for alarm's password while it stays in wheel. It leaves wheel only when +# another existing account is in wheel. Where alarm is itself an Omarchy user, +# the engine leaves the decision to that migration, which skips only alarm's +# own run. +repair_bootstrap_admin() { + local members member others=0 + members=$(awk -F: '$1 == "wheel" { print $4 }' "$R/etc/group" 2>/dev/null) || members="" + if omarchy_users | awk '{ print $1 }' | grep -Fxq alarm; then + say "alarm uses Omarchy here: its wheel membership is left to the runtime's migration" + else + if [[ ,$members, == *,alarm,* ]]; then + IFS=, read -ra members <<<"$members" + for member in "${members[@]}"; do + if [[ -n $member && $member != "alarm" ]] && awk -F: -v user="$member" '$1 == user { found = 1 } END { exit !found }' "$R/etc/passwd"; then + others=1 + fi + done + if (( others )); then + say "Removing Asahi's bootstrap account alarm from wheel" + gpasswd -d alarm wheel >/dev/null || die "cannot remove alarm from wheel" + fi + fi + repaired_migrations+=(1789158179) + fi +} + +# The Intel Mac Broadcom quirk (migration 1789172112): an older runtime wrote +# it on Apple Silicon too, where it breaks the WPA handshake. Only the exact +# block it wrote goes, and what the file held before it stays. The migration +# also required the Wi-Fi chip's PCI ID; on Apple Silicon the block does harm +# whichever chip carries it, so the engine does not. The rebuild it owes is +# recorded first, under the migration's own marker, so an interrupted run of +# either finishes it. +repair_broadcom_block() { + local conf=$R/etc/modprobe.d/brcmfmac.conf pending=$R/var/lib/omarchy/migrations/1789172112-initramfs-pending + local block content rest file + block="# Broadcom's firmware supplicant and authenticator fail the WPA four-way +# handshake on Apple hardware, which surfaces as a rejected password. Disable +# both so wpa_supplicant performs the handshake instead. +options brcmfmac feature_disable=0x82000" + if [[ -f $conf ]]; then + content=$(<"$conf") + if [[ $content == "$block" || $content == *$'\n'"$block" ]]; then + say "Removing the Intel Mac Broadcom quirk from $conf" + install -D -m 644 /dev/null "$pending" && sync "$pending" "$(dirname "$pending")" || + die "cannot record the initramfs rebuild the Broadcom repair needs" + interrupt_for_test mid broadcom + rest=${content%"$block"} + rest=${rest%$'\n'} + if [[ -z $rest && ! -L $conf ]]; then + rm -f -- "$conf" && sync "$(dirname "$conf")" + else + # A link keeps pointing where it did: its target is rewritten. + file=$(readlink -f -- "$conf") || die "cannot resolve $conf" + if [[ -n $rest ]]; then + printf '%s\n' "$rest" + fi | durable_write "$file" + fi || die "cannot remove the Broadcom quirk from $conf" + fi + fi + interrupt_for_test mid broadcom-rebuild + if [[ -f $pending ]]; then + omarchy-mac-boot-update >/dev/null || die "cannot rebuild the boot image without the Broadcom quirk" + rm -f "$pending" + fi + repaired_migrations+=(1789172112) +} + +# A UTF-8 locale (migration 1789146110): Asahi ALARM ships LANG=C. The leaf +# changes only an unset LANG, C or POSIX. +repair_locale() { + if ! runtime_leaf_present install/config/locale.sh; then + say "This Omarchy has no locale setup leaf: the locale was not checked" + return 0 + fi + run_runtime_leaf install/config/locale.sh OMARCHY_LOCALE_CONF="$R/etc/locale.conf" OMARCHY_LOCALE_GEN="$R/etc/locale.gen" >/dev/null || + die "cannot set up the UTF-8 locale" + repaired_migrations+=(1789146110) +} + +# The keyboard's function-key mode (migration 1790327324), handed to +# omarchy-mac. The line Omarchy generated here depends on the fork the Mac +# came from: fnmode=2 from the install leaf, replaced once by mx-mac +# (1790305681, fnmode=3) or quattro-upstream (1789132067, fnmode=1), as any +# of its users' migration records say, mx-mac first as in that migration. +# omarchy-mac-setup-keyboard decides once, and a fork rebuild still owed +# overrides this. +repair_keyboard_mode() { + local generated=2 user home dir + if ! command -v omarchy-mac-setup-keyboard >/dev/null; then + say "This omarchy-mac has no omarchy-mac-setup-keyboard: the keyboard mode was not handed over" + return 0 + fi + while read -r user home; do + [[ -n $user ]] || continue + dir=$R$home/.local/state/omarchy/migrations + if [[ -f $dir/1790305681.sh ]]; then + generated=3 + elif [[ -f $dir/1789132067.sh && $generated == 2 ]]; then + generated=1 + fi + done < <(omarchy_users) + env OMARCHY_MAC_FIXTURE_ROOT="$R" omarchy-mac-setup-keyboard "$generated" >/dev/null || + die "cannot hand the keyboard's function-key mode to omarchy-mac" + repaired_migrations+=(1790327324) +} + +repair_system() { + local output + repaired_migrations=() + repair_snapper + repair_bootstrap_admin + repair_broadcom_block + repair_locale + repair_keyboard_mode + # The Broadcom and keyboard repairs can rebuild the UKI. + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the repairs: $(tail -n 1 <<<"$output")" + printf '%s\n' "${repaired_migrations[@]}" | durable_write "$repaired" || die "cannot record the repairs made" +} + +# --- Fork leftovers -------------------------------------------------------------- +# +# Earlier Apple installs and omarchy-mx-mac wrote these files, which the Mac +# packages now ship as vendor defaults (omarchy-mac retired them itself until +# omacom/omarchy-mac-pkgs da8279b handed that to this migration). A copy that +# is byte for byte the one they wrote goes, kept beside itself as +# NAME.omarchy-mac-retired; an edited copy, a link (a mask included) or a +# different backup stays as it is. + +# The bytes a fork wrote as NAME. +leftover() { + case $1 in + wifi_backend.conf) + cat <<'LEFTOVER' +[device] +wifi.backend=iwd +LEFTOVER + ;; + asahi-notch.conf) + cat <<'LEFTOVER' +options appledrm show_notch=1 +LEFTOVER + ;; + omarchy-wifi-resume-fix.service) + cat <<'LEFTOVER' +[Unit] +Description=Reload brcmfmac if Wi-Fi does not return after resume +After=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +After=NetworkManager.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-wifi-resume-fix +TimeoutStartSec=120 + +[Install] +WantedBy=suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target +LEFTOVER + ;; + asahi-headset-mic.conf) + cat <<'LEFTOVER' +# The 3.5mm headset mic stays in the source list with nothing plugged in. +# Apps often pick it over the built-in array because it advertises a MONO map. +monitor.alsa.rules = [ + { + matches = [ + { node.name = "alsa_input.platform-sound.HiFi__Headset__source" } + ] + actions = { + update-props = { + priority.session = 1 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + asahi-audio-no-suspend-overlay.conf) + cat <<'LEFTOVER' +## Keep the Apple Silicon speaker and headphone outputs open between streams. +## +## PipeWire suspends an idle sink after five seconds. On Apple Silicon Macs that +## closes the ALSA device and powers down the TAS2764 speaker amplifiers (and +## the headphone codec); the next stream reopens the device and the amplifiers +## power back up with an audible pop, so every start and stop of playback +## clicks. A zero timeout keeps that node open so the amplifiers stay powered. +## The companion software-dsp.lua overlay also stops the asahi-audio convolver +## graph from pausing when a client (Chromium, mpv, ...) closes its stream. +## +## Matched by api.alsa.path rather than node.name because the Asahi rules in +## /usr/share/wireplumber/wireplumber.conf.d/99-asahi.conf rename the speaker +## node and hide it behind the per-model filter chain. Device 1 is the speaker +## array and device 0 the headphone jack on every AppleJ model. + +monitor.alsa.rules = [ + { + matches = [ + { + api.alsa.path = "~hw:AppleJ[0-9][0-9][0-9],[01]" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] +LEFTOVER + ;; + *) return 1 ;; + esac +} + +# Writes every leftover into DIR, readable by every user. +write_leftovers() { + local dir=$1 name + install -d -m 755 "$dir" || return 1 + for name in wifi_backend.conf asahi-notch.conf omarchy-wifi-resume-fix.service asahi-headset-mic.conf asahi-audio-no-suspend.conf asahi-audio-no-suspend-overlay.conf; do + leftover "$name" >"$dir/$name" && chmod 644 "$dir/$name" || return 1 + done +} + +# FILE goes when it is the regular file ORIGINAL holds byte for byte. Fails +# when a backup that differs is in the way. +retire_copy() { + local file=$1 original=$2 + [[ -f $file && ! -L $file ]] && cmp -s "$file" "$original" || return 0 + if [[ -e $file.omarchy-mac-retired || -L $file.omarchy-mac-retired ]]; then + if ! cmp -s "$file" "$file.omarchy-mac-retired"; then + echo "$file.omarchy-mac-retired differs from $file; move it aside and run the migration again" >&2 + return 1 + fi + rm -- "$file" + else + mv -- "$file" "$file.omarchy-mac-retired" + fi +} + +# The machine's leftovers: the Wi-Fi backend and notch settings, and the Wi-Fi +# resume unit, whose enablement links into /etc are pointed at the vendor unit +# omarchy-mac ships. +retire_system_leftovers() { + local dir=$state/leftovers unit=omarchy-wifi-resume-fix.service target link + write_leftovers "$dir" || die "cannot stage the fork's leftover files" + retire_copy "$R/etc/NetworkManager/conf.d/wifi_backend.conf" "$dir/wifi_backend.conf" && + retire_copy "$R/etc/modprobe.d/asahi-notch.conf" "$dir/asahi-notch.conf" && + retire_copy "$R/etc/systemd/system/$unit" "$dir/$unit" || die "cannot retire the fork's leftover files" + if [[ ! -e $R/etc/systemd/system/$unit && ! -L $R/etc/systemd/system/$unit ]] && + cmp -s "$R/etc/systemd/system/$unit.omarchy-mac-retired" "$dir/$unit"; then + for target in suspend hibernate hybrid-sleep suspend-then-hibernate; do + link=$R/etc/systemd/system/$target.target.wants/$unit + if [[ -L $link && $(readlink "$link") == "/etc/systemd/system/$unit" ]]; then + ln -sfn "/usr/lib/systemd/system/$unit" "$link" || die "cannot point $link at the vendor unit" + fi + done + fi +} + +# A user's leftovers: the WirePlumber policies the fork copied into each +# user's configuration, retired as that user. +retire_user_leftovers() { + local user=$1 home=$2 dir=$state/leftovers policies=$R$2/.config/wireplumber/wireplumber.conf.d + [[ -d $policies && ! -L $policies ]] || return 0 + [[ -d $dir ]] || write_leftovers "$dir" || return 1 + # shellcheck disable=SC2016 # expanded by the user's shell + as_user "$user" "$R$home" bash -c "$(declare -f retire_copy)"' + retire_copy "$1/asahi-headset-mic.conf" "$2/asahi-headset-mic.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend.conf" && + retire_copy "$1/asahi-audio-no-suspend.conf" "$2/asahi-audio-no-suspend-overlay.conf"' _ "$policies" "$dir" +} diff --git a/migrate/src/target.sh b/migrate/src/target.sh new file mode 100644 index 00000000000..252176998b1 --- /dev/null +++ b/migrate/src/target.sh @@ -0,0 +1,380 @@ +# The target: which channel this Mac moves to, the packages and pacman +# configuration it ends with, and whether that channel is ready for Macs. +# shellcheck disable=SC2034,SC2154 + +# The Omarchy packaging key omarchy-keyring carries. +official_key=40DFB630FF42BCFFB047046CF0134EE680CAC571 +# Trust the converged system never keeps: the forks' repositories and keys +# (omarchy-mac's rc4 channel key and mx-mac's). Adapters add their own. +retired_repos=(omarchy-aarch64) +retired_keys=(FBD6874D423C418DDB6D143EECE19CDDE306DBD2 C81AC3E2A99556F9B21D5FEA3DD49BC9F8360BDC) +# The repositories the core configuration defines; any other one is the +# administrator's and is kept. +core_repos="omarchy asahi-alarm core extra alarm aur" +candidate_repo=omarchy-mac-candidate +admin_target=$R/etc/omarchy-mac/migration-target +# The image builder's test-image pin (omarchy-mac-installer +# image-builder/builder/test_image_pin.py): its first line, a reason line and +# the IgnorePkg line. +test_pin_mark="# Test image only (omarchy-mac-installer image-builder)" +guard_mark="# omarchy-mac-migrate: a migration to Omarchy's packages is in progress." + +# The runtime pair a channel's Macs run: Omarchy's edge builds its runtime from +# the development branch as omarchy-dev. +channel_pair() { + if [[ $1 == "edge" ]]; then + echo "omarchy-dev omarchy-settings-dev" + else + echo "omarchy omarchy-settings" + fi +} + +# Every package a migrated Mac takes from its channel's [omarchy]. +channel_packages() { + echo "$(channel_pair "$1") omarchy-mac omarchy-mac-boot linux-aurora linux-aurora-headers m1n1-aurora uboot-asahi limine-mkinitcpio-hook" +} + +# Installed or refreshed in the same transaction, from whichever repository +# carries them: the keyrings official trust comes from. +keyring_packages="asahi-alarm-keyring omarchy-keyring" + +# Held back with the transaction's packages while the migration is in +# progress: the rest of the boot chain they build on. +guarded_boot="limine limine-snapper-sync asahi-scripts mkinitcpio" + +# key=value lines, comments and blank lines ignored, anything else refused. +# format=1 +# type=repository | candidate-set +# channel=stable | rc | edge the [omarchy] channel after the switch +# server=URL optional; https://pkgs.omarchy.org//$arch +# keyring=FINGERPRINT optional; the Omarchy packaging key +# packages=NAME... repository only; optional +# set=DIR candidate-set: the set's files, manifest.json and signing.json +# fingerprint=FINGERPRINT candidate-set: the only key its signatures may carry +load_target() { + local file=$1 frozen=${2:-} line key value format="" packages="" + target_type="" target_channel="" target_server="" target_keyring=$official_key + target_set="" target_fingerprint="" target_repo=omarchy + trusted "$file" || refuse "refusing the target $file: it must be a regular file owned by root and writable only by root" + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + [[ $line == *=* ]] || refuse "the target $file is malformed: $line" + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + type) target_type=$value ;; + channel) target_channel=$value ;; + server) target_server=$value ;; + keyring) target_keyring=${value^^} ;; + packages) packages=$value ;; + set) target_set=${value%/} ;; + fingerprint) target_fingerprint=${value^^} ;; + *) refuse "the target $file has an unknown key: $key" ;; + esac + done <"$file" + [[ $format == "1" ]] || refuse "the target $file is not format=1" + [[ $target_channel =~ ^(stable|rc|edge)$ ]] || refuse "the target $file names no channel (stable, rc or edge)" + if [[ -z $target_server ]]; then + target_server="https://pkgs.omarchy.org/$target_channel/\$arch" + # Unprivileged tests serve the channels themselves. + if (( fixture )) && [[ -n ${OMARCHY_MAC_MIGRATE_SERVER:-} ]]; then + target_server=${OMARCHY_MAC_MIGRATE_SERVER//@channel@/$target_channel} + fi + fi + [[ $target_server =~ ^(https|file):// ]] || refuse "the target server must be https:// or file://: $target_server" + [[ $target_keyring =~ ^[0-9A-F]{40}$ ]] || refuse "the target keyring must be a 40-digit fingerprint" + target_packages=${packages:-$(channel_packages "$target_channel")} + case $target_type in + repository) + target_id="repository $target_server" + ;; + candidate-set) + [[ $target_fingerprint =~ ^[0-9A-F]{40}$ ]] || refuse "a candidate-set target needs its signer's 40-digit fingerprint" + target_repo=$candidate_repo + if [[ -n $frozen ]]; then + # After preflight only the verified copy counts; the original may be gone. + target_set=$set_copy + else + [[ $target_set == /* ]] && trusted "$target_set" || refuse "the candidate set $target_set must be a root-owned directory writable only by root" + [[ -f $target_set/manifest.json ]] || refuse "the candidate set has no manifest.json" + fi + candidate_identity "$target_set" || refuse "cannot read the candidate manifest" + ;; + *) + refuse "the target $file has no type (repository or candidate-set)" + ;; + esac +} + +# A candidate set's packages join the channel's: what the set carries comes +# from it, the rest of the Mac set from the channel's [omarchy]. +candidate_identity() { + local names + names=$(jq -r '.packages[].name' "$1/manifest.json") || return 1 + target_packages=$( { printf '%s\n' $(channel_packages "$target_channel"); printf '%s\n' "$names"; } | awk '!seen[$0]++' | xargs) && + target_id="candidate-set $(jq -r '.set' "$1/manifest.json") $(jq -r '.set_sha256' "$1/manifest.json")" +} + +# How the transaction names NAME: from the candidate set when it carries it, +# else from [omarchy]. +target_spec() { + if [[ $target_type == "candidate-set" ]] && jq -e --arg name "$1" '.packages[] | select(.name == $name)' "$target_set/manifest.json" >/dev/null; then + printf '%s/%s\n' "$candidate_repo" "$1" + else + printf 'omarchy/%s\n' "$1" + fi +} + +# --target, else the administrator's target. +find_target() { + local candidate + for candidate in "$@" "$admin_target"; do + if [[ -n $candidate && -e $candidate ]]; then + printf '%s\n' "$candidate" + return + fi + done +} + +# The channel this Mac's own configuration follows: an omarchy-mac lane +# ([omarchy-aarch64] on omarchy-mac/omarchy-pkgs-aarch64's releases, which +# quattro names after the channel), else an official [omarchy]. Anything else +# is unknown. +config_channel() { + local conf=$1 lane official + lane=$(section_servers "$conf" omarchy-aarch64 | sed -nE 's#^https://github\.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/(stable|rc|edge)/?$#\1#p' | sort -u) + official=$(section_servers "$conf" omarchy | sed -nE 's#^https://pkgs\.omarchy\.org/(stable|rc|edge)/(\$arch|aarch64)/?$#\1#p' | sort -u) + if [[ -n $(section_servers "$conf" omarchy-aarch64) ]]; then + [[ -n $lane && $lane != *$'\n'* ]] && printf '%s\n' "$lane" + elif [[ -n $official && $official != *$'\n'* ]]; then + printf '%s\n' "$official" + else + return 1 + fi +} + +# The Server values of SECTION in CONF. +section_servers() { + awk -v want="$2" ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == want && /^[[:space:]]*Server[[:space:]]*=/ { value = $0; sub(/^[^=]*=[[:space:]]*/, "", value); sub(/[[:space:]]+$/, "", value); print value }' "$1" +} + +# The channel this Mac follows, or nothing when it cannot be told. An mx-mac +# Mac follows the fork's channel record; the rest follow their configuration. +detect_channel() { + local cohort=$1 conf=$2 channel="" + if [[ $cohort == "mx-mac" ]]; then + if command -v omarchy-apple-silicon-channel >/dev/null; then + channel=$(omarchy-apple-silicon-channel current 2>/dev/null) || channel="" + fi + else + channel=$(config_channel "$conf") || channel="" + fi + [[ $channel =~ ^(stable|rc|edge)$ ]] && printf '%s\n' "$channel" +} + +# A repository target for CHANNEL, written to FILE: what a Mac with no +# administrator's target moves to. +write_channel_target() { + printf 'format=1\ntype=repository\nchannel=%s\n' "$1" >"$2" + chmod 644 "$2" +} + +# The core Apple Silicon configuration (omacom/omarchy #13362, +# default/pacman/apple-silicon/pacman-edge.conf), with SERVER for [omarchy]. +# Unprivileged tests name their own Asahi ALARM server. +core_pacman_conf() { + local asahi=https://github.com/asahi-alarm/asahi-alarm/releases/download/aarch64 + (( ! fixture )) || asahi=${OMARCHY_MAC_MIGRATE_ASAHI_SERVER:-$asahi} + cat < 0 && /^#/ { skip--; next } + skip > 0 && /^[[:space:]]*IgnorePkg[[:space:]]*=/ { skip = 0; next } + { skip = 0 } + /^[[:space:]]*(#|$)/ { next } + { key = $0; sub(/^[[:space:]]*/, "", key); sub(/[[:space:]]*=.*$/, "", key); sub(/[[:space:]]+$/, "", key); if (!(key in core)) print }' "$1" +} + +# The test-image pin block of CONF, as it is written. +test_pin_block() { + awk -v pin="$test_pin_mark" ' + index($0, pin) == 1 { keep = 3 } + keep > 0 { print; keep-- }' "$1" +} + +# CONF's repositories that are neither the core ones nor retired, whole. +admin_repositories() { + local drop + drop="$core_repos ${retired_repos[*]} $candidate_repo" + awk -v drop="$drop" ' + BEGIN { n = split(drop, list, " "); for (i = 1; i <= n; i++) skip[list[i]] = 1; skip["options"] = 1 } + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); keep = !(name in skip) } + keep { print }' "$1" +} + +# The configuration after the switch: the core one for the target, with the +# administrator's own options and repositories kept. Applying it to its own +# output changes nothing. +future_pacman_conf() { + local conf=$1 options repositories + options=$(admin_options "$conf") + repositories=$(admin_repositories "$conf") + core_pacman_conf "$target_server" | awk -v options="$options" ' + { print } + /^LocalFileSigLevel/ && options != "" { print ""; print "# Kept from this Mac'"'"'s configuration"; print options }' + if [[ -n $repositories ]]; then + printf '\n%s\n' "$repositories" + fi +} + +# CONF with this migration's guard as the first lines of [options], and the +# test-image pin of OLD kept below it: until the package transaction is done, +# a plain pacman -Syu leaves every package the migration changes alone. +guarded_pacman_conf() { + local conf=$1 old=$2 names=$3 pin + pin=$(test_pin_block "$old") + awk -v guard="$guard_mark" -v names="$names" -v pin="$pin" ' + { print } + /^\[options\][[:space:]]*$/ && !done { + print guard " It removes these lines when its package transaction is done; sudo omarchy-mac-migrate run finishes it." + print "IgnorePkg = " names + if (pin != "") print pin + done = 1 + }' "$conf" +} + +# Administrator IgnorePkg entries (globs, as pacman reads them) matching a +# package this migration installs or removes, and IgnoreGroup entries holding +# one, one per line. +pinned_targets() { + local conf=$1 names=$2 db=$3 pattern name group member + for pattern in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnorePkg[[:space:]]*=//p'); do + for name in $names; do + # shellcheck disable=SC2053 # IgnorePkg takes globs + [[ $name != $pattern ]] || printf '%s\n' "$name" + done + done + for group in $(admin_options "$conf" | sed -nE 's/^[[:space:]]*IgnoreGroup[[:space:]]*=//p'); do + for member in $(LC_ALL=C pacman --config "$4" --dbpath "$db" -Sgq "$group" 2>/dev/null); do + [[ " $names " != *" $member "* ]] || printf '%s (group %s)\n' "$member" "$group" + done + done +} + +# An Include in [options] or an option the switch cannot keep as it is. +unsupported_options() { + awk ' + /^[[:space:]]*\[[^]]+\][[:space:]]*$/ { name = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", name); next } + name == "options" && /^[[:space:]]*Include[[:space:]]*=/ { print "an Include in [options] (" $0 ")" }' "$1" +} + +# --- Whether the channel is ready for Macs ----------------------------------- +# +# A channel takes Macs once its [omarchy] carries the Mac packages built from +# omacom/omarchy-mac-pkgs with a runtime that drives them. That is read from +# the signed archives the transaction would install, not from repository +# metadata: the runtime ships the lifecycle dispatcher, and omarchy-mac-boot +# ships its setup-boot and update-verify operations and no migration engine of +# its own. Until then every Mac on the channel defers. + +# The Mac packages the channel's repositories lack, one reason a line. +presence_problems() { + local listing name + listing=$(LC_ALL=C pacman --config "$1" --dbpath "$2" -Sl 2>/dev/null | awk '{ print $2 }' | LC_ALL=C sort -u) + for name in $(channel_pair "$target_channel") omarchy-mac omarchy-mac-boot linux-aurora m1n1-aurora uboot-asahi; do + grep -Fxq "$name" <<<"$listing" || echo "the $target_channel channel has no $name for Apple Silicon yet" + done +} + +# What the verified archives of the resolved runtime and omarchy-mac-boot +# lack, one reason a line. +archive_problems() { + local resolved=$1 conf=$2 db=$3 runtime listing + runtime=$(channel_pair "$target_channel") + runtime=${runtime%% *} + if listing=$(fetch_archive "$resolved" "$runtime" "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/bin/omarchy-lifecycle-dispatch' <<<"$listing" || + echo "the $target_channel channel's $runtime has no omarchy-lifecycle-dispatch to drive the Mac packages yet" + excluded_files "$listing" + else + echo "$listing" + fi + if listing=$(fetch_archive "$resolved" omarchy-mac-boot "$conf" "$db"); then + listing=$(bsdtar -tf "$listing" 2>/dev/null | sed 's|^\./||') + grep -qx 'usr/lib/omarchy/mac-boot/setup-boot' <<<"$listing" && grep -qx 'usr/lib/omarchy/mac-boot/update-verify' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot has no setup-boot and update-verify operations yet" + ! grep -qx 'usr/lib/omarchy-mac/boot/migrate-engine.sh' <<<"$listing" || + echo "the $target_channel channel's omarchy-mac-boot is not built from omacom/omarchy-mac-pkgs yet" + excluded_files "$listing" + else + echo "$listing" + fi +} + +# The administrator's NoExtract and NoUpgrade globs that keep a file of the +# migration's runtime or boot package (LISTING) from being installed as built. +excluded_files() { + local listing=$1 pattern path + while read -r pattern; do + [[ -n $pattern && $pattern != !* ]] || continue + while IFS= read -r path; do + [[ -n $path && $path != */ ]] || continue + # shellcheck disable=SC2053 # NoExtract and NoUpgrade take globs + if [[ $path == $pattern ]]; then + echo "NoExtract or NoUpgrade ($pattern) in $pacman_conf keeps $path from the packages the migration installs; remove it first" + break + fi + done <<<"$listing" + done < <(admin_options "$pacman_conf" | sed -nE 's/^[[:space:]]*(NoExtract|NoUpgrade)[[:space:]]*=[[:space:]]*//p' | tr ' ' '\n') +} diff --git a/migrate/src/users.sh b/migrate/src/users.sh new file mode 100644 index 00000000000..ca154e9f684 --- /dev/null +++ b/migrate/src/users.sh @@ -0,0 +1,149 @@ +# What a fresh install sets up, done for a migrated Mac: its default packages, +# the Mac services, the repairs and, for every Omarchy user, the settled +# migrations, the units first run enables and the user setup. +# shellcheck disable=SC2154 + +# The default packages the aarch64 and Apple lists add, where they are missing +# and a repository carries them (the base list's applications stay the owner's +# choice). Firmware among them rebuilds the initramfs and the UKI through +# pacman's hooks, so the boot files are checked again. +install_defaults() { + local generic apple available name missing=() absent=() output + if ! command -v omarchy-pkg-defaults >/dev/null; then + say "This Omarchy has no omarchy-pkg-defaults: the default packages were not checked" + return 0 + fi + generic=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults generic) && + apple=$(env OMARCHY_PATH="$R/usr/share/omarchy" omarchy-pkg-defaults apple-silicon) || + die "cannot read the Apple Silicon default packages" + available=$(LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Sl | awk '{ print $2 }') || + die "cannot read the repositories' packages" + while read -r name; do + [[ -n $name ]] && ! grep -Fxq -- "$name" <<<"$generic" || continue + LC_ALL=C pacman --config "$pacman_conf" --dbpath "$pacman_db" -Qq "$name" >/dev/null 2>&1 && continue + if grep -Fxq -- "$name" <<<"$available"; then + missing+=("$name") + else + absent+=("$name") + fi + done <<<"$apple" + (( ${#absent[@]} == 0 )) || say "No repository carries these default packages, so they stay missing: ${absent[*]}" + (( ${#missing[@]} )) || return 0 + say "Installing the default packages a fresh install has: ${missing[*]}" + pacman_run --config "$pacman_conf" --dbpath "$pacman_db" -S --noconfirm "${missing[@]}" || + die "cannot install the default packages: ${missing[*]}" + output=$(boot_check_pending linux-aurora 2>&1) || die "the boot files do not check after the default packages: $(tail -n 1 <<<"$output")" +} + +# --- User setup ------------------------------------------------------------------ +# +# Each Omarchy user gets the settled migrations, the units first run enables +# and the Mac user setup. What fails for one user (a broken home, a setup that +# exits nonzero) never stops the migration: it is kept in user-pending, a +# "user item" line each, and runs again at every later run and boot until it +# succeeds. The post-reboot unit stays enabled for that. + +# One item of a user's setup: settle:NAMES, a unit first run enables, +# retire-leftovers or setup-user. A pending settle keeps the names it was +# given, so a retry after the plan moved on records the same ones. +apply_user_item() { + local user=$1 home=$2 item=$3 + case $item in + settle:*) settle_migrations "$user" "$home" "${item#settle:}" ;; + retire-leftovers) retire_user_leftovers "$user" "$home" ;; + setup-user) as_user "$user" "$R$home" omarchy-lifecycle-dispatch setup-user >/dev/null ;; + *) enable_user_unit "$user" "$home" "$item" ;; + esac +} + +# The user's setup; prints what failed, one item a line. +setup_user() { + local user=$1 home=$2 item items=("settle:$(settled_for "$user" "$home")") + if [[ -f $plan/user-units ]]; then + for item in $fresh_user_units; do + grep -Fxq "$item" "$plan/user-units" || items+=("$item") + done + fi + for item in "${items[@]}" retire-leftovers setup-user; do + apply_user_item "$user" "$home" "$item" || printf '%s\n' "$item" + done +} + +# Replaces the pending record with FILE's lines, or removes it when FILE is empty. +record_user_pending() { + if [[ -s $1 ]]; then + LC_ALL=C sort -u "$1" | durable_write "$user_pending" || die "cannot record the pending user setup" + else + rm -f "$user_pending" + fi +} + +# "user item; ..." for messages, a settle item without its names. +pending_summary() { + awk '{ item = $2; sub(/:.*/, "", item); print $1 " " item }' "$user_pending" | paste -sd';' | sed 's/;/; /g' +} + +# Runs the pending items again, only those, so nothing a user turned off since +# comes back. An account that is gone or no longer uses Omarchy is dropped. +# Fails while any item is still pending. +retry_user_pending() { + local user home item left + [[ -s $user_pending ]] || return 0 + rm -f "$state"/user-pending.?????? + left=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + while read -r user item; do + home=$(omarchy_users | awk -v user="$user" '$1 == user { print $2; exit }') + [[ -n $home && -n $item ]] || continue + apply_user_item "$user" "$home" "$item" >"$left" + done <"$user_pending" + record_user_pending "$left" + rm -f "$left" + if [[ -s $user_pending ]]; then + say "User setup still pending, retried at the next run or boot: $(pending_summary)" + return 1 + fi + say "The pending user setup is done" +} + +# Outside a completed migration's cleanup: pending user setup runs again, and +# once none is left the post-reboot unit is released unless a migration is +# waiting for its reboot. +retry_user_pending_now() { + [[ -s $user_pending ]] || return 0 + # A migration still under way keeps the unit that resumes it. + if retry_user_pending && [[ ! -e $reboot_pending ]] && ! past_boundary; then + release_verify_unit + fi +} + +# A migrated Mac ends as a fresh install does: with its default packages, the +# Mac services the image's hardware setup enables, the repairs above and, for +# every Omarchy user, the migrations a fresh image records as done, the units +# first run enables and the Mac user setup. A unit the Mac already had before +# the migration is taken to be off by choice and stays off; a plan frozen +# before that was recorded enables none. The reboot that follows brings up +# what probes only at boot, such as the video decoder. +step_defaults() { + local user home item pending + install_defaults + interrupt_for_test mid defaults + retire_system_leftovers + omarchy-lifecycle-dispatch setup-system >/dev/null || die "setup-system (omarchy-lifecycle-dispatch) could not set up the Mac's services" + repair_system + interrupt_for_test mid user-setup + # What an earlier migration left pending stays pending until it succeeds. + retry_user_pending || : + rm -f "$state"/user-pending.?????? + pending=$(mktemp "$state/user-pending.XXXXXX") || die "cannot record the pending user setup" + [[ ! -f $user_pending ]] || cat "$user_pending" >"$pending" + while read -r user home; do + [[ -n $user ]] || continue + while read -r item; do + [[ -n $item ]] || continue + say "Could not apply $item for $user; it runs again after the reboot" + printf '%s %s\n' "$user" "$item" >>"$pending" + done < <(setup_user "$user" "$home" >"$fixture/pacman.log" +case $1 in + luksHeaderBackup) echo "LUKS header of $2" >"$4" ;; + luksUUID) cat "$fixture/luks-uuid" 2>/dev/null ;; +esac diff --git a/test/fixtures/mac-migrate/bin/df b/test/fixtures/mac-migrate/bin/df new file mode 100755 index 00000000000..bf143a3b366 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/df @@ -0,0 +1,3 @@ +#!/bin/bash +echo Avail +if [[ -e ${MIGRATE_FIXTURE:?}/df-low ]]; then echo 1000; else echo 999999999999; fi diff --git a/test/fixtures/mac-migrate/bin/findmnt b/test/fixtures/mac-migrate/bin/findmnt new file mode 100755 index 00000000000..0f0bc77e274 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/findmnt @@ -0,0 +1,7 @@ +#!/bin/bash +fixture=${MIGRATE_FIXTURE:?} +case "$*" in + *--mountpoint*) grep -Fxq "${!#}" "$fixture/mounts" ;; + *SOURCE*) cat "$fixture/root-source" ;; + *) exit 1 ;; +esac diff --git a/test/fixtures/mac-migrate/bin/gpasswd b/test/fixtures/mac-migrate/bin/gpasswd new file mode 100755 index 00000000000..1bd789f1e38 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/gpasswd @@ -0,0 +1,7 @@ +#!/bin/bash +# gpasswd -d USER GROUP: drops USER from GROUP's members in the fixture's /etc/group. +fixture=${MIGRATE_FIXTURE:?} +echo "gpasswd $*" >>"$fixture/boot.log" +[[ ! -e $fixture/gpasswd-fail && $1 == "-d" ]] || exit 1 +group=${OMARCHY_MAC_MIGRATE_ROOT:?}/etc/group +awk -F: -v OFS=: -v user="$2" -v name="$3" '$1 == name { n = split($4, m, ","); $4 = ""; for (i = 1; i <= n; i++) if (m[i] != user) $4 = $4 ($4 == "" ? "" : ",") m[i] } { print }' "$group" >"$group.new" && mv "$group.new" "$group" diff --git a/test/fixtures/mac-migrate/bin/gpg b/test/fixtures/mac-migrate/bin/gpg new file mode 100755 index 00000000000..e2fa9688926 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/gpg @@ -0,0 +1,19 @@ +#!/bin/bash +# Answers key listings for the fixture's pacman keyring from its keys file and +# runs the real gpg for everything else (candidate set verification). +set -euo pipefail +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +home="" +args=("$@") +for (( i = 0; i < ${#args[@]}; i++ )); do + [[ ${args[i]} != "--homedir" ]] || home=${args[i + 1]} +done +if [[ -n $home && -f $home/keys ]]; then + fpr=${!#} + line=$(grep "^$fpr " "$home/keys") || exit 2 + echo "pub:${line#* }:255:22:${fpr:24}:::::::scESC:" + echo "fpr:::::::::$fpr:" + exit 0 +fi +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec env PATH="$(tr ':' '\n' <<<"$PATH" | grep -vx "$here" | paste -sd:)" gpg "$@" diff --git a/test/fixtures/mac-migrate/bin/limine-update b/test/fixtures/mac-migrate/bin/limine-update new file mode 100755 index 00000000000..9892a4291a7 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/limine-update @@ -0,0 +1,7 @@ +#!/bin/bash +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +echo "limine-update" >>"${MIGRATE_FIXTURE:?}/boot.log" +mkdir -p "$root/boot/efi/EFI/Linux" +echo "uki" >"$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" +grep -Fq 'omarchy_linux-aurora.efi' "$root/boot/efi/limine.conf" 2>/dev/null || + echo " image_path: boot():/EFI/Linux/omarchy_linux-aurora.efi" >>"$root/boot/efi/limine.conf" diff --git a/test/fixtures/mac-migrate/bin/locale b/test/fixtures/mac-migrate/bin/locale new file mode 100755 index 00000000000..ee4f17e2281 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/locale @@ -0,0 +1,3 @@ +#!/bin/bash +# locale -a: the locales the fixture has generated. +cat "${MIGRATE_FIXTURE:?}/locales" 2>/dev/null || printf 'C\nPOSIX\n' diff --git a/test/fixtures/mac-migrate/bin/locale-gen b/test/fixtures/mac-migrate/bin/locale-gen new file mode 100755 index 00000000000..95e106bd4d8 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/locale-gen @@ -0,0 +1,4 @@ +#!/bin/bash +fixture=${MIGRATE_FIXTURE:?} +echo "locale-gen $*" >>"$fixture/boot.log" +[[ ! -e $fixture/locale-gen-fail ]] diff --git a/test/fixtures/mac-migrate/bin/log-command b/test/fixtures/mac-migrate/bin/log-command new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/log-command @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/lsblk b/test/fixtures/mac-migrate/bin/lsblk new file mode 100755 index 00000000000..f6b040051e4 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/lsblk @@ -0,0 +1,2 @@ +#!/bin/bash +cat "${MIGRATE_FIXTURE:?}/lsblk" diff --git a/test/fixtures/mac-migrate/bin/lsinitcpio b/test/fixtures/mac-migrate/bin/lsinitcpio new file mode 100755 index 00000000000..f5f827a51c4 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/lsinitcpio @@ -0,0 +1,4 @@ +#!/bin/bash +# lsinitcpio -l IMAGE: the fixture image is its own listing. +[[ ${1:-} == "-l" && -f ${2:-} ]] || exit 1 +grep -v '^kernel \|^HOOKS ' "$2" diff --git a/test/fixtures/mac-migrate/bin/mkinitcpio b/test/fixtures/mac-migrate/bin/mkinitcpio new file mode 100755 index 00000000000..0f157f83d87 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/mkinitcpio @@ -0,0 +1,27 @@ +#!/bin/bash +# mkinitcpio -p KERNEL (or -P for linux-aurora): writes /boot/initramfs-KERNEL.img +# as a listing of what the resolved HOOKS put in an image, and fails as the +# preset does without /boot/vmlinuz-KERNEL. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +echo "mkinitcpio $*" >>"$fixture/boot.log" +[[ ! -e $fixture/mkinitcpio-fail ]] || { echo "==> ERROR: mkinitcpio failed" >&2; exit 1; } +kernel=linux-aurora +[[ ${1:-} != "-p" ]] || kernel=$2 +[[ -f $root/boot/vmlinuz-$kernel ]] || { echo "==> ERROR: invalid kernel specified: '/boot/vmlinuz-$kernel'" >&2; exit 1; } +hooks=$(omarchy-mac-initramfs-hooks) || { echo "==> ERROR: cannot read the HOOKS" >&2; exit 1; } +{ + echo "kernel $(sha256sum <"$root/boot/vmlinuz-$kernel" | cut -d' ' -f1)" + echo "HOOKS $hooks" + if [[ " $hooks " == *" systemd "* ]]; then + echo usr/lib/systemd/systemd + if [[ " $hooks " == *" sd-encrypt "* ]]; then + echo usr/lib/systemd/system-generators/systemd-cryptsetup-generator + echo usr/bin/systemd-cryptsetup + fi + else + echo init_functions + for hook in $hooks; do echo "hooks/$hook"; done + fi +} >"$root/boot/initramfs-$kernel.img" diff --git a/test/fixtures/mac-migrate/bin/mount b/test/fixtures/mac-migrate/bin/mount new file mode 100755 index 00000000000..92dbb6b5f11 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/mount @@ -0,0 +1,23 @@ +#!/bin/bash +# mount MOUNTPOINT, as fstab describes it. The fixture's only mountable device +# is the ESP ($MIGRATE_FIXTURE/esp-device), whose files live in +# $MIGRATE_FIXTURE/esp: mounting it links the mountpoint there and keeps the +# directory it covers in $MIGRATE_FIXTURE/covered. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +target=${!#} +relative=${target#"$root"} +echo "mount $relative" >>"$fixture/boot.log" +device=$(awk -v point="$relative" '$1 !~ /^#/ && $2 == point { print $1; exit }' "$root/etc/fstab") +[[ -n $device ]] || { echo "mount: $relative: can't find in /etc/fstab" >&2; exit 1; } +[[ $device == "$(cat "$fixture/esp-device")" ]] || { echo "mount: $device: special device does not exist" >&2; exit 32; } +if grep -q . "$fixture/mounts" 2>/dev/null; then + echo "mount: the ESP is already mounted at $(head -n 1 "$fixture/mounts")" >&2 + exit 32 +fi +[[ -d $target && ! -L $target ]] || { echo "mount: $relative: mount point does not exist" >&2; exit 32; } +mkdir -p "$fixture/covered" +mv "$target" "$fixture/covered/${relative//\//_}" +ln -s "$fixture/esp" "$target" +echo "$target" >>"$fixture/mounts" diff --git a/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-boot-check b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-boot-check new file mode 100755 index 00000000000..e7088b51d68 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-boot-check @@ -0,0 +1,7 @@ +#!/bin/bash +fixture=${MIGRATE_FIXTURE:?} +echo "boot-check ${OMARCHY_BOOT_CHECK_ALLOW_PENDING_REBOOT:+pending }$*" >>"$fixture/boot.log" +if [[ -e $fixture/boot-check-fail ]]; then + echo "Apple Silicon boot check: $(cat "$fixture/boot-check-fail")" >&2 + exit 1 +fi diff --git a/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-channel b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-channel new file mode 100755 index 00000000000..a02fe86bb7a --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-apple-silicon-channel @@ -0,0 +1,6 @@ +#!/bin/bash +# The mx-mac fork's channel command: `current` prints $MIGRATE_FIXTURE/channel +# and fails, as the fork's does, when there is no channel record. +fixture=${MIGRATE_FIXTURE:?} +[[ ${1:-} == "current" && -f $fixture/channel ]] || exit 1 +cat "$fixture/channel" diff --git a/test/fixtures/mac-migrate/bin/omarchy-drive-recover b/test/fixtures/mac-migrate/bin/omarchy-drive-recover new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-drive-recover @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-hw-platform b/test/fixtures/mac-migrate/bin/omarchy-hw-platform new file mode 100755 index 00000000000..ad246f75c9b --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-hw-platform @@ -0,0 +1,2 @@ +#!/bin/bash +cat "${MIGRATE_FIXTURE:?}/platform" diff --git a/test/fixtures/mac-migrate/bin/omarchy-lifecycle-dispatch b/test/fixtures/mac-migrate/bin/omarchy-lifecycle-dispatch new file mode 100755 index 00000000000..5c98ee591fd --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-lifecycle-dispatch @@ -0,0 +1,44 @@ +#!/bin/bash +# The new runtime's dispatcher, with omarchy-mac-boot's and omarchy-mac's +# operations standing in: logs the call and fails while the fixture holds +# -fail. setup-boot activates Limine on a Mac that opted in (the +# gate file), as the package's limine-boot.sh does; setup-user runs as the +# user, never as root. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +operation=${1:?} +shift +if [[ $operation == "setup-user" ]]; then + echo "dispatch $operation HOME=$HOME" >>"$fixture/boot.log" +else + echo "dispatch $operation${*:+ $*}" >>"$fixture/boot.log" +fi +[[ ! -e $fixture/$operation-fail ]] || { echo "$operation: failed in the fixture" >&2; exit 1; } +case $operation in + setup-boot) + [[ -e $root/var/lib/omarchy/limine.enabled ]] || exit 0 + if [[ -e $fixture/limine-activation-fail ]]; then + echo "limine-boot: limine-update failed; activation failed" >&2 + exit 1 + fi + # A fixture holding setup-boot-leaf activates Limine with that script + # instead (the legacy suite's, which reads the real boot files). + [[ ! -f $fixture/setup-boot-leaf ]] || exec bash "$fixture/setup-boot-leaf" + echo "limine-boot activate" >>"$fixture/boot.log" + printf 'KERNEL_CMDLINE[default]="root=UUID=x"\n' >"$root/etc/default/limine" + # Killed half way through the switch, before the UKI exists. + if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "loader-leaf" && ! -e $fixture/killed-in-leaf ]]; then + : >"$fixture/killed-in-leaf" + kill -9 "$PPID" $$ + fi + limine-update + cp "$root/usr/share/limine/BOOTAA64.EFI" "$root/boot/efi/EFI/BOOT/BOOTAA64.EFI" + ;; + update-verify) + if [[ -e $fixture/boot-check-fail ]]; then + echo "Apple Silicon boot check: $(cat "$fixture/boot-check-fail")" >&2 + exit 1 + fi + ;; +esac diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-boot-update b/test/fixtures/mac-migrate/bin/omarchy-mac-boot-update new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-boot-update @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-esp b/test/fixtures/mac-migrate/bin/omarchy-mac-esp new file mode 100755 index 00000000000..f8734020def --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-esp @@ -0,0 +1,10 @@ +#!/bin/bash +# The system ESP is the fixture's /boot/efi, else its /boot, when listed as mounted. +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +if grep -Fxq "$root/boot/efi" "${MIGRATE_FIXTURE:?}/mounts"; then + echo /boot/efi +elif grep -Fxq "$root/boot" "$MIGRATE_FIXTURE/mounts"; then + echo /boot +else + exit 1 +fi diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-initramfs-hooks b/test/fixtures/mac-migrate/bin/omarchy-mac-initramfs-hooks new file mode 100755 index 00000000000..6d8d1174154 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-initramfs-hooks @@ -0,0 +1,11 @@ +#!/bin/bash +# The fixture's HOOKS file, else the real resolver over the fixture root's +# mkinitcpio.conf and drop-ins. +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +if [[ -f $fixture/hooks ]]; then + cat "$fixture/hooks" + exit 0 +fi +exec env OMARCHY_MKINITCPIO_CONF="$root/etc/mkinitcpio.conf" OMARCHY_MKINITCPIO_CONF_DIR="$root/etc/mkinitcpio.conf.d" \ + OMARCHY_MKINITCPIO_PRESET_DIR="$root/etc/mkinitcpio.d" OMARCHY_MKINITCPIO_KERNEL=linux-aurora "$root/usr/bin/omarchy-mac-initramfs-hooks" diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-limine-cmdline b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-cmdline new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-cmdline @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-limine-deploy b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-deploy new file mode 100755 index 00000000000..281290af939 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-limine-deploy @@ -0,0 +1,4 @@ +#!/bin/bash +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +echo "omarchy-mac-limine-deploy" >>"${MIGRATE_FIXTURE:?}/boot.log" +cp "$root/usr/share/limine/BOOTAA64.EFI" "$root/boot/efi/EFI/BOOT/BOOTAA64.EFI" diff --git a/test/fixtures/mac-migrate/bin/omarchy-mac-setup-keyboard b/test/fixtures/mac-migrate/bin/omarchy-mac-setup-keyboard new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-mac-setup-keyboard @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/omarchy-pkg-defaults b/test/fixtures/mac-migrate/bin/omarchy-pkg-defaults new file mode 100755 index 00000000000..2623b3088d9 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/omarchy-pkg-defaults @@ -0,0 +1,8 @@ +#!/bin/bash +# Stands in for the target runtime's command: the generic list's applications +# and the Apple Silicon additions (#13362's install/omarchy-apple-silicon.packages). +case ${1:-} in + generic) printf '%s\n' obs-studio zram-generator hyprland ;; + apple-silicon) printf '%s\n' omarchy-mac omarchy-mac-boot asahi-bless avd-fw libva-v4l2_request-avd widevine wf-recorder ;; + *) exit 2 ;; +esac diff --git a/test/fixtures/mac-migrate/bin/pacman b/test/fixtures/mac-migrate/bin/pacman new file mode 100755 index 00000000000..6442c7e2025 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/pacman @@ -0,0 +1,408 @@ +#!/bin/bash +# A pacman stand-in for the migration fixtures. A package is "name version"; the +# local database is /local/packages and a sync database is +# /sync/.db, both lists of packages. Repositories are file:// +# directories holding .db. $MIGRATE_FIXTURE/conflicts lists +# "package conflicting-package..." for every repository. +# +# Legacy fixtures opt into more of pacman: +# - $MIGRATE_FIXTURE/files/ lists the paths package installs; +# /local/files records "package path" for what is installed. A +# transaction refuses to write over a path that exists unless a package it +# replaces owns it or --overwrite names it, then writes its files. A path in +# $MIGRATE_FIXTURE/backups is a backup= file: one no package owns is never a +# conflict; one whose contents are not a package's own copy stays as it is, +# and when no package owned it the package's copy lands as .pacnew. A +# --dbonly transaction records the paths and writes none, as pacman does. +# - With $MIGRATE_FIXTURE/verify-signatures, a sync database line may carry a +# third column, the key that signed the package; where the repository's +# SigLevel requires signatures, the key must be trusted in --gpgdir's keys. +# - $MIGRATE_FIXTURE/depends lists "package version dependency..." for -R. +# - With $MIGRATE_FIXTURE/kernel-hook, a transaction runs the kernel's install +# hook: the removed Apple kernel leaves /boot, and linux-aurora's image is +# copied there and its initramfs built. +set -euo pipefail + +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +config=$root/etc/pacman.conf +dbpath=$root/var/lib/pacman +gpgdir=$root/etc/pacman.d/gnupg +cachedirs=() overwrites=() ignored=() dbonly=0 ask="" format="" op="" flags="" args=() +while (( $# )); do + case $1 in + --config) config=$2; shift 2 ;; + --dbpath) dbpath=$2; shift 2 ;; + --cachedir) cachedirs+=("$2"); shift 2 ;; + --gpgdir) gpgdir=$2; shift 2 ;; + --overwrite) overwrites+=("$2"); shift 2 ;; + --ignore=*) IFS=, read -ra more <<<"${1#--ignore=}"; ignored+=("${more[@]}"); shift ;; + --logfile) shift 2 ;; + --noconfirm) shift ;; + --ask) ask=$2; shift 2 ;; + --dbonly) dbonly=1; shift ;; + --print-format) format=$2; shift 2 ;; + --*) echo "fake pacman: unknown option $1" >&2; exit 1 ;; + -Q* | -S* | -R*) op=${1:1:1}; flags=${1:2}; shift ;; + *) args+=("$1"); shift ;; + esac +done +files_db=$dbpath/local/files +[[ ! -d $fixture/files || -f $files_db ]] || : >"$files_db" + +# A path is written over only when nothing is there, a package the +# transaction replaces owns it, or an --overwrite glob matches it. +may_overwrite() { + local pattern + for pattern in ${overwrites[@]+"${overwrites[@]}"}; do + # shellcheck disable=SC2053 # a glob, as pacman matches it + [[ $1 == $pattern ]] && return 0 + done + return 1 +} + +remove_files() { # package [upgraded: an upgrade keeps backup= files for the new version] + local path + [[ -f $files_db ]] || return 0 + while read -r path; do + [[ -z ${2:-} ]] || ! grep -Fxq "$path" "$fixture/backups" 2>/dev/null || continue + [[ -d $root$path && ! -L $root$path ]] || rm -f "$root$path" + done < <(awk -v name="$1" '$1 == name { sub(/^[^ ]+ /, ""); print }' "$files_db") + awk -v name="$1" '$1 != name' "$files_db" >"$files_db.new" && mv "$files_db.new" "$files_db" +} + +# Repositories and their servers, Include files read under the fixture root. +repositories() { + awk -v root="$root" ' + function scan(file, line) { while ((getline line < file) > 0) handle(line); close(file) } + function handle(line, value) { + if (line ~ /^\[/) { name = line; gsub(/[][]/, "", name) } + else if (line ~ /^Include = /) { value = line; sub(/^Include = /, "", value); scan(root value) } + else if (line ~ /^Server = / && name != "options") { value = line; sub(/^Server = /, "", value); gsub(/\$repo/, name, value); print name, value } + } + { handle($0) }' "$config" +} + +declare -A version=() +while read -r name ver; do + [[ -z $name ]] || version[$name]=$ver +done <"$dbpath/local/packages" + +if [[ $op == "Q" && $flags == *l* ]]; then + if [[ $flags == *p* ]]; then + base=${args[0]##*/} + base=${base%%.pkg.tar.*} + base=${base%-*} + base=${base%-*} + [[ ! -f $fixture/files/${base%-*} ]] || cat "$fixture/files/${base%-*}" + else + # Like pacman, a named package that is not installed is an error. + for name in ${args[@]+"${args[@]}"}; do + [[ -n ${version[$name]:-} ]] || { echo "error: package '$name' was not found" >&2; exit 1; } + done + if [[ -f $files_db ]]; then + if (( ${#args[@]} )); then + awk 'NR == FNR { want[$0]; next } $1 in want' <(printf '%s\n' "${args[@]}") "$files_db" | + if [[ $flags == *q* ]]; then sed 's/^[^ ]* //'; else cat; fi + elif [[ $flags == *q* ]]; then sed 's/^[^ ]* //' "$files_db"; else cat "$files_db"; fi + fi + fi + exit 0 +fi + +if [[ $op == "R" ]]; then + for name in "${args[@]}"; do + [[ -n ${version[$name]:-} ]] || { echo "error: target not found: $name" >&2; exit 1; } + done + if [[ -f $fixture/depends ]]; then + while read -r name ver deps; do + [[ ${version[$name]:-} == "$ver" && " ${args[*]} " != *" $name "* ]] || continue + for dep in $deps; do + [[ " ${args[*]} " != *" $dep "* ]] || + { echo "error: failed to prepare transaction (could not satisfy dependencies)" >&2; echo ":: removing $dep breaks dependency '$dep' required by $name" >&2; exit 1; } + done + done <"$fixture/depends" + fi + if (( ! dbonly )); then + [[ ! -e $dbpath/db.lck ]] || { echo "error: failed to init transaction (unable to lock database)" >&2; exit 1; } + : >"$dbpath/db.lck" + echo "remove ${args[*]}" >>"$fixture/pacman.log" + for name in "${args[@]}"; do remove_files "$name"; done + elif [[ -f $files_db ]]; then + for name in "${args[@]}"; do + awk -v name="$name" '$1 != name' "$files_db" >"$files_db.new" && mv "$files_db.new" "$files_db" + done + fi + for name in "${args[@]}"; do unset "version[$name]"; done + for name in "${!version[@]}"; do + echo "$name ${version[$name]}" + done | LC_ALL=C sort >"$dbpath/local/packages" + if (( ! dbonly )); then + echo "hooks" >>"$fixture/pacman.log" + rm -f "$dbpath/db.lck" + fi + exit 0 +fi + +if [[ $op == "Q" ]]; then + if (( ${#args[@]} )); then + for name in "${args[@]}"; do + # Like pacman, a name no package has is answered by an installed provider + # ($MIGRATE_FIXTURE/provides lists "package provided-name..."). + if [[ -z ${version[$name]:-} && -f $fixture/provides ]]; then + provider=$(awk -v wanted="$name" '{ for (i = 2; i <= NF; i++) if ($i == wanted) print $1 }' "$fixture/provides" | + while read -r candidate; do [[ -z ${version[$candidate]:-} ]] || { echo "$candidate"; break; }; done) + name=${provider:-$name} + fi + [[ -n ${version[$name]:-} ]] || { echo "error: package '$name' was not found" >&2; exit 1; } + [[ $flags == *q* ]] && echo "$name" || echo "$name ${version[$name]}" + done + else + for name in $(printf '%s\n' "${!version[@]}" | LC_ALL=C sort); do + [[ $flags == *q* ]] && echo "$name" || echo "$name ${version[$name]}" + done + fi + exit 0 +fi +[[ $op == "S" ]] || { echo "fake pacman: unsupported operation" >&2; exit 1; } +# Groups: none in the fixtures. +[[ $flags != *g* ]] || exit 0 + +if [[ $flags == *y* ]]; then + mkdir -p "$dbpath/sync" + while read -r repo server; do + source_db=${server#file://}/$repo.db + [[ $server == file://* && -f $source_db ]] || { echo "error: failed to synchronize all databases ($repo)" >&2; exit 1; } + cp "$source_db" "$dbpath/sync/$repo.db" + if [[ -f $source_db.sig ]]; then + cp "$source_db.sig" "$dbpath/sync/$repo.db.sig" + else + rm -f "$dbpath/sync/$repo.db.sig" + fi + done < <(repositories) + [[ $flags == *u* || ${#args[@]} -gt 0 ]] || exit 0 +fi + +# A database signature reads "signed "; pacman rejects a +# database beside one that does not match it. +while read -r repo server; do + sig=$dbpath/sync/$repo.db.sig + [[ ! -f $sig || $(<"$sig") == "signed $(sha256sum "$dbpath/sync/$repo.db" | cut -d' ' -f1)" ]] || + { echo "error: database '$repo' is not valid (invalid or corrupted database (PGP signature))" >&2; exit 1; } +done < <(repositories) + +lookup() { # name [repo] -> "repo version" + local name=$1 wanted=${2:-} repo server + while read -r repo server; do + [[ -z $wanted || $repo == "$wanted" ]] || continue + [[ -f $dbpath/sync/$repo.db ]] || continue + awk -v repo="$repo" -v name="$name" '$1 == name { print repo, $2; found = 1; exit } END { exit !found }' "$dbpath/sync/$repo.db" && return 0 + done < <(repositories) + return 1 +} + +if [[ $flags == *l* ]]; then + while read -r repo server; do + [[ ${#args[@]} == 0 || $repo == "${args[0]}" ]] || continue + [[ -f $dbpath/sync/$repo.db ]] && awk -v repo="$repo" '{ print repo, $1, $2 }' "$dbpath/sync/$repo.db" + done < <(repositories) + exit 0 +fi + +newer() { + [[ $1 != "$2" && $(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n 1) == "$1" ]] +} + +# The SigLevel each repository answers to: its own, or the global one. +siglevel() { + awk -v wanted="$1" ' + /^\[/ { name = $0; gsub(/[][]/, "", name) } + /^SigLevel = / { value = $0; sub(/^SigLevel = /, "", value); if (name == "options") global = value; else level[name] = value } + END { print ((wanted in level) ? level[wanted] : global) }' "$config" +} + +verify_signatures() { + local name repo signer level + [[ -e $fixture/verify-signatures ]] || return 0 + for name in "${!changed[@]}"; do + repo=${changed[$name]} + level=" $(siglevel "$repo") " + [[ ! $level =~ \ (Package)?(Optional|Never|TrustAll)\ ]] || continue + signer=$(awk -v name="$name" -v ver="${version[$name]}" '$1 == name && $2 == ver { print $3; exit }' "$dbpath/sync/$repo.db") + if [[ -z $signer ]] || ! grep -Eq "^$signer [fu]\$" "$gpgdir/keys"; then + echo "error: $name: signature from \"${signer:-nobody}\" is unknown trust" >&2 + echo "error: failed to commit transaction (invalid or corrupted package (PGP signature))" >&2 + exit 1 + fi + done +} + +declare -A changed=() original=() +for name in "${!version[@]}"; do + original[$name]=${version[$name]} +done +if [[ $flags == *u* ]]; then + for name in "${!version[@]}"; do + [[ " ${ignored[*]} " != *" $name "* ]] || continue + found=$(lookup "$name") || continue + if newer "${found#* }" "${version[$name]}"; then + version[$name]=${found#* } + changed[$name]=${found% *} + fi + done +fi +for target in "${args[@]}"; do + name=${target#*/} repo="" + [[ $target != */* ]] || repo=${target%%/*} + found=$(lookup "$name" "$repo") || { echo "error: target not found: $target" >&2; exit 1; } + version[$name]=${found#* } + changed[$name]=${found% *} +done +provides() { # package name: $MIGRATE_FIXTURE/provides lists "package provided-name..." + [[ -f $fixture/provides ]] && awk -v p="$1" -v n="$2" '$1 == p { for (i = 2; i <= NF; i++) if ($i == n) found = 1 } END { exit !found }' "$fixture/provides" +} +# Two packages of the transaction in conflict: like pacman, keep the one that +# provides the other and drop the other from the targets. +drop() { # package kept + echo "warning: removing '$1' from target list because it conflicts with '$2'" >&2 + if [[ -n ${original[$1]:-} ]]; then version[$1]=${original[$1]}; else unset "version[$1]"; fi + unset "changed[$1]" +} +if [[ -f $fixture/conflicts ]]; then + while read -r name conflicting; do + [[ -n ${changed[$name]:-} ]] || continue + for other in $conflicting; do + if [[ -n ${changed[$other]:-} ]]; then + if provides "$other" "$name"; then + drop "$name" "$other" + continue + elif provides "$name" "$other"; then + # The dropped package, still installed, conflicts with the target. + drop "$other" "$name" + else + continue + fi + fi + [[ -n ${version[$other]:-} && -z ${changed[$other]:-} ]] || continue + [[ $ask == "4" ]] || { echo "error: unresolvable package conflicts detected" >&2; exit 1; } + unset "version[$other]" + done + done <"$fixture/conflicts" +fi + +if [[ $flags == *p* ]]; then + for name in $(printf '%s\n' "${!changed[@]}" | LC_ALL=C sort); do + line=${format//%r/${changed[$name]}} + line=${line//%n/$name} + echo "${line//%v/${version[$name]}}" + done + exit 0 +fi +if [[ $flags == *w* ]]; then + verify_signatures + for name in "${!changed[@]}"; do + file="$name-${version[$name]}-aarch64.pkg.tar.zst" + for dir in "${cachedirs[@]}"; do + [[ ! -f $dir/$file ]] || continue 2 + done + # A package whose archive the fixture built is that archive. + if [[ -f $fixture/archives/$name ]]; then + cp "$fixture/archives/$name" "${cachedirs[0]}/$file" + else + echo "fake" >"${cachedirs[0]}/$file" + fi + echo "download $name ${version[$name]}" >>"$fixture/pacman.log" + done + exit 0 +fi +if (( ! dbonly )); then + [[ ! -e $fixture/fail-transaction ]] || { echo "error: failed to commit transaction" >&2; exit 1; } + [[ ! -e $dbpath/db.lck ]] || { echo "error: failed to init transaction (unable to lock database)" >&2; exit 1; } + verify_signatures + if [[ -d $fixture/files ]]; then + declare -A owner=() + while read -r name path; do owner[$path]=$name; done <"$files_db" + clashes=() + for name in "${!changed[@]}"; do + [[ -f $fixture/files/$name ]] || continue + while read -r path; do + [[ -e $root$path || -L $root$path ]] || continue + [[ ! -d $root$path || -L $root$path ]] || continue + holder=${owner[$path]:-} + if [[ -n $holder && ( -n ${changed[$holder]:-} || -z ${version[$holder]:-} ) ]]; then + continue + fi + [[ -n $holder ]] || ! grep -Fxq "$path" "$fixture/backups" 2>/dev/null || continue + may_overwrite "$path" && [[ -z $holder ]] && continue + clashes+=("$name: $path exists in filesystem${holder:+ (owned by $holder)}") + done <"$fixture/files/$name" + done + if (( ${#clashes[@]} )); then + echo "error: failed to commit transaction (conflicting files)" >&2 + printf '%s\n' "${clashes[@]}" >&2 + exit 1 + fi + fi + : >"$dbpath/db.lck" + echo "transaction ${args[*]}" >>"$fixture/pacman.log" + # A package scriptlet that arms first boot, as a fresh image's would. + if [[ -e $fixture/scriptlet-arms-first-boot ]]; then + mkdir -p "$root/var/lib/omarchy/mac-first-boot" + : >"$root/var/lib/omarchy/mac-first-boot/pending" + fi +fi +if (( ! dbonly )) && [[ -d $fixture/files ]]; then + cp "$files_db" "$files_db.before" + for name in $(awk '{ print $1 }' "$files_db" | sort -u); do + [[ -n ${version[$name]:-} && -z ${changed[$name]:-} ]] || remove_files "$name" ${changed[$name]:+upgraded} + done + for name in "${!changed[@]}"; do + [[ -f $fixture/files/$name ]] || continue + while read -r path; do + mkdir -p "$(dirname "$root$path")" + echo "$name $path" >>"$files_db" + if [[ -f $root$path && $(<"$root$path") != "$name "* ]] && grep -Fxq "$path" "$fixture/backups" 2>/dev/null; then + [[ -n ${owner[$path]:-} ]] || echo "$name ${version[$name]}" >"$root$path.pacnew" + continue + fi + rm -f "$root$path" + echo "$name ${version[$name]}" >"$root$path" + # Killed part way through extraction: files on disk, none recorded yet. + if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "extraction" && ! -e $fixture/killed-in-extraction ]]; then + : >"$fixture/killed-in-extraction" + mv "$files_db.before" "$files_db" + kill -9 "$PPID" $$ + fi + done <"$fixture/files/$name" + done + rm -f "$files_db.before" +fi +if (( dbonly )) && [[ -d $fixture/files && -f $files_db ]]; then + awk 'NR == FNR { keep[$1]; next } $1 in keep' \ + <(echo "-"; for name in "${!version[@]}"; do [[ -n ${changed[$name]:-} ]] || echo "$name"; done) "$files_db" >"$files_db.new" + for name in "${!changed[@]}"; do + [[ ! -f $fixture/files/$name ]] || sed "s|^|$name |" "$fixture/files/$name" >>"$files_db.new" + done + mv "$files_db.new" "$files_db" +fi +for name in "${!version[@]}"; do + echo "$name ${version[$name]}" +done | LC_ALL=C sort >"$dbpath/local/packages" +if (( ! dbonly )); then + # Killed after its database write, before its hooks: the lock stays behind. + if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "transaction" && ! -e $fixture/killed-in-pacman ]]; then + : >"$fixture/killed-in-pacman" + kill -9 "$PPID" $$ + fi + if [[ -e $fixture/kernel-hook ]]; then + for name in linux-asahi linux-aurora; do + [[ -n ${version[$name]:-} ]] || rm -f "$root/boot/vmlinuz-$name" "$root/boot/initramfs-$name.img" + done + if [[ -n ${version[linux-aurora]:-} ]]; then + cp "$root"/usr/lib/modules/*-aurora/vmlinuz "$root/boot/vmlinuz-linux-aurora" + mkinitcpio -p linux-aurora >/dev/null + fi + fi + echo "hooks" >>"$fixture/pacman.log" + rm -f "$dbpath/db.lck" +fi diff --git a/test/fixtures/mac-migrate/bin/pacman-key b/test/fixtures/mac-migrate/bin/pacman-key new file mode 100755 index 00000000000..8741ddcab29 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/pacman-key @@ -0,0 +1,36 @@ +#!/bin/bash +# pacman-key for the fixtures: /keys lists "fingerprint validity". +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +gpgdir=$root/etc/pacman.d/gnupg +action="" args=() +while (( $# )); do + case $1 in + --gpgdir) gpgdir=$2; shift 2 ;; + --keyserver) shift 2 ;; + --populate | --recv-keys | --lsign-key | --delete) action=$1; shift ;; + *) args+=("$1"); shift ;; + esac +done +keys=$gpgdir/keys +set_key() { + grep -v "^$1 " "$keys" >"$keys.new" || true + [[ -z $2 ]] || echo "$1 $2" >>"$keys.new" + mv "$keys.new" "$keys" +} +if [[ $gpgdir == "$root/etc/pacman.d/gnupg" ]]; then + echo "pacman-key $action ${args[*]}" >>"$fixture/pacman.log" +else + echo "copy pacman-key $action ${args[*]}" >>"$fixture/pacman.log" +fi +case $action in + --populate) + for name in "${args[@]}"; do + while read -r fpr; do set_key "$fpr" f; done <"$root/usr/share/pacman/keyrings/$name-trusted" + done + ;; + --recv-keys) [[ -f $fixture/keyserver/${args[0]} ]] || exit 1; set_key "${args[0]}" - ;; + --lsign-key) grep -q "^${args[0]} " "$keys" || exit 1; set_key "${args[0]}" f ;; + --delete) set_key "${args[0]}" "" ;; +esac diff --git a/test/fixtures/mac-migrate/bin/repo-add b/test/fixtures/mac-migrate/bin/repo-add new file mode 100755 index 00000000000..83a338d5f81 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/repo-add @@ -0,0 +1,15 @@ +#!/bin/bash +# repo-add for the fixtures: indexes name-version-release-arch.pkg.tar.* files. +set -euo pipefail +[[ $1 == "-q" ]] && shift +db=$1 +shift +for file in "$@"; do + [[ $file == *.pkg.tar.* && $file != *.sig ]] || { echo "==> ERROR: '$file' is not a package file" >&2; exit 1; } + base=${file##*/} + base=${base%%.pkg.tar.*} + base=${base%-*} + release=${base##*-} + base=${base%-*} + echo "${base%-*} ${base##*-}-$release" +done >"${db%.db.tar.gz}.db" diff --git a/test/fixtures/mac-migrate/bin/sudo b/test/fixtures/mac-migrate/bin/sudo new file mode 100755 index 00000000000..0b830fd4640 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/sudo @@ -0,0 +1,3 @@ +#!/bin/bash +# Runtime leaves call sudo when they do not run as root; the fixture is theirs. +exec "$@" diff --git a/test/fixtures/mac-migrate/bin/systemctl b/test/fixtures/mac-migrate/bin/systemctl new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/systemctl @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/umount b/test/fixtures/mac-migrate/bin/umount new file mode 100755 index 00000000000..0505a22f5c0 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/umount @@ -0,0 +1,18 @@ +#!/bin/bash +# umount MOUNTPOINT: the ESP's link goes and the directory it covered comes back. +set -euo pipefail +fixture=${MIGRATE_FIXTURE:?} +root=${OMARCHY_MAC_MIGRATE_ROOT:?} +target=${!#} +relative=${target#"$root"} +echo "umount $relative" >>"$fixture/boot.log" +[[ ! -e $fixture/umount-busy ]] || { echo "umount: $relative: target is busy." >&2; exit 32; } +grep -Fxq "$target" "$fixture/mounts" && [[ -L $target ]] || { echo "umount: $relative: not mounted" >&2; exit 32; } +rm "$target" +if [[ -d $fixture/covered/${relative//\//_} ]]; then + mv "$fixture/covered/${relative//\//_}" "$target" +else + mkdir -p "$target" +fi +grep -Fxv "$target" "$fixture/mounts" >"$fixture/mounts.new" || true +mv "$fixture/mounts.new" "$fixture/mounts" diff --git a/test/fixtures/mac-migrate/bin/update-grub b/test/fixtures/mac-migrate/bin/update-grub new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/update-grub @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/bin/update-m1n1 b/test/fixtures/mac-migrate/bin/update-m1n1 new file mode 100755 index 00000000000..ce44c0166d2 --- /dev/null +++ b/test/fixtures/mac-migrate/bin/update-m1n1 @@ -0,0 +1,6 @@ +#!/bin/bash +# Stands in for a command the engine only calls: logs the call, and fails while +# the fixture holds -fail. +fixture=${MIGRATE_FIXTURE:?} +echo "${0##*/} $*" >>"$fixture/boot.log" +[[ ! -e $fixture/${0##*/}-fail ]] diff --git a/test/fixtures/mac-migrate/lib.sh b/test/fixtures/mac-migrate/lib.sh new file mode 100644 index 00000000000..038dc5f76ac --- /dev/null +++ b/test/fixtures/mac-migrate/lib.sh @@ -0,0 +1,206 @@ +# Shared by the omarchy-mac-migrate suites (test/shell.d/mac-migrate-*-test.sh). +# +# Each case builds a fixture root (a Mac's pacman configuration, package +# database, keyring, /boot and ESP) and runs bin/omarchy-mac-migrate +# unprivileged against it, with pacman, the keyring, the boot tools, the new +# runtime's dispatcher and the device probes replaced by the stand-ins in +# test/fixtures/mac-migrate/bin. Candidate sets are signed with a disposable +# key by the real gpg; the archives preflight reads (the runtime and +# omarchy-mac-boot) are real tar files. +# shellcheck disable=SC2034 + +require_command gpg +require_command gpgv +require_command jq +require_command flock +require_command bsdtar + +# The failure's evidence follows its description. +fail() { + printf 'not ok - %s\n' "$1" >&2 + [[ -z ${2:-} ]] || printf '%s\n' "$2" >&2 + exit 1 +} + +tmp=$(mktemp -d) +trap 'for home in signer other subkey-home; do gpgconf --homedir "$tmp/$home" --kill gpg-agent 2>/dev/null; done; rm -rf "$tmp"' EXIT +stubs=$ROOT/test/fixtures/mac-migrate/bin +tool=$ROOT/bin/omarchy-mac-migrate +steps=(preflight backup keyring prefetch repositories transaction boot-chain loader defaults verify unpin reboot retire) +official=40DFB630FF42BCFFB047046CF0134EE680CAC571 + +make_key() { + mkdir -m 700 "$tmp/$1" + gpg --batch --homedir "$tmp/$1" --pinentry-mode loopback --passphrase '' \ + --quick-gen-key "Migration test $1" ed25519 sign 1d 2>/dev/null + gpg --batch --homedir "$tmp/$1" --with-colons --list-secret-keys 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }' +} +signer=$(make_key signer) +other=$(make_key other) +gpg --batch --homedir "$tmp/signer" --armor --export "$signer" >"$tmp/signer.asc" 2>/dev/null +gpg --batch --homedir "$tmp/other" --armor --export "$other" >"$tmp/other.asc" 2>/dev/null + +# make_archive NAME VERSION FILE: a package archive pacman and bsdtar can read, +# with the files a release ships that preflight looks for. Other packages are +# noise. +make_archive() { + local name=$1 version=$2 file=$3 dir + dir=$(mktemp -d) + printf 'pkgname = %s\npkgver = %s\n' "$name" "$version" >"$dir/.PKGINFO" + case $name in + omarchy-dev | omarchy) + install -D -m 755 /dev/null "$dir/usr/bin/omarchy-lifecycle-dispatch" + ;; + omarchy-mac-boot) + install -D -m 755 /dev/null "$dir/usr/lib/omarchy/mac-boot/setup-boot" + install -D -m 755 /dev/null "$dir/usr/lib/omarchy/mac-boot/update-verify" + for command in omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-esp omarchy-mac-kernel; do + install -D -m 755 /dev/null "$dir/usr/bin/$command" + done + ;; + esac + if [[ $name == omarchy-dev || $name == omarchy || $name == omarchy-mac-boot ]]; then + bsdtar -czf "$file" -C "$dir" .PKGINFO usr + else + head -c 512 /dev/urandom >"$file" + fi + rm -rf "$dir" +} + +# The packages of a fixture candidate set: the edge runtime pair (or SET_PAIR) +# and the Mac set. +set_packages() { + cat </dev/null + done < <(set_packages) + printf '%s\n' "${entries[@]}" | jq -s '{schema: 1, set: "apple-test-fixture", packages: .}' >"$dir/manifest.json.new" + jq --arg digest "$(jq -r '.packages[] | "\(.name) \(.version) \(.filename) \(.sha256)"' "$dir/manifest.json.new" | LC_ALL=C sort | sha256sum | cut -d' ' -f1)" \ + '.set_sha256 = $digest' "$dir/manifest.json.new" >"$dir/manifest.json" + rm "$dir/manifest.json.new" + resign_set "$dir" "$key_home" +} + +resign_set() { + local dir=$1 key_home=$2 fpr + fpr=$(gpg --batch --homedir "$key_home" --with-colons --list-secret-keys 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }') + jq -n --slurpfile manifest "$dir/manifest.json" --arg fpr "$fpr" --arg sha "$(sha256sum "$dir/manifest.json" | cut -d' ' -f1)" \ + '{schema: 1, manifest_sha256: $sha, set_sha256: $manifest[0].set_sha256, signer: {fingerprint: $fpr}, + signatures: [$manifest[0].packages[] | {file: .filename}]}' >"$dir/signing.json" + rm -f "$dir/signing.json.sig" + gpg --batch --homedir "$key_home" --detach-sign --no-armor -o "$dir/signing.json.sig" "$dir/signing.json" 2>/dev/null + gpg --batch --homedir "$key_home" --armor --export "$fpr" >"$dir/candidate-signing-key.asc" 2>/dev/null +} + +make_set "$tmp/set" "$tmp/signer" + +# repo DIR [NAME]: a file:// repository in $F/repos/DIR whose database is NAME.db. +repo() { + mkdir -p "$F/repos/$1" + cat >"$F/repos/$1/${2:-$1}.db" +} + +# archive NAME VERSION: the archive a repository target's NAME downloads as. +archive() { + mkdir -p "$F/archives" + make_archive "$1" "$2" "$F/archives/$1" +} + +# The ALARM repositories the core configuration names, empty unless given, and +# the mirror list they come from. +alarm_repos() { + local name + for name in core extra alarm aur; do + [[ -f $F/repos/$name/$name.db ]] || repo "$name" "$R/etc/pacman.d/mirrorlist" +} + +# The relations between the fixtures' packages, as pacman resolves them. +relations() { + printf 'linux-aurora linux-asahi\nm1n1-aurora m1n1\nlinux-aurora-headers linux-asahi-headers\nomarchy-dev omarchy\nomarchy-settings-dev omarchy-settings\n' >"$F/conflicts" + printf 'omarchy-dev omarchy\nomarchy-settings-dev omarchy-settings\n' >"$F/provides" +} + +migrate() { + OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F OMARCHY_MAC_MIGRATE_ASAHI_SERVER="file://$F/repos/asahi-alarm" \ + OMARCHY_MAC_MIGRATE_SERVER="file://$F/repos/official-@channel@" PATH="$stubs:$PATH" "$tool" "$@" +} + +# migrate_env VAR=VALUE... -- ARGS: migrate with extra environment. +migrate_env() { + local extra=() + while [[ $1 != "--" ]]; do + extra+=("$1") + shift + done + shift + env "${extra[@]}" OMARCHY_MAC_MIGRATE_ROOT="$R" MIGRATE_FIXTURE="$F" OMARCHY_MAC_MIGRATE_ASAHI_SERVER="file://$F/repos/asahi-alarm" \ + OMARCHY_MAC_MIGRATE_SERVER="file://$F/repos/official-@channel@" PATH="$stubs:$PATH" "$tool" "$@" +} + +reboot_into_aurora() { + echo boot-2 >"$R/proc/sys/kernel/random/boot_id" + echo 7.1.12-aurora >"$R/proc/sys/kernel/osrelease" +} + +state_dir() { + printf '%s\n' "$R/var/lib/omarchy-mac/migration" +} + +# Snapshot of the fixture a refused or failed run must leave as it was. +fixture_digest() { + (cd "$R" && find . -path ./var/tmp -prune -o -path ./run/lock -prune -o -path ./var/lib/omarchy-mac/migration/deferred -prune -o -type f -print0 | + LC_ALL=C sort -z | xargs -0 sha256sum) | sha256sum +} + +finish() { + local output + output=$(migrate run 2>&1) || fail "the resumed migration runs to its reboot" "$output" + if [[ ! -f $(state_dir)/complete ]]; then + reboot_into_aurora + output=$(migrate verify 2>&1) || fail "the migration finishes after its reboot" "$output" + fi + [[ -f $(state_dir)/complete ]] || fail "the migration completes" "$(cat "$(state_dir)/journal")" +} + +kill_after() { # step + local output + output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_AFTER="$1" -- run 2>&1) && fail "the run is killed after $1" "$output" + return 0 +} + +# refused DESCRIPTION REASON-PATTERN: the run defers (75) with nothing changed. +refused() { + local status=0 output digest + digest=$(fixture_digest) + output=$(migrate run 2>&1) || status=$? + (( status == 75 )) || fail "$1: preflight refuses" "status $status: $output" + grep -q -- "$2" <<<"$output" || fail "$1: the refusal says why" "$output" + [[ ! -e $(state_dir)/journal ]] || fail "$1: no migration is started" + [[ $(fixture_digest) == "$digest" ]] || fail "$1: nothing on the system changed" + ! grep -q '^transaction\|^pacman-key' "$F/pacman.log" || fail "$1: no transaction or change to the live keyring ran" +} diff --git a/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-initramfs-hooks b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-initramfs-hooks new file mode 100755 index 00000000000..7f7571223ea --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-initramfs-hooks @@ -0,0 +1,143 @@ +#!/bin/bash + +# omarchy:summary=Print the mkinitcpio HOOKS the Apple Silicon kernel's initramfs is built with +# omarchy:hidden=true + +# Resolves the configuration the way mkinitcpio -P does for the kernel's +# default preset: a default_config or ALL_config in the preset is passed as +# -c and disables the drop-ins, as does a -c in default_options, whose -A +# and -S add and skip hooks (parsed with mkinitcpio's option table); otherwise /etc/mkinitcpio.conf is joined by +# every /etc/mkinitcpio.conf.d/*.conf in version order (LC_ALL=C.UTF-8 +# sort -V, as mkinitcpio sorts them) and sourced as one file. Prints HOOKS on +# one line and exits 0, or exits 1 when the configuration cannot be read or +# sets no HOOKS, so a caller can tell "busybox" from "unknown". + +set -euo pipefail + +conf=${OMARCHY_MKINITCPIO_CONF:-/etc/mkinitcpio.conf} +conf_dir=${OMARCHY_MKINITCPIO_CONF_DIR:-$conf.d} +preset_dir=${OMARCHY_MKINITCPIO_PRESET_DIR:-/etc/mkinitcpio.d} +kernel=${OMARCHY_MKINITCPIO_KERNEL:-$(omarchy-mac-kernel 2>/dev/null || echo linux-asahi)} + +drop_ins=1 +add_hooks=() skip_hooks=() + +# mkinitcpio's own option table (its _opt_short and _opt_long): options that +# take a value, and the ones that decide the hooks. +short_with_value='AcDIgHkprStUdz' +short_flags='hnLMPRsVv' +long_options=(add: addhooks: include: config: generate: hookdir: hookhelp: help kernel: listhooks automods + moduleroot: nocolor nopost allpresets preset: remove skiphooks: save generatedir: builddir: version verbose + compress: uki: uefi: microcode: splash: kernelimage: uefistub: cmdline: osrelease: no-cmdline ukiconfig: no-ukify) + +hook_option() { + local option=$1 value=$2 list + IFS=, read -ra list <<<"$value" + case $option in + c | config) conf=$value; drop_ins=0 ;; + A | add | addhooks) add_hooks+=("${list[@]}") ;; + S | skiphooks) skip_hooks+=("${list[@]}") ;; + esac +} + +# Parses the preset's options the way parseopts does: bundled short flags, +# a short option's value attached or next, --long=value or --long value, +# and a unique prefix of a long option. Anything it cannot place fails. +# Words after -- are positional, which mkinitcpio ignores. +preset_option_args() { + local arg name value i char candidate matches + while (( $# )); do + arg=$1 + shift + if [[ $arg == -- ]]; then + break + elif [[ $arg == --?* ]]; then + name=${arg#--} + value="" + if [[ $name == *=* ]]; then + value=${name#*=} + name=${name%%=*} + fi + matches=() + for candidate in "${long_options[@]}"; do + if [[ ${candidate%:} == "$name" ]]; then + matches=("$candidate") + break + fi + if [[ ${candidate%:} == "$name"* ]]; then + matches+=("$candidate") + fi + done + (( ${#matches[@]} == 1 )) || return 1 + if [[ ${matches[0]} == *: ]]; then + if [[ $arg != *=* ]]; then + (( $# )) || return 1 + value=$1 + shift + fi + hook_option "${matches[0]%:}" "$value" + elif [[ $arg == *=* ]]; then + return 1 + fi + elif [[ $arg == -?* ]]; then + for (( i = 1; i < ${#arg}; i++ )); do + char=${arg:i:1} + if [[ $short_with_value == *"$char"* ]]; then + value=${arg:i+1} + if [[ -z $value ]]; then + (( $# )) || return 1 + value=$1 + shift + fi + hook_option "$char" "$value" + break + fi + if [[ $short_flags != *"$char"* ]]; then + return 1 + fi + done + fi + done +} +preset="$preset_dir/$kernel.preset" +if [[ -r $preset ]]; then + # The default preset's configuration, then its options as mkinitcpio + # appends them: a later -c wins, -A and -S add and skip hooks. + preset_out=$(bash -c '. "$1" >/dev/null 2>&1 || exit 1 + printf "%s\n" "${default_config:-${ALL_config:-}}" + if [[ "${default_options@a}" == *a* ]]; then printf "%s\n" "${default_options[@]}" + elif [[ -n ${default_options:-} ]]; then printf "%s\n" $default_options; fi' _ "$preset") || exit 1 + mapfile -t preset_args <<<"$preset_out" + if [[ -n ${preset_args[0]:-} ]]; then + conf=${preset_args[0]} + drop_ins=0 + fi + preset_option_args "${preset_args[@]:1}" || exit 1 +fi +[[ -r $conf ]] || exit 1 + +files=("$conf") +if (( drop_ins )) && [[ -d $conf_dir ]]; then + while IFS= read -r -d '' name; do + if [[ -r $conf_dir/$name ]]; then + files+=("$conf_dir/$name") + fi + done < <(LC_ALL=C.UTF-8 find "$conf_dir" -maxdepth 1 -xtype f -name '*.conf' -print0 | sed -z 's/.*\///' | LC_ALL=C.UTF-8 sort -zVu) +fi + +joined=$(mktemp) +trap 'rm -f "$joined"' EXIT +cat -- "${files[@]}" >"$joined" +# A scalar HOOKS is split on spaces, as mkinitcpio's arrayize_config does. +hooks_out=$(bash -c 'unset HOOKS; . "$1" >/dev/null 2>&1 || exit 1 + set -f; [[ ${HOOKS@a} == *a* ]] || IFS=" " read -r -a HOOKS <<<"$HOOKS" + printf "%s\n" "${HOOKS[@]}"' _ "$joined") || exit 1 +mapfile -t hooks <<<"$hooks_out" +effective=() +for hook in "${hooks[@]}" "${add_hooks[@]}"; do + if [[ -n $hook && " ${skip_hooks[*]} " != *" $hook "* ]]; then + effective+=("$hook") + fi +done +(( ${#effective[@]} )) || exit 1 +printf '%s\n' "${effective[*]}" diff --git a/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-limine-cmdline b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-limine-cmdline new file mode 100755 index 00000000000..439de9eda44 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/bin/omarchy-mac-limine-cmdline @@ -0,0 +1,94 @@ +#!/bin/bash + +# omarchy:summary=Derive Limine's kernel command line from GRUB's defaults on an Apple Silicon Mac +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# /etc/default/grub stays the one place the encrypt flow, the owner's re-key +# and the console leaf write the kernel command line. This turns it into +# Limine's KERNEL_CMDLINE[default]: root=UUID= of the root filesystem, rw, one +# rootflags= carrying the subvolume a btrfs root mounts (fstab's subvol= or +# subvolid=; none on ext4 and other filesystems) and every flag GRUB's variables added, then +# GRUB_CMDLINE_LINUX and GRUB_CMDLINE_LINUX_DEFAULT without repeats. Both +# loaders boot the same line. Linked as /etc/boot/hooks/pre.d/20-omarchy-mac-cmdline +# it runs before every UKI rebuild; omarchy-mac-boot-update runs it too. + +set -uo pipefail +set -f + +grub_default=${OMARCHY_GRUB_DEFAULT:-/etc/default/grub} +limine_default=${OMARCHY_LIMINE_DEFAULT:-/etc/default/limine} +fstab=${OMARCHY_FSTAB:-/etc/fstab} + +[[ -f $grub_default && -f $limine_default ]] || exit 0 + +grub_value() { + sed -n "s/^$1=//p" "$grub_default" | tail -n 1 | sed -E "s/^\"(.*)\"$/\1/; s/^'(.*)'$/\1/" +} + +# The installed system's root filesystem, as fstab names it (also right from +# inside a chroot), else the mounted one, else the booted one (a snapshot +# boot runs on a tmpfs overlay with the fstab row neutralised). +root_uuid=$(awk '$1 !~ /^#/ && $2 == "/" && $1 ~ /^UUID=/ { sub(/^UUID=/, "", $1); print $1; exit }' "$fstab" 2>/dev/null) +[[ -n $root_uuid ]] || root_uuid=$(findmnt -no UUID / 2>/dev/null) +[[ -n $root_uuid ]] || root_uuid=$(grep -Eo '(^|[[:space:]])root=UUID=[^[:space:]]+' "${OMARCHY_CMDLINE:-/proc/cmdline}" 2>/dev/null | tail -n 1 | sed 's/.*root=UUID=//') +if [[ -z $root_uuid ]]; then + # 100 and above: the limine-entry-tool hook runner aborts the rebuild on + # it; anything lower is a warning it would build the UKI over. + echo "omarchy-mac-limine-cmdline: cannot tell the root filesystem UUID; $limine_default keeps its command line" >&2 + exit 100 +fi + +# The root filesystem the same way: fstab's row, else the mounted root. A +# snapshot boot (an overlay, no fstab row) is Omarchy's btrfs @. Only btrfs +# takes subvol=; ext4 refuses it and the boot stops in the emergency shell. +root_fstype="" root_options="" +read -r root_fstype root_options < <(awk '$1 !~ /^#/ && $2 == "/" { print $3, $4; exit }' "$fstab" 2>/dev/null) || true +[[ -n $root_fstype ]] || read -r root_fstype root_options < <(findmnt -no FSTYPE,OPTIONS / 2>/dev/null) || true +case $root_fstype in + '' | overlay | tmpfs) root_fstype=btrfs root_options=subvol=@ ;; +esac +# subvol= names it; an fstab that selects it by subvolid= keeps that. +rootflags=() +if [[ $root_fstype == btrfs ]]; then + selector=$(tr ',' '\n' <<<"$root_options" | sed -n -E 's/^subvol=\/*([^/].*)$/subvol=\1/p; s/^subvol=\/*$/subvol=\//p' | tail -n 1) + [[ -n $selector ]] || selector=$(tr ',' '\n' <<<"$root_options" | grep -E '^subvolid=[0-9]+$' | tail -n 1) + [[ -z $selector ]] || rootflags=("$selector") +fi +words=() +for word in $(grub_value GRUB_CMDLINE_LINUX) $(grub_value GRUB_CMDLINE_LINUX_DEFAULT); do + case $word in + root=* | rw | ro) ;; + rootflags=*) + IFS=, read -r -a flags <<<"${word#rootflags=}" + for flag in "${flags[@]}"; do + case $flag in + '' | subvol=* | subvolid=*) ;; + *) [[ " ${rootflags[*]-} " == *" $flag "* ]] || rootflags+=("$flag") ;; + esac + done + ;; + *) [[ " ${words[*]-} " == *" $word "* ]] || words+=("$word") ;; + esac +done + +cmdline="root=UUID=$root_uuid rw" +(( ${#rootflags[@]} == 0 )) || cmdline+=" rootflags=$(IFS=,; printf '%s' "${rootflags[*]}")" +[[ -z ${words[*]-} ]] || cmdline+=" ${words[*]}" + +# A command line that cannot be written must stop the rebuild (100 and +# above), or the UKI would be built with the previous one. +write_failed() { + echo "omarchy-mac-limine-cmdline: could not update $limine_default; $*" >&2 + exit 100 +} +tmp=$(mktemp) || write_failed "no temporary file" +trap 'rm -f "$tmp"' EXIT +awk -v line="KERNEL_CMDLINE[default]=\"$cmdline\"" ' + /^KERNEL_CMDLINE\[default\]/ { if (!done) { print line; done = 1 }; next } + { print } + END { if (!done) print line } +' "$limine_default" >"$tmp" || write_failed "could not stage the new command line" +if ! cmp -s "$tmp" "$limine_default"; then + install -m644 "$tmp" "$limine_default" || write_failed "could not install the new command line" +fi diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/90-omarchy-mac.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/90-omarchy-mac.conf new file mode 100644 index 00000000000..0457b32d8a1 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/90-omarchy-mac.conf @@ -0,0 +1,18 @@ +# Generated by the Omarchy Apple Silicon image builder. +_omarchy_asahi_hooks=() +_omarchy_asahi_added=false +for _omarchy_asahi_hook in "${HOOKS[@]}"; do + if [[ $_omarchy_asahi_hook == asahi ]]; then + _omarchy_asahi_added=true + fi + if [[ $_omarchy_asahi_hook == filesystems && $_omarchy_asahi_added == false ]]; then + _omarchy_asahi_hooks+=(asahi omarchy-vendorfw) + _omarchy_asahi_added=true + fi + _omarchy_asahi_hooks+=("$_omarchy_asahi_hook") +done +if [[ $_omarchy_asahi_added == false ]]; then + _omarchy_asahi_hooks+=(asahi omarchy-vendorfw) +fi +HOOKS=("${_omarchy_asahi_hooks[@]}") +unset _omarchy_asahi_hooks _omarchy_asahi_hook _omarchy_asahi_added diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/91-omarchy-mac-encrypt.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/91-omarchy-mac-encrypt.conf new file mode 100644 index 00000000000..306582fb00e --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/91-omarchy-mac-encrypt.conf @@ -0,0 +1,90 @@ +# Insert omarchy-mac-encrypt after vendorfw/block and sd-encrypt immediately +# before filesystems, each only if that hook is absent. 90-omarchy-mac.conf +# already put asahi and omarchy-vendorfw before filesystems; this drop-in is +# sourced after it. +# +# Both are systemd initrd units: the systemd hook replaces udev (mkinitcpio's +# stock HOOKS line) and keymap/consolefont become sd-vconsole. A HOOKS line +# carrying the busybox encrypt hook belongs to a Mac unlocked by cryptdevice=, +# which sd-encrypt cannot parse: that line is left exactly as it is. +_omarchy_mac_encrypt_hooks=() +_omarchy_mac_encrypt_have_systemd=false +_omarchy_mac_encrypt_have_vconsole=false +_omarchy_mac_encrypt_have_encrypt=false +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_encrypt_hook in + systemd) _omarchy_mac_encrypt_have_systemd=true ;; + sd-vconsole) _omarchy_mac_encrypt_have_vconsole=true ;; + encrypt) _omarchy_mac_encrypt_have_encrypt=true ;; + esac +done +if [[ $_omarchy_mac_encrypt_have_encrypt == false ]]; then +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_encrypt_hook in + udev) + if [[ $_omarchy_mac_encrypt_have_systemd == false ]]; then + _omarchy_mac_encrypt_hooks+=(systemd) + _omarchy_mac_encrypt_have_systemd=true + fi + ;; + keymap|consolefont) + if [[ $_omarchy_mac_encrypt_have_vconsole == false ]]; then + _omarchy_mac_encrypt_hooks+=(sd-vconsole) + _omarchy_mac_encrypt_have_vconsole=true + fi + ;; + *) _omarchy_mac_encrypt_hooks+=("$_omarchy_mac_encrypt_hook") ;; + esac +done +if [[ $_omarchy_mac_encrypt_have_systemd == false ]]; then + if [[ ${_omarchy_mac_encrypt_hooks[0]:-} == base ]]; then + _omarchy_mac_encrypt_hooks=(base systemd "${_omarchy_mac_encrypt_hooks[@]:1}") + else + _omarchy_mac_encrypt_hooks=(systemd "${_omarchy_mac_encrypt_hooks[@]}") + fi +fi +HOOKS=("${_omarchy_mac_encrypt_hooks[@]}") +_omarchy_mac_encrypt_hooks=() +_omarchy_mac_encrypt_have_ours=false +_omarchy_mac_encrypt_have_sd=false +_omarchy_mac_encrypt_added_ours=false +_omarchy_mac_encrypt_added_sd=false +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + if [[ $_omarchy_mac_encrypt_hook == omarchy-mac-encrypt ]]; then + _omarchy_mac_encrypt_have_ours=true + fi + if [[ $_omarchy_mac_encrypt_hook == sd-encrypt ]]; then + _omarchy_mac_encrypt_have_sd=true + fi +done +for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do + if [[ $_omarchy_mac_encrypt_hook == sd-encrypt && $_omarchy_mac_encrypt_have_ours == false && + $_omarchy_mac_encrypt_added_ours == false ]]; then + _omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt) + _omarchy_mac_encrypt_added_ours=true + fi + if [[ $_omarchy_mac_encrypt_hook == filesystems ]]; then + if [[ $_omarchy_mac_encrypt_have_ours == false && $_omarchy_mac_encrypt_added_ours == false ]]; then + _omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt) + _omarchy_mac_encrypt_added_ours=true + fi + if [[ $_omarchy_mac_encrypt_have_sd == false && $_omarchy_mac_encrypt_added_sd == false ]]; then + _omarchy_mac_encrypt_hooks+=(sd-encrypt) + _omarchy_mac_encrypt_added_sd=true + fi + fi + _omarchy_mac_encrypt_hooks+=("$_omarchy_mac_encrypt_hook") +done +if [[ $_omarchy_mac_encrypt_have_ours == false && $_omarchy_mac_encrypt_added_ours == false ]]; then + _omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt) +fi +if [[ $_omarchy_mac_encrypt_have_sd == false && $_omarchy_mac_encrypt_added_sd == false ]]; then + _omarchy_mac_encrypt_hooks+=(sd-encrypt) +fi +HOOKS=("${_omarchy_mac_encrypt_hooks[@]}") +fi +unset _omarchy_mac_encrypt_hooks _omarchy_mac_encrypt_hook \ + _omarchy_mac_encrypt_have_ours _omarchy_mac_encrypt_have_sd \ + _omarchy_mac_encrypt_added_ours _omarchy_mac_encrypt_added_sd \ + _omarchy_mac_encrypt_have_systemd _omarchy_mac_encrypt_have_vconsole \ + _omarchy_mac_encrypt_have_encrypt diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/93-omarchy-mac-plymouth.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/93-omarchy-mac-plymouth.conf new file mode 100644 index 00000000000..75cbbb50d83 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/93-omarchy-mac-plymouth.conf @@ -0,0 +1,18 @@ +# Plymouth draws the disk password prompt and the boot splash, as on x86 +# Omarchy. It goes right after systemd so its initrd units order correctly, +# only when the hook is installed, and never twice. +if [[ -f /usr/lib/initcpio/install/plymouth && " ${HOOKS[*]} " != *" plymouth "* ]]; then + _omarchy_mac_plymouth_hooks=() + _omarchy_mac_plymouth_added=false + for _omarchy_mac_plymouth_hook in "${HOOKS[@]}"; do + _omarchy_mac_plymouth_hooks+=("$_omarchy_mac_plymouth_hook") + if [[ $_omarchy_mac_plymouth_hook == systemd && $_omarchy_mac_plymouth_added == false ]]; then + _omarchy_mac_plymouth_hooks+=(plymouth) + _omarchy_mac_plymouth_added=true + fi + done + if [[ $_omarchy_mac_plymouth_added == true ]]; then + HOOKS=("${_omarchy_mac_plymouth_hooks[@]}") + fi + unset _omarchy_mac_plymouth_hooks _omarchy_mac_plymouth_added _omarchy_mac_plymouth_hook +fi diff --git a/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/94-omarchy-mac-vconsole.conf b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/94-omarchy-mac-vconsole.conf new file mode 100644 index 00000000000..9547df77813 --- /dev/null +++ b/test/fixtures/mac-migrate/mac-boot/mkinitcpio.conf.d/94-omarchy-mac-vconsole.conf @@ -0,0 +1,46 @@ +# The disk passphrase prompt types with the owner's keyboard layout, as on +# x86 Omarchy: sd-vconsole loads KEYMAP on the console (systemd-ask-password) +# and /etc/vconsole.conf gives Plymouth its XKBLAYOUT. The aarch64 +# omarchy-settings drops upstream's omarchy_hooks.conf, so this drop-in +# carries its guard: a layout that does not type Latin letters stays out of +# the initramfs, because a Latin passphrase would be untypeable in it +# (upstream #6229). The prompt then uses the kernel's US map, which is what +# such a passphrase was typed with. +# +# A systemd HOOKS line gets sd-vconsole exactly once, after keyboard (or +# after systemd without one); keymap and consolefont are its busybox +# counterparts and never belong on such a line. A busybox line (a Mac +# unlocked by cryptdevice=, which 91 leaves alone) keeps its hooks; it only +# gets the file for Plymouth, as upstream does. +# No vconsole.conf is the kernel's US map: sd-vconsole stays, nothing to bundle. +_omarchy_mac_vconsole_latin=true +if [[ -f /etc/vconsole.conf ]]; then + _omarchy_mac_vconsole_layout=$(unset XKBLAYOUT; . /etc/vconsole.conf 2>/dev/null; printf '%s' "${XKBLAYOUT:-}") + case ${_omarchy_mac_vconsole_layout%%,*} in + af | am | ara | bd | bg | by | et | ge | gr | il | in | iq | ir | kg | kh | kz | la | lk | mk | mm | mn | mv | np | rs | ru | sy | th | tj | ua) + _omarchy_mac_vconsole_latin=false ;; + esac +fi + +if [[ " ${HOOKS[*]} " == *" systemd "* ]]; then + _omarchy_mac_vconsole_hooks=() + _omarchy_mac_vconsole_anchor=systemd + [[ " ${HOOKS[*]} " != *" keyboard "* ]] || _omarchy_mac_vconsole_anchor=keyboard + for _omarchy_mac_vconsole_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_vconsole_hook in + sd-vconsole | keymap | consolefont) continue ;; + esac + _omarchy_mac_vconsole_hooks+=("$_omarchy_mac_vconsole_hook") + if [[ $_omarchy_mac_vconsole_hook == "$_omarchy_mac_vconsole_anchor" && $_omarchy_mac_vconsole_latin == true ]]; then + _omarchy_mac_vconsole_hooks+=(sd-vconsole) + _omarchy_mac_vconsole_anchor= + fi + done + HOOKS=("${_omarchy_mac_vconsole_hooks[@]}") +fi + +if [[ $_omarchy_mac_vconsole_latin == true && -f /etc/vconsole.conf ]]; then + FILES+=(/etc/vconsole.conf) +fi +unset _omarchy_mac_vconsole_latin _omarchy_mac_vconsole_layout _omarchy_mac_vconsole_hooks \ + _omarchy_mac_vconsole_anchor _omarchy_mac_vconsole_hook diff --git a/test/fixtures/mac-migrate/runtime/install/config/locale.sh b/test/fixtures/mac-migrate/runtime/install/config/locale.sh new file mode 100644 index 00000000000..e70713fb358 --- /dev/null +++ b/test/fixtures/mac-migrate/runtime/install/config/locale.sh @@ -0,0 +1,50 @@ +# An image built from a distribution's root tarball rather than the ISO (Arch +# Linux ARM's ships LANG=C) never went through the ISO's locale step, so it +# runs non-UTF-8: byte-wise sorting, ASCII-only \u escapes, and any tool that +# reads the locale for its encoding. +# Root always writes the real files; the overrides are for unprivileged tests. +locale_conf=/etc/locale.conf +locale_gen=/etc/locale.gen +if (( EUID != 0 )); then + locale_conf=${OMARCHY_LOCALE_CONF:-$locale_conf} + locale_gen=${OMARCHY_LOCALE_GEN:-$locale_gen} +fi + +# Repair only the stock state -- an unset LANG, or the bare C/POSIX the image +# ships. Any named locale is somebody's choice, C.UTF-8 included, so leave it. +# A machine with no locale.conf at all reads as unset, not as a failure: +# under pipefail the missing file would otherwise abort the installer. +current=$(sed -n 's/^LANG=//p' "$locale_conf" 2>/dev/null | tail -1 | tr -d '"') || current="" + +case ${current:-C} in + C | POSIX) ;; + *) + echo "Leaving the locale as $current" + return 0 2>/dev/null || exit 0 + ;; +esac + +echo "Setting up locale (en_US.UTF-8)..." + +if ! locale -a 2>/dev/null | grep -qi "en_US.utf-\?8"; then + if grep -q '^#en_US.UTF-8' "$locale_gen" 2>/dev/null; then + sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' "$locale_gen" + elif ! grep -q '^en_US.UTF-8' "$locale_gen" 2>/dev/null; then + echo "en_US.UTF-8 UTF-8" >>"$locale_gen" + fi + + locale-gen >/dev/null 2>&1 +fi + +# Only LANG changes; LC_* lines somebody set stay. +if grep -q '^LANG=' "$locale_conf" 2>/dev/null; then + sed -i 's/^LANG=.*/LANG=en_US.UTF-8/' "$locale_conf" +else + echo "LANG=en_US.UTF-8" >>"$locale_conf" +fi + +# The session that ran this keeps its inherited LANG; everything after it here +# should see the new one. +export LANG=en_US.UTF-8 + +echo "Locale set to en_US.UTF-8" diff --git a/test/shell.d/bin-style-test.sh b/test/shell.d/bin-style-test.sh index 5cbb9ffe514..ad3259b4183 100644 --- a/test/shell.d/bin-style-test.sh +++ b/test/shell.d/bin-style-test.sh @@ -4,8 +4,11 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +# omarchy-mac-migrate is exempt: it runs as root in an empty environment and +# carries on across the move to another runtime, so it cannot rely on these +# helpers being installed. raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \ - | rg -v '/omarchy-(cmd-|pkg-|upgrade-to-quattro|mac-setup$|system-(btrfs-migrate|boot-to-esp)$)' || true) + | rg -v '/omarchy-(cmd-|pkg-|upgrade-to-quattro|mac-setup$|mac-migrate$|system-(btrfs-migrate|boot-to-esp)$)' || true) [[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks" pass "bin commands use command helpers" diff --git a/test/shell.d/mac-migrate-legacy-test.sh b/test/shell.d/mac-migrate-legacy-test.sh new file mode 100644 index 00000000000..97d445155e2 --- /dev/null +++ b/test/shell.d/mac-migrate-legacy-test.sh @@ -0,0 +1,894 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/test/fixtures/mac-migrate/lib.sh" + +# bin/omarchy-mac-migrate moves a legacy omarchy-mac Mac (the quattro fork) +# onto Omarchy's official edge: the omarchy-dev pair, the Mac packages and the +# Aurora chain. Legacy fixtures turn on the stand-in pacman's file ownership, +# signature trust and removal dependencies: a transaction refuses to write over +# a file it does not own, and a package from a repository that requires +# signatures must be signed by a key the keyring trusts. + +fork=FBD6874D423C418DDB6D143EECE19CDDE306DBD2 +alarm=1111111111111111111111111111111111111111 +asahi=2222222222222222222222222222222222222222 +checkout=/home/owner/.local/share/omarchy +# omarchy-mac-boot's HOOKS resolver, its Limine command line and its +# mkinitcpio drop-ins, as omacom/omarchy-mac-pkgs 4399105 ships them. +mac_boot=$ROOT/test/fixtures/mac-migrate/mac-boot + +# ships PACKAGE PATH...: the files a package installs. +ships() { + local name=$1 + shift + printf '%s\n' "$@" >"$F/files/$name" +} + +# owns PACKAGE PATH...: installed files and their owner. +owns() { + local name=$1 path + shift + for path; do + mkdir -p "$(dirname "$R$path")" + echo "$name" >"$R$path" + echo "$name $path" >>"$R/var/lib/pacman/local/files" + done +} + +# unowned CONTENT PATH...: files the checkout's setup wrote. +unowned() { + local content=$1 path + shift + for path; do + mkdir -p "$(dirname "$R$path")" + echo "$content" >"$R$path" + done +} + +# A legacy Mac on the Asahi kernel and GRUB, unencrypted, with the fork's +# repository, keyring and busybox HOOKS line; the signed candidate set as the +# administrator's target, with the edge repository for everything else. LAYOUT +# is checkout (a 3.x checkout upgraded to Quattro: no omarchy package, the +# checkout wired into /usr) or channel (the pair from an rc lane, a dev link to +# a checkout). +new_fixture() { + local name=$1 layout=$2 link + F=$tmp/$name + R=$F/root + rm -rf "$F" + mkdir -p "$R" "$F/files" + mkdir -p "$R/run/systemd/system" "$R/run/lock" "$R/var/tmp" "$R/proc/sys/kernel/random" "$R/etc/default" "$R/etc/omarchy-mac" \ + "$R/var/lib/pacman/local" "$R/var/lib/pacman/sync" "$R/var/cache/pacman/pkg" "$R/etc/pacman.d/gnupg" \ + "$R/usr/share/pacman/keyrings" "$R/usr/share/limine" "$R/boot/grub" "$R/boot/efi/EFI/BOOT" "$R/boot/efi/m1n1" \ + "$R/usr/lib/modules/6.19.1-asahi" "$R/usr/lib/modules/7.1.12-aurora" "$R/var/lib/omarchy" "$R/usr/bin" \ + "$R/var/cache/omarchy/channels/transaction.Stale01" "$R/etc/sudoers.d" "$R/etc/mkinitcpio.conf.d" + echo boot-1 >"$R/proc/sys/kernel/random/boot_id" + echo 6.19.1-asahi >"$R/proc/sys/kernel/osrelease" + echo linux-asahi >"$R/usr/lib/modules/6.19.1-asahi/pkgbase" + echo linux-aurora >"$R/usr/lib/modules/7.1.12-aurora/pkgbase" + echo "menuentry linux-asahi" >"$R/boot/grub/grub.cfg" + echo grub >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" + echo m1n1 >"$R/boot/efi/m1n1/boot.bin" + echo "limine 12.9" >"$R/usr/share/limine/BOOTAA64.EFI" + echo 'GRUB_CMDLINE_LINUX=""' >"$R/etc/default/grub" + mkdir -p "$R/etc/sddm.conf.d" + printf '[Autologin]\nUser=owner\nSession=omarchy.desktop\n' >"$R/etc/sddm.conf.d/autologin.conf" + for keyring in archlinuxarm asahi-alarm; do + : >"$R/usr/share/pacman/keyrings/$keyring.gpg" + done + echo "$alarm" >"$R/usr/share/pacman/keyrings/archlinuxarm-trusted" + echo "$asahi" >"$R/usr/share/pacman/keyrings/asahi-alarm-trusted" + printf '%s f\n%s f\n%s f\n' "$alarm" "$asahi" "$fork" >"$R/etc/pacman.d/gnupg/keys" + mkdir -p "$F/keyserver" + : >"$F/keyserver/$official" + : >"$R/var/lib/pacman/local/files" + + # The checkout, never written by the migration. + mkdir -p "$R$checkout/bin" "$R$checkout/.git" "$R$checkout/default/bash" + for name in omarchy-update omarchy-hw-apple omarchy-upgrade-to-quattro-mac; do + echo "checkout $name" >"$R$checkout/bin/$name" + done + echo "checkout env" >"$R$checkout/default/bash/env-bootstrap" + echo "4.0.3" >"$R$checkout/version" + + repo core <<<"pacman 7.0.0-1 $alarm" + printf 'hyprland 0.51-1 %s\nlimine 12.9.0-1 %s\n' "$alarm" "$alarm" | repo extra + printf 'linux-asahi 6.19.1-1 %s\nm1n1 1.5.0-1 %s\nuboot-asahi 2026.01-1 %s\nasahi-alarm-keyring 20250101-1 %s\n' "$asahi" "$asahi" "$asahi" "$asahi" | + repo asahi-alarm + sed "s/\$/ $official/" <<'EDGE' | repo omarchy +omarchy 4.0.4-1 +omarchy-settings 4.0.4-1 +omarchy-dev 4.0.0.r6713.ga85e29a-1 +omarchy-settings-dev 4.0.0.r6713.ga85e29a-1 +omarchy-mac 0.1.0-6 +omarchy-mac-boot 20260927-1 +linux-aurora 7.1.12.aurora2-10 +linux-aurora-headers 7.1.12.aurora2-10 +m1n1-aurora 1.6.1.aurora1-3 +uboot-asahi 2026.07.asahi2-4 +limine-mkinitcpio-hook 1.39.0-2 +omarchy-keyring 20260920-1 +ttf-jetbrains-mono-nerd-basic 3.4.0-2 +quickshell-git 0.2-1 +EDGE + sed "s/\$/ $fork/" <<'FORK' | repo omarchy-aarch64 +omarchy 4.0.3rc4-1 +omarchy-settings 4.0.3rc4-1 +omarchy-mac-keyring 20260914-2 +quickshell-git 0.1-1 +voxtype 1.0-1 +FORK + cp "$F/repos/omarchy-aarch64/omarchy-aarch64.db" "$R/var/lib/pacman/sync/" + alarm_repos + relations + echo "omarchy 4.0.3rc4-1 omarchy-mac-keyring omarchy-settings" >"$F/depends" + : >"$F/verify-signatures" + + ships omarchy-dev /usr/share/omarchy/bin/omarchy-update /usr/share/omarchy/default/bash/env-bootstrap /usr/bin/omarchy-update + ships omarchy-settings-dev /etc/sddm.conf.d/10-theme.conf /etc/profile.d/omarchy.sh /usr/share/uwsm/env.d/10-omarchy + echo /etc/sddm.conf.d/10-theme.conf >"$F/backups" + # The legacy detector alias belongs to omarchy-mac. + ships omarchy-mac /usr/bin/omarchy-hw-apple + ships omarchy-keyring /usr/share/pacman/keyrings/omarchy.gpg /usr/share/pacman/keyrings/omarchy-trusted + ships ttf-jetbrains-mono-nerd-basic /usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf + owns omarchy-mac-keyring /usr/share/pacman/keyrings/omarchy-mac.gpg /usr/share/pacman/keyrings/omarchy-mac-revoked + printf '%s:4:\n' "$fork" >"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" + echo "omarchy-mac-keyring /usr/share/pacman/keyrings/omarchy-mac-trusted" >>"$R/var/lib/pacman/local/files" + + if [[ $layout == "checkout" ]]; then + cat >"$R/var/lib/pacman/local/packages" <<'LOCAL' +asahi-alarm-keyring 20250101-1 +hyprland 0.50-1 +limine 12.9.0-1 +linux-asahi 6.19.1-1 +m1n1 1.5.0-1 +omarchy-mac-keyring 20260914-2 +pacman 7.0.0-1 +quickshell-git 0.1-1 +uboot-asahi 2026.01-1 +voxtype 1.0-1 +LOCAL + # What omarchy-upgrade-to-quattro-mac wired, and the files its setup wrote. + ln -s "$R$checkout" "$R/usr/share/omarchy" + for link in omarchy-update omarchy-hw-apple omarchy-upgrade-to-quattro-mac; do + ln -s "$R$checkout/bin/$link" "$R/usr/bin/$link" + done + printf 'export OMARCHY_PATH="%s"\n' "$checkout" >"$R/etc/omarchy.conf" + unowned "legacy theme" /etc/sddm.conf.d/10-theme.conf + unowned "HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck)" \ + /etc/mkinitcpio.conf.d/omarchy_hooks.conf + unowned "legacy profile" /etc/profile.d/omarchy.sh + unowned "legacy uwsm" /usr/share/uwsm/env.d/10-omarchy + else + cat >"$R/var/lib/pacman/local/packages" <<'LOCAL' +asahi-alarm-keyring 20250101-1 +hyprland 0.50-1 +limine 12.9.0-1 +linux-asahi 6.19.1-1 +m1n1 1.5.0-1 +omarchy 4.0.3rc4-1 +omarchy-keyring 20260801-1 +omarchy-mac-keyring 20260914-2 +omarchy-settings 4.0.3rc4-1 +pacman 7.0.0-1 +quickshell-git 0.1-1 +ttf-jetbrains-mono-nerd-basic 3.4.0-1 +uboot-asahi 2026.01-1 +voxtype 1.0-1 +LOCAL + owns omarchy /usr/share/omarchy/bin/omarchy-update /usr/share/omarchy/bin/omarchy-upgrade-to-quattro-mac \ + /usr/share/omarchy/default/bash/env-bootstrap /usr/bin/omarchy-update + owns omarchy-settings /etc/sddm.conf.d/10-theme.conf /etc/mkinitcpio.conf.d/omarchy_hooks.conf /etc/profile.d/omarchy.sh + owns omarchy-keyring /usr/share/pacman/keyrings/omarchy.gpg /usr/share/pacman/keyrings/omarchy-trusted + echo "$official" >"$R/usr/share/pacman/keyrings/omarchy-trusted" + owns ttf-jetbrains-mono-nerd-basic /usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf + unowned "legacy uwsm" /usr/share/uwsm/env.d/10-omarchy + # A developer's link to a checkout; root's sudo path runs it. + printf 'export OMARCHY_PATH="%s"\n' "$checkout" >"$R/etc/omarchy.conf" + echo "Defaults secure_path=\"$checkout/bin:/usr/local/sbin:/usr/local/bin:/usr/bin\"" >"$R/etc/sudoers.d/omarchy-dev-path" + fi + + cat >"$R/etc/pacman.conf" <"$R/etc/pacman.d/mirrorlist.asahi-alarm" + if [[ $layout == "channel" ]]; then + # rc5's strict fork repository, and [omarchy] as omarchy-upgrade-to-quattro writes it. + sed -i -e 's/^SigLevel = Optional TrustAll$/SigLevel = PackageRequired DatabaseRequired TrustedOnly/' -e '/^Usage = Sync$/d' \ + -e '/^\[omarchy\]$/,/^Server/s/^SigLevel = Required DatabaseOptional$/SigLevel = Optional TrustAll/' "$R/etc/pacman.conf" + fi + # Copies the fork's tools left: arm-package-sources' .bak, the Quattro upgrade's timestamped one. + printf '[omarchy-aarch64]\nSigLevel = Optional TrustAll\n' >"$R/etc/pacman.conf.bak" + printf '[omarchy]\nSigLevel = Optional TrustAll\n' >"$R/etc/pacman.conf.omarchy-upgrade-to-quattro.20260801000000.bak" + cp -r "$tmp/set" "$F/set" + cat >"$R/etc/omarchy-mac/migration-target" <"$F/platform" + echo "base asahi udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck" >"$F/hooks" + printf '%s\n' "$R/boot/efi" >"$F/mounts" + echo "/dev/nvme0n1p6[/@]" >"$F/root-source" + printf '/dev/nvme0n1p6 part btrfs\n/dev/nvme0n1 disk \n' >"$F/lsblk" + : >"$F/pacman.log" + : >"$F/boot.log" +} + +checkout_digest() { + (cd "$R$checkout" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) | sha256sum +} + +# Links as well as files: a refusal must leave the checkout wired as it was. +links_digest() { + (cd "$R" && find . -path ./var/tmp -prune -o -type l -print | LC_ALL=C sort | while read -r link; do + printf '%s -> %s\n' "$link" "$(readlink "$link")" + done) | sha256sum +} + +legacy_refused() { # description reason-pattern + local links + links=$(links_digest) + refused "$1" "$2" + [[ $(links_digest) == "$links" ]] || fail "$1: no link changed" +} + +# TrustAll anywhere pacman's configuration lives: pacman.conf, every file it +# includes and every copy of it beside it. +trustall_anywhere() { + local files=("$R/etc/pacman.conf" "$R"/etc/pacman.conf.*) path + while read -r path; do + files+=("$R$path") + done < <(sed -n 's/^Include = //p' "$R/etc/pacman.conf" | sort -u) + grep -l TrustAll "${files[@]}" 2>/dev/null || true +} + +# The core Apple Silicon configuration for SERVER, as the fixtures serve it. +core_conf() { + OMARCHY_MAC_MIGRATE_ROOT=$R fixture=1 bash -c 'source <(sed -n "/^core_pacman_conf() {/,/^}/p" "$1"); core_pacman_conf "$2"' _ "$ROOT/migrate/src/target.sh" "$1" | + sed "s|^Server = https://github.com/asahi-alarm.*|Server = file://FIXTURE/repos/asahi-alarm|" +} + +# Everything a finished conversion leaves, however it got there. +outcome() { + local state + state=$(state_dir) + cat "$R/var/lib/pacman/local/packages" + LC_ALL=C sort "$R/var/lib/pacman/local/files" + sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf" + sort "$R/etc/pacman.d/gnupg/keys" + (cd "$R" && find usr/bin usr/share/omarchy usr/share/uwsm etc/sddm.conf.d etc/mkinitcpio.conf.d etc/profile.d etc/sudoers.d \ + usr/share/pacman/keyrings var/cache/omarchy \( -type f -o -type l \) | LC_ALL=C sort | while read -r path; do + if [[ -L $path ]]; then echo "$path -> $(readlink "$path" | sed "s|$R|ROOT|")"; else echo "$path: $(head -c 80 "$path")"; fi + done) + cat "$R/etc/omarchy.conf" "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" "$R/etc/default/limine" + (cd "$R/etc" && ls -d pacman.conf*) + ls "$R/var/lib/pacman/sync" + [[ ! -e $R/var/lib/pacman/db.lck ]] && echo unlocked + sed -n 's/^target=//p' "$state/complete" + (cd "$state/backup" && find . -type f | LC_ALL=C sort) + sed "s|$R|ROOT|g" "$state/backup/converted/links" + ls "$state" + [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && echo "no unit" + checkout_digest +} + +# --- A checkout upgraded to Quattro ------------------------------------------------ + +new_fixture baseline checkout +before=$(checkout_digest) +output=$(migrate run 2>&1) || fail "a legacy checkout Mac migrates to its reboot" "$output" +grep -q "Reboot to finish the migration to candidate-set apple-test-fixture" <<<"$output" || fail "the run asks for a reboot" "$output" +state=$(state_dir) +[[ $(<"$state/plan/cohort") == "legacy" ]] || fail "the Mac is a legacy omarchy-mac install" "$(cat "$state/plan/cohort")" +pass "an unencrypted legacy Mac with the fork's busybox HOOKS line is not refused" + +expected_packages='asahi-alarm-keyring 20250101-1 +hyprland 0.51-1 +limine 12.9.0-1 +limine-mkinitcpio-hook 1.39.0-2 +linux-aurora 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +omarchy-dev 4.0.0.r7000.gabc-1.1 +omarchy-keyring 20260920-1 +omarchy-mac 0.1.0-11.1 +omarchy-mac-boot 20261004-1.1 +omarchy-settings-dev 4.0.0.r7000.gabc-1.1 +pacman 7.0.0-1 +quickshell-git 0.2-1 +uboot-asahi 2026.07.asahi2-4 +voxtype 1.0-1' +[[ $(cat "$R/var/lib/pacman/local/packages") == "$expected_packages" ]] || + fail "the checkout becomes the omarchy-dev pair, the fork's builds official ones, and the fork keyring goes" "$(cat "$R/var/lib/pacman/local/packages")" +[[ -d $R/usr/share/omarchy && ! -L $R/usr/share/omarchy ]] || fail "/usr/share/omarchy is the package's, not a link to the checkout" +[[ ! -L $R/usr/bin/omarchy-update ]] && grep -qx "omarchy-dev /usr/bin/omarchy-update" "$R/var/lib/pacman/local/files" || + fail "the commands are the package's" +[[ ! -e $R/usr/bin/omarchy-upgrade-to-quattro-mac ]] || fail "links to checkout commands no package ships are gone" +[[ ! -L $R/usr/bin/omarchy-hw-apple ]] && grep -qx "omarchy-mac /usr/bin/omarchy-hw-apple" "$R/var/lib/pacman/local/files" || + fail "the legacy detector alias is omarchy-mac's, not a link to the checkout" +[[ $(<"$R/etc/omarchy.conf") == 'export OMARCHY_PATH="/usr/share/omarchy"' ]] || fail "OMARCHY_PATH is the packaged tree" "$(cat "$R/etc/omarchy.conf")" +[[ $(checkout_digest) == "$before" ]] || fail "the checkout itself is untouched" +for path in /etc/profile.d/omarchy.sh /usr/share/uwsm/env.d/10-omarchy; do + grep -qx "omarchy-settings-dev $path" "$R/var/lib/pacman/local/files" || fail "omarchy-settings-dev owns $path" + [[ $(<"$R$path") == "omarchy-settings-dev 4.0.0.r7000.gabc-1.1" ]] || fail "the package's $path replaces the setup's" + [[ $(<"$state/backup/converted/files$path") == legacy* ]] || fail "the setup's own $path is kept in the backup" +done +[[ $(<"$R/etc/sddm.conf.d/10-theme.conf") == "legacy theme" && -f $R/etc/sddm.conf.d/10-theme.conf.pacnew ]] && + grep -qx "omarchy-settings-dev /etc/sddm.conf.d/10-theme.conf" "$R/var/lib/pacman/local/files" || + fail "a configuration file the package lists in backup= keeps its contents, and the package's lands as .pacnew" +grep -q "encrypt" "$R/etc/mkinitcpio.conf.d/omarchy_hooks.conf" || fail "a file no new package brings is left alone" +[[ $(sed "s|$R|ROOT|g" "$state/backup/converted/links") == "/usr/bin/omarchy-hw-apple ROOT$checkout/bin/omarchy-hw-apple +/usr/bin/omarchy-update ROOT$checkout/bin/omarchy-update +/usr/bin/omarchy-upgrade-to-quattro-mac ROOT$checkout/bin/omarchy-upgrade-to-quattro-mac +/usr/share/omarchy ROOT$checkout" ]] || fail "every link to the checkout is recorded" "$(cat "$state/backup/converted/links")" +[[ -f $state/backup/converted/files/etc/omarchy.conf ]] || fail "the checkout's omarchy.conf is kept" +grep -q "^transaction omarchy-mac-candidate/omarchy-dev omarchy-mac-candidate/omarchy-settings-dev omarchy-mac-candidate/omarchy-mac " "$F/pacman.log" || + fail "the omarchy-dev pair is named from the candidate set" "$(grep transaction "$F/pacman.log")" +grep -q "^transaction .* quickshell-git asahi-alarm-keyring omarchy-keyring$" "$F/pacman.log" || + fail "a fork build the official repository carries and the keyrings are named" "$(grep transaction "$F/pacman.log")" +grep -q "^voxtype 1.0-1$" "$state/plan/kept" && ! grep -q "omarchy-mac-keyring" "$state/plan/kept" || + fail "a fork build with no official one is kept and listed; the keyring is not" "$(cat "$state/plan/kept")" +pass "a checkout Mac is converted to the omarchy-dev pair, its checkout unwired and its unowned files backed up" + +conf=$(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") +[[ $conf == "$(core_conf "file://FIXTURE/repos/omarchy")" ]] || + fail "pacman.conf is the core Apple Silicon configuration: the fork repository, its TrustAll and [omarchy]'s own SigLevel are gone" \ + "$(diff <(core_conf "file://FIXTURE/repos/omarchy") <(echo "$conf"))" +[[ ! -e $R/var/lib/pacman/sync/omarchy-aarch64.db ]] || fail "the fork's sync database is gone" +! grep -q "^$fork " "$R/etc/pacman.d/gnupg/keys" || fail "the rc4 fork key is gone from the keyring" +grep -q "^$official f$" "$R/etc/pacman.d/gnupg/keys" || fail "the Omarchy key is trusted" +[[ ! -e $R/usr/share/pacman/keyrings/omarchy-mac.gpg && ! -e $R/usr/share/pacman/keyrings/omarchy-mac-trusted ]] || + fail "omarchy-mac-keyring is removed, so no populate trusts the fork key again" +[[ $(grep '^transaction \|^remove ' "$F/pacman.log" | cut -d' ' -f1 | xargs) == "transaction remove" ]] && grep -q "^remove omarchy-mac-keyring$" "$F/pacman.log" || + fail "the keyring is removed after the transaction that replaced what needed it" "$(cat "$F/pacman.log")" +pass "official trust only: TrustAll, the fork repository, key and keyring are gone" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the post-reboot verification completes the migration" "$output" +grep -q "The checkout at $R$checkout is no longer used" <<<"$output" || fail "retire names the unused checkout" "$output" +[[ -f $R/etc/sddm.conf.d/autologin.conf ]] || fail "an administrator's autologin is kept" +[[ -z $(ls "$R/var/cache/omarchy/channels") ]] || fail "the fork's channel transactions are retired" +[[ -z $(trustall_anywhere) ]] || fail "no TrustAll is left in pacman's configuration, its includes or copies of it" "$(trustall_anywhere)" +for file in pacman.conf.bak pacman.conf.omarchy-upgrade-to-quattro.20260801000000.bak; do + grep -q TrustAll "$state/backup/converted/files/etc/$file" || fail "the fork's $file is kept in the backup" +done +baseline=$(outcome) +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +[[ $(outcome) == "$baseline" ]] || fail "a second run changes nothing" +pass "after the reboot the fork's channel state and TrustAll copies are retired, and a second run changes nothing" + +! grep -q '^mount \|^umount \|^mkinitcpio ' "$F/boot.log" && [[ ! -e $R/etc/crypttab && ! -e $state/backup/boot-switch ]] && + [[ $(cat "$R/etc/default/grub") == 'GRUB_CMDLINE_LINUX=""' && $(cat "$F/mounts") == "$R/boot/efi" ]] || + fail "an unencrypted Mac with its ESP at /boot/efi has no boot switch to stage" "$(cat "$F/boot.log")" +grep -q "^dispatch setup-boot$" "$F/boot.log" && grep -q "^limine-boot activate$" "$F/boot.log" || + fail "the new runtime's setup-boot activates Limine" "$(cat "$F/boot.log")" +pass "an unencrypted legacy Mac keeps its layout and unlock: Limine is its only boot change" + +# --- Interruption at every checkpoint ------------------------------------------- + +interrupt() { # when point step + local when=$1 point=$2 step=$3 status=0 output last transactions=1 partial + new_fixture "kill-$when-$point" checkout + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $point" "status $status: $output" + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "$step done"* ]] || fail "killed $when $point, the journal ends with $step done" "$last" + else + [[ $last == "$step begin"* || $last == "repositories boundary" ]] || fail "killed $when $point, the journal ends with $step begun" "$last" + fi + finish + # pacman's own half-extracted files are backed up too, beside the originals. + partial='^\./converted/files/(usr/share/omarchy/|usr/bin/omarchy-)' + [[ $(outcome | grep -Ev "$partial") == "$(grep -Ev "$partial" <<<"$baseline")" ]] || + fail "killed $when $point, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $point == "extraction" || $(outcome) == "$baseline" ]] || fail "killed $when $point, the backup matches an uninterrupted one" + [[ $when$point == "midtransaction" || $when$point == "midextraction" ]] && transactions=2 + [[ $(grep -c '^transaction ' "$F/pacman.log") == "$transactions" && $(grep -c '^remove ' "$F/pacman.log") == 1 ]] || + fail "killed $when $point: $transactions package transaction(s) and one removal" "$(cat "$F/pacman.log")" + [[ $(grep '^transaction \|^remove \|^hooks' "$F/pacman.log" | tail -n 1) == "hooks" ]] || fail "killed $when $point: the last transaction's hooks ran" +} + +for step in "${steps[@]}"; do + interrupt after "$step" "$step" + interrupt during "$step" "$step" +done +for step in backup keyring prefetch repositories boot-chain loader defaults unpin reboot retire; do + interrupt mid "$step" "$step" +done +interrupt mid loader-leaf loader +for point in unwire convert extraction transaction removals; do + interrupt mid "$point" transaction +done +pass "a kill -9 at every checkpoint, the conversion's own included, resumes to the same end" + +# --- A channel Mac with a dev link ------------------------------------------------ + +new_fixture channel channel +before=$(checkout_digest) +finish +grep -q "^omarchy-dev 4.0.0.r7000.gabc-1.1$" "$R/var/lib/pacman/local/packages" && grep -q "^omarchy-settings-dev 4.0.0.r7000.gabc-1.1$" "$R/var/lib/pacman/local/packages" && + ! grep -q "^omarchy \|^omarchy-settings " "$R/var/lib/pacman/local/packages" || + fail "the lane's rc pair is replaced by the omarchy-dev pair" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^omarchy-keyring 20260920-1$" "$R/var/lib/pacman/local/packages" && grep -q "^ttf-jetbrains-mono-nerd-basic 3.4.0-2$" "$R/var/lib/pacman/local/packages" || + fail "the packages the checkout built move to their official builds" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^transaction .* quickshell-git omarchy-keyring ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring$" "$F/pacman.log" || + fail "the checkout's own builds are named" "$(grep transaction "$F/pacman.log")" +! grep -q "omarchy-mac-keyring" "$R/var/lib/pacman/local/packages" || fail "the keyring the rc pair depended on is removed" +! grep -q "omarchy-aarch64\|TrustedOnly" "$R/etc/pacman.conf" || fail "the rc5-style fork repository is gone too" +[[ -z $(trustall_anywhere) ]] || fail "the Quattro upgrade's TrustAll on [omarchy] is gone with every other" "$(trustall_anywhere)" +! grep -A2 '^\[omarchy\]$' "$R/etc/pacman.conf" | grep -q SigLevel || fail "[omarchy] inherits the global SigLevel" +! grep -q "^$fork " "$R/etc/pacman.d/gnupg/keys" || fail "the fork key is gone" +[[ ! -e $R/etc/sudoers.d/omarchy-dev-path && $(<"$R/etc/omarchy.conf") == 'export OMARCHY_PATH="/usr/share/omarchy"' ]] || + fail "a dev link to a fork checkout no longer runs as root or as Omarchy" +grep -qx "omarchy-settings-dev /usr/share/uwsm/env.d/10-omarchy" "$R/var/lib/pacman/local/files" || + fail "an unowned file the setup wrote is taken over" +[[ ! -e $R/etc/mkinitcpio.conf.d/omarchy_hooks.conf ]] || fail "the fork settings' busybox HOOKS line goes with the package" +[[ $(checkout_digest) == "$before" ]] || fail "the checkout is untouched" +pass "a channel Mac with a strict fork repository and a dev link converts to the omarchy-dev pair, keyring and link retired" + +# --- The channel the fork's lane names --------------------------------------------- + +# Without an administrator's target, the Mac follows the channel its +# [omarchy-aarch64] lane is named after. +lane_fixture() { # name layout channel + new_fixture "$1" "$2" + rm "$R/etc/omarchy-mac/migration-target" + sed -i "s|^Server = file://$F/repos/omarchy-aarch64$|Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/$3|" "$R/etc/pacman.conf" + cp -r "$F/repos/omarchy" "$F/repos/official-$3" +} + +lane_fixture lane-rc channel rc +archive omarchy 4.0.4-1 +archive omarchy-mac-boot 20260927-1 +output=$(migrate check 2>&1) || fail "an rc lane follows the rc channel" "$output" +grep -q "Ready: run moves this Mac (legacy, grub boot) onto repository file://$F/repos/official-rc (rc)" <<<"$output" || + fail "the rc lane's Mac moves to the rc channel" "$output" +grep -q "It installs: omarchy omarchy-settings omarchy-mac " <<<"$output" || fail "the rc channel keeps the stock pair" "$output" + +lane_fixture lane-edge channel edge +archive omarchy-dev 4.0.0.r6713.ga85e29a-1 +archive omarchy-mac-boot 20260927-1 +finish +grep -q "^transaction omarchy/omarchy-dev omarchy/omarchy-settings-dev omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook quickshell-git omarchy-keyring ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring$" "$F/pacman.log" || + fail "each official package is named in [omarchy]" "$(grep transaction "$F/pacman.log")" +grep -q "^omarchy-dev 4.0.0.r6713.ga85e29a-1$" "$R/var/lib/pacman/local/packages" && ! grep -q "^omarchy \|omarchy-mac-keyring" "$R/var/lib/pacman/local/packages" || + fail "the edge lane's Mac ends on the omarchy-dev pair, fork pair and keyring gone" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^download omarchy-dev \|^download omarchy-mac-boot " "$F/pacman.log" || fail "preflight reads the verified archives" +[[ $(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") == "$(core_conf "file://FIXTURE/repos/official-edge")" ]] || + fail "pacman.conf is the core configuration for the edge channel" "$(cat "$R/etc/pacman.conf")" +[[ $(sed -n 's/^target=//p' "$(state_dir)/complete") == "repository file://$F/repos/official-edge" ]] || fail "the edge channel is the migration's target" +pass "without an administrator's target, the fork lane's channel (stable, rc or edge) is the one this Mac moves to" + +# --- Refusals ------------------------------------------------------------------------ + +new_fixture refusals checkout +rm "$R/usr/share/omarchy" +legacy_refused "a 3.x checkout" "upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" +new_fixture refusals channel +sed -i 's/^\[options\]$/[options]\nIgnorePkg = omarchy omarchy-settings # omarchy-install-pair/' "$R/etc/pacman.conf" +legacy_refused "a pinned channel install" "holds back omarchy, which the migration changes" +new_fixture refusals channel +: >"$R/var/cache/omarchy/channels/transaction.Stale01/restore-sync" +legacy_refused "an unfinished channel switch" "owes its sync databases a restore" +new_fixture refusals channel +printf '%s:4:\n%s:4:\n' "$fork" 3333333333333333333333333333333333333333 >"$R/usr/share/pacman/keyrings/omarchy-mac-trusted" +legacy_refused "a fork keyring with another key" "trusts 3333333333333333333333333333333333333333, a key this migration does not remove" +new_fixture refusals checkout +printf '\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///custom\n' >>"$R/etc/pacman.conf" +legacy_refused "a TrustAll repository the switch would keep" "\[custom\] accepts untrusted packages" +new_fixture refusals checkout +sed -i '/^\[omarchy-aarch64\]$/,/^Server/d' "$R/etc/pacman.conf" +printf '\nInclude = /etc/pacman.d/fork.conf\n' >>"$R/etc/pacman.conf" +printf '[omarchy-aarch64]\nSigLevel = Optional TrustAll\nServer = file://%s/repos/omarchy-aarch64\n' "$F" >"$R/etc/pacman.d/fork.conf" +legacy_refused "the fork repository an Include configures" "\[omarchy-aarch64\] is configured through an Include" +pass "preflight refuses pre-Quattro and mid-channel-switch legacy Macs, unknown fork keys, untrusted kept and included repositories, changing nothing" + +# --- Failures before the switch defer; after it they put things back --------------- + +# Before the repository switch a failure sets the attempt aside, nothing +# changed, and the next run starts over. +deferred() { # description reason-pattern + local status=0 conf packages + conf=$(cat "$R/etc/pacman.conf") + packages=$(cat "$R/var/lib/pacman/local/packages") + output=$(migrate run 2>&1) || status=$? + (( status == 75 )) && grep -q -- "$2" <<<"$output" || fail "$1: the migration defers" "status $status: $output" + [[ $(cat "$R/etc/pacman.conf") == "$conf" && $(cat "$R/var/lib/pacman/local/packages") == "$packages" ]] || + fail "$1: repositories and packages are untouched" + [[ -L $R/usr/share/omarchy ]] && grep -q "^$fork f$" "$R/etc/pacman.d/gnupg/keys" || fail "$1: the checkout and the fork key are untouched" + [[ ! -e $(state_dir)/journal && -n $(ls -d "$(state_dir)"/history/aborted-* 2>/dev/null) ]] || fail "$1: the attempt is set aside" + [[ $(migrate status) == *"The last run deferred: "*"$2"* ]] || fail "$1: status says why it deferred" "$(migrate status)" +} + +new_fixture fork-signed checkout +sed -i "s/^quickshell-git 0.2-1 .*/quickshell-git 0.2-1 $fork/" "$F/repos/omarchy/omarchy.db" +deferred "a package only the fork key signed" "cannot download and verify the target set" +grep -q "quickshell-git: signature from \"$fork\" is unknown trust" <<<"$output" || fail "the prefetch refuses the fork signature" "$output" +pass "packages are verified against the trust the switch leaves: one signed only by the fork key defers the migration" + +new_fixture owned checkout +echo "voxtype /usr/share/uwsm/env.d/10-omarchy" >>"$R/var/lib/pacman/local/files" +deferred "a file a kept package owns" "would overwrite /usr/share/uwsm/env.d/10-omarchy, which voxtype owns and keeps" +pass "a file another package keeps owning is never overwritten" + +new_fixture needs-keyring checkout +echo "voxtype 1.0-1 omarchy-mac-keyring" >>"$F/depends" +deferred "a kept package that needs the fork keyring" "rehearsed removal of omarchy-mac-keyring failed" +pass "the keyring's removal is rehearsed: a package still needing it defers the migration before any change" + +new_fixture prepare-fails checkout +kill_after repositories +mv "$(state_dir)/cache/pkg" "$F/pkg.moved" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "the archive of .* is not in the cache" <<<"$output" || fail "a missing archive stops the transaction" "status $status: $output" +[[ -L $R/usr/share/omarchy && -L $R/usr/bin/omarchy-update && $(<"$R/etc/omarchy.conf") == "export OMARCHY_PATH=\"$checkout\"" ]] || + fail "a conversion that cannot be prepared leaves the checkout wired" +mv "$F/pkg.moved" "$(state_dir)/cache/pkg" +finish +[[ -d $R/usr/share/omarchy && ! -L $R/usr/share/omarchy ]] || fail "the retried transaction converts the checkout" +pass "a conversion that cannot be prepared changes nothing, and the retry converts" + +new_fixture pruned checkout +echo cached >"$R/var/cache/pacman/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst" +kill_after prefetch +[[ $(stat -c %i "$(state_dir)/cache/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst") == $(stat -c %i "$R/var/cache/pacman/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst") ]] || + fail "an archive only pacman's cache holds is linked into the migration's" +rm "$R/var/cache/pacman/pkg/quickshell-git-0.2-1-aarch64.pkg.tar.zst" +finish +pass "the archives the conversion reads survive pacman's cache being pruned" + +new_fixture pacman-fails channel +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && [[ -f $R/etc/sudoers.d/omarchy-dev-path && $(<"$R/etc/omarchy.conf") == "export OMARCHY_PATH=\"$checkout\"" ]] || + fail "a failed transaction gives a dev link back its OMARCHY_PATH and sudo path" "status $status: $output" +rm "$F/fail-transaction" +finish +[[ ! -e $R/etc/sudoers.d/omarchy-dev-path ]] || fail "the retry drops the dev link's sudo path" + +new_fixture pacman-fails checkout +kill_after repositories +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "the package transaction failed" <<<"$output" || fail "a failed transaction fails the step" "status $status: $output" +[[ $(readlink "$R/usr/share/omarchy") == "$R$checkout" && $(readlink "$R/usr/bin/omarchy-update") == "$R$checkout/bin/omarchy-update" ]] || + fail "the checkout's links come back when pacman fails" +grep -q "^IgnorePkg = " "$R/etc/pacman.conf" || fail "the guard stays while the transaction is owed" +rm "$F/fail-transaction" +finish +[[ -d $R/usr/share/omarchy && ! -L $R/usr/share/omarchy ]] || fail "the retried transaction converts the checkout" +pass "a failed transaction puts the checkout's links and a dev link's paths back, and the retry converts" + +# --- The boot switch -------------------------------------------------------------- + +luks_uuid=5b1f0c2e-8a44-4f1d-9d7e-3c2a1b0e9f11 +fs_uuid=0a1b2c3d-4e5f-4061-8a9b-c0d1e2f3a4b5 +converged_hooks="base systemd autodetect microcode modconf kms keyboard sd-vconsole block asahi omarchy-vendorfw omarchy-mac-encrypt sd-encrypt filesystems fsck" +fork_hooks="base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck" + +# The ESP mounted at /boot, as omarchy-system-boot-to-esp leaves it: GRUB, the +# Asahi kernel and its busybox image live on it, and the root's own /boot is an +# empty directory beneath. The fixture's mount stand-ins link a mountpoint to +# $F/esp. HOOKS and images come from omarchy-mac-boot's real resolver over +# mkinitcpio.conf and the drop-ins (the Apple boot package's and the fork's +# omarchy_hooks.conf), and the transaction runs the kernel's install hook. +esp_at_boot() { + local hooks=$1 + rm "$F/hooks" + : >"$F/kernel-hook" + install -m 755 "$mac_boot/bin/omarchy-mac-initramfs-hooks" "$mac_boot/bin/omarchy-mac-limine-cmdline" "$R/usr/bin/" + cp "$mac_boot"/mkinitcpio.conf.d/*.conf "$R/etc/mkinitcpio.conf.d/" + mkdir -p "$F/esp" "$F/covered" + mv "$R/boot/efi/EFI" "$R/boot/efi/m1n1" "$R/boot/grub" "$F/esp/" + rmdir "$R/boot/efi" + mv "$R/boot" "$F/covered/_boot" + ln -s "$F/esp" "$R/boot" + printf '%s\n' "$R/boot" >"$F/mounts" + echo UUID=4A1B-2C3D >"$F/esp-device" + echo "aurora kernel 7.1.12" >"$R/usr/lib/modules/7.1.12-aurora/vmlinuz" + echo "asahi kernel 6.19.1" >"$F/esp/vmlinuz-linux-asahi" + printf 'MODULES=(btrfs)\nHOOKS=(%s)\n' "$hooks" >"$R/etc/mkinitcpio.conf" + printf 'UUID=%s / btrfs rw,noatime,compress=zstd:3,subvol=/@ 0 0\nUUID=4A1B-2C3D /boot vfat rw,relatime,fmask=0022,dmask=0022 0 2\n' "$fs_uuid" >"$R/etc/fstab" + # The fork's line, which sorts after every Apple drop-in and sets HOOKS outright. + printf 'HOOKS=(%s)\n' "$fork_hooks" >"$R/etc/mkinitcpio.conf.d/omarchy_hooks.conf" + OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F PATH="$stubs:$PATH" mkinitcpio -p linux-asahi >/dev/null + : >"$F/boot.log" + # omarchy-mac-boot's setup-boot through the dispatcher: the menu's kernel line + # derived from GRUB's defaults by the real omarchy-mac-limine-cmdline, and a + # UKI carrying the kernel line and /boot's kernel and initramfs. It needs the + # ESP at /boot/efi. + cat >"$F/setup-boot-leaf" <<'LEAF' +root=$OMARCHY_MAC_MIGRATE_ROOT +[[ -d $root/boot/efi/EFI/BOOT ]] || { echo "limine-boot: the ESP is not mounted at /boot/efi" >&2; exit 1; } +echo "limine-boot activate" >>"$MIGRATE_FIXTURE/boot.log" +printf 'ESP_PATH="/boot/efi"\nKERNEL_CMDLINE[default]=""\n' >"$root/etc/default/limine" +OMARCHY_GRUB_DEFAULT=$root/etc/default/grub OMARCHY_LIMINE_DEFAULT=$root/etc/default/limine OMARCHY_FSTAB=$root/etc/fstab \ + "$root/usr/bin/omarchy-mac-limine-cmdline" || exit 1 +if [[ ${OMARCHY_MAC_MIGRATE_KILL_MID:-} == "loader-leaf" && ! -e $MIGRATE_FIXTURE/killed-in-leaf ]]; then + : >"$MIGRATE_FIXTURE/killed-in-leaf" + kill -9 "$PPID" $$ +fi +limine-update || exit 1 +{ sed -n 's/^KERNEL_CMDLINE\[default\]=//p' "$root/etc/default/limine"; cat "$root/boot/vmlinuz-linux-aurora" "$root/boot/initramfs-linux-aurora.img"; } \ + >"$root/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" +cp "$root/usr/share/limine/BOOTAA64.EFI" "$root/boot/efi/EFI/BOOT/BOOTAA64.EFI" +LEAF +} + +# An encrypted legacy Mac as the quattro guided installer (#155) left it: the +# root is LUKS2 opened as root by cryptdevice= from GRUB's defaults, and busybox +# encrypt is in mkinitcpio.conf's own HOOKS and the fork's omarchy_hooks.conf. +encrypted_fixture() { + new_fixture "$1" "${2:-checkout}" + esp_at_boot "base asahi udev autodetect microcode modconf kms keyboard keymap consolefont block encrypt filesystems fsck" + echo "$luks_uuid" >"$F/luks-uuid" + printf 'GRUB_DEFAULT=0\nGRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=%s:root:allow-discards loglevel=3 quiet splash"\nGRUB_CMDLINE_LINUX=""\n' \ + "$luks_uuid" >"$R/etc/default/grub" + printf 'menuentry Omarchy {\n linux /vmlinuz-linux-asahi root=UUID=%s rw rootflags=subvol=@ cryptdevice=UUID=%s:root:allow-discards\n initrd /initramfs-linux-asahi.img\n}\n' \ + "$fs_uuid" "$luks_uuid" >"$F/esp/grub/grub.cfg" + printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p6 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" + echo "/dev/mapper/root[/@]" >"$F/root-source" +} + +# GRUB's chain on the ESP still boots and unlocks: GRUB holds U-Boot's slot, +# its menu passes cryptdevice=, and every image on the ESP is busybox with +# encrypt. +grub_boots_esp() { + local image found=0 + [[ $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "grub" ]] && grep -q "cryptdevice=UUID=$luks_uuid:root" "$F/esp/grub/grub.cfg" || return 1 + for image in "$F"/esp/initramfs-linux-*.img; do + [[ -f $image ]] || continue + grep -qx hooks/encrypt "$image" && grep -qx init_functions "$image" || return 1 + found=1 + done + (( found )) +} + +# Everything the boot switch leaves, however it got there. +switch_outcome() { + outcome + cat "$R/etc/fstab" "$R/etc/default/grub" "$R/etc/mkinitcpio.conf" + cat "$R/etc/crypttab" 2>/dev/null || echo "no crypttab" + ls "$R/etc/mkinitcpio.conf.d" + (cd "$R/boot" && find . -type f | LC_ALL=C sort && cat initramfs-linux-aurora.img) + (cd "$F/esp" && find . -type f | LC_ALL=C sort && cat EFI/Linux/omarchy_linux-aurora.efi) + sed "s|$R|ROOT|" "$F/mounts" + [[ -L $R/boot/efi && ! -L $R/boot ]] && echo "the ESP at /boot/efi, the root's /boot beneath" + grep '^cryptsetup' "$F/pacman.log" | cut -d' ' -f2 | LC_ALL=C sort -u +} + +encrypted_fixture encrypted checkout +output=$(migrate run 2>&1) || fail "an encrypted legacy Mac migrates to its reboot" "$output" +grep -q "Reboot to finish" <<<"$output" || fail "the encrypted Mac's run asks for a reboot" "$output" +state=$(state_dir) +[[ $(<"$state/plan/adapter/unlock") == "busybox $luks_uuid 1" && $(<"$state/plan/esp") == "/boot" ]] || + fail "the plan records the busybox unlock and the ESP at /boot" "$(cat "$state/plan/adapter/unlock" "$state/plan/esp")" +pass "an encrypted legacy Mac (busybox encrypt, /boot on the ESP) is not refused" + +[[ $(sed "s|$R|ROOT|" "$F/mounts") == "ROOT/boot/efi" && -L $R/boot/efi && ! -L $R/boot ]] && + grep -qx "UUID=4A1B-2C3D /boot/efi vfat rw,relatime,fmask=0022,dmask=0022 0 2" "$R/etc/fstab" || + fail "the ESP moves from /boot to /boot/efi, in fstab and mounted" "$(cat "$R/etc/fstab" "$F/mounts")" +[[ $(<"$R/boot/vmlinuz-linux-aurora") == "aurora kernel 7.1.12" && -d $R/boot/grub ]] || + fail "the root's /boot gets the Aurora kernel and the directory update-grub needs" +[[ $(sed -n 's/^HOOKS //p' "$R/boot/initramfs-linux-aurora.img") == "$converged_hooks" ]] || + fail "the root's /boot gets the converged systemd image the Apple boot package composes" "$(cat "$R/boot/initramfs-linux-aurora.img")" +[[ $(cat "$R/etc/crypttab") == "root UUID=$luks_uuid none luks,discard" ]] || fail "crypttab names the root's LUKS partition, discards kept" "$(cat "$R/etc/crypttab")" +[[ $(cat "$R/etc/default/grub") == "GRUB_DEFAULT=0 +GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet splash\" +GRUB_CMDLINE_LINUX=\"rd.luks.name=$luks_uuid=root rd.luks.options=$luks_uuid=discard\"" ]] || + fail "GRUB's defaults trade cryptdevice= for rd.luks.name= and rd.luks.options=" "$(cat "$R/etc/default/grub")" +grep -qx "HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block filesystems fsck)" "$R/etc/mkinitcpio.conf" && + [[ ! -e $R/etc/mkinitcpio.conf.d/omarchy_hooks.conf && -f $state/backup/boot-switch/files/etc/mkinitcpio.conf.d/omarchy_hooks.conf ]] || + fail "busybox encrypt and asahi leave mkinitcpio.conf and the fork's omarchy_hooks.conf goes to the backup" "$(cat "$R/etc/mkinitcpio.conf")" +for path in etc/fstab etc/default/grub etc/mkinitcpio.conf; do + grep -q "cryptdevice\|encrypt\|/boot vfat" "$state/backup/boot-switch/files/$path" || fail "the switch keeps the original $path" +done +[[ -f $state/backup/boot-switch/absent/etc/crypttab ]] || fail "the switch records that crypttab did not exist" +pass "the ESP moves to /boot/efi and the unlock to crypttab, rd.luks.name= and the converged systemd image, each original kept" + +uki=$(cat "$F/esp/EFI/Linux/omarchy_linux-aurora.efi") +[[ $(head -n 1 <<<"$uki") == "\"root=UUID=$fs_uuid rw rootflags=subvol=@ rd.luks.name=$luks_uuid=root rd.luks.options=$luks_uuid=discard loglevel=3 quiet splash\"" ]] && + grep -qx "aurora kernel 7.1.12" <<<"$uki" && grep -qx "usr/lib/systemd/system-generators/systemd-cryptsetup-generator" <<<"$uki" || + fail "Limine's UKI boots Aurora with the systemd image and unlocks the root by rd.luks.name=, without cryptdevice=" "$uki" +[[ $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" && -e $R/var/lib/omarchy/limine.enabled ]] || fail "Limine takes U-Boot's slot" +grep -q "^HOOKS $fork_hooks$" "$F/esp/initramfs-linux-aurora.img" && grep -qx hooks/encrypt "$F/esp/initramfs-linux-aurora.img" || + fail "the transaction still built the busybox image GRUB boots" "$(cat "$F/esp/initramfs-linux-aurora.img")" +[[ $(grep -E '^(mkinitcpio|update-grub|boot-check|umount|mount|dispatch setup-boot|limine-boot)' "$F/boot.log" | tr '\n' '|') == \ + "boot-check pending --boot-chain|mkinitcpio -p linux-aurora|update-grub |boot-check pending --boot-chain linux-aurora|umount /boot|mount /boot/efi|mkinitcpio -p linux-aurora|dispatch setup-boot|limine-boot activate|boot-check pending --boot-chain linux-aurora|boot-check pending --boot-chain linux-aurora|" ]] || + fail "the busybox image and GRUB are rebuilt and checked, then the ESP moves and the new image is built, before setup-boot gives Limine the slot" "$(cat "$F/boot.log")" +[[ $(grep '^cryptsetup' "$F/pacman.log" | cut -d' ' -f2 | sort -u | xargs) == "luksHeaderBackup luksUUID" ]] || + fail "the LUKS header, keyslots and passphrase are never changed, only backed up and read" "$(grep cryptsetup "$F/pacman.log")" +pass "Limine's UKI unlocks the same LUKS root with its passphrase through sd-encrypt; the header is only backed up" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the encrypted Mac's migration completes after its reboot" "$output" +[[ -f $state/complete ]] || fail "the encrypted Mac's migration completes" +[[ ! -e $F/esp/vmlinuz-linux-aurora && ! -e $F/esp/initramfs-linux-aurora.img && ! -e $F/esp/vmlinuz-linux-asahi && ! -e $F/esp/grub ]] && + [[ -f $F/esp/m1n1/boot.bin && -f $F/esp/EFI/BOOT/BOOTAA64.EFI && -f $F/esp/EFI/Linux/omarchy_linux-aurora.efi ]] || + fail "retire removes the kernels and GRUB the moved ESP still carried, and keeps m1n1, Limine and the UKI" "$(cd "$F/esp" && find . -type f)" +tar -tf "$state/backup/esp.tar" | grep -q '^./grub/grub.cfg$' && tar -tf "$state/backup/esp.tar" | grep -q '^./vmlinuz-linux-asahi$' || + fail "the backup holds the ESP as it was, GRUB's chain included" +[[ ! -e $state/backup/boot.tar ]] || fail "an ESP at /boot is backed up once" +encrypted_baseline=$(switch_outcome) +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "a second run changes nothing" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +pass "after the verified reboot the moved ESP's old kernels and GRUB are retired, and a second run changes nothing" + +# A channel install, whose fork omarchy-settings owned omarchy_hooks.conf: the +# transaction takes it away with the package. +encrypted_fixture encrypted-channel channel +finish +[[ $(sed -n 's/^HOOKS //p' "$R/boot/initramfs-linux-aurora.img") == "$converged_hooks" ]] || + fail "the Apple drop-ins move the channel Mac's line to systemd and sd-encrypt too" "$(cat "$R/boot/initramfs-linux-aurora.img")" +[[ $(cat "$R/etc/crypttab") == "root UUID=$luks_uuid none luks,discard" && ! -e $R/etc/mkinitcpio.conf.d/omarchy_hooks.conf ]] || + fail "the channel Mac unlocks through crypttab with no busybox line left" +pass "an encrypted channel Mac switches its unlock the same way" + +# --- The boot switch cut short ------------------------------------------------------ + +switch_interrupt() { # when point step + local when=$1 point=$2 step=$3 status=0 output last + encrypted_fixture "switch-kill-$when-$point" + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$point" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $point" "status $status: $output" + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "$step done"* ]] || fail "killed $when $point, the journal ends with $step done" "$last" + else + [[ $last == "$step begin"* || $last == "repositories boundary" ]] || fail "killed $when $point, the journal ends with $step begun" "$last" + fi + if [[ ! -e $R/var/lib/omarchy/limine.enabled || $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "grub" ]]; then + grub_boots_esp || fail "killed $when $point before Limine took the slot, GRUB's chain still unlocks the root" "$(cd "$F/esp" && find . -type f)" + fi + finish + [[ $(switch_outcome) == "$encrypted_baseline" ]] || + fail "killed $when $point, the resumed switch ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +} + +for step in "${steps[@]}"; do + switch_interrupt after "$step" "$step" + switch_interrupt during "$step" "$step" +done +for point in backup transaction boot-chain loader defaults unpin reboot retire; do + switch_interrupt mid "$point" "$point" +done +for point in esp-fstab esp-unmounted unlock initramfs loader-leaf; do + switch_interrupt mid "$point" loader +done +pass "a kill -9 anywhere in the switch leaves GRUB's chain unlocking the root until Limine takes the slot, and resumes to the same end" + +# --- A failed stage leaves GRUB -------------------------------------------------- + +# Everything GRUB's chain and the root's configuration read, as before the stage. +before_stage() { + (cd "$F/esp" && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum) + (cd "$R" && sha256sum etc/fstab etc/default/grub etc/mkinitcpio.conf etc/mkinitcpio.conf.d/*) + cat "$R/etc/crypttab" 2>/dev/null || echo "no crypttab" + (cd "$R/boot" && find . | LC_ALL=C sort) + (cd "$F/covered/_boot" 2>/dev/null && find . | LC_ALL=C sort) + sed "s|$R|ROOT|" "$F/mounts" + [[ -L $R/boot ]] && echo "the ESP at /boot" +} + +stage_fails() { # description fixture-change reason-pattern + local status=0 output snapshot + encrypted_fixture "stage-fails-$1" + kill_after boot-chain + eval "$2" + snapshot=$(before_stage) + output=$(migrate run 2>&1) || status=$? + (( status == 1 )) && grep -q -- "$3" <<<"$output" && grep -q "GRUB is still the loader" <<<"$output" || + fail "$1: the loader step fails and says GRUB boots" "status $status: $output" + [[ $(before_stage) == "$snapshot" ]] || fail "$1: the stage is undone, the ESP back at /boot" "$(diff <(echo "$snapshot") <(before_stage))" + grub_boots_esp && [[ ! -e $R/var/lib/omarchy/limine.enabled ]] || fail "$1: GRUB's chain still unlocks the root" + [[ $(migrate status) == *"failed at loader"* ]] || fail "$1: status names the failed loader step" "$(migrate status)" +} + +stage_fails mkinitcpio ': >"$F/mkinitcpio-fail"' "mkinitcpio -p linux-aurora failed" +rm "$F/mkinitcpio-fail" +finish +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "the retried switch ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +stage_fails limine ': >"$F/limine-activation-fail"' "Limine could not be activated" +rm "$F/limine-activation-fail" +finish +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "the retried activation ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +stage_fails local-hooks 'printf "HOOKS+=(encrypt)\n" >"$R/etc/mkinitcpio.conf.d/99-local.conf"' "do not unlock the root through systemd" +# The ESP cannot leave /boot: nothing on it may be removed on the way back. +stage_fails umount-busy ': >"$F/umount-busy"' "cannot unmount the ESP from /boot" +[[ ! -e $(state_dir)/backup/boot-switch ]] || fail "an undone stage keeps no stale originals for the next" +rm "$F/umount-busy" +finish +[[ $(switch_outcome) == "$encrypted_baseline" ]] || fail "the retried move ends where an uninterrupted one does" "$(diff <(echo "$encrypted_baseline") <(switch_outcome))" +pass "a stage or activation that fails is undone, GRUB keeps booting the Mac, and the retry finishes" + +# --- An unencrypted Mac with its ESP at /boot --------------------------------------- + +new_fixture plain-esp-boot checkout +esp_at_boot "base asahi udev autodetect microcode modconf kms keyboard keymap consolefont block filesystems fsck" +hooks_before=$(OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F PATH="$stubs:$PATH" omarchy-mac-initramfs-hooks) +grub_before=$(cat "$R/etc/default/grub") +finish +[[ -L $R/boot/efi && ! -L $R/boot ]] && grep -q " /boot/efi vfat " "$R/etc/fstab" || fail "an unencrypted Mac's ESP moves to /boot/efi too" +[[ ! -e $R/etc/crypttab && $(cat "$R/etc/default/grub") == "$grub_before" ]] && ! grep -q '^cryptsetup' "$F/pacman.log" || + fail "an unencrypted Mac stays unencrypted: no crypttab, no LUKS, its kernel line kept" +[[ $(sed -n 's/^HOOKS //p' "$R/boot/initramfs-linux-aurora.img") == "$hooks_before" ]] || + fail "an unencrypted Mac keeps its HOOKS" "$(cat "$R/boot/initramfs-linux-aurora.img")" +[[ $(cat "$F/esp/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" ]] && grep -q "^\"root=UUID=$fs_uuid rw rootflags=subvol=@\"$" "$F/esp/EFI/Linux/omarchy_linux-aurora.efi" || + fail "an unencrypted Mac boots Aurora's UKI from Limine with no unlock" "$(cat "$F/esp/EFI/Linux/omarchy_linux-aurora.efi")" +pass "an unencrypted Mac with its ESP at /boot moves it and boots Limine, and stays unencrypted" + +# --- Refusals of an encrypted Mac ---------------------------------------------------- + +encrypted_fixture refusals +sed -i "s/:root:allow-discards/:cryptroot/" "$R/etc/default/grub" +legacy_refused "a mapping other than root" "do not pass the one cryptdevice=UUID=$luks_uuid:root" +encrypted_fixture refusals +sed -i "s/^GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"cryptkey=rootfs:\/key\"/" "$R/etc/default/grub" +legacy_refused "a key file" "found: cryptkey=rootfs:/key cryptdevice" +encrypted_fixture refusals +sed -i "s/$luks_uuid:root/0000-1111:root/" "$R/etc/default/grub" +legacy_refused "another LUKS partition" "do not pass the one cryptdevice=UUID=$luks_uuid:root" +encrypted_fixture refusals +sed -i 's/ encrypt / /' "$R/etc/mkinitcpio.conf" +legacy_refused "busybox encrypt only in a drop-in" "not in /etc/mkinitcpio.conf's own HOOKS" +encrypted_fixture refusals +printf 'root UUID=0000-1111 none luks\n' >"$R/etc/crypttab" +legacy_refused "crypttab naming another root" "/etc/crypttab names another root" +encrypted_fixture refusals +sed -i 's/loglevel=3/loglevel=$LEVEL/' "$R/etc/default/grub" +legacy_refused "a kernel line with shell expansion" "uses shell expansion" +encrypted_fixture refusals +: >"$R/var/lib/omarchy/limine.enabled" +printf 'KERNEL_CMDLINE[default]=""\n' >"$R/etc/default/limine" +legacy_refused "a Limine Mac with its ESP at /boot" "moves those only on a GRUB Mac" +encrypted_fixture refusals +sed -i '/ \/boot vfat /d' "$R/etc/fstab" +legacy_refused "an ESP at /boot fstab does not mount" "no single vfat line mounting it there" +new_fixture refusals checkout +printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p6 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" +echo "/dev/mapper/root[/@]" >"$F/root-source" +echo "$luks_uuid" >"$F/luks-uuid" +printf 'GRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=%s:root"\n' "$luks_uuid" >"$R/etc/default/grub" +printf 'HOOKS=(base udev block encrypt filesystems)\n' >"$R/etc/mkinitcpio.conf" +legacy_refused "an encrypted Mac whose ESP is at /boot/efi" "kernels are not on the ESP mounted at /boot" +pass "preflight refuses encrypted legacy Macs outside the guided installer's layout, changing nothing" diff --git a/test/shell.d/mac-migrate-mx-test.sh b/test/shell.d/mac-migrate-mx-test.sh new file mode 100644 index 00000000000..589cc66f1f5 --- /dev/null +++ b/test/shell.d/mac-migrate-mx-test.sh @@ -0,0 +1,685 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/test/fixtures/mac-migrate/lib.sh" + +# bin/omarchy-mac-migrate moves an mx-mac Mac, as the M1 Pro runs it, onto +# Omarchy's official packages: the fork's omarchy-dev pair and bundle, its +# signed [omarchy] and [omarchy-aurora] releases and keys, Aurora, Limine and +# an encrypted root. On edge the fork's omarchy-dev pair, which sorts above +# Omarchy's own dev builds, is downgraded to them by name in the one +# transaction. The fake pacman rejects a database beside a signature that does +# not match it, as pacman does. + +fork_key=C81AC3E2A99556F9B21D5FEA3DD49BC9F8360BDC +release_key=5983B1CA32CB778F4D74D24ECFF35022CA5B5959 +fork_dev=4.0.4.r7081.gca187b0-1 +official_dev=4.0.0.r6713.ga85e29a-1 + +# The signed candidate set: the edge pair and Mac set, a build below the fork's +# pinta and a package the Mac never had. +SET_EXTRA=$'pinta 3.1.2-1.1\navd-fw 0.1-1' make_set "$tmp/mx-set" "$tmp/signer" + +# A fork release's database is signed; the signature matches only that database. +sign_repo() { + local db=$F/repos/$1/$2.db + echo "signed $(sha256sum "$db" | cut -d' ' -f1)" >"$db.sig" +} + +# The fork's [omarchy] and [omarchy-aurora] release sections, as its channel +# updaters write them. +fork_pacman_conf() { + cat <"$R/etc/pacman.conf" + for name in omarchy-fork/omarchy omarchy-aurora/omarchy-aurora; do + cp "$F/repos/$name.db" "$F/repos/$name.db.sig" "$R/var/lib/pacman/sync/" + done + grep -q "^$fork_key " "$R/etc/pacman.d/gnupg/keys" || echo "$fork_key f" >>"$R/etc/pacman.d/gnupg/keys" +} + +# An mx-mac Mac as the M1 Pro runs it: the fork's runtime pair and bundle, +# Aurora and U-Boot from the fork's releases, Limine in the loader slot and the +# encrypted root unlocked by sd-encrypt. No omarchy-mac or omarchy-mac-boot is +# installed. Omarchy's edge [omarchy] carries the Mac set and its own dev pair; +# the administrator's target is the signed candidate set on edge. +new_fixture() { + F=$tmp/$1 + R=$F/root + rm -rf "$F" + mkdir -p "$R/run/systemd/system" "$R/run/lock" "$R/var/tmp" "$R/proc/sys/kernel/random" "$R/etc/default" "$R/etc/omarchy-mac" \ + "$R/var/lib/pacman/local" "$R/var/lib/pacman/sync" "$R/var/cache/pacman/pkg" "$R/etc/pacman.d/gnupg" \ + "$R/usr/share/pacman/keyrings" "$R/usr/share/limine" "$R/boot/grub" "$R/boot/efi/EFI/BOOT" "$R/boot/efi/EFI/Linux" "$R/boot/efi/m1n1" \ + "$R/usr/lib/modules/7.1.12-aurora" "$R/var/lib/omarchy/mac-first-boot" + echo boot-1 >"$R/proc/sys/kernel/random/boot_id" + echo 7.1.12-aurora >"$R/proc/sys/kernel/osrelease" + echo linux-aurora >"$R/usr/lib/modules/7.1.12-aurora/pkgbase" + echo "limine 12.9" >"$R/usr/share/limine/BOOTAA64.EFI" + cp "$R/usr/share/limine/BOOTAA64.EFI" "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" + echo uki >"$R/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" + printf '/+Omarchy\n //linux-aurora\n path: boot():/EFI/Linux/omarchy_linux-aurora.efi#abc\n' >"$R/boot/efi/limine.conf" + : >"$R/var/lib/omarchy/limine.enabled" + printf 'ESP_PATH="/boot/efi"\nKERNEL_CMDLINE[default]="root=UUID=x rd.luks.name=abc=root"\n' >"$R/etc/default/limine" + echo "menuentry linux-aurora" >"$R/boot/grub/grub.cfg" + echo m1n1 >"$R/boot/efi/m1n1/boot.bin" + echo "GRUB_CMDLINE_LINUX=\"rd.luks.name=abc=root\"" >"$R/etc/default/grub" + echo "root UUID=abc none luks" >"$R/etc/crypttab" + printf 'format=1\nsequence=57\ntag=asahi-quattro-ca187b0a\n' >"$R/var/lib/omarchy/asahi-quattro-release" + printf 'format=1\nsequence=58\ntag=asahi-quattro-0123abcd\n' >"$R/var/lib/omarchy/asahi-quattro-release.pending" + printf 'format=1\ntag=asahi-packages-stable-2949b88c\n' >"$R/var/lib/omarchy/asahi-package-repository" + printf 'format=1\nchannel=aurora\nrelease_tag=aurora-packages-3caea469\n' >"$R/var/lib/omarchy/aurora-target.descriptor" + printf 'format=1\nchannel=edge\nkernel=linux-aurora\n' >"$R/var/lib/omarchy/apple-silicon-channel" + printf 'format=1\nlane=edge\n' >"$R/var/lib/omarchy/apple-silicon-aurora-lane" + printf 'format=1\nencrypt=1\n' >"$R/var/lib/omarchy/mac-first-boot/install.conf" + for keyring in archlinuxarm asahi-alarm omarchy; do + : >"$R/usr/share/pacman/keyrings/$keyring.gpg" + done + echo 1111111111111111111111111111111111111111 >"$R/usr/share/pacman/keyrings/archlinuxarm-trusted" + echo 2222222222222222222222222222222222222222 >"$R/usr/share/pacman/keyrings/asahi-alarm-trusted" + echo "$official" >"$R/usr/share/pacman/keyrings/omarchy-trusted" + printf '%s f\n' 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 "$official" "$fork_key" "$release_key" \ + >"$R/etc/pacman.d/gnupg/keys" + mkdir -p "$F/keyserver" + : >"$F/keyserver/$official" + + cat >"$R/var/lib/pacman/local/packages" <"$R/var/cache/pacman/pkg/$file" + done + repo core <<<"pacman 7.0.0-1" + printf 'hyprland 0.51-1\nlimine 12.9.0-1\nquickshell 0.3.1-1\n' | repo extra + printf 'asahi-scripts 20260127.1-1\nasahi-alarm-keyring 20250101-1\n' | repo asahi-alarm + printf 'dotnet-runtime 9.0.8.sdk100-1\nhyprland 0.50-1\nlimine-mkinitcpio-hook 1.36.0-3\nmise 2026.9.4-1\nobs-studio 32.2.2-1\npinta 3.1.2-2\nuboot-asahi 2026.07.asahi2-3\n' | + repo omarchy-fork omarchy + printf 'linux-aurora 7.1.12.aurora2-7\nlinux-aurora-headers 7.1.12.aurora2-7\nm1n1-aurora 1.6.1.aurora1-2\n' | repo omarchy-aurora + sign_repo omarchy-fork omarchy + sign_repo omarchy-aurora omarchy-aurora + repo omarchy <"$F/provides" + cat >"$F/conflicts" <<'CONFLICTS' +omarchy omarchy-dev +omarchy-settings omarchy-settings-dev +quickshell quickshell-git +mise-bin mise +linux-aurora linux-asahi +m1n1-aurora m1n1 +linux-aurora-headers linux-asahi-headers +CONFLICTS + + cp -r "$tmp/mx-set" "$F/set" + cat >"$R/etc/omarchy-mac/migration-target" <"$F/channel" + echo apple-silicon >"$F/platform" + echo "base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck" >"$F/hooks" + printf '%s\n' "$R/boot/efi" >"$F/mounts" + echo /dev/mapper/root >"$F/root-source" + printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p6 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" + : >"$F/pacman.log" + : >"$F/boot.log" +} + +# The archives preflight reads from a repository target: both runtimes and +# omarchy-mac-boot, at the versions [omarchy] lists. +repository_archives() { + archive omarchy-dev "$official_dev" + archive omarchy 4.0.4-1 + archive omarchy-mac-boot 20260927-1 +} + +# repository_target CHANNEL: the official [omarchy] as the administrator's +# repository target for CHANNEL. +repository_target() { + printf 'format=1\ntype=repository\nchannel=%s\nserver=file://%s/repos/omarchy\n' "$1" "$F" >"$R/etc/omarchy-mac/migration-target" + repository_archives +} + +# Everything a finished migration leaves that must not depend on how it got +# there: packages, configuration, databases, keys, loader, records and backups. +outcome() { + local state + state=$(state_dir) + cat "$R/var/lib/pacman/local/packages" + sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf" + sort "$R/etc/pacman.d/gnupg/keys" + cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" "$R/etc/default/limine" "$R/etc/crypttab" "$R/boot/efi/limine.conf" + ls "$R/var/lib/omarchy" + ls "$R/var/lib/pacman/sync" + cat "$R/var/lib/pacman/sync/omarchy.db" + [[ ! -e $R/var/lib/pacman/db.lck ]] && echo unlocked + sed -n 's/^target=//p' "$state/complete" + (cd "$state/backup" && find . -type f | LC_ALL=C sort && sed 's/^[0-9a-f]* //' SHA256SUMS) + ls "$state" + [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && echo "no unit" +} + +# --- The whole transition -------------------------------------------------- + +new_fixture baseline +crypt_before=$(cat "$R/etc/crypttab" "$R/boot/efi/limine.conf") +digest=$(fixture_digest) +output=$(migrate check 2>&1) || fail "check passes on an mx-mac Mac ready to migrate" "$output" +grep -q "Ready: run moves this Mac (mx-mac, limine boot, encrypted) onto candidate-set apple-test-fixture .* (edge)" <<<"$output" || + fail "check plans the Mac with the mx-mac adapter" "$output" +[[ $(fixture_digest) == "$digest" && ! -e $(state_dir)/journal ]] || fail "check changes nothing" +output=$(migrate run 2>&1) || fail "an mx-mac Mac migrates to its reboot" "$output" +grep -q "Reboot to finish the migration to candidate-set apple-test-fixture" <<<"$output" || fail "the run asks for a reboot" "$output" +state=$(state_dir) +[[ $(cat "$state/plan/cohort") == "mx-mac" ]] || fail "the Mac is planned as mx-mac" "$(cat "$state/plan/cohort")" +grep -q "^ExecStart=/var/lib/omarchy-mac/migration/tool/omarchy-mac-migrate verify$" "$R/etc/systemd/system/omarchy-mac-migrate-verify.service" && + cmp -s "$tool" "$state/tool/omarchy-mac-migrate" || fail "the post-reboot unit runs the tool's own kept copy" +pass "an mx-mac Mac is planned by its own adapter and migrated up to its reboot" + +expected_packages="asahi-alarm-keyring 20250101-1 +dotnet-runtime-bin 10.0.401-2 +hyprland 0.51-1 +limine 12.9.0-1 +limine-mkinitcpio-hook 1.39.0-2 +linux-aurora 7.1.12.aurora2-11 +linux-aurora-headers 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +mise-bin 2026.9.12-1 +obs-studio 32.2.2-1 +omarchy-dev 4.0.0.r7000.gabc-1.1 +omarchy-keyring 20251027-1 +omarchy-mac 0.1.0-11.1 +omarchy-mac-boot 20261004-1.1 +omarchy-nvim 2026.9.21-1 +omarchy-settings-dev 4.0.0.r7000.gabc-1.1 +pacman 7.0.0-1 +pinta 3.1.2-1.1 +quickshell 0.3.1-1 +ttf-jetbrains-mono-nerd-basic 3.5.1-1 +uboot-asahi 2026.07.asahi2-4" +[[ $(cat "$R/var/lib/pacman/local/packages") == "$expected_packages" ]] || + fail "one transaction moves the dev pair and the bundle to official builds and Aurora to the target" "$(diff <(echo "$expected_packages") "$R/var/lib/pacman/local/packages")" +grep -qx "transaction omarchy-mac-candidate/omarchy-dev omarchy-mac-candidate/omarchy-settings-dev omarchy-mac-candidate/omarchy-mac omarchy-mac-candidate/omarchy-mac-boot omarchy-mac-candidate/linux-aurora omarchy-mac-candidate/linux-aurora-headers omarchy-mac-candidate/m1n1-aurora omarchy-mac-candidate/uboot-asahi omarchy-mac-candidate/limine-mkinitcpio-hook omarchy-mac-candidate/pinta dotnet-runtime-bin hyprland mise-bin omarchy-keyring omarchy-nvim quickshell ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring" "$F/pacman.log" || + fail "the target's packages come from the target, and each other fork build an official repository carries is named" "$(grep transaction "$F/pacman.log")" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "one package transaction" +[[ $(cat "$state/plan/allowed-removals") == $'dotnet-runtime\nmise\nquickshell-git' && + $(cat "$state/plan/removals") == "$(cat "$state/plan/allowed-removals")" ]] || + fail "only the fork builds official ones of another name replace may be removed, and each is removed by name if it survives" "$(cat "$state/plan/allowed-removals" "$state/plan/removals")" +grep -qx "remove dotnet-runtime" "$F/pacman.log" || fail "a fork build its counterpart does not conflict with is removed by name" "$(cat "$F/pacman.log")" +grep -qx "obs-studio 32.2.2-1" "$state/plan/kept" || fail "a fork build with no official one is kept and listed" "$(cat "$state/plan/kept")" +! grep -q "^avd-fw " "$R/var/lib/pacman/local/packages" || fail "a target package the Mac never had is not installed" +pass "one transaction replaces the fork's bundle, downgrades a higher fork build, keeps what has no official build and adds no package the Mac lacks" + +# omarchy-dev 4.0.4.r7081 (the fork's) sorts above the target's 4.0.0.r7000: the +# pair is named in the candidate repository and downgraded in place, never +# removed and reinstalled. +! grep -q "^omarchy-dev$\|^omarchy-settings-dev$" "$state/plan/allowed-removals" "$state/plan/removals" || + fail "the fork's dev pair is never planned for removal on edge" +! grep -q "^remove .*omarchy-dev\|^remove .*omarchy-settings-dev" "$F/pacman.log" && [[ ! -e $state/overwrite ]] || + fail "the dev pair changes in the transaction, with no removal after it and no overwrite" "$(cat "$F/pacman.log")" +pass "on edge the fork's higher omarchy-dev pair is downgraded by name to the target's in the one transaction" + +conf=$(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") +expected_conf=$(OMARCHY_MAC_MIGRATE_ROOT=$R fixture=1 bash -c 'source <(sed -n "/^core_pacman_conf() {/,/^}/p" "$1"); core_pacman_conf "file://FIXTURE/repos/omarchy"' _ "$ROOT/migrate/src/target.sh" | + sed "s|^Server = https://github.com/asahi-alarm.*|Server = file://FIXTURE/repos/asahi-alarm|") +[[ $conf == "$expected_conf" ]] || fail "pacman.conf is the core Apple Silicon configuration, without either fork section" "$(diff <(echo "$expected_conf") <(echo "$conf"))" +[[ ! -e $R/var/lib/pacman/sync/omarchy-aurora.db && ! -e $R/var/lib/pacman/sync/omarchy-aurora.db.sig ]] || fail "the fork's Aurora database is gone" +[[ ! -e $R/var/lib/pacman/sync/omarchy.db.sig ]] && cmp -s "$F/repos/omarchy/omarchy.db" "$R/var/lib/pacman/sync/omarchy.db" || + fail "the official [omarchy] database replaces the fork's, without the fork's signature beside it" +! grep -q "^$fork_key \|^$release_key " "$R/etc/pacman.d/gnupg/keys" || fail "the fork's package and release keys are deleted" "$(cat "$R/etc/pacman.d/gnupg/keys")" +grep -q "^$official f" "$R/etc/pacman.d/gnupg/keys" && ! grep -q "$signer" "$R/etc/pacman.d/gnupg/keys" || + fail "the Omarchy key stays trusted and the candidate key never enters pacman's keyring" +pass "no fork repository, signature or key is left, and the official database is the one pacman reads" + +! grep -q "update-grub" "$F/boot.log" || fail "a Limine Mac does not touch GRUB" "$(cat "$F/boot.log")" +[[ $(grep -E '^(update-m1n1|omarchy-mac-limine-cmdline|limine-update|dispatch setup-boot|limine-boot activate|dispatch setup-system|dispatch update-verify)' "$F/boot.log" | tr '\n' '|') == \ + "update-m1n1 |omarchy-mac-limine-cmdline |limine-update|dispatch setup-boot|limine-boot activate|limine-update|dispatch setup-system|dispatch update-verify|" ]] || + fail "m1n1 and the UKI are rebuilt, then the new setup-boot refreshes Limine, setup-system and update-verify run" "$(cat "$F/boot.log")" +[[ $(cat "$R/etc/crypttab" "$R/boot/efi/limine.conf") == "$crypt_before" && -e $R/var/lib/omarchy/limine.enabled ]] || + fail "the unlock settings and the Limine menu are unchanged" +grep -q "^cryptsetup luksHeaderBackup /dev/nvme0n1p6 " "$F/pacman.log" && [[ -f $state/backup/luks-header.img ]] || + fail "the LUKS header of the root partition is backed up" +tar -xOf "$state/backup/etc.tar" etc/pacman.conf | grep -q '^\[omarchy-aurora\]' || fail "the backup holds the fork's pacman.conf" +pass "encryption and Limine are kept: the UKI is rebuilt, setup-boot refreshes Limine, the LUKS header is backed up" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the post-reboot verification completes the migration" "$output" +grep -q "Kept, with no official build: obs-studio" <<<"$output" || fail "completion names what was kept" "$output" +for name in asahi-quattro-release asahi-quattro-release.pending asahi-package-repository aurora-target.descriptor apple-silicon-channel apple-silicon-aurora-lane; do + [[ ! -e $R/var/lib/omarchy/$name && -f $state/backup/mx-mac-state/$name ]] || fail "the fork updaters' $name is moved into the backup" +done +[[ $(stat -c %a "$state/backup/mx-mac-state") == 700 ]] || fail "the retired state is readable by root only" +[[ -e $R/var/lib/omarchy/mac-first-boot/install.conf ]] || fail "state no fork updater owns stays" +[[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service && ! -e $state/tool ]] || fail "the post-reboot unit and the tool's copy go" +[[ $(migrate status) == *"State: complete"* ]] || fail "status reports completion" +baseline=$(outcome) +pass "after the reboot, the bundle and channel updaters' state is retired into the backup" + +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +grep -q "Already migrated to candidate-set apple-test-fixture" <<<"$output" && [[ $(outcome) == "$baseline" ]] || + fail "a second run changes nothing" "$output" +pass "the migration is idempotent" + +# --- Interruption at every journal step --------------------------------------- + +interrupt() { # when step + local when=$1 step=$2 status=0 output last recorded transactions=1 + new_fixture "kill-$when-$step" + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $step" "status $status: $output" + case $step in + loader-leaf) recorded=loader ;; + removals) recorded=transaction ;; + mx-mac-retire) recorded=retire ;; + *) recorded=$step ;; + esac + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "$recorded done"* ]] || fail "the journal ends with $step done" "$last" + else + [[ $last == "$recorded begin"* || $last == "repositories boundary" ]] || fail "the journal ends with $step begun" "$last" + fi + finish + [[ $(outcome) == "$baseline" ]] || fail "killed $when $step, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $when$step == "midtransaction" ]] && transactions=2 + [[ $(grep -c '^transaction ' "$F/pacman.log") == "$transactions" ]] || fail "killed $when $step: $transactions package transaction(s)" "$(cat "$F/pacman.log")" + [[ $(grep '^transaction \|^hooks' "$F/pacman.log" | tail -n 1) == "hooks" ]] || fail "killed $when $step: the last transaction's hooks ran" + [[ $(grep -c '^remove dotnet-runtime$' "$F/pacman.log") == 1 ]] || fail "killed $when $step: the planned removal runs once" "$(cat "$F/pacman.log")" +} + +for step in "${steps[@]}"; do + interrupt after "$step" +done +pass "a kill -9 between any two journal steps resumes to the same end, with one transaction" +for step in "${steps[@]}"; do + interrupt during "$step" +done +pass "a kill -9 after any step's work but before its record resumes to the same end" +for step in backup keyring prefetch repositories transaction removals boot-chain loader loader-leaf defaults unpin reboot mx-mac-retire retire; do + interrupt mid "$step" +done +pass "a kill -9 in the middle of any step, the removals after the transaction and the adapter's retire included, resumes to the same end" + +# --- The fork moving under a migration ----------------------------------------- + +# The fork's omarchy update runs before the migration resumes: its channel +# updaters put the fork sections, databases and key back, and its bundle +# updater moves the runtime pair. +for step in keyring repositories; do + new_fixture "fork-update-$step" + kill_after "$step" + fork_update + sed -i 's/^omarchy-dev .*/omarchy-dev 4.0.4.r7090.g0123456-1/' "$R/var/lib/pacman/local/packages" + finish + [[ $(outcome) == "$baseline" ]] || fail "after the fork's update past $step, the migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "after $step: one transaction" + [[ $step != "repositories" ]] || grep -q " prefetch reset pacman.conf or a retired key came back after the repository switch" "$(state_dir)/journal" || + fail "the fork's update after the switch sends the migration back to rehearse" "$(cat "$(state_dir)/journal")" +done +pass "the fork's own update between steps is undone: the switch runs again and no fork section survives" + +new_fixture conf-only +kill_after repositories +fork_pacman_conf >"$R/etc/pacman.conf" +finish +grep -q " prefetch reset pacman.conf or a retired key came back after the repository switch" "$(state_dir)/journal" || fail "a rewritten pacman.conf alone is noticed" "$(cat "$(state_dir)/journal")" +[[ $(outcome) == "$baseline" ]] || fail "a rewritten pacman.conf is switched again" "$(diff <(echo "$baseline") <(outcome))" +pass "a channel updater's rewrite of pacman.conf after the switch is switched back before the transaction" + +new_fixture key-only +kill_after repositories +echo "$release_key f" >>"$R/etc/pacman.d/gnupg/keys" +finish +grep -q " prefetch reset pacman.conf or a retired key came back after the repository switch" "$(state_dir)/journal" || + fail "a fork key trusted again alone is noticed" "$(cat "$(state_dir)/journal")" +[[ $(outcome) == "$baseline" ]] || fail "a fork key trusted again is deleted again" "$(diff <(echo "$baseline") <(outcome))" +pass "a fork key trusted again after the switch is deleted again before the transaction" + +# --- The channel ------------------------------------------------------------------ + +# Without an administrator's target, an mx-mac Mac follows the channel the fork +# records (omarchy-apple-silicon-channel current), served from Omarchy's +# official repository for that channel. +channel_fixture() { # name channel + new_fixture "$1" + rm "$R/etc/omarchy-mac/migration-target" + echo "$2" >"$F/channel" +} + +for channel in rc stable; do + channel_fixture "channel-$channel" "$channel" + # Omarchy's rc and stable today: the runtime, no Mac packages. + printf 'omarchy 4.0.4-1\nomarchy-settings 4.0.4-1\nomarchy-keyring 20251027-1\n' | repo "official-$channel" omarchy + refused "an mx-mac Mac on $channel" "the $channel channel has no omarchy-mac for Apple Silicon yet" + output=$(migrate run 2>&1 || true) + grep -q "The $channel channel has no Mac release yet" <<<"$output" || fail "the $channel deferral says why" "$output" + [[ $(migrate status) == *"The last run deferred: "*"$channel channel has no omarchy-mac"* ]] || fail "status says why $channel deferred" "$(migrate status)" +done +pass "an mx-mac Mac on rc or stable defers with nothing changed while its channel has no Mac packages" + +channel_fixture channel-held held +refused "a held channel" "cannot tell which Omarchy channel this Mac follows" +channel_fixture channel-none edge +rm "$F/channel" +refused "no channel record" "cannot tell which Omarchy channel this Mac follows" +pass "an mx-mac Mac whose channel the fork cannot name defers with nothing changed" + +channel_fixture channel-edge edge +cp -r "$F/repos/omarchy" "$F/repos/official-edge" +repository_archives +output=$(migrate check 2>&1) || fail "an mx-mac Mac on edge is ready" "$output" +grep -q "Ready: run moves this Mac (mx-mac, limine boot, encrypted) onto repository file://$F/repos/official-edge (edge)" <<<"$output" || + fail "the fork's edge channel moves to Omarchy's edge repository" "$output" +finish +grep -qx "transaction omarchy/omarchy-dev omarchy/omarchy-settings-dev omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/linux-aurora-headers omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook dotnet-runtime-bin hyprland mise-bin omarchy-keyring omarchy-nvim pinta quickshell ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring" "$F/pacman.log" || + fail "every Mac package and the dev pair are named in the official [omarchy]" "$(grep transaction "$F/pacman.log")" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "one package transaction" +grep -qx "omarchy-dev $official_dev" "$R/var/lib/pacman/local/packages" && grep -qx "omarchy-settings-dev $official_dev" "$R/var/lib/pacman/local/packages" && + grep -qx "omarchy-mac-boot 20260927-1" "$R/var/lib/pacman/local/packages" || + fail "Omarchy's own dev pair replaces the fork's higher one" "$(cat "$R/var/lib/pacman/local/packages")" +! grep -q "^remove .*omarchy-dev" "$F/pacman.log" && ! grep -q "omarchy-dev" "$(state_dir)/plan/allowed-removals" || + fail "the dev pair is downgraded inside the transaction, never removed" "$(cat "$F/pacman.log")" +grep -q "^download omarchy-dev $official_dev$" "$F/pacman.log" && grep -q "^download omarchy-mac-boot 20260927-1$" "$F/pacman.log" || + fail "preflight reads the channel's signed runtime and boot archives" "$(cat "$F/pacman.log")" +grep -qx "Server = file://$F/repos/official-edge" "$R/etc/pacman.conf" || fail "[omarchy] is Omarchy's edge repository" "$(cat "$R/etc/pacman.conf")" +pass "with no administrator's target, an mx-mac Mac on edge moves to Omarchy's own dev pair, downgraded by name in one transaction" + +# --- Repository targets ------------------------------------------------------------ + +# On stable the fork's pair gives way to omarchy and omarchy-settings, whose +# conflicts remove it in the transaction. +new_fixture repository-stable +repository_target stable +finish +grep -qx "transaction omarchy/omarchy omarchy/omarchy-settings omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/linux-aurora-headers omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook dotnet-runtime-bin hyprland mise-bin omarchy-keyring omarchy-nvim pinta quickshell ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring" "$F/pacman.log" || + fail "a stable repository target names the Mac packages in the official [omarchy], not the fork's" "$(grep transaction "$F/pacman.log")" +grep -qx "omarchy 4.0.4-1" "$R/var/lib/pacman/local/packages" && ! grep -q "^omarchy-dev \|^omarchy-settings-dev " "$R/var/lib/pacman/local/packages" || + fail "the official runtime replaces the fork's dev pair" "$(cat "$R/var/lib/pacman/local/packages")" +[[ $(cat "$(state_dir)/plan/allowed-removals") == $'dotnet-runtime\nmise\nomarchy-dev\nomarchy-settings-dev\nquickshell-git' ]] || + fail "on stable the fork's pair may be removed" "$(cat "$(state_dir)/plan/allowed-removals")" +! grep -q "^remove .*omarchy-dev" "$F/pacman.log" || fail "the fork's pair leaves through omarchy's conflict" "$(cat "$F/pacman.log")" +pass "a stable repository target, whose [omarchy] has the fork section's name, replaces the fork's builds" + +# omacom's own omarchy-dev provides omarchy: once it is newer than the fork's, +# an upgrade would take it and pacman would drop the omarchy target. +new_fixture newer-official-dev +repository_target stable +sed -i 's/^omarchy-dev .*/omarchy-dev 4.0.5.r1-1/' "$F/repos/omarchy/omarchy.db" +printf 'omarchy-dev omarchy\n' >>"$F/provides" +finish +grep -qx "omarchy 4.0.4-1" "$R/var/lib/pacman/local/packages" && ! grep -q "^omarchy-dev " "$R/var/lib/pacman/local/packages" || + fail "the runtime pair still comes from the target when an official omarchy-dev is newer than the fork's" "$(cat "$R/var/lib/pacman/local/packages")" +pass "the planned removals stay out of the upgrade, so a newer official omarchy-dev cannot displace the omarchy target" + +# --- Commands that change hands -------------------------------------------------- + +# A real mx-mac Mac has no omarchy-mac-boot: its omarchy-dev owns five of the +# commands omarchy-mac-boot ships. The one transaction installs omarchy-mac-boot +# while the fork's omarchy-dev is replaced (on edge, by Omarchy's own, which +# ships none of them; on stable, through omarchy's conflict), so the commands +# change hands with nothing overwritten. +handover="/usr/bin/omarchy-apple-silicon-boot-check /usr/bin/omarchy-mac-boot-update /usr/bin/omarchy-mac-limine-active /usr/bin/omarchy-mac-limine-cmdline /usr/bin/omarchy-mac-limine-deploy" + +handover_fixture() { + local path + new_fixture "$1" + mkdir -p "$F/files" "$R/usr/bin" + : >"$R/var/lib/pacman/local/files" + for path in $handover; do + printf '%s\n' "$path" >>"$F/files/omarchy-mac-boot" + printf 'omarchy-dev %s\n' "$path" >>"$R/var/lib/pacman/local/files" + echo "omarchy-dev $fork_dev" >"$R$path" + done +} + +handed_over() { # version + local path + for path in $handover; do + [[ $(cat "$R$path") == "omarchy-mac-boot $1" ]] && grep -qx "omarchy-mac-boot $path" "$R/var/lib/pacman/local/files" && + ! grep -qx "omarchy-dev $path" "$R/var/lib/pacman/local/files" || + fail "$path moves from the fork's omarchy-dev to omarchy-mac-boot" "$(grep -F "$path" "$R/var/lib/pacman/local/files")" + done + [[ ! -e $(state_dir)/overwrite ]] && ! grep -q "^remove .*omarchy-dev" "$F/pacman.log" || + fail "omarchy-dev changes inside the transaction, with nothing overwritten" "$(cat "$F/pacman.log")" +} + +handover_fixture handover-edge +finish +handed_over 20261004-1.1 +grep -qx "omarchy-dev 4.0.0.r7000.gabc-1.1" "$R/var/lib/pacman/local/packages" || fail "the fork's omarchy-dev is downgraded in place" +handover_fixture handover-stable +repository_target stable +finish +handed_over 20260927-1 +pass "the commands the fork's omarchy-dev owned pass to omarchy-mac-boot inside the one transaction, without an overwrite" + +# Without omarchy's conflict, omarchy-dev would leave in the removals after the +# transaction, and pacman -R would delete the commands omarchy-mac-boot took over. +handover_fixture after-removal +repository_target stable +sed -i '/^omarchy omarchy-dev$/d' "$F/conflicts" +conf_before=$(cat "$R/etc/pacman.conf") +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would leave omarchy-dev to be removed after it, but the packages it installs also own /usr/bin/omarchy-apple-silicon-boot-check" <<<"$output" || + fail "a removal that would take handed-over files stops the rehearsal, deferred" "status $status: $output" +[[ $(cat "$R/etc/pacman.conf") == "$conf_before" ]] && grep -q "^omarchy-dev " "$R/var/lib/pacman/local/packages" && + ! grep -q "^transaction \|^remove " "$F/pacman.log" || fail "nothing changes before such a transaction" "$(cat "$F/pacman.log")" +[[ ! -e $(state_dir)/journal ]] || fail "the attempt is set aside, so the next run starts over" +pass "a planned removal that shares files with the packages installed defers before anything changes" + +# --- Refusals and failures ------------------------------------------------------- + +new_fixture refusals +printf '\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///custom\n' >>"$R/etc/pacman.conf" +refused "an unknown TrustAll repository" "\[custom\] accepts untrusted packages" +new_fixture refusals +echo "base udev autodetect microcode modconf kms keyboard keymap block encrypt filesystems fsck" >"$F/hooks" +refused "busybox encrypt" "busybox encrypt" +new_fixture refusals +: >"$R/var/lib/omarchy/mac-first-boot/pending" +refused "an unfinished first boot" "first boot has not finished" +new_fixture refusals +rm "$F/hooks" +refused "HOOKS that cannot be read" "cannot read the initramfs HOOKS" +new_fixture refusals +head -c 16 /dev/urandom >>"$F/set/$(jq -r '.packages[0].filename' "$F/set/manifest.json")" +refused "a tampered candidate package" "does not verify: omarchy-dev-.* is missing or changed" +new_fixture refusals +repository_target stable +sed -i '/^omarchy-mac /d' "$F/repos/omarchy/omarchy.db" +refused "a target without omarchy-mac" "the stable channel has no omarchy-mac for Apple Silicon yet" +new_fixture refusals +repository_target stable +printf 'packages=omarchy omarchy-mac omarchy-mac-boot linux-aurora\n' >>"$R/etc/omarchy-mac/migration-target" +refused "a target without the runtime pair" "the target has no omarchy-settings to replace the fork's runtime pair" +pass "preflight refuses legacy unlock, untrusted repositories, an unfinished first boot, unreadable HOOKS and an incomplete or unverifiable target, changing nothing" + +new_fixture weak-fork +sed -i '/^\[omarchy-aurora\]/,/^$/s/^SigLevel = .*/SigLevel = Optional TrustAll/' "$R/etc/pacman.conf" +finish +[[ $(outcome) == "$baseline" ]] || fail "a fork section the switch drops is not a refusal, whatever its SigLevel" "$(diff <(echo "$baseline") <(outcome))" +pass "the fork's own sections are retired, not refused" + +new_fixture removal +echo "omarchy-mac obs-studio" >>"$F/conflicts" +conf_before=$(cat "$R/etc/pacman.conf") +packages_before=$(cat "$R/var/lib/pacman/local/packages") +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove obs-studio; nothing was changed" <<<"$output" || fail "an unexpected removal stops the rehearsal, deferred" "status $status: $output" +[[ $(cat "$R/etc/pacman.conf") == "$conf_before" && $(cat "$R/var/lib/pacman/local/packages") == "$packages_before" ]] || + fail "the fork's repositories and packages are untouched after a failed rehearsal" +[[ ! -e $(state_dir)/journal && -n $(ls -d "$(state_dir)"/history/aborted-* 2>/dev/null) ]] || fail "the attempt is set aside, so the next run starts over" +pass "a rehearsal that would remove a kept fork build defers before the switch" + +new_fixture after-boundary +kill_after repositories +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && [[ -f $(state_dir)/journal ]] || fail "a failure after the switch is a failure, never a deferral" "status $status: $output" +rm "$F/fail-transaction" +finish +[[ $(outcome) == "$baseline" ]] || fail "the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" +pass "after the repository switch a failure stops the migration for the next run to resume" + +# --- Other mx-mac Macs ------------------------------------------------------------- + +# Omarchy's edge carries quickshell-git itself: a fork build moves to the +# official build of its own name before a renamed one. +new_fixture same-name +printf 'quickshell-git 0.3.0.r20.g28771c7-3\n' >>"$F/repos/omarchy/omarchy.db" +finish +grep -qx "quickshell-git 0.3.0.r20.g28771c7-3" "$R/var/lib/pacman/local/packages" && ! grep -q "^quickshell " "$R/var/lib/pacman/local/packages" || + fail "a fork build an official repository carries by its own name moves to that build" "$(cat "$R/var/lib/pacman/local/packages")" +! grep -qx "quickshell-git" "$(state_dir)/plan/allowed-removals" && grep -q "^transaction .* quickshell-git ttf-jetbrains-mono-nerd-basic asahi-alarm-keyring$" "$F/pacman.log" || + fail "the official build of the same name is named and nothing is removed for it" "$(cat "$(state_dir)/plan/allowed-removals" "$F/pacman.log")" +pass "a fork build moves to the official build of its own name before a renamed counterpart" + +new_fixture grub +rm "$R/var/lib/omarchy/limine.enabled" "$R/etc/default/limine" "$R/boot/efi/limine.conf" "$R/boot/efi/EFI/Linux/omarchy_linux-aurora.efi" +echo grub >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" +finish +grep -q "^update-grub" "$F/boot.log" && grep -q "^limine-boot activate" "$F/boot.log" && + [[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" && -e $R/var/lib/omarchy/limine.enabled ]] || + fail "an mx-mac Mac on GRUB is switched to Limine" "$(cat "$F/boot.log")" +[[ $(cat "$(state_dir)/plan/boot") == "grub" ]] || fail "the Mac is planned as a GRUB Mac" +pass "an mx-mac Mac that still boots GRUB is switched to Limine" + +# --- Omarchy's own dev channel ------------------------------------------------------ + +# omacom's omarchy-dev on a Mac that never ran the fork. +official_dev_fixture() { + new_fixture "$1" + rm -f "$R"/var/lib/omarchy/asahi-* "$R/var/lib/omarchy/aurora-target.descriptor" "$R"/var/lib/omarchy/apple-silicon-* \ + "$R/etc/omarchy-mac/migration-target" "$R"/var/lib/pacman/sync/omarchy-aurora.db* "$R/var/lib/pacman/sync/omarchy.db.sig" + cat >"$R/etc/pacman.conf" <&1) || fail "a Mac on Omarchy's dev channel is not refused" "$output" +grep -q "runs Omarchy's own packages (omarchy-dev from pkgs.omarchy.org): nothing to migrate" <<<"$output" && + [[ $(fixture_digest) == "$digest" && ! -e $(state_dir)/journal ]] || + fail "a Mac on Omarchy's dev channel has nothing to migrate and nothing changes" "$output" +official_dev_fixture official-dev-fork-key +echo "$fork_key f" >>"$R/etc/pacman.d/gnupg/keys" +cp -r "$F/repos/omarchy" "$F/repos/official-edge" +repository_archives +digest=$(fixture_digest) +output=$(migrate check 2>&1) || fail "omarchy-dev beside a retired key is migrated" "$output" +grep -q "Ready: run moves this Mac (tester, limine boot, encrypted) onto repository file://$F/repos/official-edge (edge)" <<<"$output" && + [[ $(fixture_digest) == "$digest" ]] || fail "omarchy-dev that is not the fork's, beside retired trust, is moved like a tester to its channel" "$output" +pass "omarchy-dev from Omarchy's own repositories is nothing to migrate; beside retired trust it is moved like a tester" + +# --- Migrations a converted Mac records as done ---------------------------------------- + +new_fixture settled +printf 'root:x:0:0::/root:/bin/bash\ntester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +migrations=$R/home/tester/.local/state/omarchy/migrations +mkdir -p "$migrations" +: >"$migrations/1790305681.sh" +printf '2026-09-20T10:00:00+10:00\thandled\tHyprland configuration replaced by the Quattro user transition\n' >"$migrations/1781063758.sh.skipped" +printf '2026-09-20T10:00:00+10:00\tskipped\tunsupported AUR browser replacement on Asahi\n' >"$migrations/1784510887.sh.skipped" +printf '2026-09-20T10:00:00+10:00\tskipped\tsystemd-oomd reclaim tuning is held until validated on Asahi\n' >"$migrations/1785424256.sh.skipped" +printf '2026-09-20T10:00:00+10:00\thandled\tnot one the adapter audited\n' >"$migrations/1786567036.sh.skipped" +finish +for name in 1781063758 1784476564 1785424256 1786391100 1789444024 1789158179 1789172112 1790327324; do + [[ -f $migrations/$name.sh ]] || fail "$name is recorded as done" "$(ls "$migrations")" +done +for name in 1784510887 1786567036; do + [[ ! -e $migrations/$name.sh ]] || fail "$name is left to run on the new packages" +done +grep -qx 'omarchy-mac-setup-keyboard 3' "$F/boot.log" || fail "mx-mac's keyboard migration names the generated keyboard line" "$(grep keyboard "$F/boot.log")" +grep -q "^dispatch setup-user HOME=$R/home/tester$" "$F/boot.log" || fail "the Mac user setup runs for the fork's user" "$(grep setup-user "$F/boot.log")" +pass "a converted Mac records the fork's handled migrations and those a fresh Mac image never runs as done, and leaves the rest to run" diff --git a/test/shell.d/mac-migrate-test.sh b/test/shell.d/mac-migrate-test.sh new file mode 100644 index 00000000000..a2bc9c92c5b --- /dev/null +++ b/test/shell.d/mac-migrate-test.sh @@ -0,0 +1,968 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/test/fixtures/mac-migrate/lib.sh" + +# bin/omarchy-mac-migrate moves a tester Mac (a quattro-upstream or convergence +# build, from a candidate set or the collaboration repository) onto Omarchy's +# official edge: the omarchy-dev pair, the Mac packages and the Aurora chain. + +retired=FBD6874D423C418DDB6D143EECE19CDDE306DBD2 + +# A GRUB tester on the Asahi kernel with an encrypted root: runtime and +# settings 4.0.2-2 and a newer cursor-bin from the unsigned collaboration +# repository, omarchy-mac above the candidate's release; the signed candidate +# set as the administrator's target. +new_fixture() { + local name=$1 + F=$tmp/$name + R=$F/root + rm -rf "$F" + mkdir -p "$R" + mkdir -p "$R/run/systemd/system" "$R/run/lock" "$R/var/tmp" "$R/proc/sys/kernel/random" "$R/etc/default" "$R/etc/omarchy-mac" \ + "$R/var/lib/pacman/local" "$R/var/lib/pacman/sync" "$R/var/cache/pacman/pkg" "$R/etc/pacman.d/gnupg" \ + "$R/usr/share/pacman/keyrings" "$R/usr/share/limine" "$R/boot/grub" "$R/boot/efi/EFI/BOOT" "$R/boot/efi/m1n1" \ + "$R/usr/lib/modules/6.19.1-asahi" "$R/usr/lib/modules/7.1.12-aurora" "$R/var/lib/omarchy/migrations" + echo boot-1 >"$R/proc/sys/kernel/random/boot_id" + echo 6.19.1-asahi >"$R/proc/sys/kernel/osrelease" + echo linux-asahi >"$R/usr/lib/modules/6.19.1-asahi/pkgbase" + echo linux-aurora >"$R/usr/lib/modules/7.1.12-aurora/pkgbase" + echo "menuentry linux-asahi" >"$R/boot/grub/grub.cfg" + echo grub >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" + echo m1n1 >"$R/boot/efi/m1n1/boot.bin" + echo "limine 12.9" >"$R/usr/share/limine/BOOTAA64.EFI" + echo "GRUB_CMDLINE_LINUX=\"rd.luks.name=abc=root\"" >"$R/etc/default/grub" + echo "root UUID=abc none" >"$R/etc/crypttab" + : >"$R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending" + for keyring in archlinuxarm asahi-alarm omarchy; do + : >"$R/usr/share/pacman/keyrings/$keyring.gpg" + done + echo 1111111111111111111111111111111111111111 >"$R/usr/share/pacman/keyrings/archlinuxarm-trusted" + echo 2222222222222222222222222222222222222222 >"$R/usr/share/pacman/keyrings/asahi-alarm-trusted" + : >"$R/usr/share/pacman/keyrings/omarchy-trusted" + printf '%s f\n%s f\n%s f\n' 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 "$retired" \ + >"$R/etc/pacman.d/gnupg/keys" + mkdir -p "$F/keyserver" + : >"$F/keyserver/$official" + + cat >"$R/var/lib/pacman/local/packages" <<'LOCAL' +asahi-alarm-keyring 20250101-1 +cursor-bin 3.20.17-1 +hyprland 0.50-1 +limine 12.9.0-1 +linux-asahi 6.19.1-1 +m1n1 1.5.0-1 +omarchy 4.0.2-2 +omarchy-mac 0.1.0-5.9 +omarchy-settings 4.0.2-2 +pacman 7.0.0-1 +uboot-asahi 2026.01-1 +widget-extra 1.0-1 +LOCAL + for file in omarchy-4.0.2-2-aarch64.pkg.tar.xz omarchy-settings-4.0.2-2-aarch64.pkg.tar.xz linux-asahi-6.19.1-1-aarch64.pkg.tar.zst \ + m1n1-1.5.0-1-aarch64.pkg.tar.zst uboot-asahi-2026.01-1-aarch64.pkg.tar.zst; do + echo cached >"$R/var/cache/pacman/pkg/$file" + done + repo core <<<"pacman 7.0.0-1" + printf 'hyprland 0.51-1\nlimine 12.9.0-1\n' | repo extra + printf 'linux-asahi 6.19.1-1\nm1n1 1.5.0-1\nuboot-asahi 2026.01-1\nasahi-alarm-keyring 20250101-1\n' | repo asahi-alarm + printf 'omarchy 4.0.2-1\nomarchy-settings 4.0.2-1\ncursor-bin 3.20.10-1\n' | repo omarchy-old + repo omarchy <<'EDGE' +omarchy 4.0.4-1 +omarchy-settings 4.0.4-1 +omarchy-dev 4.0.0.r6713.ga85e29a-1 +omarchy-settings-dev 4.0.0.r6713.ga85e29a-1 +omarchy-mac 0.1.0-6 +omarchy-mac-boot 20260927-1 +omarchy-keyring 20251027-1 +linux-aurora 7.1.12.aurora2-11 +linux-aurora-headers 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +uboot-asahi 2026.07.asahi2-4 +limine-mkinitcpio-hook 1.39.0-2 +cursor-bin 3.20.10-1 +EDGE + printf 'omarchy 4.0.2-2\nomarchy-settings 4.0.2-2\ncursor-bin 3.20.17-1\nwidget-extra 1.0-1\n' | repo omarchy-aarch64 + cp "$F/repos/omarchy-aarch64/omarchy-aarch64.db" "$R/var/lib/pacman/sync/" + alarm_repos + relations + + cat >"$R/etc/pacman.conf" <"$R/etc/omarchy-mac/migration-target" <"$F/platform" + echo "base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck" >"$F/hooks" + printf '%s\n' "$R/boot/efi" >"$F/mounts" + echo /dev/mapper/root >"$F/root-source" + printf '/dev/mapper/root crypt btrfs\n/dev/nvme0n1p5 part crypto_LUKS\n/dev/nvme0n1 disk \n' >"$F/lsblk" + : >"$F/pacman.log" + : >"$F/boot.log" +} + +# Everything a finished migration leaves that must not depend on how it got +# there: packages, configuration, keys, loader, records and backups. +outcome() { + local state + state=$(state_dir) + cat "$R/var/lib/pacman/local/packages" + sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf" + sort "$R/etc/pacman.d/gnupg/keys" + cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" "$R/etc/default/limine" + ls "$R/var/lib/omarchy" + ls "$R/var/lib/pacman/sync" + [[ ! -e $R/var/lib/pacman/db.lck ]] && echo unlocked + sed -n 's/^target=//p' "$state/complete" + (cd "$state/backup" && find . -type f | LC_ALL=C sort && sed 's/^[0-9a-f]* //' SHA256SUMS) + ls "$state" + [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && echo "no unit" +} + +# --- The whole transition -------------------------------------------------- + +new_fixture baseline +output=$(migrate run 2>&1) || fail "a tester migrates to its reboot" "$output" +grep -q "Reboot to finish the migration to candidate-set apple-test-fixture" <<<"$output" || fail "the run asks for a reboot" "$output" +[[ $(migrate status) == *"State: waiting for a reboot"* ]] || fail "status reports the pending reboot" "$(migrate status)" +grep -q "^systemctl enable omarchy-mac-migrate-verify.service" "$F/boot.log" || fail "the post-reboot check is enabled" +state=$(state_dir) +grep -q "^ExecStart=/var/lib/omarchy-mac/migration/tool/omarchy-mac-migrate verify$" "$R/etc/systemd/system/omarchy-mac-migrate-verify.service" && + cmp -s "$tool" "$state/tool/omarchy-mac-migrate" || fail "the unit runs the tool's own kept copy" +output=$(migrate verify 2>&1) || fail "verify before the reboot waits" "$output" +[[ ! -f $state/complete ]] || fail "nothing completes before the reboot" +pass "a GRUB tester is migrated up to its reboot, which it waits for" + +expected_packages='asahi-alarm-keyring 20250101-1 +cursor-bin 3.20.10-1 +hyprland 0.51-1 +limine 12.9.0-1 +limine-mkinitcpio-hook 1.39.0-2 +linux-aurora 7.1.12.aurora2-11 +m1n1-aurora 1.6.1.aurora1-3 +omarchy-dev 4.0.0.r7000.gabc-1.1 +omarchy-keyring 20251027-1 +omarchy-mac 0.1.0-11.1 +omarchy-mac-boot 20261004-1.1 +omarchy-settings-dev 4.0.0.r7000.gabc-1.1 +pacman 7.0.0-1 +uboot-asahi 2026.07.asahi2-4 +widget-extra 1.0-1' +[[ $(cat "$R/var/lib/pacman/local/packages") == "$expected_packages" ]] || + fail "the transaction swaps the runtime for the omarchy-dev pair, replaces every same-name build, even higher ones, and swaps the Asahi kernel and m1n1" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^transaction omarchy-mac-candidate/omarchy-dev omarchy-mac-candidate/omarchy-settings-dev omarchy-mac-candidate/omarchy-mac " "$F/pacman.log" || + fail "targets are named in the candidate repository" "$(grep transaction "$F/pacman.log")" +grep -q "^transaction .* cursor-bin asahi-alarm-keyring omarchy-keyring$" "$F/pacman.log" || + fail "a collaboration build the official repositories carry and the keyrings are named too" "$(grep transaction "$F/pacman.log")" +! grep -q "headers" "$F/pacman.log" || fail "no kernel headers are installed where there were none" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "one package transaction" +grep -q "^widget-extra 1.0-1$" "$state/plan/kept" || fail "a build with no official counterpart is kept and listed" +pass "one transaction moves to the omarchy-dev pair and replaces same-name candidates, including higher-versioned ones, and keeps what has no official build" + +conf=$(sed "s|$F|FIXTURE|g" "$R/etc/pacman.conf") +expected_conf=$(OMARCHY_MAC_MIGRATE_ROOT=$R fixture=1 bash -c 'source <(sed -n "/^core_pacman_conf() {/,/^}/p" "$1"); core_pacman_conf "file://FIXTURE/repos/omarchy"' _ "$ROOT/migrate/src/target.sh" | + sed "s|^Server = https://github.com/asahi-alarm.*|Server = file://FIXTURE/repos/asahi-alarm|") +[[ $conf == "$expected_conf" ]] || fail "pacman.conf is the core Apple Silicon configuration for the target" "$(diff <(echo "$expected_conf") <(echo "$conf"))" +! grep -q 'candidate\|IgnorePkg\|TrustAll\|omarchy-aarch64' "$R/etc/pacman.conf" || fail "no candidate repository, pin, guard or collaboration repository stays" +[[ ! -e $R/var/lib/pacman/sync/omarchy-aarch64.db && ! -e $R/var/lib/pacman/sync/omarchy-mac-candidate.db ]] || + fail "the retired and candidate databases are gone" +grep -q "^pacman-key --populate archlinuxarm asahi-alarm omarchy" "$F/pacman.log" || fail "the installed keyrings are populated" +grep -q "^pacman-key --recv-keys $official" "$F/pacman.log" && grep -q "^$official f" "$R/etc/pacman.d/gnupg/keys" || + fail "the missing Omarchy key is fetched by fingerprint and trusted" +! grep -q "^$retired " "$R/etc/pacman.d/gnupg/keys" || fail "the retired fork key is deleted" +! grep -q "$signer" "$R/etc/pacman.d/gnupg/keys" || fail "the candidate key never enters pacman's keyring" +pass "the core pacman configuration, official trust bootstrapped by fingerprint, legacy trust and candidates gone" + +for file in installed etc.tar boot.tar esp.tar luks-header.img packages/omarchy-4.0.2-2-aarch64.pkg.tar.xz \ + packages/linux-asahi-6.19.1-1-aarch64.pkg.tar.zst packages/m1n1-1.5.0-1-aarch64.pkg.tar.zst SHA256SUMS; do + [[ -f $state/backup/$file ]] || fail "the backup holds $file" +done +grep -q "^omarchy-mac 0.1.0-5.9$" "$state/backup/packages.missing" || fail "an uncached package is listed as missing from the backup" +(cd "$state/backup" && sha256sum -c --quiet SHA256SUMS) || fail "the backup's digests verify" +[[ $(stat -c %a "$state/backup") == 700 ]] || fail "the backup is readable by root only" +tar -xOf "$state/backup/esp.tar" ./EFI/BOOT/BOOTAA64.EFI | grep -qx grub || fail "the ESP backup predates the switch" +grep -q "^cryptsetup luksHeaderBackup /dev/nvme0n1p5 " "$F/pacman.log" || fail "the LUKS header of the root partition is backed up" +pass "packages, /etc, /boot, the ESP and the LUKS header are backed up before anything changes" + +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" && -e $R/var/lib/omarchy/limine.enabled ]] || + fail "Limine takes the loader slot" +[[ $(grep -E '^(update-m1n1|update-grub|boot-check pending --boot-chain linux-aurora|dispatch setup-boot|limine-boot activate|dispatch setup-system|dispatch update-verify)' "$F/boot.log" | tr '\n' '|') == \ + "update-m1n1 |update-grub |boot-check pending --boot-chain linux-aurora|dispatch setup-boot|limine-boot activate|boot-check pending --boot-chain linux-aurora|dispatch setup-system|boot-check pending --boot-chain linux-aurora|dispatch update-verify|" ]] || + fail "m1n1 and U-Boot are rebuilt and checked, the new runtime's setup-boot activates Limine, then setup-system and update-verify run" "$(cat "$F/boot.log")" +pass "the boot chain is rebuilt and checked, and the new runtime's setup-boot, setup-system and update-verify run before the reboot" + +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "the post-reboot verification completes the migration" "$output" +[[ -f $state/complete && ! -e $state/reboot-pending && ! -e $state/cache ]] || fail "completion retires the working state" +grep -q "^boot-check --boot-chain linux-aurora$" "$F/boot.log" || fail "the booted chain passes the full boot check" +[[ $(grep -c '^dispatch update-verify' "$F/boot.log") == 2 ]] || fail "update-verify runs again after the reboot" +grep -q "^systemctl disable omarchy-mac-migrate-verify.service" "$F/boot.log" && [[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service && ! -e $state/tool ]] || + fail "the post-reboot unit and the tool's copy go" +[[ ! -e $R/var/lib/omarchy/migrations/omarchy-aarch64-sync-pending ]] || fail "the collaboration repository's marker is retired" +[[ $(migrate status) == *"State: complete"* ]] || fail "status reports completion" +baseline=$(outcome) +pass "after the reboot, Aurora through Limine is verified and compatibility state is retired" + +output=$(migrate run 2>&1) || fail "a second run succeeds" "$output" +grep -q "Already migrated to candidate-set apple-test-fixture" <<<"$output" || fail "a second run says it is done" "$output" +[[ $(outcome) == "$baseline" ]] || fail "a second run changes nothing" +rm -rf "$state" +output=$(migrate run 2>&1) || fail "a Mac already on the target set passes" "$output" +grep -q "already runs the target set" <<<"$output" && [[ ! -e $state/journal ]] || fail "a Mac already on the target set is left alone" "$output" +pass "the migration is idempotent" + +new_fixture check +digest=$(fixture_digest) +output=$(migrate check 2>&1) || fail "check passes on a Mac ready to migrate" "$output" +grep -q "Ready: run moves this Mac (tester, grub boot, encrypted) onto candidate-set apple-test-fixture" <<<"$output" || + fail "check says what run would do" "$output" +[[ $(fixture_digest) == "$digest" && ! -e $(state_dir)/journal ]] && ! grep -q '^transaction\|^pacman-key' "$F/pacman.log" || + fail "check changes nothing" +pass "check runs preflight alone and changes nothing" + +# --- Interruption at every journal step --------------------------------------- + +interrupt() { # when step + local when=$1 step=$2 status=0 output last transactions=1 + new_fixture "kill-$when-$step" + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- run 2>&1) || status=$? + if (( status == 0 )); then + reboot_into_aurora + output=$(migrate_env "OMARCHY_MAC_MIGRATE_KILL_${when^^}=$step" -- verify 2>&1) || status=$? + fi + (( status == 137 )) || fail "the run is killed $when $step" "status $status: $output" + last=$(tail -n 1 "$(state_dir)/journal" | cut -d' ' -f2-) + if [[ $when == "after" ]]; then + [[ $last == "${step%-leaf} done"* ]] || fail "the journal ends with $step done" "$last" + else + [[ $last == "${step%-leaf} begin"* || $last == "repositories boundary" ]] || fail "the journal ends with $step begun" "$last" + fi + finish + [[ $(outcome) == "$baseline" ]] || fail "killed $when $step, the resumed migration ends where an uninterrupted one does" "$(diff <(echo "$baseline") <(outcome))" + [[ $when$step == "midtransaction" ]] && transactions=2 + [[ $(grep -c '^transaction ' "$F/pacman.log") == "$transactions" ]] || fail "killed $when $step: $transactions package transaction(s)" "$(cat "$F/pacman.log")" + [[ $(tail -n 1 < <(grep '^transaction \|^hooks' "$F/pacman.log")) == "hooks" ]] || fail "killed $when $step: the last transaction's hooks ran" +} + +for step in "${steps[@]}"; do + interrupt after "$step" +done +pass "a kill -9 between any two journal steps resumes to the same end, with one transaction" +for step in "${steps[@]}"; do + interrupt during "$step" +done +pass "a kill -9 after any step's work but before its record resumes to the same end, with one transaction" +for step in backup keyring prefetch repositories transaction boot-chain loader loader-leaf defaults unpin reboot retire; do + interrupt mid "$step" +done +pass "a kill -9 in the middle of any step resumes to the same end; pacman killed before its hooks runs the transaction again" + +# After the repository switch a boot resumes the migration: the fork's own +# update may be gone with its packages. +for step in repositories transaction loader; do + new_fixture "boot-resumes-$step" + kill_after "$step" + [[ -f $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] && grep -q "^systemctl enable omarchy-mac-migrate-verify" "$F/boot.log" || + fail "after $step the unit that resumes at boot is armed" + output=$(migrate verify 2>&1) || fail "after $step a boot resumes the migration" "$output" + grep -q "Reboot to finish" <<<"$output" || fail "after $step the boot's run reaches the reboot" "$output" +done +new_fixture boot-before-switch +kill_after prefetch +[[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] || fail "before the switch no unit is armed" +output=$(migrate verify 2>&1) && [[ -z $output ]] || fail "before the switch a boot does nothing" "$output" +pass "from the repository switch on, the next boot resumes an unfinished migration" + +# --- The guard while the transaction is pending ---------------------------------- + +new_fixture guard +kill_after repositories +guarded=$(cat "$R/etc/pacman.conf") +grep -q "^# omarchy-mac-migrate: a migration to Omarchy's packages is in progress." <<<"$guarded" || + fail "the switched configuration carries the guard" "$guarded" +ignore=$(sed -n 's/^IgnorePkg = //p' <<<"$guarded") +for name in omarchy-dev omarchy-settings-dev omarchy-mac omarchy-mac-boot linux-aurora m1n1-aurora uboot-asahi limine-mkinitcpio-hook omarchy omarchy-settings linux-asahi m1n1 limine asahi-scripts mkinitcpio; do + [[ " $ignore " == *" $name "* ]] || fail "the guard holds $name back" "$ignore" +done +pass "between the switch and the end of setup, a plain pacman -Syu leaves every package the migration changes alone" +for step in transaction boot-chain loader defaults verify; do + new_fixture "guard-$step" + kill_after "$step" + grep -q "^IgnorePkg = " "$R/etc/pacman.conf" || fail "after $step the guard stays" +done +new_fixture guard-unpin +kill_after unpin +! grep -q "IgnorePkg" "$R/etc/pacman.conf" || fail "after unpin the guard is gone" +pass "the guard stays through setup and verification and goes once they pass" + +# A test image's pin stays until the transaction is done, then goes with the guard. +new_fixture pinned-image +pin_mark="# Test image only (omarchy-mac-installer image-builder): keeps the candidate set's runtime," +sed -i "/^\[options\]$/a $pin_mark\n# whose version sorts below the channel's. Keep these three lines.\nIgnorePkg = omarchy omarchy-mac omarchy-settings" "$R/etc/pacman.conf" +kill_after repositories +grep -Fq "$pin_mark" "$R/etc/pacman.conf" && grep -q "^IgnorePkg = omarchy omarchy-mac omarchy-settings$" "$R/etc/pacman.conf" || + fail "the test image's pin stays beside the guard" "$(cat "$R/etc/pacman.conf")" +finish +! grep -q "IgnorePkg\|Test image only" "$R/etc/pacman.conf" || fail "the pin goes once the migration is set up" "$(cat "$R/etc/pacman.conf")" +pass "a test image's pin stays until the new packages are set up, and goes with the guard" + +# An administrator's own options and repositories stay. +new_fixture admin-options +sed -i '/^\[options\]$/a IgnorePkg = firefox\nNoExtract = usr/share/help/*' "$R/etc/pacman.conf" +printf '\n[custom]\nServer = file://%s/repos/custom\n' "$F" >>"$R/etc/pacman.conf" +repo custom <<<"widget-custom 1.0-1" +finish +grep -qx "IgnorePkg = firefox" "$R/etc/pacman.conf" && grep -qx "NoExtract = usr/share/help/\*" "$R/etc/pacman.conf" && + grep -qx "\[custom\]" "$R/etc/pacman.conf" || fail "an administrator's options and repositories are kept" "$(cat "$R/etc/pacman.conf")" +[[ $(grep -c '^IgnorePkg' "$R/etc/pacman.conf") == 1 ]] || fail "only the administrator's IgnorePkg stays" +pass "an administrator's own options and repositories stay in the core configuration" + +# --- Preflight refusals --------------------------------------------------------- + +new_fixture refusals +sed -i '/^omarchy /d' "$R/var/lib/pacman/local/packages" +refused "Omarchy 3.x" "upgrade the 3.x install with omarchy-upgrade-to-quattro-mac first" +new_fixture refusals +echo "base udev autodetect microcode modconf kms keyboard keymap block encrypt filesystems fsck" >"$F/hooks" +refused "busybox encrypt" "busybox encrypt" +new_fixture refusals +printf '\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///custom\n' >>"$R/etc/pacman.conf" +refused "an unknown TrustAll repository" "\[custom\] accepts untrusted packages" +new_fixture refusals +printf '\n[custom]\nInclude = /etc/pacman.d/custom\n' >>"$R/etc/pacman.conf" +printf 'SigLevel = Optional TrustAll\nServer = file:///custom\n' >"$R/etc/pacman.d/custom" +refused "a TrustAll repository an Include configures" "\[custom\] accepts untrusted packages" +new_fixture refusals +sed -i '/^\[options\]$/a IgnorePkg = omarchy-mac*' "$R/etc/pacman.conf" +refused "an administrator's pin on a target" "holds back omarchy-mac, which the migration changes" +new_fixture refusals +sed -i '/^\[options\]$/a NoExtract = usr/bin/omarchy-lifecycle-*' "$R/etc/pacman.conf" +refused "a NoExtract that drops the dispatcher" "NoExtract or NoUpgrade (usr/bin/omarchy-lifecycle-\*) .* keeps usr/bin/omarchy-lifecycle-dispatch" +new_fixture refusals +sed -i '/^\[options\]$/a Include = /etc/pacman.d/extra-options' "$R/etc/pacman.conf" +refused "an Include in [options]" "an Include in \[options\]" +new_fixture refusals +: >"$R/var/lib/pacman/db.lck" +refused "a pacman lock" "pacman is busy" +new_fixture refusals +echo "/boot/initramfs-linux-asahi.img does not hold the 6.19.1 modules" >"$F/boot-check-fail" +refused "incoherent boot files" "boot files are not coherent" +grep -q "^boot-check pending --boot-chain$" "$F/boot.log" || fail "preflight checks the installed boot chain, not the running kernel" "$(cat "$F/boot.log")" +new_fixture refusals +mkdir -p "$R/var/lib/omarchy/mac-first-boot" +: >"$R/var/lib/omarchy/mac-first-boot/pending" +refused "an unfinished first boot" "first boot has not finished" +new_fixture refusals +echo "linux-aurora 7.1.11-1" >>"$R/var/lib/pacman/local/packages" +refused "two kernels" "expected one Apple kernel" +new_fixture refusals +rm "$R/etc/default/grub" +refused "no GRUB defaults" "no /etc/default/grub" +new_fixture refusals +rm "$R/usr/share/pacman/keyrings/asahi-alarm.gpg" +refused "no Asahi keyring" "asahi-alarm-keyring is not installed" +new_fixture refusals +rmdir "$R/run/systemd/system" +refused "an image build" "not a booted system" +new_fixture refusals +mkdir -p "$R/sys/class/power_supply/macsmc-battery" "$R/sys/class/power_supply/macsmc-ac" +echo Battery >"$R/sys/class/power_supply/macsmc-battery/type" +echo 12 >"$R/sys/class/power_supply/macsmc-battery/capacity" +echo Mains >"$R/sys/class/power_supply/macsmc-ac/type" +echo 0 >"$R/sys/class/power_supply/macsmc-ac/online" +refused "a low battery" "battery is below 30%" +echo 1 >"$R/sys/class/power_supply/macsmc-ac/online" +output=$(migrate run 2>&1) || fail "a low battery on the charger migrates" "$output" +new_fixture refusals +: >"$F/df-low" +refused "no space" "needs .* MiB free" +new_fixture refusals +: >"$F/mounts" +refused "no system ESP" "system ESP is not mounted at /boot/efi" +new_fixture refusals +head -c 16 /dev/urandom >>"$F/set/$(jq -r '.packages[2].filename' "$F/set/manifest.json")" +refused "a tampered candidate package" "does not verify: omarchy-mac-.* is missing or changed" +new_fixture refusals +sed -i "s/^fingerprint=.*/fingerprint=$other/" "$R/etc/omarchy-mac/migration-target" +refused "a set signed by another key" "does not verify: its key is not $other" +new_fixture refusals +resign_set "$F/set" "$tmp/other" +refused "a set re-signed by an untrusted key" "does not verify: its key is not $signer" +new_fixture refusals +resign_set "$F/set" "$tmp/other" +cat "$tmp/signer.asc" >>"$F/set/candidate-signing-key.asc" +refused "a receipt signed by another key in the key file" "does not verify: signing.json is not signed by $signer" +new_fixture refusals +cat "$tmp/other.asc" >>"$F/set/candidate-signing-key.asc" +package=$(jq -r '.packages[1].filename' "$F/set/manifest.json") +rm "$F/set/$package.sig" +gpg --batch --homedir "$tmp/other" --detach-sign --no-armor -o "$F/set/$package.sig" "$F/set/$package" 2>/dev/null +refused "a package signed by another key in the key file" "does not verify: $package is not signed by $signer" +new_fixture refusals +jq '.packages |= map(select(.name != "uboot-asahi"))' "$F/set/manifest.json" >"$F/manifest" && mv "$F/manifest" "$F/set/manifest.json" +refused "a changed manifest" "signing.json does not bind this manifest" +new_fixture refusals +rm "$F/keyserver/$official" +refused "an unreachable Omarchy key" "cannot fetch and trust the Omarchy packaging key" +pass "preflight refuses unsupported cohorts, legacy unlock, untrusted or pinning configurations, busy or incoherent systems, low power or space and unverifiable sets, changing nothing" + +# A key whose signing subkey made the signatures is named by its primary fingerprint. +mkdir -m 700 "$tmp/subkey-home" +gpg --batch --homedir "$tmp/subkey-home" --pinentry-mode loopback --passphrase '' --quick-gen-key "Migration test subkey" ed25519 cert 1d 2>/dev/null +subkey_primary=$(gpg --batch --homedir "$tmp/subkey-home" --with-colons --list-secret-keys 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }') +gpg --batch --homedir "$tmp/subkey-home" --pinentry-mode loopback --passphrase '' --quick-add-key "$subkey_primary" ed25519 sign 1d 2>/dev/null +new_fixture subkey +rm -rf "$F/set" +make_set "$F/set" "$tmp/subkey-home" +sed -i "s/^fingerprint=.*/fingerprint=$subkey_primary/" "$R/etc/omarchy-mac/migration-target" +output=$(migrate run 2>&1) || fail "a set signed by the pinned key's signing subkey verifies" "$output" +grep -q "Reboot to finish" <<<"$output" || fail "the subkey-signed set migrates to its reboot" "$output" +pass "signatures count only when the pinned key (or its signing subkey) made them, whatever else the key file holds" + +new_fixture elsewhere +echo generic-aarch64 >"$F/platform" +output=$(migrate run 2>&1) || fail "another platform is a no-op" "$output" +grep -q "Not an Apple Silicon Mac" <<<"$output" && [[ ! -e $(state_dir) ]] || fail "another platform is left alone" "$output" +if OMARCHY_MAC_MIGRATE_ROOT="" "$tool" run 2>/dev/null; then fail "a normal user without a fixture root is refused"; fi +pass "other platforms and unprivileged callers change nothing" + +new_fixture target-trust +chmod 666 "$R/etc/omarchy-mac/migration-target" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "refusing the target" <<<"$output" && [[ ! -e $(state_dir)/journal ]] || fail "a target others can write is refused, deferred with nothing changed" "$output" +chmod 644 "$R/etc/omarchy-mac/migration-target" +chmod 777 "$F/set" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) || fail "a candidate directory others can write is refused" "$output" +pass "target files and candidate sets must be writable by their owner only" + +# --- The channel ------------------------------------------------------------------ + +# Without an administrator's target, the Mac follows the channel its own +# configuration names, here the collaboration repository's edge lane. +channel_fixture() { + new_fixture "$1" + rm "$R/etc/omarchy-mac/migration-target" + sed -i "s|^Server = file://$F/repos/omarchy-aarch64$|Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/releases/download/${2:-edge}|" "$R/etc/pacman.conf" +} + +channel_fixture no-channel +sed -i "s|^Server = https://github.com/omarchy-mac/omarchy-pkgs-aarch64/.*|Server = https://example.org/elsewhere|" "$R/etc/pacman.conf" +refused "an unknown channel" "cannot tell which Omarchy channel this Mac follows" +channel_fixture stable stable +# Omarchy's stable channel today: no Mac packages. +printf 'omarchy 4.0.4-1\nomarchy-settings 4.0.4-1\nomarchy-keyring 20251027-1\n' | repo official-stable omarchy +refused "the stable channel without Mac packages" "the stable channel has no omarchy-mac for Apple Silicon yet" +output=$(migrate run 2>&1 || true) +grep -q "The stable channel has no Mac release yet" <<<"$output" || fail "the deferral says why" "$output" +[[ $(migrate status) == *"The last run deferred: "*"stable channel has no omarchy-mac"* ]] || fail "status says why it deferred" "$(migrate status)" +channel_fixture edge-lane edge +cp -r "$F/repos/omarchy" "$F/repos/official-edge" +archive omarchy-dev 4.0.0.r6713.ga85e29a-1 +archive omarchy-mac-boot 20260927-1 +output=$(migrate check 2>&1) || fail "an edge lane follows the edge channel" "$output" +grep -q "onto repository file://$F/repos/official-edge (edge)" <<<"$output" || fail "the edge lane's Mac moves to the edge channel" "$output" +pass "a Mac whose channel cannot be told, or has no Mac release, defers with nothing changed" + +# A repository target whose archives are not ready for Macs yet. +repository_fixture() { + new_fixture "$1" + printf 'format=1\ntype=repository\nchannel=edge\nserver=file://%s/repos/omarchy\n' "$F" >"$R/etc/omarchy-mac/migration-target" + archive omarchy-dev 4.0.0.r6713.ga85e29a-1 + archive omarchy-mac-boot 20260927-1 +} +repository_fixture edge-old-runtime +make_archive omarchy-mac-boot 20260927-1 "$F/archives/omarchy-mac-boot" +printf 'pkgname = omarchy-dev\npkgver = 4.0.0.r6713.ga85e29a-1\n' >"$tmp/PKGINFO" && bsdtar -czf "$F/archives/omarchy-dev" -C "$tmp" --transform 's|PKGINFO|.PKGINFO|' PKGINFO 2>/dev/null || + (mkdir -p "$tmp/old" && cp "$tmp/PKGINFO" "$tmp/old/.PKGINFO" && bsdtar -czf "$F/archives/omarchy-dev" -C "$tmp/old" .PKGINFO) +refused "a runtime without the dispatcher" "has no omarchy-lifecycle-dispatch" +repository_fixture edge-old-boot +mkdir -p "$tmp/oldboot/usr/lib/omarchy-mac/boot" "$tmp/oldboot/usr/lib/omarchy/mac-boot" +printf 'pkgname = omarchy-mac-boot\npkgver = 20260927-1\n' >"$tmp/oldboot/.PKGINFO" +: >"$tmp/oldboot/usr/lib/omarchy-mac/boot/migrate-engine.sh" +: >"$tmp/oldboot/usr/lib/omarchy/mac-boot/setup-boot" +: >"$tmp/oldboot/usr/lib/omarchy/mac-boot/update-verify" +bsdtar -czf "$F/archives/omarchy-mac-boot" -C "$tmp/oldboot" .PKGINFO usr +refused "an omarchy-mac-boot with its own migration engine" "not built from omacom/omarchy-mac-pkgs yet" +repository_fixture edge-linked-tool +mkdir -p "$tmp/linkboot/usr/lib/omarchy/mac-boot" "$tmp/linkboot/usr/bin" +printf 'pkgname = omarchy-mac-boot\npkgver = 20260927-1\n' >"$tmp/linkboot/.PKGINFO" +: >"$tmp/linkboot/usr/lib/omarchy/mac-boot/setup-boot" +: >"$tmp/linkboot/usr/lib/omarchy/mac-boot/update-verify" +for command in omarchy-mac-initramfs-hooks omarchy-apple-silicon-boot-check omarchy-mac-kernel; do + install -m 755 /dev/null "$tmp/linkboot/usr/bin/$command" +done +ln -s /tmp/elsewhere "$tmp/linkboot/usr/bin/omarchy-mac-esp" +bsdtar -czf "$F/archives/omarchy-mac-boot" -C "$tmp/linkboot" .PKGINFO usr +refused "an omarchy-mac-boot whose boot tool is a link" "omarchy-mac-boot's commands include a link" +repository_fixture edge-ready +output=$(migrate run 2>&1) || fail "a ready repository target migrates" "$output" +grep -q "^omarchy-dev 4.0.0.r6713.ga85e29a-1$" "$R/var/lib/pacman/local/packages" && grep -q "^omarchy-mac-boot 20260927-1$" "$R/var/lib/pacman/local/packages" || + fail "the official builds replace higher-versioned tester builds" "$(cat "$R/var/lib/pacman/local/packages")" +grep -q "^transaction omarchy/omarchy-dev omarchy/omarchy-settings-dev omarchy/omarchy-mac omarchy/omarchy-mac-boot omarchy/linux-aurora omarchy/m1n1-aurora omarchy/uboot-asahi omarchy/limine-mkinitcpio-hook cursor-bin asahi-alarm-keyring omarchy-keyring$" "$F/pacman.log" || + fail "each official package is named in [omarchy]" "$(grep transaction "$F/pacman.log")" +grep -q "^download omarchy-dev \|^download omarchy-mac-boot " "$F/pacman.log" || fail "preflight reads the verified archives" +pass "a channel is ready for Macs only when its signed archives carry the dispatcher and a boot package without its own migration engine" + +# --- Failures after preflight -------------------------------------------------- + +new_fixture removal +echo "omarchy-mac-boot widget-extra" >>"$F/conflicts" +conf_before=$(cat "$R/etc/pacman.conf") +packages_before=$(cat "$R/var/lib/pacman/local/packages") +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove widget-extra; nothing was changed" <<<"$output" || fail "an unexpected removal stops the rehearsal, deferred" "status $status: $output" +[[ $(cat "$R/etc/pacman.conf") == "$conf_before" && $(cat "$R/var/lib/pacman/local/packages") == "$packages_before" ]] || + fail "the repositories and packages are untouched after a failed rehearsal" +[[ ! -e $(state_dir)/journal && -n $(ls -d "$(state_dir)"/history/aborted-* 2>/dev/null) ]] || fail "the attempt is set aside, so the next run starts over" +[[ $(migrate status) == *"would also remove widget-extra"* ]] || fail "status says why it deferred" "$(migrate status)" +pass "a rehearsal that would remove more than the plan allows defers before the switch, and the next run starts over" + +new_fixture after-boundary +kill_after repositories +: >"$F/fail-transaction" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && [[ -f $(state_dir)/journal ]] || fail "a failure after the switch is a failure, never a deferral" "status $status: $output" +rm "$F/fail-transaction" +finish +pass "after the repository switch a failure stops the migration for the next run to resume" + +new_fixture loader +: >"$F/limine-activation-fail" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "Limine could not be activated; GRUB is still the loader" <<<"$output" || fail "a failed Limine activation fails the step" "$output" +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "grub" && ! -e $R/var/lib/omarchy/limine.enabled ]] || fail "a failed activation leaves GRUB as the loader" +rm "$F/limine-activation-fail" +finish +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" ]] || fail "the retried loader step activates Limine" +pass "a failed Limine activation leaves GRUB active, and the retry finishes" + +new_fixture update-verify +kill_after defaults +echo "the UKI does not hold the 7.1.12 modules" >"$F/update-verify-fail" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "update-verify does not pass" <<<"$output" || fail "a failing update-verify stops before the reboot" "$output" +! grep -q "^systemctl enable omarchy-mac-migrate-verify.service$" <(grep -A100 'dispatch update-verify' "$F/boot.log" | tail -n +2) || true +grep -q "^IgnorePkg" "$R/etc/pacman.conf" || fail "the guard stays while verification fails" +rm "$F/update-verify-fail" +finish +pass "update-verify must pass before the reboot is offered, and after it" + +new_fixture aborted-boot +output=$(migrate run 2>&1) || fail "the migration reaches its reboot" "$output" +echo boot-2 >"$R/proc/sys/kernel/random/boot_id" +status=0 +output=$(migrate verify 2>&1) || status=$? +(( status == 1 )) && grep -q "this boot runs 6.19.1-asahi, not linux-aurora 7.1.12-aurora" <<<"$output" || fail "a boot of the old kernel fails verification" "$output" +[[ ! -f $(state_dir)/complete && -e $(state_dir)/reboot-pending ]] || fail "an unverified boot retires nothing" +pass "a reboot that did not come up on Aurora is not accepted" + +new_fixture busy +kill_after backup +printf 'format=1\ntype=repository\nchannel=stable\nserver=file://%s/repos/omarchy\n' "$F" >"$F/stable-target" +output=$(migrate run --target "$F/stable-target" 2>&1) && fail "another target is refused while one is in progress" "$output" +grep -q "a migration to candidate-set apple-test-fixture .* is in progress" <<<"$output" || fail "the refusal names the migration in progress" "$output" +pass "a migration in progress keeps its target" + +new_fixture first-boot +: >"$F/scriptlet-arms-first-boot" +finish +[[ ! -e $R/var/lib/omarchy/mac-first-boot/pending ]] || fail "a first-boot marker armed by the transaction is removed" +pass "fresh-image first boot is never armed on an existing Mac" + +new_fixture locked +kill_after keyring +journal_before=$(cat "$(state_dir)/journal") +exec 8>"$R/run/lock/omarchy-mac-migrate.lock" +flock -n 8 +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "another migration run is in progress" <<<"$output" && [[ $(cat "$(state_dir)/journal") == "$journal_before" && -d $(state_dir)/backup ]] || + fail "a run that cannot take the lock before the switch defers and leaves the state alone" "status $status: $output" +exec 8>&- +kill_after repositories +exec 8>"$R/run/lock/omarchy-mac-migrate.lock" +flock -n 8 +status=0 +output=$(migrate verify 2>&1) || status=$? +(( status == 1 )) || fail "past the switch, a run that cannot take the lock fails" "status $status: $output" +exec 8>&- +finish +pass "a second run never touches the state of the run holding the lock" + +# --- The tool that resumes ---------------------------------------------------------- + +new_fixture handover +kill_after transaction +copy=$(state_dir)/tool/omarchy-mac-migrate +cmp -s "$tool" "$copy" || fail "the migration keeps a copy of the tool" +sed 's/^tool_version=1$/tool_version=99/' "$tool" >"$F/newer" && chmod 755 "$F/newer" +output=$(OMARCHY_MAC_MIGRATE_ROOT=$R MIGRATE_FIXTURE=$F OMARCHY_MAC_MIGRATE_ASAHI_SERVER="file://$F/repos/asahi-alarm" PATH="$stubs:$PATH" "$F/newer" run 2>&1) || + fail "a newer tool resumes the migration" "$output" +cmp -s "$F/newer" "$copy" || fail "a newer tool of the same journal format becomes the kept copy" +new_fixture handback +kill_after transaction +sed -i 's/^tool_version=1$/tool_version=99/' "$(state_dir)/tool/omarchy-mac-migrate" +output=$(migrate run 2>&1) || fail "an older tool hands the migration to the copy that started it" "$output" +grep -q "Resuming with the tool this migration started with (version 99)" <<<"$output" || fail "the hand-over is said" "$output" +pass "a migration resumes with the tool that started it, unless a newer one of the same journal format takes over" + +# --- The system moving under a migration -------------------------------------- + +# omarchy update runs pacman -Syu before the migration resumes. +for step in prefetch repositories; do + new_fixture "moved-$step" + kill_after "$step" + sed -i 's/^hyprland .*/hyprland 0.52-1/' "$R/var/lib/pacman/local/packages" + finish + grep -q "^hyprland 0.52-1$" "$R/var/lib/pacman/local/packages" || fail "after $step, the upgrade in between is kept" + grep -q " prefetch reset " "$(state_dir)/journal" || fail "after $step, the changed system is rehearsed again" "$(cat "$(state_dir)/journal")" + [[ $(grep -c '^transaction ' "$F/pacman.log") == 1 ]] || fail "after $step, one transaction" +done +pass "an update between the rehearsal and the transaction sends the migration back to rehearse, instead of sticking" + +new_fixture frozen-databases +kill_after keyring +printf 'hyprland 0.53-1\nlimine 12.9.0-1\n' >"$F/repos/extra/extra.db" +finish +grep -q "^hyprland 0.51-1$" "$R/var/lib/pacman/local/packages" || fail "the transaction installs what preflight read, not a newer sync" +pass "the transaction installs the set preflight qualified, from the databases it froze" + +new_fixture snapshot +kill_after keyring +echo "widget-conflict 1.0-1" >>"$R/var/lib/pacman/local/packages" +echo "omarchy-mac-boot widget-conflict" >>"$F/conflicts" +sed -i '/^widget-extra /d' "$R/var/lib/pacman/local/packages" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove widget-conflict; nothing was changed" <<<"$output" || + fail "a package installed after preflight is still guarded against removal" "$output" +! grep -q "remove widget-extra" <<<"$output" || fail "a package removed after preflight is not reported" "$output" +pass "the removal guard compares against what the rehearsal started from" + +new_fixture held-lock +kill_after repositories +: >"$R/var/lib/pacman/db.lck" +mkdir -p "$R/proc/4242/fd" +ln -s "$R/var/lib/pacman/db.lck" "$R/proc/4242/fd/3" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "pacman is running (process 4242)" <<<"$output" && [[ -e $R/var/lib/pacman/db.lck ]] || + fail "a lock another package manager holds is left alone" "$output" +rm -rf "$R/proc/4242" +finish +pass "a held pacman lock stops the transaction; a stale one is cleared" + +new_fixture resynced +kill_after repositories +printf 'hyprland 0.53-1\nlimine 12.9.0-1\n' >"$R/var/lib/pacman/sync/extra.db" +finish +grep -q "^hyprland 0.51-1$" "$R/var/lib/pacman/local/packages" || fail "a sync after the rehearsal does not change what the transaction installs" +new_fixture interrupted-then-moved +output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_MID=transaction -- run 2>&1) && fail "pacman is killed after its database write" +echo "late-extra 1.0-1" >>"$R/var/lib/pacman/local/packages" +finish +grep -q " prefetch reset " "$(state_dir)/journal" || fail "the changed packages are rehearsed again" +[[ $(grep -c '^transaction ' "$F/pacman.log") == 2 && $(grep '^transaction \|^hooks' "$F/pacman.log" | tail -n 1) == "hooks" ]] || + fail "a transaction killed before its hooks runs again after a new rehearsal" "$(cat "$F/pacman.log")" +pass "the transaction uses the rehearsed databases, and a killed one runs again even after a new rehearsal" + +new_fixture frozen-set +kill_after preflight +head -c 16 /dev/urandom >>"$F/set/$(jq -r '.packages[3].filename' "$F/set/manifest.json")" +finish +mv "$F/set" "$F/set.gone" +output=$(migrate status) && [[ $output == *"State: complete"* ]] || fail "status needs no candidate set" +pass "after preflight only the verified copy of the set is used, and the original may change or go" + +new_fixture enable +: >"$F/systemctl-fail" +conf_before=$(cat "$R/etc/pacman.conf") +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "cannot install omarchy-mac-migrate-verify.service" <<<"$output" && [[ $(cat "$R/etc/pacman.conf") == "$conf_before" ]] || + fail "a unit that cannot be enabled stops the run before the switch, deferred" "status $status: $output" +[[ ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] || fail "the deferred attempt takes its unit with it" +rm "$F/systemctl-fail" +finish +pass "the unit that resumes the migration must be enabled before the switch goes ahead" + +# A reset back to prefetch before the switch is still before it. +new_fixture reset-before-switch +kill_after prefetch +sed -i 's/^hyprland .*/hyprland 0.52-1/' "$R/var/lib/pacman/local/packages" +echo "omarchy-mac-boot widget-extra" >>"$F/conflicts" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 75 )) && grep -q "would also remove widget-extra" <<<"$output" && [[ ! -e $(state_dir)/journal ]] || + fail "a refusal after a reset before the switch defers" "status $status: $output" +pass "the boundary is the switch's first write, not its step's start: a reset and a refusal before it still defer" + +# --- A fresh install's defaults ---------------------------------------------------- + +user_unit() { # name target + mkdir -p "$R/usr/lib/systemd/user" + printf '[Unit]\nDescription=%s\n\n[Install]\nWantedBy=%s\n' "$1" "$2" >"$R/usr/lib/systemd/user/$1" +} + +# Two users: one who has used Omarchy, with a unit enabled, one masked and one +# installed and turned off; one account Omarchy never ran for. +new_fixture defaults +printf 'root:x:0:0::/root:/bin/bash\ntester:x:1000:1000::/home/tester:/bin/bash\nguest:x:1001:1001::/home/guest:/bin/bash\n' >"$R/etc/passwd" +home=$R/home/tester +mkdir -p "$home/.local/state/omarchy" "$home/.config/systemd/user/graphical-session.target.wants" "$R/home/guest" +for unit in bt-agent.service omarchy-sleep-lock.service omarchy-migrate-notify.service omarchy-fcitx5.service; do + user_unit "$unit" graphical-session.target +done +user_unit omarchy-recover-internal-monitor.service graphical-session-pre.target +ln -s /usr/lib/systemd/user/bt-agent.service "$home/.config/systemd/user/graphical-session.target.wants/bt-agent.service" +ln -s /dev/null "$home/.config/systemd/user/omarchy-fcitx5.service" +echo "zram-generator 1.2-1" >>"$R/var/lib/pacman/local/packages" +echo "avd-fw 0.1-1" >>"$F/repos/asahi-alarm/asahi-alarm.db" +echo "libva-v4l2_request-avd 1.0-1" >>"$F/repos/omarchy/omarchy.db" +echo "obs-studio 32.0-1" >>"$F/repos/extra/extra.db" +kill_after preflight +# The target's omarchy brings units this Mac never had. +user_unit omarchy-brightness-keyboard-auto.service graphical-session.target +user_unit omarchy-crash-watch.service graphical-session.target +user_unit owed.service graphical-session.target +output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_MID=defaults -- run 2>&1) && fail "the run is killed in the middle of its defaults" +grep -q "Installing the default packages a fresh install has: avd-fw libva-v4l2_request-avd" <<<"$output" || fail "the missing Apple defaults are named" "$output" +grep -q "No repository carries these default packages, so they stay missing: .*widevine" <<<"$output" || + fail "defaults no repository carries are named, not fatal" "$output" +finish +[[ $(grep -c '^transaction avd-fw libva-v4l2_request-avd$' "$F/pacman.log") == 1 ]] || + fail "the missing Apple defaults are installed once, across a resumed step" "$(cat "$F/pacman.log")" +! grep -q "obs-studio\|zram-generator" <(grep '^transaction' "$F/pacman.log") || fail "the base list's applications and installed defaults are left alone" +grep -q "^dispatch setup-system" "$F/boot.log" || fail "the Mac services a fresh install enables are set up through the dispatcher" +wants=$home/.config/systemd/user/graphical-session.target.wants +for unit in omarchy-brightness-keyboard-auto.service omarchy-crash-watch.service owed.service; do + [[ $(readlink "$wants/$unit") == "/usr/lib/systemd/user/$unit" ]] || fail "a unit new to this Mac is enabled as first run does: $unit" "$(ls -la "$wants")" +done +[[ ! -e $wants/omarchy-sleep-lock.service && ! -L $wants/omarchy-sleep-lock.service ]] || fail "a unit the Mac had and the user turned off stays off" +[[ $(readlink "$home/.config/systemd/user/omarchy-fcitx5.service") == /dev/null && ! -L $wants/omarchy-fcitx5.service ]] || fail "a masked unit stays masked" +[[ $(readlink "$wants/bt-agent.service") == /usr/lib/systemd/user/bt-agent.service ]] || fail "an enabled unit is left as it is" +[[ ! -e $R/home/guest/.config ]] || fail "an account Omarchy never ran for is left alone" +[[ $(grep -c "^dispatch setup-user HOME=$home$" "$F/boot.log") -ge 1 ]] && ! grep -q "setup-user HOME=$R/home/guest\|setup-user HOME=$R/root" "$F/boot.log" || + fail "the Mac user setup runs through the dispatcher for each Omarchy user only" "$(grep setup-user "$F/boot.log")" +pass "a migrated Mac gains the Apple defaults, the Mac services and the user units a fresh install has, keeping every choice made" + +new_fixture defaults-failing +echo "avd-fw 0.1-1" >>"$F/repos/asahi-alarm/asahi-alarm.db" +: >"$F/setup-system-fail" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "setup-system (omarchy-lifecycle-dispatch) could not set up the Mac's services" <<<"$output" || fail "a failed system setup fails the step" "$output" +[[ $(migrate status) == *"failed at defaults"* ]] || fail "status names the failed defaults" "$(migrate status)" +rm "$F/setup-system-fail" +finish +[[ $(grep -c '^transaction avd-fw$' "$F/pacman.log") == 1 ]] || fail "the retry installs nothing twice" "$(cat "$F/pacman.log")" +pass "a failed defaults step stops before the reboot and is retried" + +first_run_units=$(git -C "$ROOT" show 69d80cccd:install/user/first-run/enable-user-units.sh 2>/dev/null | sed -n '/systemctl --user enable --now/,/[^\\]$/p' | grep -o '[a-z0-9-]*\.service' | xargs || true) +engine_units=$(sed -n 's/^fresh_user_units="\(.*\)"$/\1/p' "$ROOT/migrate/src/engine.sh") +if [[ -n $first_run_units ]]; then + [[ $first_run_units == "$engine_units" ]] || fail "the migration enables the user units upstream's first run enables" "first run: $first_run_units; migration: $engine_units" + pass "the migration's user units are upstream first run's" +fi + +# --- Repairs the runtime's Mac migrations made -------------------------------------- + +broadcom_block="# Broadcom's firmware supplicant and authenticator fail the WPA four-way +# handshake on Apple hardware, which surfaces as a rejected password. Disable +# both so wpa_supplicant performs the handshake instead. +options brcmfmac feature_disable=0x82000" + +# Two Omarchy users, one from quattro-upstream and one from mx-mac; alarm still +# in wheel beside the owner; the Intel Broadcom block after an owner's line; LANG=C. +repairs_fixture() { + new_fixture "$1" + printf 'root:x:0:0::/root:/bin/bash\nalarm:x:1000:1000::/home/alarm:/bin/bash\ntester:x:1001:1001::/home/tester:/bin/bash\nother:x:1002:1002::/home/other:/bin/bash\n' >"$R/etc/passwd" + printf 'root:x:0:\nwheel:x:998:alarm,tester\nalarm:x:1000:\n' >"$R/etc/group" + mkdir -p "$R/home/tester/.local/state/omarchy/migrations" "$R/home/other/.local/state/omarchy/migrations" "$R/home/alarm" "$R/etc/modprobe.d" + : >"$R/home/tester/.local/state/omarchy/migrations/1789132067.sh" + : >"$R/home/other/.local/state/omarchy/migrations/1790305681.sh" + printf 'options brcmfmac roamoff=1\n%s\n' "$broadcom_block" >"$R/etc/modprobe.d/brcmfmac.conf" + echo LANG=C >"$R/etc/locale.conf" + printf '#en_US.UTF-8 UTF-8\n#de_DE.UTF-8 UTF-8\n' >"$R/etc/locale.gen" + mkdir -p "$R/usr/share/omarchy/install/config" + # The target runtime's leaf (omacom/omarchy #13362 69d80cccd). + cp "$ROOT/test/fixtures/mac-migrate/runtime/install/config/locale.sh" "$R/usr/share/omarchy/install/config/locale.sh" + cat >"$R/usr/share/omarchy/install/config/snapper.sh" <<'LEAF' +# Stands in for the runtime's Snapper leaf: its exit status is the fixture's. +echo "snapper-leaf OMARCHY_PATH=$OMARCHY_PATH" >>"$MIGRATE_FIXTURE/boot.log" +exit "$(cat "$MIGRATE_FIXTURE/snapper-status" 2>/dev/null || echo 0)" +LEAF +} + +repaired_names="1789146110 1789148088 1789158179 1789172112 1790327324" + +repairs_fixture repairs +kill_after preflight +output=$(migrate_env OMARCHY_MAC_MIGRATE_KILL_MID=broadcom -- run 2>&1) && fail "the run is killed in the middle of the Broadcom repair" +[[ -f $R/var/lib/omarchy/migrations/1789172112-initramfs-pending ]] || fail "the rebuild is owed before the Broadcom block goes" +finish +[[ $(<"$R/etc/modprobe.d/brcmfmac.conf") == "options brcmfmac roamoff=1" ]] || fail "only the Broadcom block goes" "$(cat "$R/etc/modprobe.d/brcmfmac.conf")" +[[ ! -e $R/var/lib/omarchy/migrations/1789172112-initramfs-pending && $(grep -c '^omarchy-mac-boot-update' "$F/boot.log") == 1 ]] || + fail "the boot image is rebuilt once, across the interrupted repair" "$(cat "$F/boot.log")" +[[ $(grep '^wheel:' "$R/etc/group") == "wheel:x:998:tester" ]] || fail "alarm leaves wheel beside another administrator" "$(cat "$R/etc/group")" +[[ $(<"$R/etc/locale.conf") == "LANG=en_US.UTF-8" ]] && grep -qx 'en_US.UTF-8 UTF-8' "$R/etc/locale.gen" || fail "a C locale becomes en_US.UTF-8" "$(cat "$R/etc/locale.conf" "$R/etc/locale.gen")" +grep -qx "snapper-leaf OMARCHY_PATH=$R/usr/share/omarchy" "$F/boot.log" || fail "the Snapper leaf runs" "$(cat "$F/boot.log")" +grep -qx 'omarchy-mac-setup-keyboard 3' "$F/boot.log" || fail "mx-mac's history names the generated keyboard line" "$(grep keyboard "$F/boot.log")" +for user in tester other; do + for name in $repaired_names; do + [[ -f $R/home/$user/.local/state/omarchy/migrations/$name.sh ]] || fail "$user has the repaired migration $name recorded as done" + done + [[ -f $R/home/$user/.local/state/omarchy/migrations/1785424256.sh ]] || fail "$user has systemd-oomd's migration settled (off on Macs)" + [[ ! -e $R/home/$user/.local/state/omarchy/migrations/1790347292.sh ]] || fail "the retired platform migration is not recorded" +done +[[ ! -e $R/home/alarm/.local ]] || fail "an account Omarchy never ran for gets no records" +pass "the engine removes the Broadcom block, retires alarm from wheel, sets the locale, runs Snapper, hands over the keyboard, and settles those migrations" + +# --- User setup that fails stays pending ------------------------------------------ + +new_fixture user-pending +printf 'tester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +home=$R/home/tester +mkdir -p "$home/.local/state/omarchy" "$home/.config/systemd/user" +kill_after preflight +user_unit omarchy-crash-watch.service graphical-session.target +chmod 555 "$home/.config/systemd/user" +: >"$F/setup-user-fail" +output=$(migrate run 2>&1) || fail "user setup that fails does not stop the migration" "$output" +grep -q "Could not apply omarchy-crash-watch.service for tester" <<<"$output" && grep -q "Could not apply setup-user for tester" <<<"$output" || + fail "each failed item is reported" "$output" +[[ $(migrate status) == *"User setup pending"*"tester setup-user"* ]] || fail "status lists pending user setup" "$(migrate status)" +reboot_into_aurora +output=$(migrate verify 2>&1) || fail "verify completes the migration with user setup pending" "$output" +[[ -f $(state_dir)/complete && -s $(state_dir)/user-pending ]] || fail "the migration completes while user setup stays pending" +[[ -f $R/etc/systemd/system/omarchy-mac-migrate-verify.service && -x $(state_dir)/tool/omarchy-mac-migrate ]] || + fail "the unit and the tool's copy stay while user setup is pending" +chmod 755 "$home/.config/systemd/user" +rm "$F/setup-user-fail" +output=$(migrate verify 2>&1) || fail "a boot retries pending user setup" "$output" +[[ ! -e $(state_dir)/user-pending && ! -e $R/etc/systemd/system/omarchy-mac-migrate-verify.service && ! -e $(state_dir)/tool ]] || + fail "once nothing is pending the unit and the tool's copy go" +pass "a user's unit or setup that fails stays pending, runs again at each boot until it succeeds, then releases the unit" + +# A user's setup that succeeds on a retry before the reboot step keeps the unit +# that resumes the migration. +new_fixture user-pending-mid +printf 'tester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +mkdir -p "$R/home/tester/.local/state/omarchy" +: >"$F/setup-user-fail" +kill_after defaults +grep -q setup-user "$(state_dir)/user-pending" || fail "the failed setup-user is pending" +rm "$F/setup-user-fail" +: >"$F/update-verify-fail" +status=0 +output=$(migrate verify 2>&1) || status=$? +(( status == 1 )) && [[ ! -e $(state_dir)/user-pending && -f $R/etc/systemd/system/omarchy-mac-migrate-verify.service ]] || + fail "a boot that retries user setup and then fails keeps the unit that resumes the migration" "status $status: $output" +rm "$F/update-verify-fail" +finish +pass "the unit that resumes a migration stays until the migration no longer needs it" + +# --- A tester already on Aurora and Limine ------------------------------------ + +# A converged test image (the M1 and M2 today): Aurora, m1n1-aurora, Limine in +# the slot, candidate builds, an unencrypted root. +new_fixture limine +sed -i -e 's/^linux-asahi .*/linux-aurora 7.1.12.aurora2-9/' -e 's/^m1n1 .*/m1n1-aurora 1.6.1.aurora1-2/' "$R/var/lib/pacman/local/packages" +echo 7.1.12-aurora >"$R/proc/sys/kernel/osrelease" +rm "$R/boot/grub/grub.cfg" +: >"$R/var/lib/omarchy/limine.enabled" +printf 'KERNEL_CMDLINE[default]="root=UUID=x"\n' >"$R/etc/default/limine" +echo "limine 12.8" >"$R/boot/efi/EFI/BOOT/BOOTAA64.EFI" +echo /dev/nvme0n1p5 >"$F/root-source" +printf '/dev/nvme0n1p5 part btrfs\n/dev/nvme0n1 disk \n' >"$F/lsblk" +finish +grep -q "^linux-aurora 7.1.12.aurora2-11$" "$R/var/lib/pacman/local/packages" || fail "the Aurora kernel moves to the target's build" +grep -q "^omarchy-mac-limine-cmdline" "$F/boot.log" && grep -q "^dispatch setup-boot" "$F/boot.log" && grep -q "^limine-boot activate" "$F/boot.log" || + fail "a Limine Mac rebuilds its menu and UKI, then the new setup-boot refreshes Limine" "$(cat "$F/boot.log")" +! grep -q "update-grub" "$F/boot.log" || fail "a Limine Mac does not touch GRUB" "$(cat "$F/boot.log")" +[[ $(cat "$R/boot/efi/EFI/BOOT/BOOTAA64.EFI") == "limine 12.9" ]] || fail "the slot holds the packaged Limine" +[[ ! -e $(state_dir)/backup/luks-header.img ]] && ! grep -q cryptsetup "$F/pacman.log" || fail "an unencrypted root has no header to back up" +pass "a Limine tester on an unencrypted root keeps Limine, rebuilds its UKI and deploys the packaged loader" + +# --- The build ------------------------------------------------------------------ + +"$ROOT/migrate/build" --check || fail "bin/omarchy-mac-migrate is built from migrate/src" +pass "the committed tool is what migrate/src builds" + +# --- Fork leftovers ------------------------------------------------------------ + +leftover_copy() { # name: the bytes a fork wrote, from the tool itself + bash -c "source <(sed -n '/^leftover() {/,/^}/p' \"\$1\"); leftover \"\$2\"" _ "$ROOT/migrate/src/repairs.sh" "$1" +} +new_fixture leftovers +printf 'tester:x:1000:1000::/home/tester:/bin/bash\n' >"$R/etc/passwd" +home=$R/home/tester +policies=$home/.config/wireplumber/wireplumber.conf.d +mkdir -p "$home/.local/state/omarchy" "$policies" "$R/etc/NetworkManager/conf.d" "$R/etc/modprobe.d" "$R/etc/systemd/system/suspend.target.wants" +leftover_copy wifi_backend.conf >"$R/etc/NetworkManager/conf.d/wifi_backend.conf" +printf 'options appledrm show_notch=0\n' >"$R/etc/modprobe.d/asahi-notch.conf" +leftover_copy omarchy-wifi-resume-fix.service >"$R/etc/systemd/system/omarchy-wifi-resume-fix.service" +ln -s /etc/systemd/system/omarchy-wifi-resume-fix.service "$R/etc/systemd/system/suspend.target.wants/omarchy-wifi-resume-fix.service" +leftover_copy asahi-headset-mic.conf >"$policies/asahi-headset-mic.conf" +leftover_copy asahi-audio-no-suspend-overlay.conf >"$policies/asahi-audio-no-suspend.conf" +finish +[[ ! -e $R/etc/NetworkManager/conf.d/wifi_backend.conf && -f $R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired ]] || + fail "a byte-identical Wi-Fi backend copy retires" +[[ $(<"$R/etc/modprobe.d/asahi-notch.conf") == "options appledrm show_notch=0" && ! -e $R/etc/modprobe.d/asahi-notch.conf.omarchy-mac-retired ]] || + fail "an edited copy stays" +[[ ! -e $R/etc/systemd/system/omarchy-wifi-resume-fix.service && + $(readlink "$R/etc/systemd/system/suspend.target.wants/omarchy-wifi-resume-fix.service") == /usr/lib/systemd/system/omarchy-wifi-resume-fix.service ]] || + fail "the fork's resume unit retires and its enablement points at the vendor unit" +[[ ! -e $policies/asahi-headset-mic.conf && -f $policies/asahi-headset-mic.conf.omarchy-mac-retired && + ! -e $policies/asahi-audio-no-suspend.conf && -f $policies/asahi-audio-no-suspend.conf.omarchy-mac-retired ]] || + fail "the user's copied WirePlumber policies retire, either revision of mx-mac's speaker policy" "$(ls -la "$policies")" +pass "the fork's byte-identical leftovers retire, keeping a backup; edited copies stay" +new_fixture leftovers-backup +mkdir -p "$R/etc/NetworkManager/conf.d" +leftover_copy wifi_backend.conf >"$R/etc/NetworkManager/conf.d/wifi_backend.conf" +printf 'administrator backup\n' >"$R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired" +status=0 +output=$(migrate run 2>&1) || status=$? +(( status == 1 )) && grep -q "wifi_backend.conf.omarchy-mac-retired differs" <<<"$output" || fail "a different backup stops the step and says why" "$output" +[[ $(<"$R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired") == "administrator backup" ]] || fail "the administrator's backup is kept" +rm "$R/etc/NetworkManager/conf.d/wifi_backend.conf.omarchy-mac-retired" +finish +pass "a backup that differs is never overwritten: the step stops until it is moved" diff --git a/test/shell.d/mac-move-migration-test.sh b/test/shell.d/mac-move-migration-test.sh new file mode 100644 index 00000000000..5e048bb3d08 --- /dev/null +++ b/test/shell.d/mac-move-migration-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Migration 1791080196 marks an Apple Silicon Mac for the move onto Omarchy's +# official packages, once and machine-wide; omarchy update then runs +# omarchy-mac-migrate before any fork step and stops once it moved the Mac. +migration=$ROOT/migrations/1791080196.sh +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" +printf '#!/bin/bash\n[[ $(cat "$FIXTURE/platform") == apple ]]\n' >"$tmp/bin/omarchy-hw-apple" +cat >"$tmp/bin/sudo" <<'SH' +#!/bin/bash +echo "sudo $*" >>"$FIXTURE/ran" +exec "$@" +SH +chmod 755 "$tmp/bin"/* +marker=$tmp/state/1791080196 + +run_migration() { + rm -f "$tmp/ran" + FIXTURE=$tmp PATH="$tmp/bin:$PATH" OMARCHY_PATH=$ROOT OMARCHY_MAC_MOVE_MARKER=$marker bash -euo pipefail "$migration" +} + +[[ $(stat -c %a "$migration") == 644 ]] || fail "the migration is mode 644" +head -n 1 "$migration" | grep -q '^echo ' || fail "the migration starts with an echo" + +echo generic >"$tmp/platform" +run_migration >/dev/null || fail "another platform: the migration completes" +[[ ! -e $tmp/ran && ! -e $marker ]] || fail "another platform: nothing runs" +pass "anything but an Apple Silicon Mac completes the migration and marks nothing" + +echo apple >"$tmp/platform" +run_migration >/dev/null || fail "a Mac: the migration completes" +[[ -e $marker ]] && grep -q "^sudo install -Dm644 /dev/null $marker$" "$tmp/ran" || fail "a Mac is marked as root" "$(cat "$tmp/ran" 2>/dev/null)" +run_migration >/dev/null && [[ ! -e $tmp/ran ]] || fail "another account: nothing runs once the Mac is marked" +pass "a Mac is marked for the move once, machine-wide" + +# The move comes first in omarchy update, before any fork update, and the +# update stops once it has run. +update=$ROOT/bin/omarchy-update +move=$(grep -n 'sudo "$OMARCHY_PATH/bin/omarchy-mac-migrate" run || move_status' "$update" | cut -d: -f1) +dev=$(grep -n '^ omarchy-update-dev$' "$update" | cut -d: -f1) +system=$(grep -n '^ omarchy-update-system-pkgs$' "$update" | cut -d: -f1) +[[ -n $move && -n $dev && -n $system ]] && (( move < dev && move < system )) || fail "omarchy update moves a marked Mac before any fork update" "move $move dev $dev system $system" +grep -q '/var/lib/omarchy/migrations/1791080196' "$update" || fail "omarchy update moves only a marked Mac" +awk -v from="$move" 'NR > from && /move_status == 0/ { found = 1 } found && /exit 0/ { ok = 1; exit } END { exit !ok }' "$update" || + fail "a moved Mac's update stops there" +awk -v from="$move" 'NR > from && /move_status == 75/ { found = 1 } found && /updating this Mac as before/ { ok = 1; exit } END { exit !ok }' "$update" || + fail "a deferred move lets the fork update go on" +pass "omarchy update moves a marked Mac before any fork update, stops once it moved, and goes on when the move defers" + +# The update hook's three outcomes, run for real against a stand-in tool. +for outcome in 0 75 1 nothing; do + work=$tmp/update-$outcome + mkdir -p "$work/bin" "$work/omarchy/bin" + code=$outcome + [[ $outcome != nothing ]] || code=0 + printf '#!/bin/bash\necho "migrate $*" >>"%s/ran"\nexit %s\n' "$work" "$code" >"$work/omarchy/bin/omarchy-mac-migrate" + for command in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot \ + omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook \ + omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart; do + printf '#!/bin/bash\n[[ $1 == held ]] && exit 0\necho "%s $*" >>"%s/ran"\n' "$command" "$work" >"$work/bin/$command" + done + printf '#!/bin/bash\nexec "$@"\n' >"$work/bin/sudo" + chmod 755 "$work/bin"/* "$work/omarchy/bin"/* + : >"$work/marker" + status=0 + mkdir -p "$work/state" + if [[ $outcome == 0 ]]; then + : >"$work/state/reboot-pending" + fi + OMARCHY_UPDATE_LOGGED=1 OMARCHY_MAC_MOVE_MARKER=$work/marker OMARCHY_MAC_MOVE_STATE=$work/state OMARCHY_PATH=$work/omarchy PATH="$work/bin:$PATH" \ + bash "$update" -y >"$work/out" 2>&1 || status=$? + case $outcome in + nothing) (( status == 0 )) && grep -q '^omarchy-update-system-pkgs' "$work/ran" || + fail "a run that moved nothing lets the fork update go on" "$(cat "$work/ran" "$work/out")" ;; + 0) (( status == 0 )) && ! grep -q '^omarchy-update-dev\|^omarchy-update-system-pkgs' "$work/ran" && grep -q "now runs Omarchy's official packages" "$work/out" || + fail "a moved Mac's update stops before any fork step" "$(cat "$work/ran" "$work/out")" ;; + 75) (( status == 0 )) && grep -q '^omarchy-update-system-pkgs' "$work/ran" && grep -q '^omarchy-migrate' "$work/ran" || + fail "a deferred move updates the Mac as before" "$(cat "$work/ran" "$work/out")" ;; + 1) (( status != 0 )) && ! grep -q '^omarchy-update-system-pkgs' "$work/ran" || + fail "a failed move stops the update" "$(cat "$work/ran" "$work/out")" ;; + esac +done +pass "omarchy update stops after a move, goes on after a deferral or a run that moved nothing, and stops on a failure"