mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user