Give an installed machine a way to catch up
./install only ever adds. It copies over /, links dotfiles, installs packages -- and has no way to say "remove that file", "disable that unit", "that symlink points nowhere now". So a machine set up months ago keeps whatever this repository has since decided was wrong, and the only thing that ever fixes it is somebody reading a commit message. With a curl installer in the README, that stopped being hypothetical. A migration is one script that performs one repair, exactly once, on the machines that need it. Named by the commit timestamp that authored it, so glob order is chronological without a sequence number two branches could both pick. Marked in ~/.local/state on success and only on success, so a repair that failed stays pending rather than being recorded as done and hidden forever. Ordered, and stopped at the first failure, because a later repair may assume an earlier one landed. A fresh install marks everything without running it, the way Migrations.qml stamps a pre-versioning settings file at its baseline. The first real one removes the dangling ~/.config/forge symlink left behind when the GNOME session was cut: link-dotfiles could link it but never unlink it. Verified both ways -- a no-op on a machine that never had it, an actual repair on one that did. Root work goes through panama-sudo --reason so the password prompt names the repair, and the contract fails any migration reaching for bare sudo.
This commit is contained in:
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Migrations: the half of an upgrade `./install` cannot do.
|
||||
#
|
||||
# The installer only ever adds. Migrations are the one mechanism that can
|
||||
# remove a file, disable a unit, or repair a symlink on a machine that already
|
||||
# exists -- which means they run, unattended, against machines nobody testing
|
||||
# them has ever seen. The properties that make that safe:
|
||||
#
|
||||
# 1. Applied exactly once. A marker is written on success and never again.
|
||||
# 2. A FAILURE LEAVES NO MARKER. A repair that could not complete has not
|
||||
# happened, and recording it as done would hide it forever.
|
||||
# 3. Ordered, and stopped at the first failure. Later migrations may assume
|
||||
# earlier ones landed; running them anyway turns one stuck repair into an
|
||||
# unpredictable machine.
|
||||
# 4. A fresh install runs none of them. The machine was just built from this
|
||||
# checkout, so every repair they describe is already true of it.
|
||||
# 5. Root work goes through `panama-sudo --reason`, never bare sudo, so the
|
||||
# password prompt names the repair.
|
||||
#
|
||||
# Driven against fixture migrations in a throwaway state directory. The real
|
||||
# migrations are only checked for the properties every one of them must have.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
runner="$repo_dir/bin/panama-migrate"
|
||||
authoring="$repo_dir/bin/panama-dev-migration"
|
||||
installer="$repo_dir/install"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -x "$runner" ]] || { printf 'migrations contract: %s is not executable\n' "$runner" >&2; exit 1; }
|
||||
[[ -x "$authoring" ]] || note 'panama-dev-migration is missing or not executable'
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
fake_repo="$work/repo"
|
||||
state="$work/state/panama/migrations"
|
||||
mkdir -p "$fake_repo/bin" "$fake_repo/migrations"
|
||||
cp "$runner" "$fake_repo/bin/panama-migrate"
|
||||
|
||||
# Fixture migrations, named the way the real ones are: epoch seconds, so glob
|
||||
# order is chronological.
|
||||
ran="$work/ran"
|
||||
write_migration() {
|
||||
local stamp="$1" body="$2"
|
||||
cat >"$fake_repo/migrations/$stamp.sh" <<MIGRATION
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$stamp" >>"$ran"
|
||||
$body
|
||||
MIGRATION
|
||||
}
|
||||
|
||||
run() {
|
||||
PANAMA_PATH="$fake_repo" XDG_STATE_HOME="$work/state" \
|
||||
"$fake_repo/bin/panama-migrate" "$@" 2>&1
|
||||
}
|
||||
|
||||
# ── 1. Applied exactly once, in order ────────────────────────────────────────
|
||||
|
||||
: >"$ran"
|
||||
write_migration 1000000001 'exit 0'
|
||||
write_migration 1000000002 'exit 0'
|
||||
|
||||
run run >/dev/null || note 'a run of two good migrations failed'
|
||||
[[ "$(cat "$ran")" == $'1000000001\n1000000002' ]] \
|
||||
|| note "migrations did not run oldest-first (got: $(tr '\n' ' ' <"$ran"))"
|
||||
|
||||
: >"$ran"
|
||||
run run >/dev/null || note 'a second run failed'
|
||||
[[ -s "$ran" ]] && note 'a migration ran twice'
|
||||
|
||||
# ── 2 & 3. A failure leaves no marker, and stops the ones after it ───────────
|
||||
|
||||
: >"$ran"
|
||||
write_migration 1000000003 'exit 1'
|
||||
write_migration 1000000004 'exit 0'
|
||||
|
||||
run run >/dev/null && note 'a failing migration reported success'
|
||||
grep -qx 1000000003 "$ran" || note 'the failing migration did not run at all'
|
||||
grep -qx 1000000004 "$ran" && note 'a migration after a failure still ran'
|
||||
[[ -e "$state/1000000003.sh" ]] && note 'a failed migration was marked applied'
|
||||
|
||||
# It retries, and the one behind it is still waiting.
|
||||
: >"$ran"
|
||||
write_migration 1000000003 'exit 0'
|
||||
run run >/dev/null || note 'a repaired migration did not succeed on retry'
|
||||
grep -qx 1000000003 "$ran" || note 'the previously failed migration was not retried'
|
||||
grep -qx 1000000004 "$ran" || note 'the migration behind the failure never ran'
|
||||
|
||||
# ── 4. Fresh installs run nothing ────────────────────────────────────────────
|
||||
|
||||
rm -rf "$work/state"
|
||||
: >"$ran"
|
||||
run --baseline >/dev/null || note 'baseline failed'
|
||||
[[ -s "$ran" ]] && note 'baseline ran a migration instead of marking it'
|
||||
: >"$ran"
|
||||
run run >/dev/null || note 'a run after baseline failed'
|
||||
[[ -s "$ran" ]] && note 'a migration ran on a machine that was baselined'
|
||||
|
||||
# ── --pending is the notifier's question ─────────────────────────────────────
|
||||
|
||||
run --pending >/dev/null && note '--pending reported work when everything is applied'
|
||||
write_migration 1000000005 'exit 0'
|
||||
run --pending >/dev/null || note '--pending reported nothing when a migration is waiting'
|
||||
|
||||
# ── 5. Everything the repository actually ships ──────────────────────────────
|
||||
|
||||
shopt -s nullglob
|
||||
for migration in "$repo_dir"/migrations/*.sh; do
|
||||
name="$(basename "$migration")"
|
||||
[[ -x "$migration" ]] || note "$name is not executable"
|
||||
[[ "$name" =~ ^[0-9]{10,}\.sh$ ]] \
|
||||
|| note "$name is not named with an epoch timestamp, so its order is undefined"
|
||||
|
||||
# Bare sudo would prompt with polkit's generic text, in a script the user
|
||||
# did not run by hand. panama-sudo makes the prompt name the repair.
|
||||
if grep -qE '(^|[^-[:alnum:]])sudo ' "$migration" && ! grep -q 'panama-sudo' "$migration"; then
|
||||
note "$name calls bare sudo; root work goes through panama-sudo --reason"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── The installer wires it in ────────────────────────────────────────────────
|
||||
|
||||
grep -q 'panama-migrate' "$installer" \
|
||||
|| note 'install never runs or baselines migrations, so a fresh machine is never marked'
|
||||
grep -q -- '--baseline' "$installer" \
|
||||
|| note 'install does not baseline a fresh machine, so it would run repairs it never needed'
|
||||
|
||||
notifier="$repo_dir/bin/panama-migrate-notify"
|
||||
if [[ ! -x "$notifier" ]]; then
|
||||
note 'panama-migrate-notify is missing, so pending repairs announce themselves to nobody'
|
||||
else
|
||||
grep -q 'org.freedesktop.Notifications' "$notifier" \
|
||||
|| note 'the notifier does not wait for the notification server, so an early session swallows it'
|
||||
fi
|
||||
grep -q 'panama-migrate-notify' "$repo_dir/config/dot/hypr/autostart.lua" \
|
||||
|| note 'nothing starts the migration notifier at login'
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
printf 'migrations contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'migrations contract: PASS\n'
|
||||
Reference in New Issue
Block a user