#!/usr/bin/env bash # `panama update`: the routine command, and the promises that make it routine. # # An update that asks questions is an update nobody runs, and a machine nobody # updates drifts until the next reinstall. So the properties below are the # whole point of the command rather than details of it: # # 1. --upgrade NEVER runs the interview. This is the regression that started # the redesign: ./install asked the full first-install questionnaire every # time, including on a machine whose answers could not have changed. # 2. --upgrade runs exactly the stages that need no answer, and never # setup-identity or install-hardware. Both act only on interview answers. # 3. install-packages is gated on a content hash of the package lists, and # the hash is NOT recorded when the stage fails -- the same rule # panama-migrate applies to its markers, for the same reason. # 4. A conflicted `git stash pop` leaves no conflict markers in the tree. # Every dotfile here is a symlink into the checkout, so a half-merged file # is not something to fix later: it is live in ~/.config immediately, and # a broken .qml costs the running shell. # # Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and # gsettings shimmed, so this never touches the machine running it. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" installer="$repo_dir/install" panama="$repo_dir/bin/panama" findings=() note() { findings+=("$1"); } [[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; } tmp="$(mktemp -d -t panama-update-contract.XXXXXX)" trap 'rm -rf "$tmp"' EXIT STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-server link-server setup-identity install-hardware) # A PANAMA_PATH that looks enough like the real one for install to run, and # records what it was asked to do instead of doing it. build_fixture() { local root="$1" packages_rc="${2:-0}" rm -rf "$root" mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \ "$root/config/dot/quickshell/scripts" cp "$installer" "$root/install" : >"$root/bin/ascii" printf 'base-package\n' >"$root/setup/packages/base" local stage for stage in "${STAGE_NAMES[@]}"; do cat >"$root/setup/scripts/$stage" <>"\$PANAMA_RAN" EOF chmod +x "$root/setup/scripts/$stage" done # The one stage whose exit code the caller wants to control. cat >"$root/setup/scripts/install-packages" <>"\$PANAMA_RAN" exit $packages_rc EOF chmod +x "$root/setup/scripts/install-packages" cat >"$root/setup/scripts/interview" <<'EOF' #!/usr/bin/env bash printf 'interview\n' >>"$PANAMA_RAN" : >"$PANAMA_ANSWERS" EOF chmod +x "$root/setup/scripts/interview" cat >"$root/bin/panama-migrate" <<'EOF' #!/usr/bin/env bash printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN" EOF chmod +x "$root/bin/panama-migrate" cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF' #!/usr/bin/env bash printf 'doctor\n' >>"$PANAMA_RAN" EOF chmod +x "$root/config/dot/quickshell/scripts/panama-doctor" # Nothing that reaches the real machine. sudo would prompt in a test run, # and gsettings would genuinely change the tester's screensaver. mkdir -p "$root/shim" cat >"$root/shim/sudo" <<'EOF' #!/usr/bin/env bash [[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0 exit 0 EOF cat >"$root/shim/gsettings" <<'EOF' #!/usr/bin/env bash exit 0 EOF cat >"$root/shim/hostnamectl" <<'EOF' #!/usr/bin/env bash exit 0 EOF chmod +x "$root/shim"/* } # Run the fixture installer and echo what ran, one stage per line. run_install() { local root="$1"; shift local status=0 : >"$root/ran" PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \ XDG_STATE_HOME="$root/state" bash "$root/install" "$@" \ >"$root/out" 2>&1 || status=$? cat "$root/ran" return "$status" } # ── 1. The interview never runs on an upgrade ──────────────────────────────── build_fixture "$tmp/a" install_status=0 ran="$(run_install "$tmp/a" --upgrade)" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "initial install --upgrade failed with status $install_status" if grep -qx 'interview' <<<"$ran"; then note 'install --upgrade ran the interview, which is the whole regression this prevents' fi # And the control: a real install must still ask. build_fixture "$tmp/b" install_status=0 ran_install="$(run_install "$tmp/b")" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "plain fixture install failed with status $install_status" if ! grep -qx 'interview' <<<"$ran_install"; then note 'a plain ./install no longer asks the interview, so a new machine is never configured' fi # ── 2. Exactly the answer-free stages ──────────────────────────────────────── for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage" done for stage in setup-identity install-hardware; do grep -qx "$stage" <<<"$ran" \ && note "install --upgrade ran $stage, which exists only to act on interview answers" done # A stage added to STAGES without a decision about which path owns it shows up # here, because this list is written down twice on purpose. declared=() if declared_output="$(python3 - "$installer" <<'PY' import re import sys found = False for line in open(sys.argv[1], encoding="utf-8"): match = re.match(r'^\s*STAGES=\((.*)\)\s*$', line) if match and "$" not in match.group(1): found = True print("\n".join(re.findall(r"[\w-]+", match.group(1)))) if not found: print("install has no STAGES assignment", file=sys.stderr) sys.exit(1) PY )"; then while IFS= read -r stage; do [[ -n "$stage" ]] && declared+=("$stage") done <<<"$declared_output" else note 'could not read the install STAGES assignment' fi for stage in "${declared[@]}"; do printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \ || note "install declares a stage this contract has never heard of: $stage" done # ── 3. The packages hash gates the stage, and a failure does not record it ─── # Second run, nothing changed: the stage must be skipped. install_status=0 ran_again="$(run_install "$tmp/a" --upgrade)" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "repeat install --upgrade failed with status $install_status" grep -qx 'install-packages' <<<"$ran_again" \ && note 'install-packages ran again with the package lists unchanged' # --packages overrides the hash. install_status=0 ran_forced="$(run_install "$tmp/a" --upgrade --packages)" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "install --upgrade --packages failed with status $install_status" grep -qx 'install-packages' <<<"$ran_forced" \ || note '--packages did not force install-packages to run' # A changed list brings the stage back. printf 'another-package\n' >>"$tmp/a/setup/packages/base" install_status=0 ran_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "install --upgrade failed after a package-list change with status $install_status" grep -qx 'install-packages' <<<"$ran_changed" \ || note 'a changed package list did not bring install-packages back' # A failing stage must not record the hash, or the failure is hidden forever. build_fixture "$tmp/c" 1 install_status=0 run_install "$tmp/c" --upgrade >/dev/null || install_status=$? [[ "$install_status" -eq 1 ]] \ || note "install --upgrade returned $install_status instead of the failing stage status" if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then note 'install-packages failed but its hash was recorded, so it will never be retried' fi # A full install always runs the stage, whatever any recorded hash says. build_fixture "$tmp/d" install_status=0 run_install "$tmp/d" --upgrade >/dev/null || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "hash-seeding install --upgrade failed with status $install_status" install_status=0 ran_full="$(run_install "$tmp/d")" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "full fixture reinstall failed with status $install_status" grep -qx 'install-packages' <<<"$ran_full" \ || note 'a full ./install skipped install-packages because of a recorded hash' # ── 4. A conflicted pop never leaves markers in a live config ──────────────── # # The fixture also covers a clean fast-forward, installer status propagation, # and the boundary between update and sync before forcing the conflict below. # Each fixture has the same three repositories as a real update: a bare remote, # a clone that publishes upstream changes, and the machine clone being updated. build_cli_fixture() ( local root="$1" rm -rf "$root" || return 1 mkdir -p "$root" || return 1 git init -q --bare "$root/origin.git" || return 1 git clone -q "$root/origin.git" "$root/upstream" 2>/dev/null || return 1 git -C "$root/upstream" config user.email contract@panama || return 1 git -C "$root/upstream" config user.name contract || return 1 mkdir -p "$root/upstream/bin" || return 1 cp "$panama" "$root/upstream/bin/panama" || return 1 cat >"$root/upstream/install" <<'EOF' || return 1 #!/usr/bin/env bash printf '%s\n' "$*" >>"${PANAMA_UPDATE_FIXTURE_LOG:?}" exit "${PANAMA_UPDATE_INSTALL_RC:-0}" EOF chmod +x "$root/upstream/bin/panama" "$root/upstream/install" || return 1 printf 'one\n' >"$root/upstream/f" || return 1 git -C "$root/upstream" add -A || return 1 git -C "$root/upstream" commit -qm initial || return 1 git -C "$root/upstream" push -qu origin HEAD || return 1 git clone -q "$root/origin.git" "$root/machine" || return 1 git -C "$root/machine" config user.email contract@panama || return 1 git -C "$root/machine" config user.name contract || return 1 ) advance_upstream() ( local root="$1" file="$2" contents="$3" printf '%s\n' "$contents" >"$root/upstream/$file" || return 1 git -C "$root/upstream" add "$file" || return 1 git -C "$root/upstream" commit -qm "update $file" || return 1 git -C "$root/upstream" push -q || return 1 ) # This write fails before the later Git commands. The helper must return that # failure rather than let a final successful push hide it. helper_probe="$tmp/helper-failure" if build_cli_fixture "$helper_probe"; then if advance_upstream "$helper_probe" missing/child probe \ 2>"$helper_probe/intermediate-failure.err"; then note 'advance_upstream hid an intermediate fixture setup failure' fi else note 'the fixture helper failure probe could not be built' fi clean="$tmp/clean-update" if build_cli_fixture "$clean" && advance_upstream "$clean" release new; then machine_before="$(git -C "$clean/machine" rev-parse HEAD)" upstream_after="$(git -C "$clean/upstream" rev-parse HEAD)" [[ "$machine_before" != "$upstream_after" ]] \ || note 'the clean update fixture started current, so it cannot prove a fast-forward' : >"$clean/install.log" update_status=0 PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \ "$clean/machine/bin/panama" update >"$clean/update.out" 2>&1 \ || update_status=$? [[ "$update_status" -eq 0 ]] \ || note "panama update failed on a clean clone with status $update_status" [[ "$(git -C "$clean/machine" rev-parse HEAD)" == "$upstream_after" ]] \ || note 'panama update did not fast-forward the clean machine clone' grep -qx -- '--upgrade' "$clean/install.log" \ || note 'panama update did not invoke the installer with --upgrade' : >"$clean/install.log" update_status=0 PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" PANAMA_UPDATE_INSTALL_RC=23 \ "$clean/machine/bin/panama" update >"$clean/failing-update.out" 2>&1 \ || update_status=$? [[ "$update_status" -eq 23 ]] \ || note "panama update returned $update_status instead of installer status 23" grep -qx -- '--upgrade' "$clean/install.log" \ || note 'the failing update did not reach the fixture installer' : >"$clean/install.log" printf 'local sync\n' >"$clean/machine/synced" sync_status=0 printf 'y\ncontract sync\n' \ | PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \ "$clean/machine/bin/panama" sync >"$clean/sync.out" 2>&1 \ || sync_status=$? [[ "$sync_status" -eq 0 ]] \ || note "panama sync failed in the local fixture with status $sync_status" [[ ! -s "$clean/install.log" ]] \ || note 'panama sync invoked the installer; sync and update are separate jobs' else note 'the clean update fixture could not be built' fi # A conflicted stash pop must not leave markers in the checkout. The failed pop # keeps the stash, so the local version remains recoverable after the reset. conflict="$tmp/conflict-update" if build_cli_fixture "$conflict"; then printf 'local\n' >"$conflict/machine/f" if advance_upstream "$conflict" f upstream; then : >"$conflict/install.log" conflict_status=0 PANAMA_UPDATE_FIXTURE_LOG="$conflict/install.log" \ "$conflict/machine/bin/panama" update >"$conflict/update.out" 2>&1 \ || conflict_status=$? [[ "$conflict_status" -eq 0 ]] \ || note "panama update failed while recovering a stash conflict with status $conflict_status" [[ "$(<"$conflict/machine/f")" == upstream ]] \ || note 'panama update did not reset the conflicted file to the upstream version' if git -C "$conflict/machine" grep -qE '^(<<<<<<<|=======|>>>>>>>)' -- .; then note 'panama update left conflict markers in the machine checkout' fi [[ -n "$(git -C "$conflict/machine" stash list)" ]] \ || note 'panama update dropped the stash after its conflicted pop' recovered="$(git -C "$conflict/machine" show 'stash@{0}:f' 2>/dev/null)" [[ "$recovered" == local ]] \ || note 'the stash left by panama update does not contain the local version' else note 'the conflict fixture could not publish its upstream edit' fi else note 'the conflict update fixture could not be built' fi if (( ${#findings[@]} > 0 )); then printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'update command contract: PASS\n'