Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bin/omarchy-update
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions bin/omarchy-update-system-pkgs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
26 changes: 25 additions & 1 deletion bin/omarchy-update-system-pkgs-when-conflicted
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
155 changes: 155 additions & 0 deletions test/shell.d/update-package-conflict-test.sh
Original file line number Diff line number Diff line change
@@ -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" \
</dev/null >"$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"
108 changes: 108 additions & 0 deletions test/shell.d/update-sequence-test.sh
Original file line number Diff line number Diff line change
@@ -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 </dev/null || fail "a confirmed update reports a failure"
diff <(expected_steps confirmed) <(steps_run) >"$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"