43e99d160c
gates / gates (push) Successful in 26s
An install over an app's kept drive folder (appdata/<app> non-empty) asks the household: "use my kept data" (a load from the newest copy of THIS drive's install, own unit or second-drive mirror, then the template's after_load) or "start fresh" (the folder is renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted). The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed confirmation, the only deletion of kept data). FileBrowser gets a read-only source. The drive-full warning names the kept folders. <drive>/kept is protected and outside every backup leg. R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked GetStackComposePath (true for every catalog app) and restored nextcloud with no env. Now isStackDeployed; pinned with a production-shaped provider. Red-proofs: audits/night-2026-09-26/E/redproofs/. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
271 lines
12 KiB
Go
271 lines
12 KiB
Go
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 <drive>/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("<drive>/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)
|
|
}
|
|
}
|