From ccfd22da7f5a7b181807642c76e2177ba15a6418 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:52:50 +0100 Subject: [PATCH] docs(deploy): record update-path resilience contract and proof Record the deployment-installability contract section for the auto-update RETURN-trap and failed-staging config-backup repairs, and add the Go proof that exercises the extracted perform_update and asserts the trap does not leak into a later function return. This completes the two runtime commits for canonical governance as one reviewed unit each. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 37 +++++++++ .../installtests/pulse_auto_update_test.go | 78 +++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6d221cada2..3aa1a0b01d 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5546,3 +5546,40 @@ models workflow cancellation independently of successful needs, covers all six boundaries and retains the final `always()` evidence/verdict join. Evidence uploads and cleanup remain unchanged. This is source-policy regression proof, not hosted cancellation acceptance or authorization to retry a frozen workflow. + +### Unattended update resilience on successful installs and low-space aborts + +The unattended updater and the installer it downloads must leave the host in a +consistent state on every exit path, including failure. + +`scripts/pulse-auto-update.sh`'s `perform_update` installs a shell-global +`trap ... RETURN` that references its own local installer temp-file names. A +RETURN trap is not function-scoped: after `perform_update` returned it fired +again when a later function returned, expanding the now out-of-scope locals +under `set -u` and exiting non-zero. A completed, version-verified install was +therefore reported by `pulse-update.service` as failed with +`installer_tmp: unbound variable`. The trap now clears itself (`trap - RETURN`) +as it runs, so only the returning `perform_update` triggers it. + +`install.sh backup_existing` snapshots the configuration before the update +reaches its staging disk-headroom check. When that check failed, the freshly +created snapshot remained under the backup parent (or the hardened-unit +fallback `$INSTALL_DIR/config-backups`), so every automatic retry added another +full copy and a low-space root filesystem became progressively worse. The +installer now records the snapshot it created and removes it when +`download_pulse` aborts at the headroom check, because nothing was staged or +replaced. + +Neither change alters the update channel, signature verification, disk-headroom +thresholds, snapshot retention (`CONFIG_BACKUP_KEEP_COUNT`), rollback or +service-restart guarantees. No release or installed-host acceptance is claimed. + +Verification: `scripts/tests/test-pulse-auto-update.sh` gains a case that fails +without the trap fix with `installer_tmp: unbound variable` and passes with it. +`scripts/installtests/pulse_auto_update_test.go` executes the extracted +`perform_update` and then a later function return, asserting no +unbound-variable error, and `scripts/installtests/root_install_sh_test.go` +covers the recorded snapshot and its removal. Local shell suites +(`test-pulse-auto-update.sh`, `test-install-update-resilience.sh`, +`test-script-reference-integrity.sh`) pass; hosted Go installtest execution +remains required and is not claimed here. diff --git a/scripts/installtests/pulse_auto_update_test.go b/scripts/installtests/pulse_auto_update_test.go index 23080c5fef..9f96a69713 100644 --- a/scripts/installtests/pulse_auto_update_test.go +++ b/scripts/installtests/pulse_auto_update_test.go @@ -525,3 +525,81 @@ echo "SERVICE:$SERVICE_UP" } } } + +// TestPerformUpdateReturnTrapDoesNotLeak asserts the #2128 fix: perform_update +// installs a shell-global RETURN trap that references its own locals. Once the +// function returns the trap must clear itself, otherwise it fires again when a +// later function returns, expands the now out-of-scope locals under `set -u` +// and fails an otherwise successful update with "installer_tmp: unbound +// variable". +func TestPerformUpdateReturnTrapDoesNotLeak(t *testing.T) { + script := ` +set -uo pipefail +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +GITHUB_REPO="rcourtman/Pulse" +INSTALL_DIR="$TMP/opt/pulse" +CONFIG_DIR="$TMP/etc/pulse" +mkdir -p "$INSTALL_DIR/bin" "$CONFIG_DIR" +printf 'v5.1.24\n' > "$INSTALL_DIR/VERSION" +printf '#!/usr/bin/env bash\necho v5.1.24\n' > "$INSTALL_DIR/bin/pulse" +chmod +x "$INSTALL_DIR/bin/pulse" +export INSTALL_DIR +export FAKE_NEW_VERSION="v5.1.25" + +log() { echo "[$1] ${*:2}"; } +detect_service_name() { echo pulse; } +get_current_version() { tr -d '\r\n' < "$INSTALL_DIR/VERSION"; } +verify_release_signature() { return 0; } +sleep() { :; } +systemctl() { return 0; } + +# The fake installer bumps VERSION, simulating a successful install. +curl() { + local out="" prev="" arg + for arg in "$@"; do + if [[ "$prev" == "-o" ]]; then out="$arg"; fi + prev="$arg" + done + if [[ -n "$out" ]]; then + case "$out" in + *.sig.*) printf 'dummy-signature\n' > "$out" ;; + *) + cat > "$out" <<'INSTALLER' +#!/usr/bin/env bash +printf '%s\n' "${FAKE_NEW_VERSION}" > "${PULSE_INSTALL_DIR}/VERSION" +exit 0 +INSTALLER + ;; + esac + fi + return 0 +} +` + extractAutoUpdateFunction(t, "is_prerelease_tag") + ` +` + extractAutoUpdateFunction(t, "resolve_install_script_url") + ` +` + extractAutoUpdateFunction(t, "wait_for_service_active") + ` +` + extractAutoUpdateFunction(t, "ensure_service_restarted") + ` +` + extractAutoUpdateFunction(t, "perform_update") + ` +# Call perform_update from an enclosing function so a leaked RETURN trap fires +# again when that function returns with perform_update's locals out of scope, +# exactly as it did when main returned in the field. +run_update() { perform_update v5.1.25; } +if run_update; then echo "RESULT:succeeded"; else echo "RESULT:failed"; fi +echo "DONE" +` + + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + got := string(out) + if !strings.Contains(got, "RESULT:succeeded") { + t.Fatalf("perform_update did not report success on a successful install:\n%s", got) + } + if !strings.Contains(got, "DONE") { + t.Fatalf("a later function return did not complete:\n%s", got) + } + if strings.Contains(got, "unbound variable") { + t.Fatalf("RETURN trap leaked out of perform_update and expanded out-of-scope locals:\n%s", got) + } +}