From 07d54cd345d58f04de23fa58ea785fc6580a0fbd Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:44:43 +0100 Subject: [PATCH] fix(installer): discard unneeded config backup when staging cannot start backup_existing snapshots the configuration before the update stages, but a staging disk-headroom failure then exited and left that snapshot behind. On a small root filesystem each automatic retry added another full backup and made the low-space condition progressively worse until updates could no longer run (#2127). Record the snapshot and remove it when download_pulse aborts at the headroom check, before anything was replaced. Add regression coverage. Change-source: pulse-maintainer --- install.sh | 20 ++++++ scripts/installtests/root_install_sh_test.go | 73 ++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/install.sh b/install.sh index 551f5c370..85ad224e8 100755 --- a/install.sh +++ b/install.sh @@ -2893,10 +2893,25 @@ backup_existing() { rm -rf "$backup_dir" return 1 fi + LAST_CONFIG_BACKUP_DIR="$backup_dir" prune_config_backups "$backup_parent" "$(basename "$CONFIG_DIR").backup." fi } +# Drop the configuration snapshot most recently taken by backup_existing when +# the update aborts before it stages or replaces anything. That snapshot was a +# safety copy for an install that never began; leaving it behind makes the +# low-space condition that caused the failure progressively worse on every +# automatic retry until the filesystem fills (#2127). +discard_last_config_backup() { + if [[ -n "${LAST_CONFIG_BACKUP_DIR:-}" && -e "$LAST_CONFIG_BACKUP_DIR" ]]; then + if ! rm -rf -- "$LAST_CONFIG_BACKUP_DIR"; then + print_warn "Could not remove unneeded configuration backup ${LAST_CONFIG_BACKUP_DIR}" + fi + fi + LAST_CONFIG_BACKUP_DIR="" +} + resolve_archive_override() { local archive_path="$1" @@ -3395,6 +3410,11 @@ download_pulse() { rm -f "$BUILD_FROM_SOURCE_MARKER" if ! ensure_update_disk_headroom "/tmp" "$INSTALL_DIR"; then + # The configuration snapshot taken earlier is not needed: the update + # never reached staging. Remove it so a retry loop on a low-space + # filesystem does not accumulate backups and worsen the shortage + # (#2127). + discard_last_config_backup exit 1 fi diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index cc85af292..1845d6e61 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -424,6 +424,79 @@ func TestRootInstallScriptConfigBackupRotatesOldSnapshots(t *testing.T) { } } +// Issue #2127: when an update aborts at the staging disk-headroom check it has +// not replaced anything, so the configuration snapshot taken for it is dead +// weight. Leaving it behind makes the low-space condition worse on every +// automatic retry. backup_existing records the snapshot and +// discard_last_config_backup must remove it and clear the record. +func TestRootInstallScriptDiscardsUnneededConfigBackup(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores write bits, so the read-only fallback path cannot be simulated") + } + configDir := t.TempDir() + installDir := t.TempDir() + + script := ` + set -euo pipefail + print_error() { :; } + print_info() { :; } + print_warn() { :; } + CONFIG_DIR="$CONFIG_DIR_UNDER_TEST" + INSTALL_DIR="$INSTALL_DIR_UNDER_TEST" + CONFIG_BACKUP_MIN_EXTRA_BYTES=0 +` + extractRootInstallShellFunction(t, "bytes_to_human") + ` +` + extractRootInstallShellFunction(t, "get_available_bytes_for_path") + ` +` + extractRootInstallShellFunction(t, "get_directory_size_bytes") + ` +` + extractRootInstallShellFunction(t, "ensure_config_backup_headroom") + ` +` + extractRootInstallShellFunction(t, "prune_config_backups") + ` +` + extractRootInstallShellFunction(t, "backup_existing") + ` +` + extractRootInstallShellFunction(t, "discard_last_config_backup") + ` + date() { printf '20260920-160000\n'; } + chmod a-w "$(dirname "$CONFIG_DIR_UNDER_TEST")" 2>/dev/null || true + trap 'chmod u+w "$(dirname "$CONFIG_DIR_UNDER_TEST")" 2>/dev/null || true' EXIT + backup_existing + if [[ -z "${LAST_CONFIG_BACKUP_DIR:-}" || ! -d "$LAST_CONFIG_BACKUP_DIR" ]]; then + echo "backup_existing did not record the created snapshot" >&2 + exit 1 + fi + backup_path="$LAST_CONFIG_BACKUP_DIR" + discard_last_config_backup + if [[ -n "${LAST_CONFIG_BACKUP_DIR:-}" ]]; then + echo "discard_last_config_backup did not clear the recorded path" >&2 + exit 1 + fi + if [[ -e "$backup_path" ]]; then + echo "unneeded configuration backup was not removed" >&2 + exit 1 + fi + ` + + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), + "CONFIG_DIR_UNDER_TEST="+configDir, + "INSTALL_DIR_UNDER_TEST="+installDir, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } +} + +// download_pulse must discard the earlier configuration snapshot when the +// staging disk-headroom check fails, so the update path cannot accumulate +// backups while the filesystem is short of space (#2127). +func TestRootInstallScriptDiscardsBackupWhenStagingHeadroomFails(t *testing.T) { + downloadPulse := extractRootInstallShellFunction(t, "download_pulse") + headroom := strings.Index(downloadPulse, `ensure_update_disk_headroom "/tmp" "$INSTALL_DIR"`) + discard := strings.Index(downloadPulse, `discard_last_config_backup`) + if headroom < 0 || discard < 0 { + t.Fatalf("download_pulse must check staging headroom and discard the unneeded config backup (headroom=%d discard=%d)", headroom, discard) + } + if discard < headroom { + t.Fatalf("download_pulse must discard the config backup after the headroom check, not before") + } +} + func TestRootInstallScriptV5ToV6PreflightWarnsWhenAgentScopeMissing(t *testing.T) { configDir := t.TempDir() if err := os.WriteFile(filepath.Join(configDir, "api_tokens.json"), []byte(`[{"id":"tok-1","name":"admin","hash":"hash","scopes":["settings:read"]}]`), 0600); err != nil {