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
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Bringing an already-installed machine up to what this checkout expects.
|
||||
#
|
||||
# `./install` is additive: it copies files over `/`, links dotfiles, and
|
||||
# installs packages. It has no way to say "remove that file", "disable that
|
||||
# unit", "that symlink now points at the wrong place". So a machine installed
|
||||
# in March keeps March's mistakes forever, and the only thing that ever fixes
|
||||
# them is the person who happens to read a commit message.
|
||||
#
|
||||
# A migration is one shell script that performs one such repair, exactly once,
|
||||
# on machines that need it.
|
||||
#
|
||||
# migrations/<unix-timestamp>.sh
|
||||
#
|
||||
# The name is the commit timestamp of HEAD when it was authored, so glob order
|
||||
# over fixed-width epoch seconds IS chronological order -- no sequence numbers
|
||||
# to collide on across branches. `panama-dev-migration` stamps them.
|
||||
#
|
||||
# State is one empty marker file per migration under
|
||||
# $XDG_STATE_HOME/panama/migrations. Present means applied. There is no
|
||||
# database and no version integer, because the failure mode of a version
|
||||
# integer is that one bad migration strands every later one behind it.
|
||||
#
|
||||
# The rules a migration must follow are in the template that
|
||||
# `panama-dev-migration` writes, and they are worth repeating here because
|
||||
# this runner cannot enforce them:
|
||||
#
|
||||
# * Safe to run twice. The marker only records that it succeeded once.
|
||||
# * Tolerant of the repair already being correct -- a user may have fixed it
|
||||
# by hand, or a later `./install` may have overwritten it back.
|
||||
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
|
||||
# so the prompt names the repair. See bin/panama-sudo.
|
||||
#
|
||||
# The marker is written ONLY on success, so a migration that fails stays
|
||||
# pending and is retried at the next login. That is deliberate: a repair that
|
||||
# could not complete has not happened, and recording it as done would hide it
|
||||
# forever.
|
||||
#
|
||||
# This mirrors config/dot/quickshell/config/Migrations.qml, which does the same
|
||||
# job for the settings JSON and documents the same reasoning. That one handles
|
||||
# renamed preference keys; this one handles everything else.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
|
||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations"
|
||||
|
||||
export PANAMA_PATH
|
||||
|
||||
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; }
|
||||
err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# Every migration this checkout ships, oldest first. Empty is a valid state.
|
||||
all_migrations() {
|
||||
[[ -d "$MIGRATIONS_DIR" ]] || return 0
|
||||
local file
|
||||
for file in "$MIGRATIONS_DIR"/*.sh; do
|
||||
[[ -e "$file" ]] || continue
|
||||
basename "$file"
|
||||
done | sort
|
||||
}
|
||||
|
||||
pending_migrations() {
|
||||
local name
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
[[ -e "$STATE_DIR/$name" ]] || printf '%s\n' "$name"
|
||||
done < <(all_migrations)
|
||||
}
|
||||
|
||||
run_one() {
|
||||
local name="$1" file="$MIGRATIONS_DIR/$1"
|
||||
info "$name"
|
||||
# A subshell with its own strictness: a migration that forgets `set -e` is
|
||||
# still stopped by its first failing command, and one that sets shell
|
||||
# options cannot leak them into the next migration.
|
||||
if bash -euo pipefail "$file"; then
|
||||
mkdir -p "$STATE_DIR"
|
||||
: >"$STATE_DIR/$name"
|
||||
ok "$name applied"
|
||||
return 0
|
||||
fi
|
||||
err "$name failed and will be retried at the next login"
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
local pending
|
||||
pending="$(pending_migrations)"
|
||||
if [[ -z "$pending" ]]; then
|
||||
ok "Nothing to migrate; this machine matches the checkout."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local count failed=0 name
|
||||
count="$(grep -c . <<<"$pending")"
|
||||
info "$count migration(s) to apply"
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
# Stop at the first failure rather than continuing. Migrations are
|
||||
# ordered, and a later one may assume an earlier one landed; running
|
||||
# it anyway turns one stuck repair into an unpredictable machine.
|
||||
if ! run_one "$name"; then
|
||||
failed=1
|
||||
break
|
||||
fi
|
||||
done <<<"$pending"
|
||||
|
||||
if (( failed )); then
|
||||
warn "Re-running 'panama migrate' is safe and will retry from the failure."
|
||||
return 1
|
||||
fi
|
||||
ok "This machine now matches the checkout."
|
||||
}
|
||||
|
||||
# The check the login notifier runs. Exit 0 means work is waiting, so it reads
|
||||
# as `if panama-migrate --pending; then notify; fi`.
|
||||
cmd_pending() {
|
||||
local pending
|
||||
pending="$(pending_migrations)"
|
||||
[[ -n "$pending" ]] || return 1
|
||||
grep -c . <<<"$pending"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local name
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
if [[ -e "$STATE_DIR/$name" ]]; then
|
||||
printf 'applied %s\n' "$name"
|
||||
else
|
||||
printf 'pending %s\n' "$name"
|
||||
fi
|
||||
done < <(all_migrations)
|
||||
}
|
||||
|
||||
# Re-run one that already succeeded. For developing a migration, and for the
|
||||
# rare case where a repair was undone by something else.
|
||||
cmd_force() {
|
||||
local name="${1:-}"
|
||||
[[ -n "$name" ]] || { err "force needs a migration name"; return 2; }
|
||||
[[ -e "$MIGRATIONS_DIR/$name" ]] || { err "no such migration: $name"; return 2; }
|
||||
rm -f "$STATE_DIR/$name"
|
||||
run_one "$name"
|
||||
}
|
||||
|
||||
# Mark everything applied without running it. This is what a fresh install
|
||||
# does: the machine was just built from this checkout, so every repair those
|
||||
# migrations describe is already true of it, and running them would apply
|
||||
# fixes for versions it never had.
|
||||
cmd_baseline() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
local name count=0
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
[[ -e "$STATE_DIR/$name" ]] && continue
|
||||
: >"$STATE_DIR/$name"
|
||||
count=$(( count + 1 ))
|
||||
done < <(all_migrations)
|
||||
ok "Marked $count migration(s) as already applied."
|
||||
}
|
||||
|
||||
case "${1:-run}" in
|
||||
run) shift || true; cmd_run "$@" ;;
|
||||
--pending) shift || true; cmd_pending "$@" ;;
|
||||
--list|list) shift || true; cmd_list "$@" ;;
|
||||
--force) shift || true; cmd_force "$@" ;;
|
||||
--baseline) shift || true; cmd_baseline "$@" ;;
|
||||
-h|--help)
|
||||
cat <<'USAGE'
|
||||
usage: panama-migrate [run|--pending|--list|--force <name>|--baseline]
|
||||
|
||||
run Apply every pending migration, oldest first (default)
|
||||
--pending Exit 0 and print the count when work is waiting, else exit 1
|
||||
--list Show every migration and whether it has been applied
|
||||
--force Re-run one migration that already succeeded
|
||||
--baseline Mark everything applied without running it (fresh installs)
|
||||
USAGE
|
||||
;;
|
||||
*) err "unknown argument: $1"; exit 2 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user