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
This commit is contained in:
@@ -139,6 +139,7 @@ type migSeams struct {
|
||||
verify func(ctx context.Context, src, dst string) error
|
||||
stop func(name string) error
|
||||
flipRedeploy func(name, target string) error
|
||||
resolveNames func(app string) []string // F-S3: override appdata dir-name resolution in tests
|
||||
}
|
||||
|
||||
// SetMigrationDeps wires the registry + the backup-running check (mutual exclusion, Change 3).
|
||||
@@ -291,6 +292,39 @@ func (m *Manager) appSourceNS(cfg *AppConfig) (src, ns string) {
|
||||
return src, appbackup.NamespaceRoot(src, m.inGuest(src))
|
||||
}
|
||||
|
||||
// resolveAppDataDirNames returns an app's real appdata dir name(s) under its HDD_PATH, derived from
|
||||
// its compose binds (F-S2/F-S3: paperless-ngx writes appdata/paperless, not appdata/paperless-ngx).
|
||||
// Mirrors the stackAdapter Priority-1 shape (app's own HDD_PATH + ParseComposeHDDMounts); the
|
||||
// Priority-2 multi-storage union is NOT needed here — migration/tier-2 only apply to real-HDD apps.
|
||||
// A test seam (testSeams.resolveNames) overrides it so the phase machine can be driven without a
|
||||
// live stack registry. Falls back to []string{app} when nothing is derivable (legacy behavior).
|
||||
// ResolveAppDataDirNames returns an app's real appdata dir name(s) under its HDD_PATH (F-S2/F-S3),
|
||||
// for callers outside the migrate flow (the storage-page size display). Exported thin wrapper.
|
||||
func (m *Manager) ResolveAppDataDirNames(app string) []string {
|
||||
names, _ := m.resolveAppDataDirs(app)
|
||||
return names
|
||||
}
|
||||
|
||||
// resolveAppDataDirs also reports whether the app's compose DECLARES an appdata bind (declared) —
|
||||
// migCopy uses it to WARN when a declared dir is missing on disk (the silence that hid F-S2), while
|
||||
// distinguishing that from an app that genuinely has no appdata (fallback, no WARN).
|
||||
func (m *Manager) resolveAppDataDirs(app string) (names []string, declared bool) {
|
||||
if m.testSeams != nil && m.testSeams.resolveNames != nil {
|
||||
return m.testSeams.resolveNames(app), true
|
||||
}
|
||||
stack, ok := m.GetStack(app)
|
||||
if !ok {
|
||||
return []string{app}, false
|
||||
}
|
||||
cfg := LoadAppConfig(filepath.Dir(stack.ComposePath))
|
||||
if cfg == nil || cfg.Env["HDD_PATH"] == "" {
|
||||
return []string{app}, false
|
||||
}
|
||||
hddPath := cfg.Env["HDD_PATH"]
|
||||
mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
return appbackup.AppDataDirNames(hddPath, app, mounts), appbackup.AppDataBindsPresent(hddPath, mounts)
|
||||
}
|
||||
|
||||
// appsOnDrive returns the names of deployed apps whose HDD_PATH equals sourcePath.
|
||||
func (m *Manager) appsOnDrive(sourcePath string) []string {
|
||||
var out []string
|
||||
@@ -322,11 +356,14 @@ func (m *Manager) migValidate(j *MigrationJob) error {
|
||||
if m.settings == nil || !m.settings.IsStoragePathSchedulable(j.Target) {
|
||||
return fmt.Errorf("a céltároló nem elérhető vagy nem választható")
|
||||
}
|
||||
// App-dir collision: refuse if the same app dir already exists at the target.
|
||||
// App-dir collision: refuse if any of the app's resolved appdata dir(s) already exists at target.
|
||||
var collide []string
|
||||
for _, app := range j.Apps {
|
||||
if pathExists(appbackup.AppDataDir(j.TargetNS, app)) {
|
||||
collide = append(collide, app)
|
||||
for _, name := range m.ResolveAppDataDirNames(app) {
|
||||
if pathExists(appbackup.AppDataDir(j.TargetNS, name)) {
|
||||
collide = append(collide, app)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(collide) > 0 {
|
||||
@@ -351,7 +388,9 @@ func (m *Manager) migSourceSize(j *MigrationJob) int64 {
|
||||
}
|
||||
var total int64
|
||||
for _, app := range j.Apps {
|
||||
total += dirBytes(appbackup.AppDataDir(j.SourceNS, app))
|
||||
for _, name := range m.ResolveAppDataDirNames(app) {
|
||||
total += dirBytes(appbackup.AppDataDir(j.SourceNS, name))
|
||||
}
|
||||
total += dirBytes(appbackup.RecoveryUnitPath(j.SourceNS, app))
|
||||
}
|
||||
return total
|
||||
@@ -445,10 +484,21 @@ func (m *Manager) migCopy(ctx context.Context, j *MigrationJob) error {
|
||||
}
|
||||
j.CurrentApp = app
|
||||
_ = m.persistJob(j)
|
||||
// appdata subtree (collision-free post-validate)
|
||||
if err := m.copySubtree(ctx, j, appbackup.AppDataDir(j.SourceNS, app), appbackup.AppDataDir(j.TargetNS, app)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("másolás sikertelen (%s appdata): %w", app, err)
|
||||
// appdata subtree(s) — the app's REAL compose-derived dir name(s) (F-S3: paperless-ngx writes
|
||||
// appdata/paperless, not appdata/paperless-ngx); collision-free post-validate.
|
||||
names, declared := m.resolveAppDataDirs(app)
|
||||
for _, name := range names {
|
||||
src := appbackup.AppDataDir(j.SourceNS, name)
|
||||
if declared && !pathExists(src) {
|
||||
// The compose DECLARES this appdata dir but it is absent on disk — WARN instead of the
|
||||
// silent no-op that hid F-S2. copySubtree still no-ops safely below.
|
||||
m.logger.Printf("[WARN] [migrate] %s: compose declares appdata dir %q but it is absent at %s — nothing to copy",
|
||||
app, name, src)
|
||||
}
|
||||
if err := m.copySubtree(ctx, j, src, appbackup.AppDataDir(j.TargetNS, name)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("másolás sikertelen (%s appdata): %w", app, err)
|
||||
}
|
||||
}
|
||||
// the app's recovery unit (db-dumps + volume-dumps + compose + manifest)
|
||||
if err := m.copySubtree(ctx, j, appbackup.RecoveryUnitPath(j.SourceNS, app), appbackup.RecoveryUnitPath(j.TargetNS, app)); err != nil {
|
||||
@@ -488,9 +538,11 @@ func (m *Manager) migVerify(ctx context.Context, j *MigrationJob) error {
|
||||
if stateRank(u.State) >= stateRank(UnitVerified) {
|
||||
continue
|
||||
}
|
||||
if err := m.verifySubtree(ctx, appbackup.AppDataDir(j.SourceNS, app), appbackup.AppDataDir(j.TargetNS, app)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("ellenőrzés sikertelen (%s appdata): %w", app, err)
|
||||
for _, name := range m.ResolveAppDataDirNames(app) {
|
||||
if err := m.verifySubtree(ctx, appbackup.AppDataDir(j.SourceNS, name), appbackup.AppDataDir(j.TargetNS, name)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("ellenőrzés sikertelen (%s appdata): %w", app, err)
|
||||
}
|
||||
}
|
||||
if err := m.verifySubtree(ctx, appbackup.RecoveryUnitPath(j.SourceNS, app), appbackup.RecoveryUnitPath(j.TargetNS, app)); err != nil {
|
||||
u.Error = err.Error()
|
||||
@@ -553,8 +605,10 @@ func (m *Manager) migCleanup(j *MigrationJob) error {
|
||||
if u.State == UnitCleaned {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(appbackup.AppDataDir(j.SourceNS, app)); err != nil {
|
||||
return fmt.Errorf("forrás törlése sikertelen (%s appdata): %w", app, err)
|
||||
for _, name := range m.ResolveAppDataDirNames(app) {
|
||||
if err := os.RemoveAll(appbackup.AppDataDir(j.SourceNS, name)); err != nil {
|
||||
return fmt.Errorf("forrás törlése sikertelen (%s appdata): %w", app, err)
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(appbackup.RecoveryUnitPath(j.SourceNS, app)); err != nil {
|
||||
return fmt.Errorf("forrás törlése sikertelen (%s mentés): %w", app, err)
|
||||
@@ -602,7 +656,9 @@ func (m *Manager) migCleanupAllowed(j *MigrationJob) error {
|
||||
func (m *Manager) appDataSkipSet(j *MigrationJob) map[string]bool {
|
||||
skip := map[string]bool{}
|
||||
for _, app := range j.Apps {
|
||||
skip[filepath.Clean(appbackup.AppDataDir(j.SourceNS, app))] = true
|
||||
for _, name := range m.ResolveAppDataDirNames(app) {
|
||||
skip[filepath.Clean(appbackup.AppDataDir(j.SourceNS, name))] = true
|
||||
}
|
||||
}
|
||||
return skip
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user