Files
pulse/scripts/tests/test-install-update-resilience.sh
courtmanr@gmail.com 5c1c89a363 Guard auto-update asset staging and migrate stale update units
Adversarial review of 9db25ba60 found four residual defects in the
auto-update asset install, plus a doc line it left contradicting itself.

- install_auto_update_assets copied the bundled helper into the staged
  mktemp file with an unchecked cp, and both call sites invoke the
  function under `if !`, which suppresses errexit for its whole body. A
  failed copy (ENOSPC, EIO) fell through to
  configure_auto_update_script_repo, whose awk emits a lone GITHUB_REPO=
  line for empty input, so a shebang-less one-line stub replaced the
  working helper with a "script" that only ever exits 0 - silently
  disabling unattended updates. Check the cp, and refuse the swap unless
  the staged helper is non-empty and starts with #!.
- Both units were rendered with a bare truncating `cat > "$unit"` whose
  status was never checked, and the function's last statement is
  safe_systemctl daemon-reload, which returns 0 by design. A failing
  write truncated a working unit and still reported success. Render each
  unit to ${path}.tmp and commit it with a checked rename, so a failure
  leaves the installed unit byte-identical.
- The widened ReadWritePaths could not reach deployed boxes: the unit
  that grants the write access is itself the file that has to be
  rewritten, and on an existing install the sandbox running the
  installer excludes /etc/systemd/system and /usr/local/bin (EROFS). The
  Go update pipeline cannot carry it either - pulse.service runs as
  User=pulse with its own ProtectSystem=strict over the install and
  config dirs only. So probe each destination directory up front and,
  when one is blocked, re-exec this already-signature-verified installer
  through systemd-run with a new internal --repair-auto-update-units
  entry point: PID 1 forks the transient unit, so it starts in the host
  mount namespace instead of inheriting the sandbox. The installer is
  copied into the install dir first because the calling unit's
  PrivateTmp=yes hides its /tmp copy from PID 1. The escape needs root
  and systemd-run, and never recurses.
- Keep the ReadWritePaths entries as directory grants: every write now
  commits with a rename from a sibling staging file, and rename needs
  write access on the containing directory, so the file-level entries
  systemd would otherwise accept cannot work. Document the tradeoff in
  the unit and the subsystem contract instead.

The deployment-installability contract still claimed the update sandbox
leaves "only the install dir, config dir and /tmp" writable, which the
paragraph the same file gained in 9db25ba60 contradicts; the same stale
rationale had been copied into two test comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:56:52 +01:00

