190 lines
6.8 KiB
Bash
Executable File
190 lines
6.8 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."
|
|
warn "If it keeps failing, hand it to an agent: panama diagnose"
|
|
return 1
|
|
fi
|
|
ok "This machine now matches the checkout."
|
|
# Only after repairs actually ran: a hook that fires on every login when
|
|
# there was nothing to do is a hook people disable.
|
|
"$PANAMA_PATH/bin/panama-hook" post-migrate || true
|
|
}
|
|
|
|
# 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
|