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:
@@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
135 of them, under `tests/`. Run the lot, or a subset by pattern:
|
136 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
panama test # everything
|
panama test # everything
|
||||||
|
|||||||
+22
@@ -73,6 +73,9 @@ ${BOLD}Commands:${RESET}
|
|||||||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||||||
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
||||||
idempotent and this is the documented upgrade path.
|
idempotent and this is the documented upgrade path.
|
||||||
|
${GREEN}migrate${RESET} Apply repairs this machine has not had yet. The half of an
|
||||||
|
upgrade that ./install cannot do, because installing only ever
|
||||||
|
adds. Safe to re-run; nothing is applied twice.
|
||||||
${GREEN}apps${RESET} Choose applications to install: pick a category, then tick
|
${GREEN}apps${RESET} Choose applications to install: pick a category, then tick
|
||||||
what you want. The same catalog ./install offers, minus the
|
what you want. The same catalog ./install offers, minus the
|
||||||
install.
|
install.
|
||||||
@@ -321,6 +324,24 @@ cmd_upgrade() {
|
|||||||
exec "$installer" "$@"
|
exec "$installer" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: migrate
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# What ./install cannot do. The installer only ever adds -- it copies over /,
|
||||||
|
# links dotfiles, installs packages -- so a machine set up months ago keeps
|
||||||
|
# whatever this repository has since decided was wrong. Migrations are the one
|
||||||
|
# mechanism that can remove a file, disable a unit, or repair a symlink on a
|
||||||
|
# machine that already exists. See bin/panama-migrate.
|
||||||
|
cmd_migrate() {
|
||||||
|
local runner="$PANAMA_DIR/bin/panama-migrate"
|
||||||
|
if [[ ! -x "$runner" ]]; then
|
||||||
|
err "The migration runner is missing from $runner"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec "$runner" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Command: app
|
# Command: app
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -522,6 +543,7 @@ main() {
|
|||||||
doctor) shift; cmd_doctor "$@" ;;
|
doctor) shift; cmd_doctor "$@" ;;
|
||||||
test) shift; cmd_test "$@" ;;
|
test) shift; cmd_test "$@" ;;
|
||||||
upgrade) shift; cmd_upgrade "$@" ;;
|
upgrade) shift; cmd_upgrade "$@" ;;
|
||||||
|
migrate) shift; cmd_migrate "$@" ;;
|
||||||
app) shift; cmd_app "$@" ;;
|
app) shift; cmd_app "$@" ;;
|
||||||
apps) shift; cmd_apps "$@" ;;
|
apps) shift; cmd_apps "$@" ;;
|
||||||
help|-h|--help|"") usage ;;
|
help|-h|--help|"") usage ;;
|
||||||
|
|||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Stamps a new migration, so the name is never chosen by hand.
|
||||||
|
#
|
||||||
|
# panama-dev-migration "remove the stale settings-ssh-keys.sh launcher command"
|
||||||
|
#
|
||||||
|
# The filename is the commit timestamp of HEAD, which makes glob order
|
||||||
|
# chronological without a sequence number that two branches could pick at the
|
||||||
|
# same time. Two migrations authored against the same commit would collide, so
|
||||||
|
# a taken name gets the next free second rather than silently overwriting.
|
||||||
|
#
|
||||||
|
# Developer tool, not part of any install path. See bin/panama-migrate for what
|
||||||
|
# runs these and what rules they have to follow.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
|
||||||
|
|
||||||
|
describe="${1:-}"
|
||||||
|
if [[ -z "$describe" ]]; then
|
||||||
|
echo 'usage: panama-dev-migration "what this repairs"' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
stamp="$(git -C "$PANAMA_PATH" log -1 --format=%cd --date=unix 2>/dev/null || date +%s)"
|
||||||
|
mkdir -p "$MIGRATIONS_DIR"
|
||||||
|
while [[ -e "$MIGRATIONS_DIR/$stamp.sh" ]]; do
|
||||||
|
stamp=$(( stamp + 1 ))
|
||||||
|
done
|
||||||
|
file="$MIGRATIONS_DIR/$stamp.sh"
|
||||||
|
|
||||||
|
cat >"$file" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# $describe
|
||||||
|
#
|
||||||
|
# Rules, because the runner cannot enforce them:
|
||||||
|
#
|
||||||
|
# * Safe to run twice. The marker records success, not intent.
|
||||||
|
# * Tolerant of the repair already being correct -- the user may have fixed
|
||||||
|
# it by hand, or a later ./install may have put it back.
|
||||||
|
# * Root work goes through \`panama-sudo --reason "..."\`, never bare sudo,
|
||||||
|
# so the password prompt names the repair.
|
||||||
|
# * Exit non-zero to be retried at the next login. Exit zero only when the
|
||||||
|
# machine is genuinely in the state this describes.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="\${PANAMA_PATH:-\$HOME/.local/share/Panama}"
|
||||||
|
|
||||||
|
# ... the repair goes here.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x "$file"
|
||||||
|
printf 'Created %s\n' "$file"
|
||||||
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
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# "Panama has repairs waiting for this machine."
|
||||||
|
#
|
||||||
|
# A migration that nobody knows about is a migration that never runs. This
|
||||||
|
# checks once per session and, when work is pending, sends one notification
|
||||||
|
# whose action opens a terminal running `panama migrate` -- so the repair is
|
||||||
|
# always something the user chose, never something that happened to them.
|
||||||
|
#
|
||||||
|
# Waiting for the notification server first is not politeness. Quickshell owns
|
||||||
|
# org.freedesktop.Notifications, and a shell that has not started yet would
|
||||||
|
# swallow this silently -- which is precisely the session where a pending
|
||||||
|
# migration is most likely to matter.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
|
||||||
|
TERMINAL="${PANAMA_TERMINAL:-kitty}"
|
||||||
|
|
||||||
|
command -v notify-send >/dev/null 2>&1 || exit 1
|
||||||
|
|
||||||
|
count="$("$PANAMA_PATH/bin/panama-migrate" --pending)" || exit 1
|
||||||
|
[[ -n "$count" ]] || exit 1
|
||||||
|
|
||||||
|
# Up to ~15s for the shell to claim the bus name. Longer than a healthy start
|
||||||
|
# needs, short enough that a session without a shell gives up rather than
|
||||||
|
# lingering.
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if busctl --user status org.freedesktop.Notifications >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( count == 1 )); then
|
||||||
|
body="One repair is waiting for this machine."
|
||||||
|
else
|
||||||
|
body="$count repairs are waiting for this machine."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Critical so it waits to be read: a notification that expires while the user
|
||||||
|
# is elsewhere has told nobody anything. The action is the whole point -- there
|
||||||
|
# is no instruction to remember and nothing to type.
|
||||||
|
action="$(notify-send --urgency=critical --icon=system-software-update \
|
||||||
|
--app-name=Panama \
|
||||||
|
--action=migrate="Apply now" --action=later="Later" \
|
||||||
|
"Panama updates" "$body" 2>/dev/null)" || exit 0
|
||||||
|
|
||||||
|
[[ "$action" == "migrate" ]] || exit 0
|
||||||
|
exec "$TERMINAL" --hold "$PANAMA_PATH/bin/panama-migrate" run
|
||||||
@@ -39,6 +39,11 @@ hl.on("hyprland.start", function()
|
|||||||
-- prompt if Panama's ever fails to come up.
|
-- prompt if Panama's ever fails to come up.
|
||||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
|
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
|
||||||
|
|
||||||
|
-- Pending repairs for this machine, if any. Oneshot: it checks, tells the
|
||||||
|
-- user when there is something to tell, and exits. Started here rather
|
||||||
|
-- than enabled so it belongs to the Hyprland session; see the unit.
|
||||||
|
hl.exec_cmd("systemctl --user start panama-migrate-notify.service")
|
||||||
|
|
||||||
-- Text expansion. change-settings runs `espanso service register`, which
|
-- Text expansion. change-settings runs `espanso service register`, which
|
||||||
-- writes and enables espanso's own user unit; the explicit start makes the
|
-- writes and enables espanso's own user unit; the explicit start makes the
|
||||||
-- first Hyprland login after a fresh install work rather than the second.
|
-- first Hyprland login after a fresh install work rather than the second.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tell the user when Panama has repairs waiting
|
||||||
|
Documentation=https://github.com/gibbyb/Panama
|
||||||
|
# Started per-session by hypr/autostart.lua rather than enabled globally, for
|
||||||
|
# the same reason as every other Panama unit: graphical-session.target is
|
||||||
|
# active under GNOME too, and a notification about the Hyprland desktop has no
|
||||||
|
# business appearing in a GNOME session.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Resolved at runtime rather than written as a literal path: ./install and the
|
||||||
|
# stages honor an exported PANAMA_PATH, so a clone anywhere else still works.
|
||||||
|
# The sibling units reach their scripts through ~/.config/quickshell, which is
|
||||||
|
# a symlink link-dotfiles creates; bin/ has no such symlink.
|
||||||
|
ExecStart=/usr/bin/env sh -c 'exec "${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-migrate-notify"'
|
||||||
|
# The notification carries an action, so notify-send waits for the user to
|
||||||
|
# click or dismiss it. Bounded so an ignored notification does not leave this
|
||||||
|
# activating for the life of the session -- the next login asks again anyway.
|
||||||
|
TimeoutStartSec=1h
|
||||||
|
# Nothing to say is the overwhelmingly common case and is not a failure.
|
||||||
|
SuccessExitStatus=0 1
|
||||||
@@ -106,6 +106,28 @@ for stage in "${STAGES[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Migrations ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Repairs for machines that installed an older Panama: removing a file this
|
||||||
|
# repository stopped shipping, disabling a unit it stopped wanting. The
|
||||||
|
# installer itself cannot do any of that, because it only ever adds.
|
||||||
|
#
|
||||||
|
# A machine that has never seen migrations before is one of two things, and
|
||||||
|
# the difference matters. If it has no marker directory at all it was just
|
||||||
|
# built from THIS checkout, so every repair those migrations describe is
|
||||||
|
# already true of it -- they are marked applied without running, exactly as
|
||||||
|
# Migrations.qml stamps a pre-versioning settings file at its baseline rather
|
||||||
|
# than replaying upgrades it never needed. Otherwise the pending ones run.
|
||||||
|
migrate="$PANAMA_PATH/bin/panama-migrate"
|
||||||
|
if [[ -x "$migrate" ]]; then
|
||||||
|
printf '\n=== migrations ===\n'
|
||||||
|
if [[ -d "${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations" ]]; then
|
||||||
|
"$migrate" run || failed+=(migrations)
|
||||||
|
else
|
||||||
|
"$migrate" --baseline || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Did it actually work? ────────────────────────────────────────────────────
|
# ── Did it actually work? ────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# A failed-stage count only reports what exited non-zero. It says nothing about a
|
# A failed-stage count only reports what exited non-zero. It says nothing about a
|
||||||
|
|||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Remove the dangling ~/.config/forge symlink left by the GNOME session cut.
|
||||||
|
#
|
||||||
|
# `forge` was in link-dotfiles' `dirs` array while the GNOME session was still
|
||||||
|
# the fallback and Hyprland was being built beside it. Commit 47f29f9 deleted
|
||||||
|
# config/dot/forge along with the rest of that hedge, but link-dotfiles has no
|
||||||
|
# way to un-link what it once linked: a machine that ran the installer before
|
||||||
|
# that commit still has ~/.config/forge pointing into the repository at a path
|
||||||
|
# that no longer exists.
|
||||||
|
#
|
||||||
|
# Nothing reads it, so the cost is only that `ls ~/.config` shows a broken link
|
||||||
|
# forever. That is exactly the kind of small permanent wrongness migrations
|
||||||
|
# exist to clear.
|
||||||
|
#
|
||||||
|
# This is the shape every future "a dotfile directory was removed" repair takes:
|
||||||
|
# check that the link is dangling AND points into Panama, then remove it. Both
|
||||||
|
# halves matter -- a dangling link somebody else made is not ours to delete.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
|
||||||
|
link="${XDG_CONFIG_HOME:-$HOME/.config}/forge"
|
||||||
|
|
||||||
|
# Not a symlink, or not there at all: already correct. Both are the common case
|
||||||
|
# on a machine installed after the cut, and neither is a failure.
|
||||||
|
[[ -L "$link" ]] || exit 0
|
||||||
|
|
||||||
|
# Still resolves? Then something else owns this path now and it is not ours to
|
||||||
|
# remove.
|
||||||
|
[[ -e "$link" ]] && exit 0
|
||||||
|
|
||||||
|
target="$(readlink "$link")"
|
||||||
|
case "$target" in
|
||||||
|
"$PANAMA_PATH"/*) ;;
|
||||||
|
*)
|
||||||
|
echo "Leaving $link alone: it points at $target, which is not Panama's."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
rm -f "$link"
|
||||||
|
echo "Removed the dangling $link"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Migrations live here, one repair per file, named by the commit timestamp
|
||||||
|
# that authored them. See bin/panama-migrate.
|
||||||
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