diff --git a/bin/omarchy-update b/bin/omarchy-update index 6c678755..e71e8086 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -21,6 +21,10 @@ trap 'omarchy-update-stay-awake stop' EXIT omarchy-update-requires-free-space +# -y is a promise not to ask anything. Steps that would need an answer report +# and move on instead of waiting on a prompt nobody is here to give. +[[ ${1:-} != "-y" ]] || export OMARCHY_UPDATE_UNATTENDED=1 + if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # Before the snapshot: the cache is on the snapshotted subvolume, so pruning # after it frees nothing until that snapshot ages out. @@ -36,6 +40,10 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-dev omarchy-update-keyring + + # Migrations ship with the packages installed here and are written against + # them, so everything below waits on this finishing. An upgrade that stopped + # takes the update with it rather than migrating against what is still on disk. omarchy-update-system-pkgs omarchy-migrate omarchy-hook post-update diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index 15b028d5..9e6f9998 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -5,6 +5,14 @@ set -e +# The conflict handler's last resort: pacman puts its questions to the person +# running the update instead of answering them itself. Nothing here may capture +# a stream, because an upgrade without --noconfirm prompts on stderr. The +# handler has already said why, so no heading here either. +if [[ ${OMARCHY_UPDATE_CONFLICT:-} == 1 && ${OMARCHY_UPDATE_INTERACTIVE:-} == 1 ]]; then + exec sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --overwrite '/usr/share/omarchy/*' +fi + echo -e "\e[32m\nUpdate system packages\e[0m" errors=$(mktemp) @@ -22,9 +30,9 @@ if sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ fi cat "$errors" >&2 -# An upgrade blocked only by files pacman doesn't own yet is the one failure -# worth retrying: the handler clears them and runs this again. Anything else, -# including a second failure, is for a human. +# The handler takes it from here: it clears files pacman doesn't own yet and +# runs this again, and puts a package conflict to whoever started the update. +# Anything else, including a second failure, is for a human. [[ ${OMARCHY_UPDATE_RETRY:-} != 1 ]] || exit 1 # exec, so the EXIT trap above does not fire and the handler can still read the # report. It takes over deleting it. diff --git a/bin/omarchy-update-system-pkgs-when-conflicted b/bin/omarchy-update-system-pkgs-when-conflicted index 8a7b4928..ab2ec43c 100755 --- a/bin/omarchy-update-system-pkgs-when-conflicted +++ b/bin/omarchy-update-system-pkgs-when-conflicted @@ -1,6 +1,6 @@ #!/bin/bash -# omarchy:summary=Retry a system package update after resolving file conflicts +# omarchy:summary=Retry a system package update that hit a conflict # omarchy:hidden=true # Internal to omarchy-update-system-pkgs. Not a command to run by hand: it acts @@ -11,6 +11,10 @@ # release's installer, an in-place edit -- belong to nobody, and pacman refuses # to install over a file it doesn't own. This clears them and runs the upgrade # again. +# +# Packages that conflict with each other are the other blocked upgrade, and that +# one is a decision rather than a cleanup, so it goes back to the person +# watching the update. set -e @@ -57,6 +61,26 @@ restore_moved() { trap 'restore_moved; rm -f "$errors"' EXIT trap 'exit 1' INT TERM +# Pacman answers its own conflict question with No under --noconfirm, so one +# retired package can stop every upgrade after it. Which package to drop is a +# decision -- an upstream split retires one, but so does a package the user +# picked on purpose -- so put the question to whoever started the update rather +# than guess at it. Pacman printed what conflicts just above. +if grep -q '^error: unresolvable package conflicts detected$' "$errors"; then + # -y promised not to ask anything, and without a terminal there is nowhere to + # ask; either way pacman would sit on a prompt nobody answers. An upgrade that + # isn't running --noconfirm puts its questions on stderr and reads the answers + # from stdin, so those are the two that have to be a terminal. + if [[ ${OMARCHY_UPDATE_UNATTENDED:-} == 1 || ! -t 0 || ! -t 2 ]]; then + echo -e "\e[33m\nThis upgrade needs an answer. Run omarchy update interactively to give it.\e[0m" >&2 + exit 1 + fi + + echo -e "\e[33m\nA package conflict stopped this upgrade. Running it again so you can answer:\e[0m" + OMARCHY_UPDATE_INTERACTIVE=1 omarchy-update-system-pkgs + exit 0 +fi + # Paths one of these packages installs that pacman doesn't own. Moving them out # of the way is what lets the upgrade through, and works on a leftover directory # as well as a file. diff --git a/test/shell.d/update-package-conflict-test.sh b/test/shell.d/update-package-conflict-test.sh new file mode 100755 index 00000000..ee5ad159 --- /dev/null +++ b/test/shell.d/update-package-conflict-test.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +require_command script + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB + +# Fails the first -Syu with the report under test, then succeeds. Every call +# records its arguments and which of its streams reached a terminal: pacman puts +# its questions on stderr once it is not running --noconfirm, so a retry meant +# for a person has to keep that stream. +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +attempt=$(($(cat "$PACMAN_ATTEMPTS") + 1)) +echo "$attempt" >"$PACMAN_ATTEMPTS" +{ + printf 'args %s\n' "$*" + for fd in 0 1 2; do + if [[ -t $fd ]]; then printf 'tty%s yes\n' "$fd"; else printf 'tty%s no\n' "$fd"; fi + done +} >>"$PACMAN_CALLS" + +if ((attempt == 1)); then + cat "$CONFLICT_REPORT" >&2 + exit 1 +fi +echo "upgrade complete" +STUB + +chmod +x "$stub_bin/sudo" "$stub_bin/pacman" + +# Everything a blocked qemu-common upgrade leaves on stderr, and no more. The +# ":: ... Remove qemu-block-gluster? [y/N]" pacman asked is deliberately absent: +# under --noconfirm it goes to stdout, so nothing downstream of the report can +# be built on having read it. +write_conflict_report() { + echo 0 >"$test_tmp/attempts" + : >"$test_tmp/calls" + { + echo "error: unresolvable package conflicts detected" + echo "error: failed to prepare transaction (conflicting dependencies)" + } >"$test_tmp/report" +} + +update_env() { + printf '%s\n' \ + "OMARCHY_REPLACED_DIR=$test_tmp/replaced" \ + "PACMAN_ATTEMPTS=$test_tmp/attempts" \ + "PACMAN_CALLS=$test_tmp/calls" \ + "CONFLICT_REPORT=$test_tmp/report" \ + "OWNED_PATHS=" \ + "OMARCHY_UPDATE_UNATTENDED=${OMARCHY_UPDATE_UNATTENDED:-}" \ + "OMARCHY_UPDATE_INTERACTIVE=${OMARCHY_UPDATE_INTERACTIVE:-}" \ + "PATH=$stub_bin:$ROOT/bin:$PATH" +} + +# No terminal on any stream, the way a cron or ssh caller arrives. +run_headless() { + mapfile -t environment < <(update_env) + env "${environment[@]}" bash "$ROOT/bin/omarchy-update-system-pkgs" \ + "$test_tmp/out" 2>"$test_tmp/err" +} + +# script gives the update the pty that omarchy-update always runs it on, so the +# terminal checks see what a person at the keyboard would give them. Its +# transcript is stdout and stderr together, which is also what that person sees. +# $1 optionally takes one stream back off the pty. +run_on_terminal() { + mapfile -t environment < <(update_env) + env "${environment[@]}" \ + script -qec "bash '$ROOT/bin/omarchy-update-system-pkgs' ${1:-}" "$test_tmp/out" >/dev/null 2>&1 +} + +call_line() { + awk -v call="$1" -v key="$2" \ + '$1 == "args" { n++ } n == call && $1 == key { sub(/^[^ ]+ /, ""); print }' "$test_tmp/calls" +} + +write_conflict_report +run_on_terminal || fail "a package conflict is not resolved on a terminal" +(($(cat "$test_tmp/attempts") == 2)) || + fail "a package conflict does not get an interactive retry" +[[ $(call_line 2 args) == *"-Syu"* ]] || + fail "the interactive retry does not upgrade" +[[ $(call_line 2 args) != *"--noconfirm"* ]] || + fail "the interactive retry still answers pacman's questions itself" +[[ $(call_line 2 args) != *"--ask"* ]] || + fail "the interactive retry answers pacman's questions from a bitmask instead" +pass "a package conflict is put back to the person running the update" + +[[ $(call_line 2 tty0) == "yes" && $(call_line 2 tty2) == "yes" ]] || + fail "the interactive retry cannot be answered: pacman has no terminal left" +pass "the interactive retry keeps the streams pacman asks and listens on" + +# Which streams have to be a terminal follows from where pacman asks: stderr +# carries the question once --noconfirm is gone, stdin carries the answer, and +# stdout carries progress bars nobody has to see to answer. +write_conflict_report +run_on_terminal '>/dev/null' || + fail "a redirected progress stream is mistaken for an unattended update" +(($(cat "$test_tmp/attempts") == 2)) || + fail "a conflict goes unasked when only stdout is redirected" +pass "an answerable session is not turned away over its progress output" + +write_conflict_report +if run_on_terminal '2>/dev/null'; then + fail "a conflict is asked about on a stream nobody is reading" +fi +(($(cat "$test_tmp/attempts") == 1)) || + fail "pacman is left prompting where the question cannot be seen" +pass "a session that cannot show the question is not asked one" + +[[ $(call_line 1 tty2) == "no" ]] || + fail "the first upgrade no longer captures the error report" +pass "the first upgrade still captures its errors for the handler" + +write_conflict_report +if run_headless; then + fail "a package conflict passes for a completed update without a terminal" +fi +(($(cat "$test_tmp/attempts") == 1)) || + fail "a package conflict is retried with no terminal to answer on" +grep -q 'omarchy update' "$test_tmp/err" || + fail "a package conflict with no terminal does not say how to answer it" +pass "a package conflict with no terminal reports instead of hanging" + +write_conflict_report +if OMARCHY_UPDATE_UNATTENDED=1 run_on_terminal; then + fail "an unattended update stops on a prompt nobody answers" +fi +(($(cat "$test_tmp/attempts") == 1)) || + fail "an unattended update prompts anyway" +pass "-y is kept: an unattended update never waits on an answer" + +# The interactive upgrade skips the error capture the handler depends on, so +# reaching it any other way would lose the report that drives every recovery. +write_conflict_report +if OMARCHY_UPDATE_INTERACTIVE=1 run_headless; then + fail "a caller reaches the interactive upgrade on its own" +fi +[[ $(call_line 1 args) == *"--noconfirm"* ]] || + fail "a caller can ask for an interactive upgrade directly" +pass "only the conflict handler can hand the upgrade to a person" diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh new file mode 100755 index 00000000..2dd62b6e --- /dev/null +++ b/test/shell.d/update-sequence-test.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +# Every step omarchy-update runs, recorded in order with the unattended flag it +# was handed. One of them can be told to fail. +steps=( + 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 +) + +for step in "${steps[@]}"; do + cat >"$stub_bin/$step" <<'STUB' +#!/bin/bash +printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG" +[[ ${FAILING_STEP:-} != "${0##*/}" ]] || exit 1 +STUB + chmod +x "$stub_bin/$step" +done + +# OMARCHY_UPDATE_LOGGED stands in for the script(1) wrapper the update re-execs +# itself under; the stubbed lock reports itself already held. +run_update() { + : >"$test_tmp/steps" + STEP_LOG="$test_tmp/steps" \ + FAILING_STEP="${FAILING_STEP:-}" \ + OMARCHY_UPDATE_LOGGED=1 \ + PATH="$stub_bin:$PATH" \ + bash "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" +} + +steps_run() { + cut -d' ' -f1 "$test_tmp/steps" +} + +# Every step of a whole update, in order. $1 asks for the one a person confirms. +# Stay Awake bookends the work, so it is here twice. +expected_steps() { + printf '%s\n' \ + omarchy-update-lock \ + omarchy-update-requires-free-space \ + ${1:+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-stay-awake \ + omarchy-update-restart +} + +run_update -y || fail "an update where everything works reports a failure" +diff <(expected_steps) <(steps_run) >"$test_tmp/order" || + fail "an update where everything works does not run every step in order" "$(cat "$test_tmp/order")" +pass "an update where every step works runs all of them, in order" + +grep -q '^omarchy-update-system-pkgs unattended=1$' "$test_tmp/steps" || + fail "-y does not mark the update unattended" +run_update "$test_tmp/order" || + fail "a confirmed update runs a different set of steps" "$(cat "$test_tmp/order")" +grep -q '^omarchy-update-system-pkgs unattended=$' "$test_tmp/steps" || + fail "an update a person confirmed is treated as unattended" +pass "-y is what marks an update unattended, not the update itself" + +# Migrations ship with the packages the upgrade installs and are written against +# them. Running them against what is still on disk is the failure this ordering +# exists to prevent, so the update stops where the packages did. +if FAILING_STEP=omarchy-update-system-pkgs run_update -y; then + fail "an update whose packages did not upgrade passes for a whole one" +fi +for step in omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-restart; do + if grep -q "^$step " "$test_tmp/steps"; then + fail "a blocked package upgrade still runs $step" + fi +done +pass "a blocked package upgrade stops the update before it migrates"