./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.
186 lines
6.6 KiB
Bash
Executable File
186 lines
6.6 KiB
Bash
Executable File
#!/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
|