Files
admin b6810f14ff
gates / gates (push) Successful in 23s
v0.275.0: a backup's data and its version travel together (R-696, 07 §6.6, D4 option A); R-695, R-691, R-694
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
2026-09-26 10:35:22 +02:00

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)
}
}