./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.
150 lines
6.3 KiB
Bash
Executable File
150 lines
6.3 KiB
Bash
Executable File
#!/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'
|