Files
pulse/scripts/tests/test-pulse-auto-update.sh
T
pulse-triage[bot] cd1d162e30 Exercise updater smoke trap cleanup past its explicit runtime handoff
The RETURN-trap smoke fixture has a simulated manager and script binary, not a real managed Linux process. Provide its exact-service/new-version comparator and require exactly one success-path invocation, with none on prerelease refusal. Keep actual process-identity refusals in the independent source/native controls; do not relax production verification.

Change-source: pulse-maintainer
Contract-Neutral: Legacy shell smoke fixture correction only; production updater and its running-executable, signing, restoration and deadline contracts are unchanged.
2026-10-11 18:43:48 +01:00

512 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Smoke tests for scripts/pulse-auto-update.sh helper behavior (#1323).
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
AUTO_UPDATE_SCRIPT="${ROOT_DIR}/scripts/pulse-auto-update.sh"
if [[ ! -f "${AUTO_UPDATE_SCRIPT}" ]]; then
echo "pulse-auto-update.sh not found at ${AUTO_UPDATE_SCRIPT}" >&2
exit 1
fi
# Sourcing relies on the BASH_SOURCE guard so main() does not run on import.
# shellcheck disable=SC1090
source "${AUTO_UPDATE_SCRIPT}"
# Command doubles run in this shell, not through the host manager. Actual
# timeout/manager boundaries are exercised by test_auto_update_service_state.py.
timeout() { case "$1" in -k) shift 2 ;; --kill-after=*) shift ;; esac; shift; "$@"; }
failures=0
assert_success() {
local desc="$1"
shift
if "$@"; then
echo "[PASS] ${desc}"
return 0
else
echo "[FAIL] ${desc}" >&2
((failures++))
return 1
fi
}
test_wait_for_service_active_succeeds_after_retry() {
local counter; counter=$(mktemp)
printf '0\n' > "$counter"
systemctl() {
[[ "$1" == show ]] || return 1
local count; count=$(cat "$counter"); ((count += 1))
printf '%s\n' "$count" > "$counter"
printf 'LoadState=loaded\n'
if (( count >= 3 )); then echo ActiveState=active; else echo ActiveState=activating; fi
}
sleep() { :; }
local result=0
wait_for_service_active pulse 5 || result=$?
command rm "$counter"
return "$result"
}
test_wait_for_service_active_times_out_when_never_active() {
systemctl() { [[ "$1" == show ]] || return 1; printf 'LoadState=loaded\nActiveState=inactive\n'; }
sleep() { :; }
local result=0
wait_for_service_active pulse 3 || result=$?
[[ "$result" == 1 ]] || { echo "expected settled inactive timeout, got $result" >&2; return 1; }
}
test_pick_highest_stable_tag_ignores_list_order_and_prereleases() {
local picked
picked=$(pick_highest_stable_tag <<'EOF'
v5.1.37
helm-chart-6.0.5
v6.0.5-rc.4
v6.0.5
v6.0.4
v6.0.0-rc.7
EOF
)
if [[ "$picked" != "v6.0.5" ]]; then
echo "pick_highest_stable_tag picked ${picked:-<empty>}, want v6.0.5" >&2
return 1
fi
return 0
}
test_get_latest_stable_version_prefers_highest_over_created_order() {
# A v5-line maintenance release created after v6.0.5 sits first in the
# /releases list and is what /releases/latest would point at; the updater
# must still pick v6.0.5.
curl() {
local url="${*: -1}"
if [[ "$url" == *"/releases?per_page="* ]]; then
cat <<'EOF'
[
{ "tag_name": "v5.1.37", "draft": false, "prerelease": false },
{ "tag_name": "helm-chart-6.0.5", "draft": false, "prerelease": false },
{ "tag_name": "v99.0.0", "draft": true, "prerelease": false },
{ "tag_name": "v6.0.5", "draft": false, "prerelease": false },
{ "tag_name": "v6.0.5-rc.4", "draft": false, "prerelease": true },
{ "tag_name": "v6.0.4", "draft": false, "prerelease": false }
]
EOF
return 0
fi
echo "unexpected curl call in test: $*" >&2
return 1
}
local got
got=$(get_latest_stable_version)
unset -f curl
if [[ "$got" != "v6.0.5" ]]; then
echo "get_latest_stable_version returned ${got:-<empty>}, want v6.0.5" >&2
return 1
fi
return 0
}
test_get_latest_stable_version_rejects_incomplete_maturity() {
# Missing fields must remain unknown, not default to published stable.
# Both transport paths are confined; this test makes no provider request.
curl() {
local url="${*: -1}"
case "$url" in
*/releases\?per_page=*)
printf '%s\n' '[{"tag_name":"v99.0.0","prerelease":false},{"tag_name":"v99.0.1","draft":false}]'
;;
*/releases/latest)
printf '%s\n' '{"tag_name":"v99.0.0","prerelease":false}'
;;
*) echo "unexpected curl call in test: $*" >&2; return 1 ;;
esac
}
local got
got=$(get_latest_stable_version)
unset -f curl
if [[ -n "$got" ]]; then
echo "incomplete maturity must not select an unattended update, got $got" >&2
return 1
fi
}
test_perform_update_restores_backup_when_service_stays_down() {
local tmpdir
tmpdir="$(mktemp -d)"
local status=0
# perform_update installs a RETURN trap referencing these; declare them here
# so the trap is safe under set -u if it surfaces in this calling scope.
local installer_tmp="" signature_tmp=""
INSTALL_DIR="${tmpdir}/opt/pulse"
CONFIG_DIR="${tmpdir}/etc/pulse"
mkdir -p "${INSTALL_DIR}/bin" "${CONFIG_DIR}"
printf 'v5.1.24\n' > "${INSTALL_DIR}/VERSION"
cat > "${INSTALL_DIR}/bin/pulse" <<'EOF'
#!/usr/bin/env bash
echo "v5.1.24"
EOF
chmod +x "${INSTALL_DIR}/bin/pulse"
printf 'active\n' > "$INSTALL_DIR/service-state"
export INSTALL_DIR
export FAKE_NEW_VERSION="v5.1.25"
# Stub out the v6 download/verify pipeline so perform_update reaches the
# post-install service check deterministically.
is_prerelease_tag() { return 1; }
detect_service_name() { echo "pulse"; }
resolve_install_script_url() { echo "http://localhost/install.sh"; }
verify_release_signature() { return 0; }
get_current_version() { tr -d '\r\n' < "${INSTALL_DIR}/VERSION"; }
# curl writes the installer / signature to the -o target. The fake installer
# bumps VERSION to the new version, simulating a successful install.
curl() {
local out="" prev=""
local 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"
echo inactive > "${PULSE_INSTALL_DIR}/service-state"
exit 0
INSTALLER
;;
esac
fi
return 0
}
# The installer records its stop; explicit starts fail for this scenario.
systemctl() {
case "$1" in
show) printf 'LoadState=loaded\nActiveState=%s\n' "$(cat "$INSTALL_DIR/service-state")"; return 0 ;;
stop) echo inactive > "$INSTALL_DIR/service-state" ;;
*) return 1 ;;
esac
}
sleep() { :; }
if perform_update "v5.1.25"; then
echo "perform_update unexpectedly succeeded while service stayed down" >&2
status=1
fi
# perform_update installs a RETURN trap; clear it so it does not leak into
# subsequent function returns in this sourced test harness.
trap - RETURN 2>/dev/null || true
if [[ "${status}" -eq 0 ]] && [[ "$(tr -d '\r\n' < "${INSTALL_DIR}/VERSION")" != "v5.1.24" ]]; then
echo "expected VERSION to be restored to v5.1.24 after failed restart" >&2
status=1
fi
rm -rf "${tmpdir}"
return "${status}"
}
test_ensure_service_restarted_noops_when_service_was_inactive() {
ENSURE_TEST_STARTS=0
systemctl() {
if [[ "$1" == "start" ]] || [[ "$1" == "restart" ]]; then
((ENSURE_TEST_STARTS += 1))
fi
return 1
}
sleep() { :; }
ensure_service_restarted pulse "false" || return 1
if (( ENSURE_TEST_STARTS != 0 )); then
echo "expected no start attempts when the service was inactive before the update, got ${ENSURE_TEST_STARTS}" >&2
return 1
fi
return 0
}
test_ensure_service_restarted_starts_stopped_service() {
ENSURE_TEST_STARTS=0
ENSURE_TEST_UP="no"
systemctl() {
case "$1" in
show)
printf 'LoadState=loaded\n'
if [[ "$ENSURE_TEST_UP" == yes ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi
return 0
;;
is-active)
[[ "$ENSURE_TEST_UP" == "yes" ]] && return 0 || return 1
;;
start|restart)
((ENSURE_TEST_STARTS += 1))
ENSURE_TEST_UP="yes"
return 0
;;
esac
return 1
}
sleep() { :; }
ensure_service_restarted pulse "true" || return 1
if (( ENSURE_TEST_STARTS != 1 )); then
echo "expected exactly one start attempt, got ${ENSURE_TEST_STARTS}" >&2
return 1
fi
if [[ "$ENSURE_TEST_UP" != "yes" ]]; then
echo "expected the service to be running afterwards" >&2
return 1
fi
return 0
}
test_perform_update_restarts_service_when_installer_fails() {
# Regression for #1630: the installer stops the service and can then exit
# non-zero (e.g. a write to a read-only path aborts it). perform_update's
# rollback branch must restart Pulse — previously it restored the backup
# and returned 1 with the service left stopped, which also disabled every
# future timer run via pulse-update.service's ExecCondition.
local tmpdir
tmpdir="$(mktemp -d)"
local status=0
# perform_update installs a RETURN trap referencing these; declare them here
# so the trap is safe under set -u if it surfaces in this calling scope.
local installer_tmp="" signature_tmp=""
local service_name="pulse" service_was_active="false"
INSTALL_DIR="${tmpdir}/opt/pulse"
CONFIG_DIR="${tmpdir}/etc/pulse"
mkdir -p "${INSTALL_DIR}/bin" "${CONFIG_DIR}"
printf 'v5.1.24\n' > "${INSTALL_DIR}/VERSION"
cat > "${INSTALL_DIR}/bin/pulse" <<'EOF'
#!/usr/bin/env bash
echo "v5.1.24"
EOF
chmod +x "${INSTALL_DIR}/bin/pulse"
printf 'active\n' > "$INSTALL_DIR/service-state"
export INSTALL_DIR
is_prerelease_tag() { return 1; }
detect_service_name() { echo "pulse"; }
resolve_install_script_url() { echo "http://localhost/install.sh"; }
verify_release_signature() { return 0; }
get_current_version() { tr -d '\r\n' < "${INSTALL_DIR}/VERSION"; }
# curl writes the installer / signature to the -o target. The fake installer
# fails outright, like the /bin/update heredoc aborting on a read-only
# filesystem after the real installer has already stopped the service.
curl() {
local out="" prev=""
local 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
echo inactive > "${PULSE_INSTALL_DIR}/service-state"
exit 1
INSTALLER
;;
esac
fi
return 0
}
# Service is running when perform_update checks (was-active capture), then
# down (the real installer stops it before failing) until start/restart.
AUTOUPDATE_TEST_IS_ACTIVE_CALLS=0
AUTOUPDATE_TEST_UP="no"
AUTOUPDATE_TEST_STARTS=0
systemctl() {
case "$1" in
show) printf 'LoadState=loaded\nActiveState=%s\n' "$(cat "$INSTALL_DIR/service-state")"; return 0 ;;
is-active)
((AUTOUPDATE_TEST_IS_ACTIVE_CALLS += 1))
if (( AUTOUPDATE_TEST_IS_ACTIVE_CALLS == 1 )); then
return 0
fi
[[ "$AUTOUPDATE_TEST_UP" == "yes" ]] && return 0 || return 1
;;
start|restart)
((AUTOUPDATE_TEST_STARTS += 1))
AUTOUPDATE_TEST_UP="yes"
echo active > "$INSTALL_DIR/service-state"
return 0
;;
stop) AUTOUPDATE_TEST_UP="no"; echo inactive > "$INSTALL_DIR/service-state"; return 0 ;;
esac
return 1
}
sleep() { :; }
if perform_update "v5.1.25"; then
echo "perform_update unexpectedly succeeded when the installer failed" >&2
status=1
fi
# perform_update installs a RETURN trap; clear it so it does not leak into
# subsequent function returns in this sourced test harness.
trap - RETURN 2>/dev/null || true
if (( AUTOUPDATE_TEST_STARTS < 1 )); then
echo "expected the service to be restarted after installer failure, got ${AUTOUPDATE_TEST_STARTS} start attempts" >&2
status=1
fi
if [[ "$AUTOUPDATE_TEST_UP" != "yes" ]]; then
echo "expected the service to be running after the failed update" >&2
status=1
fi
if [[ "$(tr -d '\r\n' < "${INSTALL_DIR}/VERSION")" != "v5.1.24" ]]; then
echo "expected VERSION to remain v5.1.24 after failed install" >&2
status=1
fi
rm -rf "${tmpdir}"
return "${status}"
}
test_perform_update_does_not_leak_return_trap() {
# Regression for #2128: perform_update installed a RETURN trap referencing
# function-local tempfiles without disarming it. A RETURN trap is not scoped
# to the function that set it, so it re-ran when a later function returned;
# with the locals gone, `set -u` aborted the updater with
# "installer_tmp: unbound variable" even though the update had succeeded.
local tmpdir
tmpdir="$(mktemp -d)"
local status=0
INSTALL_DIR="${tmpdir}/opt/pulse"
CONFIG_DIR="${tmpdir}/etc/pulse"
mkdir -p "${INSTALL_DIR}/bin" "${CONFIG_DIR}"
printf 'v5.1.24\n' > "${INSTALL_DIR}/VERSION"
cat > "${INSTALL_DIR}/bin/pulse" <<'EOF'
#!/usr/bin/env bash
echo "v5.1.24"
EOF
chmod +x "${INSTALL_DIR}/bin/pulse"
export INSTALL_DIR
export FAKE_NEW_VERSION="v5.1.25"
is_prerelease_tag() { return 1; }
detect_service_name() { echo "pulse"; }
resolve_install_script_url() { echo "http://localhost/install.sh"; }
verify_release_signature() { return 0; }
get_current_version() { tr -d '\r\n' < "${INSTALL_DIR}/VERSION"; }
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
}
# Pulse was running and stays running, so perform_update succeeds.
systemctl() {
if [[ "$1" == show ]]; then printf 'LoadState=loaded\nActiveState=active\n'; fi
return 0
}
sleep() { :; }
# This is a RETURN-trap control with a simulated manager and script binary,
# not a Linux process-identity test. Supply its verification handoff explicitly
# and require it to be reached for the exact service/new version. Real /proc
# refusals remain in test_auto_update_service_state.py and native CI.
AUTOUPDATE_TRAP_RUNTIME_VERIFICATIONS=0
verify_update_running_executable() {
[[ $# -eq 1 && "$1" == pulse ]] || return 1
((AUTOUPDATE_TRAP_RUNTIME_VERIFICATIONS += 1))
[[ "$(get_current_version)" == v5.1.25 ]]
}
trap - RETURN 2>/dev/null || true
perform_update "v5.1.25" || status=1
if (( AUTOUPDATE_TRAP_RUNTIME_VERIFICATIONS != 1 )); then
echo "successful trap control did not reach its running-executable handoff exactly once" >&2
status=1
fi
local leaked
leaked="$(trap -p RETURN)"
if [[ -n "$leaked" ]]; then
echo "perform_update leaked a RETURN trap after a successful update: ${leaked}" >&2
status=1
fi
# The early trap must disarm too: a prerelease refusal returns before the
# tempfile trap replaces it.
is_prerelease_tag() { return 0; }
perform_update "v5.1.26" >/dev/null 2>&1 || true
leaked="$(trap -p RETURN)"
if [[ -n "$leaked" ]]; then
echo "perform_update leaked a RETURN trap on the prerelease refusal path: ${leaked}" >&2
status=1
fi
if (( AUTOUPDATE_TRAP_RUNTIME_VERIFICATIONS != 1 )); then
echo "prerelease refusal unexpectedly reached running-executable verification" >&2
status=1
fi
trap - RETURN 2>/dev/null || true
rm -rf "${tmpdir}"
return "${status}"
}
main() {
assert_success "wait_for_service_active retries until active" test_wait_for_service_active_succeeds_after_retry
assert_success "wait_for_service_active times out when never active" test_wait_for_service_active_times_out_when_never_active
assert_success "pick_highest_stable_tag ignores list order and prereleases" test_pick_highest_stable_tag_ignores_list_order_and_prereleases
assert_success "get_latest_stable_version prefers highest version over created order" test_get_latest_stable_version_prefers_highest_over_created_order
assert_success "get_latest_stable_version refuses incomplete maturity" test_get_latest_stable_version_rejects_incomplete_maturity
assert_success "perform_update restores backup when service stays down" test_perform_update_restores_backup_when_service_stays_down
assert_success "ensure_service_restarted no-ops when service was inactive" test_ensure_service_restarted_noops_when_service_was_inactive
assert_success "ensure_service_restarted starts a stopped service" test_ensure_service_restarted_starts_stopped_service
assert_success "perform_update restarts service when installer fails" test_perform_update_restarts_service_when_installer_fails
assert_success "perform_update does not leak its RETURN trap" test_perform_update_does_not_leak_return_trap
if (( failures > 0 )); then
echo "Total failures: ${failures}" >&2
return 1
fi
echo "All pulse-auto-update smoke tests passed."
}
main "$@"