b6810f14ff
gates / gates (push) Successful in 23s
The unit's data files are stamped with the versions that wrote them; the capture keeps the definition the data belongs to; a restore never starts data under another version's definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's definition); every tier's time is its data's; the conversion-copy release needs a dump on the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept view joins the folder's owning group, language switch resyncs (R-691); a restore-generated login is not shown as the password (R-694). Red-proofs in felhom.eu/documentation/audits/version-travel-2026-09-26/. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
322 lines
14 KiB
Go
322 lines
14 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|