Files
felhom-controller/controller/internal/stacks/r682_remove_interrupted_test.go
T
admin 4347983a72 R-682: a Remove cut off by a controller restart is finished at boot
RemoveStack journals itself before compose down and clears on every
return; at start a found journal finishes the remove through the same
RemoveStack (once, before the boot reconciler), keeping drive data and
backups even if the household had asked to delete them (no unattended
deletion at boot; logged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00

72 lines
2.9 KiB
Go

package stacks
import (
"os"
"path/filepath"
"testing"
)
// R-682 — the consequence: a remove the process died in is FINISHED at the next start (the app no
// longer reads installed, its record is gone), and the drive data the household asked to delete is
// KEPT (no unattended deletion at boot); the marker is gone afterwards.
func TestR682_InterruptedRemoveIsFinishedAtBootKeepingData(t *testing.T) {
drive := t.TempDir()
m, dir, composeRan := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive)
plantFile(t, filepath.Join(drive, "appdata", "app", "keep.txt")) // a path the compose binds: removable
// The state a kill mid-remove leaves: journal written (asking for drive data too), app.yaml present.
if err := markRemovePending(dir, true, 2); err != nil {
t.Fatal(err)
}
got := m.RecoverInterruptedRemoves()
if len(got) != 1 || got[0] != "app" {
t.Fatalf("RecoverInterruptedRemoves = %v, want [app]", got)
}
if _, err := os.Stat(composeRan); err != nil {
t.Error("the finishing remove must run compose down")
}
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); !os.IsNotExist(err) {
t.Errorf("app.yaml survived: the app still reads installed (err=%v)", err)
}
if st, ok := m.GetStack("app"); ok && st.Deployed {
t.Error("the app still reads deployed after the recovery")
}
if _, err := os.Stat(filepath.Join(drive, "appdata", "app", "keep.txt")); err != nil {
t.Errorf("drive data was deleted unattended at boot: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, removePendingFile)); !os.IsNotExist(err) {
t.Error("the remove marker survived the recovery")
}
}
// RemoveStack journals before compose down and clears on return; a clean start with no marker
// touches nothing.
func TestR682_RemoveJournalsAndClears(t *testing.T) {
drive := t.TempDir()
m, dir, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive)
// The stub compose records whether the journal existed when `down` ran.
seen := filepath.Join(t.TempDir(), "journal-seen")
stub := t.TempDir()
script := "#!/bin/sh\ntest -f " + filepath.Join(dir, removePendingFile) + " && touch " + seen + "\nexit 0\n"
if err := os.WriteFile(filepath.Join(stub, "docker-compose"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", stub+string(os.PathListSeparator)+os.Getenv("PATH"))
if got := m.RecoverInterruptedRemoves(); len(got) != 0 {
t.Fatalf("no marker: nothing to recover, got %v", got)
}
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
t.Fatal("a start with no marker removed the app")
}
if _, err := m.RemoveStack("app", false, nil); err != nil {
t.Fatalf("RemoveStack: %v", err)
}
if _, err := os.Stat(seen); err != nil {
t.Error("the remove journal did not exist while compose down ran")
}
if _, err := os.Stat(filepath.Join(dir, removePendingFile)); !os.IsNotExist(err) {
t.Error("the journal survived a completed remove")
}
}