mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +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
|
boundaries and retains the final `always()` evidence/verdict join. Evidence
|
||||||
uploads and cleanup remain unchanged. This is source-policy regression proof,
|
uploads and cleanup remain unchanged. This is source-policy regression proof,
|
||||||
not hosted cancellation acceptance or authorization to retry a frozen workflow.
|
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