diff --git a/scripts/refresh-email-recovery-backup.sh b/scripts/refresh-email-recovery-backup.sh index 389e01a61c..7f94f6a54e 100755 --- a/scripts/refresh-email-recovery-backup.sh +++ b/scripts/refresh-email-recovery-backup.sh @@ -5,8 +5,12 @@ umask 077 stage=dispatch work='' watchers=() +readonly owner_bashpid="$BASHPID" fail() { printf 'EMAIL_BACKUP_REFRESH=UNVERIFIED stage=%s\n' "$stage" >&2; exit 2; } cleanup() { + # A terminated watch may inherit this EXIT handler before Bash resets it. + # Only the parent owns the workspace and the watch processes. + [[ "$BASHPID" == "$owner_bashpid" ]] || return 0 for pid in "${watchers[@]}"; do kill "$pid" 2>/dev/null || true; done for pid in "${watchers[@]}"; do wait "$pid" 2>/dev/null || true; done if [[ -n "$work" ]]; then rm -rf "$work"; fi diff --git a/scripts/tests/vault-email-credentials/backup_refresh_test.go b/scripts/tests/vault-email-credentials/backup_refresh_test.go index b44106f9cb..7994ab7873 100644 --- a/scripts/tests/vault-email-credentials/backup_refresh_test.go +++ b/scripts/tests/vault-email-credentials/backup_refresh_test.go @@ -10,6 +10,44 @@ import ( "testing" ) +func TestBackupRefreshCleanupBelongsToTheParentShell(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repositoryRoot(), "scripts/refresh-email-recovery-backup.sh")) + if err != nil { + t.Fatal(err) + } + prefix, _, ok := strings.Cut(string(raw), "\n[[ ${BASH_VERSINFO") + if !ok { + t.Fatal("missing dispatch boundary") + } + // Bash can terminate an asynchronous child before it resets its inherited EXIT + // handler. Reinstall that native handler in a real child to select the otherwise + // scheduling-dependent window, then exercise the actual cleanup function. + command := exec.Command("bash", "-c", prefix+` +work="$FIXTURE/work" +mkdir "$work" +sleep 60 & +watchers=("$!") +watch_pid="${watchers[0]}" +inherited=$(trap -p EXIT) +( eval "$inherited"; exit 0 ) +[[ -d "$work" ]] || { printf 'child removed parent workspace\n'; exit 1; } +kill -0 "$watch_pid" || { printf 'child stopped parent watch\n'; exit 1; } +cleanup +watchers=() +trap - EXIT +[[ ! -e "$work" ]] || { printf 'parent retained its workspace\n'; exit 1; } +if kill -0 "$watch_pid" 2>/dev/null; then + printf 'parent retained its watch\n'; exit 1 +fi +printf 'BACKUP_CLEANUP_OWNER=PASS child=REFUSED parent=VERIFIED\n' +`) + command.Env = append(os.Environ(), "FIXTURE="+t.TempDir()) + output, err := command.CombinedOutput() + if err != nil || !strings.Contains(string(output), "BACKUP_CLEANUP_OWNER=PASS child=REFUSED parent=VERIFIED") { + t.Fatalf("cleanup ownership: %v: %s", err, output) + } +} + func TestBackupRefreshRequiresTheOwnedCompletedSnapshotAndMirror(t *testing.T) { for _, scenario := range []string{"complete", "owned-unfinished", "owned-complete", "foreign-unfinished", "jobs-read-failed", "jobs-partial", "active", "image", "pvc", "changed", "create-unknown", "failed", "replacement", "pod-missing"} { t.Run(scenario, func(t *testing.T) {