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
This commit is contained in:
pulse-triage[bot]
2026-09-20 17:52:50 +01:00
parent 07d54cd345
commit ccfd22da7f
2 changed files with 115 additions and 0 deletions
@@ -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.
@@ -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)
}
}