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:
2026-07-14 17:45:53 +02:00
parent b42904bbab
commit 68f0e0cf5c
16 changed files with 765 additions and 44 deletions
+26 -6
View File
@@ -1675,18 +1675,28 @@ func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
Name: stack.Meta.DisplayName,
Stack: stack.Meta.Slug,
}
// Try to get data size from the storage subdirectory
appDataDir := backup.AppDataDir(storagePath, stack.Name)
if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() {
detail.SizeHuman = dirSizeHuman(appDataDir)
// Try to get data size from the storage subdirectory. F-S2: the app's real appdata dir name is
// NOT always the stack name (paperless-ngx writes appdata/paperless) — sum the resolved dir(s).
// Here hddPath == storagePath (the drive's in-guest mount is the namespace root, Model A).
var total int64
var any bool
for _, name := range s.stackMgr.ResolveAppDataDirNames(stack.Name) {
d := backup.AppDataDir(storagePath, name)
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
total += dirSizeBytesWalk(d)
any = true
}
}
if any {
detail.SizeHuman = humanizeDirBytes(total)
}
details = append(details, detail)
}
return details
}
// dirSizeHuman returns a human-readable size for a directory.
func dirSizeHuman(path string) string {
// dirSizeBytesWalk returns the total size in bytes of the regular files under path (0 if absent).
func dirSizeBytesWalk(path string) int64 {
var total int64
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
@@ -1695,6 +1705,11 @@ func dirSizeHuman(path string) string {
total += info.Size()
return nil
})
return total
}
// humanizeDirBytes formats a byte count as B/KB/MB/GB (the storage-page display convention).
func humanizeDirBytes(total int64) string {
const (
KB = 1024
MB = KB * 1024
@@ -1712,6 +1727,11 @@ func dirSizeHuman(path string) string {
}
}
// dirSizeHuman returns a human-readable size for a single directory.
func dirSizeHuman(path string) string {
return humanizeDirBytes(dirSizeBytesWalk(path))
}
func formatFreeSpace(gb float64) string {
if gb >= 1000 {
return fmt.Sprintf("%.1f TB", gb/1024)