138 lines
6.1 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Tests the interactive-update service-resilience helpers in install.sh (#1323):
# after an update that stopped a running Pulse, the installer must verify the
# service came back up, retry one explicit start, and surface a clear error
# instead of silently leaving Pulse stopped (common on unprivileged LXC where
# the installer's restart silently fails).
set -uo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
INSTALL_SCRIPT="${ROOT_DIR}/install.sh"
# Sourcing relies on the BASH_SOURCE guard so main() does not run on import.
source "${INSTALL_SCRIPT}"
set +e # this test drives helpers that intentionally return non-zero
SERVICE_NAME="pulse"
# --- Deterministic stubs: no real sleeps, no real systemctl/timeout ---------
sleep() { :; }
timeout() { shift; "$@"; } # drop the duration; run the wrapped (stubbed) command
SYSTEMCTL_ACTIVE="no"
START_ATTEMPTS=0
systemctl() {
case "$1" in
is-active) [[ "$SYSTEMCTL_ACTIVE" == "yes" ]] && return 0 || return 1 ;;
stop) SYSTEMCTL_ACTIVE="no"; return 0 ;;
start) START_ATTEMPTS=$((START_ATTEMPTS + 1)); return 0 ;;
*) return 0 ;;
esac
}
safe_systemctl() { systemctl "$@"; }
PRINT_BUF=""
print_info() { PRINT_BUF+="INFO:$*"$'\n'; }
print_warn() { PRINT_BUF+="WARN:$*"$'\n'; }
print_error() { PRINT_BUF+="ERROR:$*"$'\n'; }
print_success() { PRINT_BUF+="OK:$*"$'\n'; }
fail() { echo "FAIL: $*" >&2; exit 1; }
# --- Case 1: stop_pulse_for_update records whether Pulse was running --------
SYSTEMCTL_ACTIVE="yes"; PULSE_WAS_ACTIVE="false"
stop_pulse_for_update
[[ "$PULSE_WAS_ACTIVE" == "true" ]] || fail "should record was-active=true when running"
[[ "$SYSTEMCTL_ACTIVE" == "no" ]] || fail "should stop the service"
SYSTEMCTL_ACTIVE="no"; PULSE_WAS_ACTIVE="true"
stop_pulse_for_update
[[ "$PULSE_WAS_ACTIVE" == "false" ]] || fail "should record was-active=false when not running"
# --- Case 2: no-op when Pulse was not running before the update ------------
PULSE_WAS_ACTIVE="false"; PRINT_BUF=""
ensure_pulse_running_after_update || fail "should no-op (succeed) when was-active=false"
[[ -z "$PRINT_BUF" ]] || fail "should be silent when was-active=false, got: $PRINT_BUF"
# --- Case 3: service comes back up -> success, flag consumed ----------------
PULSE_WAS_ACTIVE="true"; SYSTEMCTL_ACTIVE="yes"; PRINT_BUF=""
ensure_pulse_running_after_update || fail "should succeed when the service is active"
[[ "$PULSE_WAS_ACTIVE" == "false" ]] || fail "should consume the was-active flag"
# --- Case 4: service stays down -> retries once, then a clear error ---------
PULSE_WAS_ACTIVE="true"; SYSTEMCTL_ACTIVE="no"; START_ATTEMPTS=0; PRINT_BUF=""
if ensure_pulse_running_after_update; then
fail "should return non-zero when the service will not come up"
fi
[[ "$START_ATTEMPTS" -ge 1 ]] || fail "should attempt an explicit restart, got $START_ATTEMPTS"
[[ "$PRINT_BUF" == *"did not come back up"* ]] || fail "should surface a clear error, got: $PRINT_BUF"
# --- Cases 5+: read-only-filesystem resilience (#1630) -----------------------
# The unattended updater runs the installer under pulse-update.service, whose
# ProtectSystem=strict grants ReadWritePaths only for the install dir, config
# dir, /tmp and the auto-update helper and unit directories — so /bin (the
# default update-helper path) and /etc/profile stay read-only; transient
# read-only remounts hit the same paths. Writes outside the writable set must
# be non-fatal: aborting used to kill the installer with errexit active after
# the new binary was installed and the service stopped.
TMPDIR_RO="$(mktemp -d)"
# A regular file as the "parent directory" makes any write beneath it fail
# with ENOTDIR — unlike chmod 555, this also fails when running as root.
touch "$TMPDIR_RO/blocker"
# --- Case 5: setup_update_command must not abort the installer (errexit) ----
out=$(
set -e
PRINT_BUF=""
UPDATE_HELPER_PATH="$TMPDIR_RO/blocker/update"
PULSE_PROFILE_PATH="$TMPDIR_RO/profile"
PULSE_BASHRC_PATH="$TMPDIR_RO/bashrc"
setup_update_command
printf '%s' "$PRINT_BUF"
)
rc=$?
[[ "$rc" -eq 0 ]] || fail "setup_update_command must not abort the installer when the helper path is unwritable (rc=$rc)"
[[ "$out" == *"WARN:"*"$TMPDIR_RO/blocker/update"* ]] || fail "should warn about the unwritable helper path, got: $out"
# --- Case 6: setup_update_command still writes the helper when it can -------
out=$(
set -e
PRINT_BUF=""
UPDATE_HELPER_PATH="$TMPDIR_RO/bin/update"
PULSE_PROFILE_PATH="$TMPDIR_RO/profile"
PULSE_BASHRC_PATH="$TMPDIR_RO/bashrc"
setup_update_command
printf '%s' "$PRINT_BUF"
)
rc=$?
[[ "$rc" -eq 0 ]] || fail "setup_update_command should succeed on a writable path (rc=$rc)"
[[ -x "$TMPDIR_RO/bin/update" ]] || fail "should create an executable update helper at a writable path"
grep -q "Pulse update command" "$TMPDIR_RO/bin/update" || fail "helper should contain the expected script body"
# --- Case 7: install_binary_symlink is non-fatal on an unwritable path ------
PRINT_BUF=""
install_binary_symlink "$TMPDIR_RO/bin/update" "$TMPDIR_RO/blocker/pulse" || \
fail "install_binary_symlink must not fail on an unwritable link path"
[[ "$PRINT_BUF" == *"WARN:"* ]] || fail "should warn when the symlink cannot be created, got: $PRINT_BUF"
# --- Case 8: install_binary_symlink creates and keeps the link ---------------
PRINT_BUF=""
install_binary_symlink "$TMPDIR_RO/bin/update" "$TMPDIR_RO/bin/pulse" || \
fail "install_binary_symlink should succeed on a writable path"
[[ "$(readlink "$TMPDIR_RO/bin/pulse")" == "$TMPDIR_RO/bin/update" ]] || \
fail "symlink should point at the installed binary"
# Idempotence: with the correct link already present it must not need ln at
# all (matches an update run where the link survives but the fs is read-only).
ln() { return 1; }
PRINT_BUF=""
install_binary_symlink "$TMPDIR_RO/bin/update" "$TMPDIR_RO/bin/pulse" || \
fail "install_binary_symlink should succeed when the correct link already exists"
[[ "$PRINT_BUF" == *"already in place"* ]] || \
fail "should recognise an existing correct link, got: $PRINT_BUF"
unset -f ln
rm -rf "$TMPDIR_RO"
echo "PASS: install.sh update-resilience helpers (#1323, #1630)"