mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
A RETURN trap is not scoped to the function that installs it. perform_update left its trap referencing function-local installer_tmp/signature_tmp (and service_name/service_was_active) installed, so it re-ran when a later function returned. With those locals gone and 'set -u' active, the updater aborted with 'installer_tmp: unbound variable' immediately after logging 'Auto-update check completed' — pulse-update.service entered failed state even though the update had succeeded (reported #2128, affects v6.3.2/v6.4.1). Disarm the trap with 'trap - RETURN' on its first invocation, and use ${installer_tmp:-}/${signature_tmp:-} so a stray invocation cannot abort the script. Add a regression test asserting no RETURN trap remains after both the successful and the prerelease-refusal paths. Change-source: pulse-maintainer
459 lines
13 KiB
Bash
Executable file
459 lines
13 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}"
|
|
|
|
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 calls=0
|
|
|
|
systemctl() {
|
|
if [[ "$1" == "is-active" ]]; then
|
|
((calls += 1))
|
|
if (( calls >= 3 )); then
|
|
return 0
|
|
fi
|
|
return 1
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
sleep() { :; }
|
|
|
|
wait_for_service_active pulse 5
|
|
}
|
|
|
|
test_wait_for_service_active_times_out_when_never_active() {
|
|
systemctl() { return 1; }
|
|
sleep() { :; }
|
|
|
|
if wait_for_service_active pulse 3; then
|
|
echo "expected wait_for_service_active to fail when service never becomes active" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
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", "prerelease": false },
|
|
{ "tag_name": "helm-chart-6.0.5", "prerelease": true },
|
|
{ "tag_name": "v6.0.5", "prerelease": false },
|
|
{ "tag_name": "v6.0.5-rc.4", "prerelease": true },
|
|
{ "tag_name": "v6.0.4", "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_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"
|
|
|
|
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"
|
|
exit 0
|
|
INSTALLER
|
|
;;
|
|
esac
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Service was running before the update (first is-active call true), then
|
|
# never comes back up; start also fails -> perform_update must restore + fail.
|
|
local is_active_calls=0
|
|
systemctl() {
|
|
if [[ "$1" == "is-active" ]]; then
|
|
((is_active_calls += 1))
|
|
if (( is_active_calls == 1 )); then
|
|
return 0
|
|
fi
|
|
return 1
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
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
|
|
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"
|
|
|
|
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
|
|
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
|
|
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"
|
|
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" == "is-active" ]]; then return 0; fi
|
|
return 0
|
|
}
|
|
sleep() { :; }
|
|
|
|
trap - RETURN 2>/dev/null || true
|
|
perform_update "v5.1.25" || status=1
|
|
|
|
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
|
|
|
|
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 "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 "$@"
|