#!/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" <>"$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'