Files
felhom-controller/controller/internal/stacks/migrate_fs3_test.go
T
admin 68f0e0cf5c F-S2 + F-S3: compose-derived appdata dir resolution (v0.131.0)
The controller assumed an app's HDD appdata dir is always appdata/<stackName>.
paperless-ngx writes appdata/paperless (stack paperless-ngx), so every consumer
keying by stack name silently missed it via a stat-and-skip. One canonical
resolver appbackup.AppDataDirNames derives the real dir name(s) from the app's
compose ${HDD_PATH} binds; all consumers use it.

- F-S2 (tier-2): RunTier2 mirrors the resolved appdata/<name> (paperless docs
  got NO tier-2 copy before). Tier2Info size + RestoreTier2Files live dir use it.
  WARN when a declared appdata dir is absent. New tier2Mirror seam.
- F-S3 (migrate, NEW): all six per-app appdata legs (collision/size/copy/verify/
  cleanup/skip-set) now loop resolved names. scope="app" migration of paperless
  previously copied nothing and left an empty media dir (scope="all" was saved by
  the merge walk). WARN on missing declared dir in the copy leg.
- Multi-dir (N>1) refusal: tier-2 backup/info/restore refuse loudly (Hungarian);
  migrate supports N. No catalog app hits it today; lifted by Task 3.
- Display: storage page sums resolved dirs.
- Truth repair: the v0.130.0 "tier-2 copies the namespace wholesale" claim is
  false; corrected in CHANGELOG + main.go export-adapter comment.

+9 tests; red-proofs RP-1..RP-5 all confirmed. Controller-only, no agent/hub
coupling. Task 1 of the backup-classification-redesign arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 17:45:53 +02:00

105 lines
4.1 KiB
Go

package stacks
import (
"context"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// newAppJob builds a scope="app" job (no non-app merge unit — app scope has no merge walk).
func newAppJob(srcNS, dstNS, source, target string, apps ...string) *MigrationJob {
j := &MigrationJob{
Scope: "app", Phase: PhaseStop, Source: source, Target: target,
SourceNS: srcNS, TargetNS: dstNS, Apps: apps, Units: map[string]*MigUnit{},
}
for _, a := range apps {
j.Units[a] = &MigUnit{App: a, State: UnitPending}
}
return j
}
// TestMigrate_PaperlessShape_ScopeApp is Scenario D / RP-4: a scope="app" migration of paperless-ngx
// (stack name) must copy, verify, and clean the REAL appdata dir (paperless), never appdata/paperless-ngx.
// Every leg is asserted by captured src/dst pairs and on-disk effects — never mere absence of error.
// Companion RP-4: dropping the resolveNames loop (keying by stack name) makes the copy seam receive
// appdata/paperless-ngx and every assertion below fails.
func TestMigrate_PaperlessShape_ScopeApp(t *testing.T) {
m := newMigManager(t, "/mnt/target")
srcNS := t.TempDir()
dstNS := t.TempDir()
// Real source data under the REAL dir name (paperless), plus the recovery unit.
writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "paperless"), "media", "doc.pdf"), "PDF")
writeFile(t, filepath.Join(appbackup.RecoveryUnitPath(srcNS, "paperless-ngx"), "manifest.json"), "{}")
type pair struct{ src, dst string }
var copied, verified []pair
m.testSeams = &migSeams{
resolveNames: func(app string) []string {
if app == "paperless-ngx" {
return []string{"paperless"}
}
return []string{app}
},
stop: func(string) error { return nil },
copy: func(_ context.Context, s, d string, _ func(int64)) error {
copied = append(copied, pair{s, d})
return nil
},
verify: func(_ context.Context, s, d string) error {
verified = append(verified, pair{s, d})
return nil
},
flipRedeploy: func(string, string) error { return nil },
}
// --- direct-call assertions (accounting legs) before the destructive run ---
wantSrc := appbackup.AppDataDir(srcNS, "paperless")
// migSourceSize must probe appdata/paperless (non-zero: the PDF exists there).
if got := m.migSourceSize(newAppJob(srcNS, dstNS, "/s", "/t", "paperless-ngx")); got == 0 {
t.Error("migSourceSize = 0 — it probed appdata/paperless-ngx (empty) instead of appdata/paperless")
}
// appDataSkipSet must contain the resolved source dir.
skip := m.appDataSkipSet(newAppJob(srcNS, dstNS, "/s", "/t", "paperless-ngx"))
if !skip[filepath.Clean(wantSrc)] {
t.Errorf("skip-set = %v, want it to contain %q", skip, filepath.Clean(wantSrc))
}
// Collision check must probe appdata/paperless at the target. Target must be the schedulable path
// so migValidate reaches the collision check (TargetNS stays the temp dir where the dir exists).
writeFile(t, filepath.Join(appbackup.AppDataDir(dstNS, "paperless"), "x"), "x")
cj := newAppJob(srcNS, dstNS, "/s", "/mnt/target", "paperless-ngx")
if err := m.migValidate(cj); err == nil || !contains(err.Error(), "paperless-ngx") {
t.Errorf("collision must fire on the resolved target dir, got %v", err)
}
// --- full pipeline run (copy → verify → cleanup) ---
j := newAppJob(srcNS, dstNS, "/s", "/t", "paperless-ngx")
m.runJobSync(j)
if j.Phase != PhaseDone {
t.Fatalf("phase = %s (err=%s), want done", j.Phase, j.Error)
}
wantDst := appbackup.AppDataDir(dstNS, "paperless")
assertHasPair := func(name string, got []pair) {
t.Helper()
for _, p := range got {
if p.src == wantSrc && p.dst == wantDst {
return
}
}
t.Errorf("%s seam never saw appdata/paperless: src=%q dst=%q, got %v", name, wantSrc, wantDst, got)
}
assertHasPair("copy", copied)
assertHasPair("verify", verified)
// Cleanup removed the REAL source dir.
if pathExists(wantSrc) {
t.Errorf("cleanup left the source appdata/paperless behind: %s", wantSrc)
}
// And it never probed / created appdata/paperless-ngx.
if pathExists(appbackup.AppDataDir(srcNS, "paperless-ngx")) {
t.Errorf("stale appdata/paperless-ngx dir should never exist")
}
}