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:
@@ -0,0 +1,104 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fp joins the elements under an HDD path with OS separators — mounts in the ParseComposeHDDMounts
|
||||
// shape are already filepath.Clean'd, so tests build them the same way.
|
||||
func fp(elems ...string) string { return filepath.Join(elems...) }
|
||||
|
||||
// TestAppDataDirNames is the pure derivation table (Group A). Every case asserts the RESOLVED name
|
||||
// list, never mere absence of error. Companion RP-1: a resolver that ignores mounts and returns
|
||||
// []string{stackName} fails the paperless, two-name, and dedupe cases.
|
||||
func TestAppDataDirNames(t *testing.T) {
|
||||
const hdd = "/mnt/felhom-usb"
|
||||
cases := []struct {
|
||||
name string
|
||||
stack string
|
||||
mounts []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
// paperless shape: stack "paperless-ngx", dir "paperless" (F-S2/F-S3 core).
|
||||
name: "paperless mismatch",
|
||||
stack: "paperless-ngx",
|
||||
mounts: []string{
|
||||
fp(hdd, "appdata", "paperless", "media"),
|
||||
fp(hdd, "appdata", "paperless", "export"),
|
||||
},
|
||||
want: []string{"paperless"}, // media+export dedupe to one name
|
||||
},
|
||||
{
|
||||
// match shape: dir name == stack name (immich/nextcloud/romm).
|
||||
name: "matching name",
|
||||
stack: "nextcloud",
|
||||
mounts: []string{fp(hdd, "appdata", "nextcloud")},
|
||||
want: []string{"nextcloud"},
|
||||
},
|
||||
{
|
||||
// two DISTINCT names → both, sorted (no catalog app does this today).
|
||||
name: "two distinct names sorted",
|
||||
stack: "weird",
|
||||
mounts: []string{
|
||||
fp(hdd, "appdata", "zebra", "x"),
|
||||
fp(hdd, "appdata", "alpha", "y"),
|
||||
},
|
||||
want: []string{"alpha", "zebra"},
|
||||
},
|
||||
{
|
||||
// non-appdata HDD binds + a foreign-drive mount are filtered → fallback.
|
||||
name: "non-appdata and foreign filtered",
|
||||
stack: "romm",
|
||||
mounts: []string{
|
||||
fp(hdd, "roms"), // under HDD but not appdata/
|
||||
fp("/mnt/other-drive", "appdata", "ghost"), // foreign drive — wrong prefix
|
||||
},
|
||||
want: []string{"romm"},
|
||||
},
|
||||
{
|
||||
// whole-appdata-root bind (no name derivable) → ignored → fallback.
|
||||
name: "whole appdata root bind",
|
||||
stack: "root-binder",
|
||||
mounts: []string{fp(hdd, "appdata")},
|
||||
want: []string{"root-binder"},
|
||||
},
|
||||
{
|
||||
name: "empty mounts fallback",
|
||||
stack: "vaultwarden",
|
||||
mounts: nil,
|
||||
want: []string{"vaultwarden"},
|
||||
},
|
||||
{
|
||||
// unclean paths still resolve (Clean applied both sides).
|
||||
name: "unclean path",
|
||||
stack: "paperless-ngx",
|
||||
mounts: []string{hdd + "/appdata/paperless/../paperless/media"},
|
||||
want: []string{"paperless"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := AppDataDirNames(hdd, tc.stack, tc.mounts)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("AppDataDirNames(%q, %q, %v) = %v, want %v", hdd, tc.stack, tc.mounts, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppDataBindsPresent pins the WARN predicate: true only when a mount sits under appdata/.
|
||||
func TestAppDataBindsPresent(t *testing.T) {
|
||||
const hdd = "/mnt/felhom-usb"
|
||||
if !AppDataBindsPresent(hdd, []string{fp(hdd, "appdata", "paperless", "media")}) {
|
||||
t.Error("declared appdata bind should report present")
|
||||
}
|
||||
if AppDataBindsPresent(hdd, []string{fp(hdd, "roms")}) {
|
||||
t.Error("non-appdata bind should NOT report present")
|
||||
}
|
||||
if AppDataBindsPresent(hdd, nil) {
|
||||
t.Error("no mounts should NOT report present")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@
|
||||
// cross-drive, or drive-mount code in the backup package.
|
||||
package appbackup
|
||||
|
||||
import "path/filepath"
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FelhomDataDir is the namespace directory on storage drives for all felhom-managed data.
|
||||
const FelhomDataDir = "felhom-data"
|
||||
@@ -64,7 +68,61 @@ func AppVolumeDumpPath(nsRoot, stackName string) string {
|
||||
return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "volume-dumps")
|
||||
}
|
||||
|
||||
// AppDataDir returns the app data directory under a felhom-data namespace root.
|
||||
// AppDataDir returns the app data directory under a felhom-data namespace root. The final segment
|
||||
// is the app's real appdata dir NAME — usually the stack name, but NOT always: paperless-ngx writes
|
||||
// appdata/paperless (F-S2/F-S3). Callers that key by stack name silently miss such apps; use
|
||||
// AppDataDirNames to resolve the real name(s) from the app's compose binds and pass them here.
|
||||
func AppDataDir(nsRoot, stackName string) string {
|
||||
return filepath.Join(nsRoot, "appdata", stackName)
|
||||
}
|
||||
|
||||
// AppDataDirNames returns the app's real directory name(s) under <hddPath>/appdata, derived from its
|
||||
// compose HDD bind mounts (F-S2/F-S3: the dir name is NOT always the stack name — paperless-ngx
|
||||
// writes appdata/paperless). hddMounts are resolved host paths in the ParseComposeHDDMounts shape
|
||||
// (each is <hddPath> itself or a subpath, filepath.Clean'd). The first path element under
|
||||
// <hddPath>/appdata/ is taken as the dir name; results are deduped and sorted. Falls back to
|
||||
// []string{stackName} when no appdata-prefixed mount is derivable (no HDD appdata binds, unreadable
|
||||
// compose, nil provider) — the exact legacy behavior.
|
||||
//
|
||||
// Today every catalog app resolves to exactly ONE name (immich→immich, nextcloud→nextcloud,
|
||||
// romm→romm, paperless-ngx→paperless). The N>1 return is defensive: tier-2 refuses it loudly,
|
||||
// migrate handles it naturally.
|
||||
func AppDataDirNames(hddPath, stackName string, hddMounts []string) []string {
|
||||
prefix := filepath.Clean(hddPath) + string(filepath.Separator) + "appdata" + string(filepath.Separator)
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
for _, mnt := range hddMounts {
|
||||
cm := filepath.Clean(mnt)
|
||||
if !strings.HasPrefix(cm, prefix) {
|
||||
continue // not under appdata/ (a whole-root bind, a different subtree, a foreign drive)
|
||||
}
|
||||
rem := strings.TrimPrefix(cm, prefix)
|
||||
first := strings.Split(rem, string(filepath.Separator))[0]
|
||||
if first == "" {
|
||||
continue
|
||||
}
|
||||
if !seen[first] {
|
||||
seen[first] = true
|
||||
names = append(names, first)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return []string{stackName}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// AppDataBindsPresent reports whether any of the app's resolved HDD mounts sits under
|
||||
// <hddPath>/appdata/ — i.e. the compose actually DECLARES an appdata bind. Callers use it to
|
||||
// distinguish "no appdata to back up" (silent skip is correct) from "declared appdata dir missing
|
||||
// on disk" (the silence that hid F-S2 — worth a WARN). Same prefix rule as AppDataDirNames.
|
||||
func AppDataBindsPresent(hddPath string, hddMounts []string) bool {
|
||||
prefix := filepath.Clean(hddPath) + string(filepath.Separator) + "appdata" + string(filepath.Separator)
|
||||
for _, mnt := range hddMounts {
|
||||
if strings.HasPrefix(filepath.Clean(mnt), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user