367 lines
15 KiB
Bash
Executable File
367 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# `panama update`: the routine command, and the promises that make it routine.
|
|
#
|
|
# An update that asks questions is an update nobody runs, and a machine nobody
|
|
# updates drifts until the next reinstall. So the properties below are the
|
|
# whole point of the command rather than details of it:
|
|
#
|
|
# 1. --upgrade NEVER runs the interview. This is the regression that started
|
|
# the redesign: ./install asked the full first-install questionnaire every
|
|
# time, including on a machine whose answers could not have changed.
|
|
# 2. --upgrade runs exactly the stages that need no answer, and never
|
|
# setup-identity or install-hardware. Both act only on interview answers.
|
|
# 3. install-packages is gated on a content hash of the package lists, and
|
|
# the hash is NOT recorded when the stage fails -- the same rule
|
|
# panama-migrate applies to its markers, for the same reason.
|
|
# 4. A conflicted `git stash pop` leaves no conflict markers in the tree.
|
|
# Every dotfile here is a symlink into the checkout, so a half-merged file
|
|
# is not something to fix later: it is live in ~/.config immediately, and
|
|
# a broken .qml costs the running shell.
|
|
#
|
|
# Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and
|
|
# gsettings shimmed, so this never touches the machine running it.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
installer="$repo_dir/install"
|
|
panama="$repo_dir/bin/panama"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
[[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; }
|
|
|
|
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
|
link-vicinae-scripts setup-server link-server setup-identity
|
|
install-hardware)
|
|
|
|
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
|
# records what it was asked to do instead of doing it.
|
|
build_fixture() {
|
|
local root="$1" packages_rc="${2:-0}"
|
|
rm -rf "$root"
|
|
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
|
|
"$root/config/dot/quickshell/scripts"
|
|
|
|
cp "$installer" "$root/install"
|
|
: >"$root/bin/ascii"
|
|
printf 'base-package\n' >"$root/setup/packages/base"
|
|
|
|
local stage
|
|
for stage in "${STAGE_NAMES[@]}"; do
|
|
cat >"$root/setup/scripts/$stage" <<EOF
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$stage" >>"\$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/$stage"
|
|
done
|
|
# The one stage whose exit code the caller wants to control.
|
|
cat >"$root/setup/scripts/install-packages" <<EOF
|
|
#!/usr/bin/env bash
|
|
printf 'install-packages\n' >>"\$PANAMA_RAN"
|
|
exit $packages_rc
|
|
EOF
|
|
chmod +x "$root/setup/scripts/install-packages"
|
|
|
|
cat >"$root/setup/scripts/interview" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'interview\n' >>"$PANAMA_RAN"
|
|
: >"$PANAMA_ANSWERS"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/interview"
|
|
|
|
cat >"$root/bin/panama-migrate" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/bin/panama-migrate"
|
|
|
|
cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'doctor\n' >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/config/dot/quickshell/scripts/panama-doctor"
|
|
|
|
# Nothing that reaches the real machine. sudo would prompt in a test run,
|
|
# and gsettings would genuinely change the tester's screensaver.
|
|
mkdir -p "$root/shim"
|
|
cat >"$root/shim/sudo" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
[[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/gsettings" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/hostnamectl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
chmod +x "$root/shim"/*
|
|
}
|
|
|
|
# Run the fixture installer and echo what ran, one stage per line.
|
|
run_install() {
|
|
local root="$1"; shift
|
|
local status=0
|
|
: >"$root/ran"
|
|
PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \
|
|
XDG_STATE_HOME="$root/state" bash "$root/install" "$@" \
|
|
>"$root/out" 2>&1 || status=$?
|
|
cat "$root/ran"
|
|
return "$status"
|
|
}
|
|
|
|
# ── 1. The interview never runs on an upgrade ────────────────────────────────
|
|
|
|
build_fixture "$tmp/a"
|
|
install_status=0
|
|
ran="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "initial install --upgrade failed with status $install_status"
|
|
|
|
if grep -qx 'interview' <<<"$ran"; then
|
|
note 'install --upgrade ran the interview, which is the whole regression this prevents'
|
|
fi
|
|
|
|
# And the control: a real install must still ask.
|
|
build_fixture "$tmp/b"
|
|
install_status=0
|
|
ran_install="$(run_install "$tmp/b")" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "plain fixture install failed with status $install_status"
|
|
if ! grep -qx 'interview' <<<"$ran_install"; then
|
|
note 'a plain ./install no longer asks the interview, so a new machine is never configured'
|
|
fi
|
|
|
|
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
|
|
|
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
|
|
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
|
done
|
|
for stage in setup-identity install-hardware; do
|
|
grep -qx "$stage" <<<"$ran" \
|
|
&& note "install --upgrade ran $stage, which exists only to act on interview answers"
|
|
done
|
|
|
|
# A stage added to STAGES without a decision about which path owns it shows up
|
|
# here, because this list is written down twice on purpose.
|
|
declared=()
|
|
if declared_output="$(python3 - "$installer" <<'PY'
|
|
import re
|
|
import sys
|
|
|
|
found = False
|
|
for line in open(sys.argv[1], encoding="utf-8"):
|
|
match = re.match(r'^\s*STAGES=\((.*)\)\s*$', line)
|
|
if match and "$" not in match.group(1):
|
|
found = True
|
|
print("\n".join(re.findall(r"[\w-]+", match.group(1))))
|
|
if not found:
|
|
print("install has no STAGES assignment", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
)"; then
|
|
while IFS= read -r stage; do
|
|
[[ -n "$stage" ]] && declared+=("$stage")
|
|
done <<<"$declared_output"
|
|
else
|
|
note 'could not read the install STAGES assignment'
|
|
fi
|
|
for stage in "${declared[@]}"; do
|
|
printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \
|
|
|| note "install declares a stage this contract has never heard of: $stage"
|
|
done
|
|
|
|
# ── 3. The packages hash gates the stage, and a failure does not record it ───
|
|
|
|
# Second run, nothing changed: the stage must be skipped.
|
|
install_status=0
|
|
ran_again="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "repeat install --upgrade failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_again" \
|
|
&& note 'install-packages ran again with the package lists unchanged'
|
|
|
|
# --packages overrides the hash.
|
|
install_status=0
|
|
ran_forced="$(run_install "$tmp/a" --upgrade --packages)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "install --upgrade --packages failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_forced" \
|
|
|| note '--packages did not force install-packages to run'
|
|
|
|
# A changed list brings the stage back.
|
|
printf 'another-package\n' >>"$tmp/a/setup/packages/base"
|
|
install_status=0
|
|
ran_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "install --upgrade failed after a package-list change with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_changed" \
|
|
|| note 'a changed package list did not bring install-packages back'
|
|
|
|
# A failing stage must not record the hash, or the failure is hidden forever.
|
|
build_fixture "$tmp/c" 1
|
|
install_status=0
|
|
run_install "$tmp/c" --upgrade >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 1 ]] \
|
|
|| note "install --upgrade returned $install_status instead of the failing stage status"
|
|
if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then
|
|
note 'install-packages failed but its hash was recorded, so it will never be retried'
|
|
fi
|
|
|
|
# A full install always runs the stage, whatever any recorded hash says.
|
|
build_fixture "$tmp/d"
|
|
install_status=0
|
|
run_install "$tmp/d" --upgrade >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "hash-seeding install --upgrade failed with status $install_status"
|
|
install_status=0
|
|
ran_full="$(run_install "$tmp/d")" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "full fixture reinstall failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_full" \
|
|
|| note 'a full ./install skipped install-packages because of a recorded hash'
|
|
|
|
# ── 4. A conflicted pop never leaves markers in a live config ────────────────
|
|
#
|
|
# The fixture also covers a clean fast-forward, installer status propagation,
|
|
# and the boundary between update and sync before forcing the conflict below.
|
|
|
|
# Each fixture has the same three repositories as a real update: a bare remote,
|
|
# a clone that publishes upstream changes, and the machine clone being updated.
|
|
build_cli_fixture() (
|
|
local root="$1"
|
|
rm -rf "$root" || return 1
|
|
mkdir -p "$root" || return 1
|
|
git init -q --bare "$root/origin.git" || return 1
|
|
git clone -q "$root/origin.git" "$root/upstream" 2>/dev/null || return 1
|
|
git -C "$root/upstream" config user.email contract@panama || return 1
|
|
git -C "$root/upstream" config user.name contract || return 1
|
|
|
|
mkdir -p "$root/upstream/bin" || return 1
|
|
cp "$panama" "$root/upstream/bin/panama" || return 1
|
|
cat >"$root/upstream/install" <<'EOF' || return 1
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$*" >>"${PANAMA_UPDATE_FIXTURE_LOG:?}"
|
|
exit "${PANAMA_UPDATE_INSTALL_RC:-0}"
|
|
EOF
|
|
chmod +x "$root/upstream/bin/panama" "$root/upstream/install" || return 1
|
|
printf 'one\n' >"$root/upstream/f" || return 1
|
|
git -C "$root/upstream" add -A || return 1
|
|
git -C "$root/upstream" commit -qm initial || return 1
|
|
git -C "$root/upstream" push -qu origin HEAD || return 1
|
|
|
|
git clone -q "$root/origin.git" "$root/machine" || return 1
|
|
git -C "$root/machine" config user.email contract@panama || return 1
|
|
git -C "$root/machine" config user.name contract || return 1
|
|
)
|
|
|
|
advance_upstream() (
|
|
local root="$1" file="$2" contents="$3"
|
|
printf '%s\n' "$contents" >"$root/upstream/$file" || return 1
|
|
git -C "$root/upstream" add "$file" || return 1
|
|
git -C "$root/upstream" commit -qm "update $file" || return 1
|
|
git -C "$root/upstream" push -q || return 1
|
|
)
|
|
|
|
# This write fails before the later Git commands. The helper must return that
|
|
# failure rather than let a final successful push hide it.
|
|
helper_probe="$tmp/helper-failure"
|
|
if build_cli_fixture "$helper_probe"; then
|
|
if advance_upstream "$helper_probe" missing/child probe \
|
|
2>"$helper_probe/intermediate-failure.err"; then
|
|
note 'advance_upstream hid an intermediate fixture setup failure'
|
|
fi
|
|
else
|
|
note 'the fixture helper failure probe could not be built'
|
|
fi
|
|
|
|
clean="$tmp/clean-update"
|
|
if build_cli_fixture "$clean" && advance_upstream "$clean" release new; then
|
|
machine_before="$(git -C "$clean/machine" rev-parse HEAD)"
|
|
upstream_after="$(git -C "$clean/upstream" rev-parse HEAD)"
|
|
[[ "$machine_before" != "$upstream_after" ]] \
|
|
|| note 'the clean update fixture started current, so it cannot prove a fast-forward'
|
|
|
|
: >"$clean/install.log"
|
|
update_status=0
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
|
|
"$clean/machine/bin/panama" update >"$clean/update.out" 2>&1 \
|
|
|| update_status=$?
|
|
[[ "$update_status" -eq 0 ]] \
|
|
|| note "panama update failed on a clean clone with status $update_status"
|
|
[[ "$(git -C "$clean/machine" rev-parse HEAD)" == "$upstream_after" ]] \
|
|
|| note 'panama update did not fast-forward the clean machine clone'
|
|
grep -qx -- '--upgrade' "$clean/install.log" \
|
|
|| note 'panama update did not invoke the installer with --upgrade'
|
|
|
|
: >"$clean/install.log"
|
|
update_status=0
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" PANAMA_UPDATE_INSTALL_RC=23 \
|
|
"$clean/machine/bin/panama" update >"$clean/failing-update.out" 2>&1 \
|
|
|| update_status=$?
|
|
[[ "$update_status" -eq 23 ]] \
|
|
|| note "panama update returned $update_status instead of installer status 23"
|
|
grep -qx -- '--upgrade' "$clean/install.log" \
|
|
|| note 'the failing update did not reach the fixture installer'
|
|
|
|
: >"$clean/install.log"
|
|
printf 'local sync\n' >"$clean/machine/synced"
|
|
sync_status=0
|
|
printf 'y\ncontract sync\n' \
|
|
| PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
|
|
"$clean/machine/bin/panama" sync >"$clean/sync.out" 2>&1 \
|
|
|| sync_status=$?
|
|
[[ "$sync_status" -eq 0 ]] \
|
|
|| note "panama sync failed in the local fixture with status $sync_status"
|
|
[[ ! -s "$clean/install.log" ]] \
|
|
|| note 'panama sync invoked the installer; sync and update are separate jobs'
|
|
else
|
|
note 'the clean update fixture could not be built'
|
|
fi
|
|
|
|
# A conflicted stash pop must not leave markers in the checkout. The failed pop
|
|
# keeps the stash, so the local version remains recoverable after the reset.
|
|
conflict="$tmp/conflict-update"
|
|
if build_cli_fixture "$conflict"; then
|
|
printf 'local\n' >"$conflict/machine/f"
|
|
if advance_upstream "$conflict" f upstream; then
|
|
: >"$conflict/install.log"
|
|
conflict_status=0
|
|
PANAMA_UPDATE_FIXTURE_LOG="$conflict/install.log" \
|
|
"$conflict/machine/bin/panama" update >"$conflict/update.out" 2>&1 \
|
|
|| conflict_status=$?
|
|
[[ "$conflict_status" -eq 0 ]] \
|
|
|| note "panama update failed while recovering a stash conflict with status $conflict_status"
|
|
[[ "$(<"$conflict/machine/f")" == upstream ]] \
|
|
|| note 'panama update did not reset the conflicted file to the upstream version'
|
|
if git -C "$conflict/machine" grep -qE '^(<<<<<<<|=======|>>>>>>>)' -- .; then
|
|
note 'panama update left conflict markers in the machine checkout'
|
|
fi
|
|
[[ -n "$(git -C "$conflict/machine" stash list)" ]] \
|
|
|| note 'panama update dropped the stash after its conflicted pop'
|
|
recovered="$(git -C "$conflict/machine" show 'stash@{0}:f' 2>/dev/null)"
|
|
[[ "$recovered" == local ]] \
|
|
|| note 'the stash left by panama update does not contain the local version'
|
|
else
|
|
note 'the conflict fixture could not publish its upstream edit'
|
|
fi
|
|
else
|
|
note 'the conflict update fixture could not be built'
|
|
fi
|
|
|
|
if (( ${#findings[@]} > 0 )); then
|
|
printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'update command contract: PASS\n'
|