#!/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) PACKAGE_BEHAVIOR_INPUTS=( setup/lib/artifact-provenance setup/lib/chatgpt-package setup/lib/extras-catalog setup/lib/machine-role ) copy_hash_inputs() { local root="$1" source relative while IFS= read -r -d '' source; do relative="${source#"$repo_dir"/}" mkdir -p "$(dirname "$root/$relative")" cp -- "$source" "$root/$relative" done < <( find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0 find "$repo_dir/setup/provenance" -type f -print0 ) for relative in "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do mkdir -p "$(dirname "$root/$relative")" cp -- "$repo_dir/$relative" "$root/$relative" done } # 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}" trust_rc="${3:-0}" rm -rf "$root" mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \ "$root/setup/lib" "$root/setup/provenance/keys" \ "$root/config/dot/quickshell/scripts" cp "$installer" "$root/install" : >"$root/bin/ascii" copy_hash_inputs "$root" 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 $trust_rc fi printf 'install-packages\n' >>"\$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 cat >"$root/shim/dnf" <<'EOF' #!/usr/bin/env bash printf 'dnf-transaction\n' >>"$PANAMA_RAN" exit 0 EOF for prerequisite in gum lspci mokutil fwupdmgr; do ln -s gsettings "$root/shim/$prerequisite" done 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" } run_hash() { local root="$1" sed -n '/^hash_packages() {/,/^}$/p' "$root/install" >"$root/hash-only" printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only" PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err" } assert_hash_failure() { local root="$1" description="$2" status=0 digest digest="$(run_hash "$root")" || status=$? [[ "$status" -ne 0 && -z "$digest" ]] \ || note "$description produced a digest instead of failing closed" } # ── 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 ─── package_inputs=() while IFS= read -r -d '' input; do package_inputs+=("${input#"$repo_dir"/}") done < <(find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0) provenance_inputs=() while IFS= read -r -d '' input; do provenance_inputs+=("${input#"$repo_dir"/}") done < <(find "$repo_dir/setup/provenance" -type f -print0) (( ${#package_inputs[@]} > 0 )) \ || note 'the current repository has no top-level package input to exercise' (( ${#provenance_inputs[@]} > 0 )) \ || note 'the current repository has no provenance input to exercise' # 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' # Every current package and provenance member is part of the state definition. # Dynamically discovering them makes this fail when a new reviewed input is # added but omitted from hash_packages. for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \ 'setup/scripts/install-packages' "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do printf 'changed %s\n' "$relative" >>"$tmp/a/$relative" install_status=0 ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "install --upgrade failed after changing $relative with status $install_status" grep -qx 'install-packages' <<<"$ran_input_changed" \ || note "a changed $relative did not bring install-packages back" done # A path-only change must invalidate state even when the file bytes are exact. for relative in "${package_inputs[0]:-}" "${provenance_inputs[0]:-}"; do [[ -n "$relative" ]] || continue build_fixture "$tmp/path-rename" run_install "$tmp/path-rename" --upgrade >/dev/null mv -- "$tmp/path-rename/$relative" "$tmp/path-rename/$relative.renamed" install_status=0 ran_renamed="$(run_install "$tmp/path-rename" --upgrade)" || install_status=$? [[ "$install_status" -eq 0 ]] \ || note "install --upgrade failed after renaming $relative with status $install_status" grep -qx 'install-packages' <<<"$ran_renamed" \ || note "renaming $relative without changing bytes did not bring install-packages back" done # Fixed hash inputs must not silently disappear or degrade into a directory or # link. An unreadable package input also proves a failed content read cannot be # hidden by the final digest command. for fixed_input in setup/scripts/install-packages "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do for case_name in missing directory symlink unreadable; do case_root="$tmp/hash-${fixed_input//\//-}-$case_name" build_fixture "$case_root" fixed_path="$case_root/$fixed_input" case "$case_name" in missing) rm -- "$fixed_path" ;; directory) rm -- "$fixed_path"; mkdir -- "$fixed_path" ;; symlink) printf 'untrusted target\n' >"$case_root/untrusted-target" rm -- "$fixed_path" ln -s "$case_root/untrusted-target" "$fixed_path" ;; unreadable) chmod 000 "$fixed_path" ;; esac assert_hash_failure "$case_root" "$fixed_input $case_name" done done read_failure_root="$tmp/hash-package-read-failure" build_fixture "$read_failure_root" chmod 000 "$read_failure_root/${package_inputs[0]}" assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable" # A hash failure is an installer failure, not a reason to skip the package # stage and retain a stale stamp. build_fixture "$tmp/hash-failure" run_install "$tmp/hash-failure" --upgrade >/dev/null cp -- "$tmp/hash-failure/state/panama/packages-hash" "$tmp/hash-failure/stamp-before" rm -- "$tmp/hash-failure/setup/lib/artifact-provenance" install_status=0 ran_hash_failure="$(run_install "$tmp/hash-failure" --upgrade)" || install_status=$? [[ "$install_status" -ne 0 ]] \ || note 'a failed package-state hash returned success' grep -qx 'install-packages' <<<"$ran_hash_failure" \ && note 'a failed package-state hash still ran install-packages' cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \ || note 'a failed package-state hash wrote a new packages-hash stamp' # Inputs changed while install-packages was running were not the inputs it # consumed at the start. Do not stamp the later bytes as successfully applied. build_fixture "$tmp/hash-mid-stage-drift" cat >"$tmp/hash-mid-stage-drift/setup/scripts/install-packages" <<'EOF' #!/usr/bin/env bash if [[ "${1:-}" == --trust-preflight ]]; then printf 'trust-preflight\n' >>"$PANAMA_RAN" exit 0 fi printf 'install-packages\n' >>"$PANAMA_RAN" printf '\nchanged during package installation\n' >>"$PANAMA_PATH/setup/lib/machine-role" EOF chmod +x "$tmp/hash-mid-stage-drift/setup/scripts/install-packages" install_status=0 run_install "$tmp/hash-mid-stage-drift" --upgrade >/dev/null || install_status=$? [[ "$install_status" -ne 0 ]] \ || note 'mid-stage package input drift returned success' [[ ! -e "$tmp/hash-mid-stage-drift/state/panama/packages-hash" ]] \ || note 'mid-stage package input drift stamped bytes the stage did not start with' # 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 grep -qx 'link-dotfiles' "$tmp/c/ran" \ || note 'an ordinary package-stage failure no longer allows later safe stages' # An invalid enabled Terra root is not an ordinary package failure. It must # stop before the installer's bootstrap DNF and before every stage. build_fixture "$tmp/terra-preflight-hard" 0 78 install_status=0 run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$? [[ "$install_status" -eq 78 ]] \ || note "initial Terra trust failure returned $install_status instead of 78" asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")" [[ "$asserted_preflight" == trust-preflight ]] \ || note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}" # The trust verifier is itself mandatory. Losing its executable adapter must # fail closed before interview, bootstrap, or stage work. build_fixture "$tmp/terra-preflight-missing" rm "$tmp/terra-preflight-missing/setup/scripts/install-packages" install_status=0 run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$? [[ "$install_status" -eq 78 ]] \ || note "missing Terra trust verifier returned $install_status instead of 78" [[ ! -s "$tmp/terra-preflight-missing/ran" ]] \ || note 'missing Terra trust verifier allowed later work' # The package stage repeats the preflight to close a configuration-change race. # Its hard status must also stop link stages and install-hardware immediately. build_fixture "$tmp/terra-stage-hard" 78 0 install_status=0 run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$? [[ "$install_status" -eq 78 ]] \ || note "stage-time Terra trust failure returned $install_status instead of 78" grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \ || note 'stage-time Terra trust fixture never reached install-packages' for suppressed in link-dotfiles link-skills link-user change-settings install-hardware dnf-transaction; do grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \ && note "stage-time Terra trust failure still ran $suppressed" done # 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. # Make every ambient configuration source hostile before constructing the Git # fixtures. A hermetic fixture overrides these values with its own empty state; # consuming any of them either leaves a sentinel or prevents a commit. hostile="$tmp/hostile-environment" mkdir -p "$hostile/home" "$hostile/xdg-config" "$hostile/xdg-state" \ "$hostile/xdg-cache" "$hostile/xdg-data" "$hostile/hooks" \ "$hostile/template/hooks" for profile in .bash_profile .bashrc .profile; do cat >"$hostile/home/$profile" <<'EOF' printf 'profile\n' >>"${PANAMA_HOSTILE_PROFILE_SENTINEL:?}" EOF done cat >"$hostile/bash-env" <<'EOF' printf 'BASH_ENV\n' >>"${PANAMA_HOSTILE_BASH_ENV_SENTINEL:?}" EOF cat >"$hostile/hooks/pre-commit" <<'EOF' #!/usr/bin/env bash printf 'global hook\n' >>"${PANAMA_HOSTILE_GIT_SENTINEL:?}" exit 97 EOF cat >"$hostile/template/hooks/pre-commit" <<'EOF' #!/usr/bin/env bash # PANAMA_HOSTILE_TEMPLATE_HOOK printf 'template hook\n' >>"${PANAMA_HOSTILE_TEMPLATE_SENTINEL:?}" exit 98 EOF chmod +x "$hostile/hooks/pre-commit" "$hostile/template/hooks/pre-commit" cat >"$hostile/global.gitconfig" </dev/null || return 1 configure_fixture_repo "$root" "$root/upstream" || 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 fixture_git "$root" -C "$root/upstream" add -A || return 1 fixture_git "$root" -C "$root/upstream" commit -qm initial || return 1 fixture_git "$root" -C "$root/upstream" push -qu origin HEAD || return 1 fixture_git "$root" clone -q "$root/origin.git" "$root/machine" || return 1 configure_fixture_repo "$root" "$root/machine" || return 1 ) advance_upstream() ( local root="$1" file="$2" contents="$3" printf '%s\n' "$contents" >"$root/upstream/$file" || return 1 fixture_git "$root" -C "$root/upstream" add "$file" || return 1 fixture_git "$root" -C "$root/upstream" commit -qm "update $file" || return 1 fixture_git "$root" -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="$(fixture_git "$clean" -C "$clean/machine" rev-parse HEAD)" upstream_after="$(fixture_git "$clean" -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 run_cli_fixture_environment "$clean" \ 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" [[ "$(fixture_git "$clean" -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 run_cli_fixture_environment "$clean" \ 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' \ | run_cli_fixture_environment "$clean" \ 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 run_cli_fixture_environment "$conflict" \ 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 fixture_git "$conflict" -C "$conflict/machine" \ grep -qE '^(<<<<<<<|=======|>>>>>>>)' -- .; then note 'panama update left conflict markers in the machine checkout' fi [[ -n "$(fixture_git "$conflict" -C "$conflict/machine" stash list)" ]] \ || note 'panama update dropped the stash after its conflicted pop' recovered="$(fixture_git "$conflict" -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 for sentinel in profile-sourced bash-env-sourced global-config-sourced template-hook-sourced; do [[ ! -e "$hostile/$sentinel" ]] \ || note "the update Git fixture consumed hostile state: $sentinel" done template_copy="$( find "$tmp" -path "$hostile" -prune -o \ -type f -path '*/hooks/pre-commit' \ -exec grep -lF 'PANAMA_HOSTILE_TEMPLATE_HOOK' {} + 2>/dev/null )" [[ -z "$template_copy" ]] \ || note "the update Git fixture copied a hostile template hook: $template_copy" 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'