v0.55.0: Phase 3 — auto off-drive Tier 2 (rootfs-headroom guard)

Tier 2 rsync-mirrors each HDD app's recovery unit + appdata to a DIFFERENT physical
disk (the only off-drive protection bind-mounted userdata can get; PBS can't reach it).
Auto-enabled, auto-target: prefer another registered drive (different physical disk via
system.SamePhysicalDevice), else the internal SSD for SMALL units only — with a
size-aware headroom guard that REFUSES rather than fill the ~8G guest rootfs, recording
an honest "needs 2nd HDD" status. Status persisted via the surviving CrossDriveBackup;
"2. mentés" UI card now populated. Daily tier2-backup job + POST /api/backup/tier2.

- backup/tier2.go (engine+selection+headroom), tier2_test.go (headroom arithmetic)
- system.SamePhysicalDevice (linux Stat_t.Dev + stub)
- handlers.go Tier2 UI population + tier2DestLabel; backups.html honest no-target reason
- fixed stale TestBackupCopiesOnPath (old felhom-data layout -> in-guest layout)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 13:24:49 +02:00
parent d8fe8f5ead
commit d2071430ea
12 changed files with 446 additions and 5 deletions
+37
View File
@@ -686,11 +686,48 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
row.StatusText = "Adatbázis mentés sikertelen"
}
// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
if cd.LastStatus == "no_target" {
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
row.Tier2Configured = false
row.Tier2StatusBadge = "Nincs 2. meghajtó"
row.Tier2LastError = cd.LastError
} else if cd.Enabled {
row.Tier2Configured = true
row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
row.Tier2Schedule = "Naponta"
row.Tier2LastRun = cd.LastRun
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastError = cd.LastError
row.Tier2SizeHuman = cd.LastSizeHuman
switch cd.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
case "error":
row.Tier2StatusBadge = "Hiba"
case "running":
row.Tier2StatusBadge = "Fut..."
default:
row.Tier2StatusBadge = "—"
}
}
}
rows = append(rows, row)
}
return rows
}
// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
func tier2DestLabel(destPath, systemDataPath string) string {
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
return "belső SSD (csak DB/konfiguráció)"
}
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
}
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
@@ -276,11 +276,13 @@ func TestSortDisksForView(t *testing.T) {
}
}
// P4 (4B): a drive's cross-drive backup copies (felhom-data/backups/secondary/<app>) are listed so the
// wipe confirmation can warn they'd be destroyed. Shared repo / infra dirs and files are skipped.
// P4 (4B): a drive's cross-drive backup copies (backups/secondary/<app>) are listed so the wipe
// confirmation can warn they'd be destroyed. Shared repo / infra dirs and files are skipped.
// Layout is Model-A in-guest: the drive mount IS the felhom-data namespace root (no felhom-data
// subdir), matching NamespaceRoot(where, true) and where Tier 2 (Phase 3) writes its copies.
func TestBackupCopiesOnPath(t *testing.T) {
root := t.TempDir()
sec := filepath.Join(root, "felhom-data", "backups", "secondary")
sec := filepath.Join(root, "backups", "secondary")
for _, d := range []string{"immich", "nextcloud", "restic", "_infra"} {
if err := os.MkdirAll(filepath.Join(sec, d), 0o755); err != nil {
t.Fatal(err)
@@ -358,8 +358,10 @@
</div>
{{else}}
<span class="layer-auto-ok">✓ 1. mentés auto</span>
<span class="layer-unconfigured">⚠ Nincs 2. másolat</span>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
<span class="layer-unconfigured">⚠ Nincs 2. (off-drive) másolat</span>
{{if .Tier2LastError}}
<span class="layer-reason" style="opacity:.85" title="A 2. mentés automatikus — külön beállítás nem kell">{{.Tier2LastError}}</span>
{{end}}
{{end}}
</div>
<!-- Tier 3: Remote backup (future) -->