package stacks import ( "errors" "fmt" "io" "log" "os" "path/filepath" "strings" "syscall" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // keptManager is a real Manager over a temp stacks dir with one app, "cloudapp", whose compose binds // its private data (appdata/cloudapp), a household folder (userdata/Photos) and the shared media root. // Nothing here reaches Docker (R-650): no test calls a path that runs compose. func keptManager(t *testing.T) (*Manager, string) { t.Helper() dir := t.TempDir() drive := filepath.Join(dir, "drive") cfg := &config.Config{} cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.SystemDataPath = filepath.Join(dir, "system") cfg.Stacks.ComposeCommand = "docker compose" app := filepath.Join(cfg.Paths.StacksDir, "cloudapp") must(t, os.MkdirAll(app, 0o755)) compose := "services:\n cloudapp:\n image: busybox\n volumes:\n" + " - ${HDD_PATH}/appdata/cloudapp:/data\n" + " - ${HDD_PATH}/userdata/Photos:/photos\n" + " - ${HDD_PATH}/media:/media\n" must(t, os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte(compose), 0o644)) must(t, os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\ndeploy_fields:\n - env_var: HDD_PATH\n label: Drive\n type: path\n required: true\n"), 0o644)) m, err := NewManager(cfg, log.New(io.Discard, "", 0)) must(t, err) must(t, m.ScanStacks()) for _, d := range []string{"appdata/cloudapp/user1", "userdata/Photos", "media"} { must(t, os.MkdirAll(filepath.Join(drive, d), 0o755)) } must(t, os.WriteFile(filepath.Join(drive, "appdata/cloudapp/user1/file.txt"), []byte("old"), 0o644)) must(t, os.WriteFile(filepath.Join(drive, "userdata/Photos/p.jpg"), []byte("photo"), 0o644)) must(t, os.WriteFile(filepath.Join(drive, "media/song.mp3"), []byte("song"), 0o644)) return m, drive } func must(t *testing.T, err error) { t.Helper() if err != nil { t.Fatal(err) } } // Rule 1 — only the app's private appdata bind is "old data"; the household's shared folders never are. // COMPANION RED-PROOF: drop the appdata prefix check in OldAppDataPaths → the Photos and media folders // are returned and this fails. func TestKept_OnlyAppdataBindsAreOldData(t *testing.T) { m, drive := keptManager(t) got := m.OldAppData("cloudapp", drive) want := []string{filepath.Join(drive, "appdata/cloudapp")} if fmt.Sprint(got) != fmt.Sprint(want) { t.Fatalf("old data = %v, want only %v (a household folder must never be moved)", got, want) } // An EMPTY private folder is not old data. must(t, os.RemoveAll(filepath.Join(drive, "appdata/cloudapp/user1"))) if got := m.OldAppData("cloudapp", drive); len(got) != 0 { t.Fatalf("an empty folder was called old data: %v", got) } } // Rule 2 — start fresh is a RENAME (same inode, nothing copied) and a failed move puts back what moved. // COMPANION RED-PROOF: delete the undo() call on the rename failure → the first folder stays inside the // kept folder and this fails at "was not put back". func TestKept_KeepAsideIsARenameAndRollsBack(t *testing.T) { m, drive := keptManager(t) src := filepath.Join(drive, "appdata/cloudapp") second := filepath.Join(drive, "appdata/cloudapp-extra") must(t, os.MkdirAll(second, 0o755)) must(t, os.WriteFile(filepath.Join(second, "x"), []byte("x"), 0o644)) before, err := os.Stat(filepath.Join(src, "user1/file.txt")) must(t, err) now := time.Date(2026, 9, 25, 11, 0, 0, 0, time.UTC) // A: the second rename fails with EXDEV → both stay where they were, no kept folder, the error says so. calls := 0 renameFn = func(a, b string) error { calls++ if calls == 2 { return &os.LinkError{Op: "rename", Old: a, New: b, Err: syscall.EXDEV} } return os.Rename(a, b) } defer func() { renameFn = os.Rename }() _, err = m.KeepAside("cloudapp", drive, []string{src, second}, "", now) if err == nil || !strings.Contains(err.Error(), "cross drives") { t.Fatalf("want a cross-drive refusal, got %v", err) } if _, err := os.Stat(filepath.Join(src, "user1/file.txt")); err != nil { t.Fatalf("the first folder was not put back after the failed move: %v", err) } if _, err := os.Stat(KeptDirFor(drive, "cloudapp", now)); !os.IsNotExist(err) { t.Fatalf("a failed start-fresh left a kept folder behind (%v)", err) } // B: success — the data is in the kept folder under its drive-relative path, as the SAME file. renameFn = os.Rename kept, err := m.KeepAside("cloudapp", drive, []string{src}, "", now) must(t, err) after, err := os.Stat(filepath.Join(kept, "appdata/cloudapp/user1/file.txt")) must(t, err) if !os.SameFile(before, after) { t.Fatal("the kept file is not the same inode — it was copied, not moved") } if _, err := os.Stat(src); !os.IsNotExist(err) { t.Fatalf("the old folder is still in place after start fresh (%v)", err) } if mk := readKeptMarker(kept); mk == nil || mk.App != "cloudapp" || fmt.Sprint(mk.Paths) != "[appdata/cloudapp]" { t.Fatalf("marker = %+v", mk) } if !strings.HasPrefix(kept, filepath.Join(drive, KeptDirName)+string(filepath.Separator)) || strings.Contains(kept, "userdata") { t.Fatalf("kept folder %s is not under /kept (and never under userdata)", kept) } } // Rule 3 — the kept folder is protected from an app removal's drive clean-up. // COMPANION RED-PROOF: drop the KeptDirName line from ProtectedHDDPaths → fails. func TestKept_KeptDirIsProtected(t *testing.T) { if !ProtectedHDDPaths("/mnt/d")["/mnt/d/"+KeptDirName] { t.Fatal("/kept is not in ProtectedHDDPaths") } } // The list: a dated folder (with its unit) and a leftover appdata folder are listed; a live app's // folder is not; the leftover is named after the catalog app that declares it. func TestKept_ListShowsKeptAndNeverALiveFolder(t *testing.T) { m, drive := keptManager(t) // A leftover of cloudapp (not installed): listed, owner resolved from the catalog definition. items := m.ListKept([]string{drive}) if len(items) != 1 || items[0].Kind != KeptKindLeftover || items[0].App != "cloudapp" || items[0].DisplayName != "Cloud App" { t.Fatalf("leftover listing = %+v", items) } // Installed now: its folder is LIVE and disappears from the list. m.mu.Lock() s := m.stacks["cloudapp"] s.Deployed = true s.AppConfig = &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive}} m.mu.Unlock() if items := m.ListKept([]string{drive}); len(items) != 0 { t.Fatalf("a live app's folder was listed as kept data: %+v", items) } // A dated kept folder with a unit moved in. unit := filepath.Join(drive, "backups/primary/cloudapp") must(t, os.MkdirAll(unit, 0o755)) must(t, os.WriteFile(filepath.Join(unit, "manifest.json"), []byte("{}"), 0o644)) old := filepath.Join(drive, "appdata/older") must(t, os.MkdirAll(old, 0o755)) must(t, os.WriteFile(filepath.Join(old, "f"), []byte("f"), 0o644)) kept, err := m.KeepAside("cloudapp", drive, []string{old}, unit, time.Now()) must(t, err) items = m.ListKept([]string{drive}) if len(items) != 1 || items[0].Kind != KeptKindDated || items[0].Path != kept || items[0].UnitDir != filepath.Join(kept, keptUnitDir) { t.Fatalf("dated listing = %+v", items) } } // Rule 4 — Delete removes ONLY a listed kept item; anything else is refused and untouched. // COMPANION RED-PROOF: skip the FindKept check in DeleteKept → the live folder is deleted and this fails. func TestKept_DeleteRefusesAnythingNotListed(t *testing.T) { m, drive := keptManager(t) m.mu.Lock() s := m.stacks["cloudapp"] s.Deployed = true s.AppConfig = &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive}} m.mu.Unlock() for _, p := range []string{ filepath.Join(drive, "appdata/cloudapp"), // a LIVE app's folder filepath.Join(drive, "userdata/Photos"), // the household's files drive, // the drive itself filepath.Join(drive, "appdata/cloudapp/../../userdata"), } { if _, err := m.DeleteKept([]string{drive}, p); !errors.Is(err, ErrKeptNotListed) { t.Fatalf("delete of %s: want ErrKeptNotListed, got %v", p, err) } } for _, p := range []string{"appdata/cloudapp/user1/file.txt", "userdata/Photos/p.jpg", "media/song.mp3"} { if _, err := os.Stat(filepath.Join(drive, p)); err != nil { t.Fatalf("%s was touched by a refused delete: %v", p, err) } } // A listed item IS deleted. left := filepath.Join(drive, "appdata/gone") must(t, os.MkdirAll(left, 0o755)) must(t, os.WriteFile(filepath.Join(left, "f"), []byte("f"), 0o644)) if _, err := m.DeleteKept([]string{drive}, left); err != nil { t.Fatal(err) } if _, err := os.Stat(left); !os.IsNotExist(err) { t.Fatalf("the listed kept item is still there (%v)", err) } } // The install never runs into old data silently: no choice (or a wrong one) is refused BEFORE anything // is written — no app.yaml, the old data in place. // COMPANION RED-PROOF: delete the OldAppDataPaths block in DeployStack → the deploy saves app.yaml and // goes on to compose (this test then fails at "no choice was accepted"). func TestKept_DeployRefusesOverOldDataWithoutAChoice(t *testing.T) { m, drive := keptManager(t) for _, choice := range []string{"", "use", "whatever"} { _, err := m.DeployStack(DeployRequest{StackName: "cloudapp", Values: map[string]string{"HDD_PATH": drive}, KeptData: choice}) if !errors.Is(err, ErrKeptDataChoice) { t.Fatalf("choice %q: no choice was accepted — want ErrKeptDataChoice, got %v", choice, err) } if _, err := os.Stat(filepath.Join(m.cfg.Paths.StacksDir, "cloudapp", "app.yaml")); !os.IsNotExist(err) { t.Fatalf("choice %q: app.yaml was written by a refused install", choice) } if st, _ := m.GetStack("cloudapp"); st.Deploying || st.Deployed { t.Fatalf("choice %q: the stack is left Deploying=%v Deployed=%v", choice, st.Deploying, st.Deployed) } } if _, err := os.Stat(filepath.Join(drive, "appdata/cloudapp/user1/file.txt")); err != nil { t.Fatalf("the old data moved without a choice: %v", err) } } // Load puts a dated item's files back, refusing when the destination already holds something. func TestKept_RestoreKeptFilesRefusesAnOccupiedFolder(t *testing.T) { m, drive := keptManager(t) src := filepath.Join(drive, "appdata/cloudapp") kept, err := m.KeepAside("cloudapp", drive, []string{src}, "", time.Now()) must(t, err) it, ok := m.FindKept([]string{drive}, kept) if !ok { t.Fatal("kept folder not listed") } must(t, os.MkdirAll(filepath.Join(src, "new"), 0o755)) // a fresh install wrote here if _, err := m.RestoreKeptFiles(it); !errors.Is(err, ErrKeptOccupied) { t.Fatalf("want ErrKeptOccupied over an occupied folder, got %v", err) } must(t, os.RemoveAll(src)) if _, err := m.RestoreKeptFiles(it); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(src, "user1/file.txt")); err != nil { t.Fatalf("the file did not come back: %v", err) } m.FinishKeptLoad(it, "") if _, err := os.Stat(kept); !os.IsNotExist(err) { t.Fatalf("the emptied kept folder is still listed (%v)", err) } } // after_load runs the template's ONE command, as its user, in its service. func TestKept_AfterLoadRunsTheDeclaredCommand(t *testing.T) { m, _ := keptManager(t) var got []string m.afterLoadFn = func(dir string, args ...string) (string, error) { got = args; return "ok", nil } m.mu.Lock() m.stacks["cloudapp"].Meta.AfterLoad = &AfterLoadCommand{Service: "cloudapp", User: "www-data", Command: []string{"php", "occ", "files:scan", "--all"}} m.stacks["cloudapp"].State = StateRunning m.mu.Unlock() if err := m.runAfterLoadNow("cloudapp"); err != nil { t.Fatal(err) } if want := "exec -T -u www-data cloudapp php occ files:scan --all"; strings.Join(got, " ") != want { t.Fatalf("after_load ran %q, want %q", strings.Join(got, " "), want) } } // TestKept_OwnerIsNeverTheFileBrowser — found live on 9202 (0.274.0-rc1): the file browser's compose binds // every kept folder by its absolute path (the read-only view), and the list named two leftovers // "Filebrowser". An owner is an app that binds the folder THROUGH ${HDD_PATH} — the folder or one inside it. // COMPANION RED-PROOF (REPORT.md): drop the ${HDD_PATH} filter — this fails at "named Filebrowser". func TestKept_OwnerIsNeverTheFileBrowser(t *testing.T) { m, drive := keptManager(t) fb := filepath.Join(m.cfg.Paths.StacksDir, "filebrowser") must(t, os.MkdirAll(fb, 0o755)) must(t, os.WriteFile(filepath.Join(fb, "docker-compose.yml"), []byte("services:\n filebrowser:\n image: fb\n volumes:\n - "+ filepath.Join(drive, "appdata/cloudapp")+":/srv/kept:ro\n - "+filepath.Join(drive, "appdata/paperless")+":/srv/kept2:ro\n"), 0o644)) must(t, os.WriteFile(filepath.Join(fb, ".felhom.yml"), []byte("display_name: Filebrowser\n"), 0o644)) pl := filepath.Join(m.cfg.Paths.StacksDir, "paperless-ngx") must(t, os.MkdirAll(pl, 0o755)) must(t, os.WriteFile(filepath.Join(pl, "docker-compose.yml"), []byte("services:\n web:\n image: p\n volumes:\n - ${HDD_PATH}/appdata/paperless/media:/m\n"), 0o644)) must(t, os.WriteFile(filepath.Join(pl, ".felhom.yml"), []byte("display_name: Paperless-ngx\n"), 0o644)) must(t, m.ScanStacks()) must(t, os.MkdirAll(filepath.Join(drive, "appdata/paperless/media"), 0o755)) must(t, os.WriteFile(filepath.Join(drive, "appdata/paperless/media/doc.pdf"), []byte("x"), 0o644)) got := map[string]string{} for _, it := range m.ListKept([]string{drive}) { got[filepath.Base(it.Path)] = it.DisplayName } if got["cloudapp"] != "Cloud App" || got["paperless"] != "Paperless-ngx" { t.Fatalf("the leftovers must be named by the app that binds them through HDD_PATH, never the file browser: %v", got) } } // R-695 (v0.275.0) — an EMPTY dated kept folder (what Docker recreates when a file-browser bind outlives // a Delete) is never listed, so it is never bound and cannot recreate itself. A dated folder that holds // something is still listed. // // COMPANION RED-PROOF (REPORT.md): drop the dirHasEntries check in ListKept's dated loop — the empty // folder is then listed and this fails. func TestR695_AnEmptyDatedKeptFolderIsNeverListed(t *testing.T) { m, drive := keptManager(t) m.mu.Lock() s := m.stacks["cloudapp"] s.Deployed = true s.AppConfig = &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive}} m.mu.Unlock() empty := filepath.Join(drive, KeptDirName, "nextcloud", "2026-09-25_141014") must(t, os.MkdirAll(empty, 0o755)) full := filepath.Join(drive, KeptDirName, "nextcloud", "2026-09-24_101010") must(t, os.MkdirAll(full, 0o755)) must(t, os.WriteFile(filepath.Join(full, "f"), []byte("kept"), 0o644)) items := m.ListKept([]string{drive}) if len(items) != 1 || items[0].Path != full { t.Fatalf("listing = %+v — want only the folder that holds something", items) } }