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
|
||||
|
||||
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
|
||||
panama test # everything
|
||||
|
||||
+22
@@ -73,6 +73,9 @@ ${BOLD}Commands:${RESET}
|
||||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||||
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
||||
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
|
||||
what you want. The same catalog ./install offers, minus the
|
||||
install.
|
||||
@@ -321,6 +324,24 @@ cmd_upgrade() {
|
||||
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
|
||||
# ----------------------------------------------------------------------------
|
||||
@@ -522,6 +543,7 @@ main() {
|
||||
doctor) shift; cmd_doctor "$@" ;;
|
||||
test) shift; cmd_test "$@" ;;
|
||||
upgrade) shift; cmd_upgrade "$@" ;;
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
app) shift; cmd_app "$@" ;;
|
||||
apps) shift; cmd_apps "$@" ;;
|
||||
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.
|
||||
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
|
||||
-- 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.
|
||||
|
||||
@@ -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
|
||||
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? ────────────────────────────────────────────────────
|
||||
#
|
||||
# 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