From d2071430eadda23f9306fb97d93fcb5e5f5f422c Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 13 Jun 2026 13:24:49 +0200 Subject: [PATCH] =?UTF-8?q?v0.55.0:=20Phase=203=20=E2=80=94=20auto=20off-d?= =?UTF-8?q?rive=20Tier=202=20(rootfs-headroom=20guard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 27 ++ controller/cmd/controller/main.go | 20 ++ controller/internal/api/router.go | 20 ++ controller/internal/backup/backup.go | 3 + controller/internal/backup/recovery_unit.go | 5 + controller/internal/backup/tier2.go | 281 ++++++++++++++++++ controller/internal/backup/tier2_test.go | 30 ++ controller/internal/system/mounts_linux.go | 11 + controller/internal/system/mounts_other.go | 3 + controller/internal/web/handlers.go | 37 +++ .../internal/web/storage_handlers_test.go | 8 +- .../internal/web/templates/backups.html | 6 +- 12 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 controller/internal/backup/tier2.go create mode 100644 controller/internal/backup/tier2_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e871b87..8656e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ ## Changelog +### v0.55.0 — Phase 3: auto off-drive Tier 2 (rootfs-headroom guard, durable off-disk target) (2026-06-13) + +Tier 2 = an **off-drive copy** of each HDD app's recovery unit + bulk userdata to a **different physical +disk** — the only off-drive protection browsable HDD userdata can get (PBS can't reach bind mounts). +Auto-enabled for every HDD app; the target is auto-picked and the dangerous case (the small guest +rootfs) is refused rather than filled. + +- **Engine** `internal/backup/tier2.go` (`RunTier2`/`RunAllTier2`): rsync `-a --delete` of the recovery + unit (`backups/primary//`) and the app's `appdata//` to `/backups/secondary//`. + restic is **not** revived — plain browsable mirror. +- **Auto target selection:** prefer another registered user-data drive on a **different physical disk** + (can hold bulk userdata); else fall back to the internal SSD for **small units only**. Off-disk is + enforced by `system.SamePhysicalDevice` (block-device identity; new exported helper, linux + stub) — + defense-in-depth re-checked before the copy. +- **Rootfs-headroom guard (the key safety):** the SSD target is the ~8 GB guest rootfs, so a size-aware + guard (`tier2FitsHeadroom`, unit-tested) **refuses** unless the unit fits while leaving a reserve free + (`max(2 GB, 20% of total)`). When nothing fits, it records an **honest** "needs a 2nd HDD" status + rather than silently doing nothing or endangering the rootfs. +- **Status + UI:** results persist via the surviving `settings.CrossDriveBackup` (rsync method, dest, + last-run/status/size). The "2. mentés" card is now **populated** (`buildAppBackupRows`): real target + ("belső SSD (csak DB/konfiguráció)" vs an external drive) on success, or the honest no-off-drive-target + reason. Notifications via the surviving `NotifyCrossDrive{Completed,Failed}` hooks. +- **Scheduling + trigger:** daily `tier2-backup` job (03:30, after the DB dump); manual + `POST /api/backup/tier2`. +- Fixed a stale pre-existing test (`TestBackupCopiesOnPath`) that still used the old + `felhom-data/backups/secondary` layout — now the Model-A in-guest layout the Tier 2 copies actually use. + ### v0.54.0 — Phase 2b: restore-from-recovery-unit + fail-closed data-key gate (2026-06-13) Restore now recreates an app from its on-drive recovery unit **plus the guest's own secrets** — never diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 4bbc03a..4e64da4 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -341,6 +341,26 @@ func main() { backupMgr.RefreshCache(nextDBDump) return nil }) + + // Tier 2: off-drive copy of each HDD app's recovery unit + userdata (auto-enabled, auto-target). + // Runs after the DB dump so it copies a fresh unit. + backupMgr.SetTier2Notifier(func(stackName, destLabel string, dur time.Duration, err error) { + if err != nil { + notifier.NotifyCrossDriveFailed(notify.CrossDriveDetails{ + StackName: stackName, Method: "rsync", DestPath: destLabel, + Duration: dur.Round(time.Second).String(), Error: err.Error(), + }) + } else { + notifier.NotifyCrossDriveCompleted(notify.CrossDriveDetails{ + StackName: stackName, Method: "rsync", DestPath: destLabel, + Duration: dur.Round(time.Second).String(), + }) + } + }) + sched.Daily("tier2-backup", "03:30", func(ctx context.Context) error { + backupMgr.RunAllTier2() + return nil + }) } // Metrics prune — daily at 04:00 diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index a033ab2..841ba1e 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -221,6 +221,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/backup/run" && req.Method == http.MethodPost: r.triggerBackup(w, req) + // POST /api/backup/tier2 — run off-drive Tier 2 copies for all HDD apps + case path == "/backup/tier2" && req.Method == http.MethodPost: + r.triggerTier2(w, req) + // GET /api/metrics/system case path == "/metrics/system" && req.Method == http.MethodGet: r.metricsSystem(w, req) @@ -749,6 +753,22 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"}) } +// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a +// different physical disk). Auto-targets and applies the rootfs-headroom guard internally. +func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) { + if r.backupMgr == nil { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"}) + return + } + if r.backupMgr.IsRunning() { + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"}) + return + } + r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered") + go r.backupMgr.RunAllTier2() + writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"}) +} + // --- Metrics handlers --- func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) { diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 26a1584..59e107e 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -28,6 +28,9 @@ type Manager struct { systemDataPath string // fallback drive for SSD-only apps version string // controller version, stamped into recovery-unit manifests + // tier2Notify, if set, is called after each Tier 2 copy (success: err==nil) for notifications. + tier2Notify func(stackName, destLabel string, dur time.Duration, err error) + mu sync.Mutex lastDBDump *DBDumpStatus running bool diff --git a/controller/internal/backup/recovery_unit.go b/controller/internal/backup/recovery_unit.go index 572ee0f..286fde0 100644 --- a/controller/internal/backup/recovery_unit.go +++ b/controller/internal/backup/recovery_unit.go @@ -53,6 +53,11 @@ func (m *Manager) SetVersion(v string) { m.mu.Unlock() } +// SetTier2Notifier wires the notification callback invoked after each Tier 2 copy. +func (m *Manager) SetTier2Notifier(fn func(stackName, destLabel string, dur time.Duration, err error)) { + m.tier2Notify = fn +} + // CaptureRecoveryUnit writes/refreshes an app's secret-free recovery unit: it captures the // compose + metadata + a secret-stripped app.yaml into compose/, enumerates the DB/volume dumps // already present, and writes manifest.json. It NEVER writes a secret value or the Docker image. diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go new file mode 100644 index 0000000..9df6e97 --- /dev/null +++ b/controller/internal/backup/tier2.go @@ -0,0 +1,281 @@ +package backup + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// Tier 2 = an off-drive (different physical disk) copy of an HDD app's recovery unit + bulk userdata. +// It is the ONLY off-drive protection that browsable HDD userdata can get — PBS can't reach bind +// mounts. Auto-enabled for every HDD app; the target is auto-picked: prefer another registered +// user-data drive (can hold bulk), else the internal SSD for SMALL units only — and the SSD is the +// guest rootfs (~8 GB), so we REFUSE rather than fill it (a size-aware headroom guard). When no +// off-drive target fits, we record an honest "needs a 2nd HDD" status instead of silently doing +// nothing useful. + +const gibibyte = 1024 * 1024 * 1024 + +var ( + errNoOffDiskTarget = errors.New("no off-drive target (single drive, app already on the system disk)") + errSSDNoHeadroom = errors.New("the internal SSD lacks headroom for this app's data — a 2nd drive is required for off-drive backup") +) + +// Tier2Target is a resolved off-drive destination for an app's Tier 2 copy. +type Tier2Target struct { + NamespaceRoot string // felhom-data namespace root on the target drive + Label string // human label (UI) + IsSystemDrive bool // target is the internal SSD/system drive (DB/config only) + Reason string // why this target (Hungarian, for UI/logs) +} + +// tier2FitsHeadroom reports whether a unit of unitGB fits on a system/rootfs drive while leaving a +// reserve free. Reserve = max(2 GB, 20% of total) — this is what protects the small (~8 GB) guest +// rootfs from being filled by a Tier 2 copy. Pure function (unit-tested). +func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool { + reserve := totalGB * 0.20 + if reserve < 2.0 { + reserve = 2.0 + } + return (availGB - unitGB) >= reserve +} + +// selectTier2Target auto-picks the off-drive destination for an app's Tier 2 copy. +func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tier2Target, error) { + sourceDrive := m.GetAppDrivePath(stackName) + if sourceDrive == "" { + return nil, fmt.Errorf("no source drive for %s", stackName) + } + + // 1. Prefer another registered user-data drive on a DIFFERENT physical disk (can hold bulk userdata). + if m.settings != nil { + for _, sp := range m.settings.GetSchedulableStoragePaths() { + if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) { + continue + } + label := sp.Label + if label == "" { + label = filepath.Base(sp.Path) + } + return &Tier2Target{ + NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root + Label: label, + IsSystemDrive: false, + Reason: "másik adatmeghajtó", + }, nil + } + } + + // 2. Fall back to the internal SSD (system data path) — SMALL units only. + sys := m.systemDataPath + if sys == "" || system.SamePhysicalDevice(sourceDrive, sys) { + return nil, errNoOffDiskTarget // single drive / app already on the system disk + } + if !m.tier2FitsSystemDrive(sys, unitSizeBytes) { + return nil, errSSDNoHeadroom // would fill the ~8 GB rootfs — refuse, don't fill + } + return &Tier2Target{ + NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended + Label: "belső SSD (rendszer)", + IsSystemDrive: true, + Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell", + }, nil +} + +// tier2FitsSystemDrive checks the size-aware rootfs-headroom guard for the SSD target. +func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool { + di := system.GetDiskUsage(sys) + if di == nil { + return false // can't determine free space → refuse (fail-closed for the rootfs) + } + return tier2FitsHeadroom(di.AvailGB, di.TotalGB, float64(unitSizeBytes)/gibibyte) +} + +// RunTier2 makes/refreshes the off-drive copy of a single HDD app's recovery unit + userdata. +// Best-effort and idempotent (rsync mirror). Records status into settings for the UI; returns an +// error only on an actual copy failure (no valid target is a recorded status, not an error). +func (m *Manager) RunTier2(stackName string) error { + sourceDrive := m.GetAppDrivePath(stackName) + if sourceDrive == "" { + return fmt.Errorf("no source drive for %s", stackName) + } + sourceNsRoot := m.namespaceRoot(sourceDrive) + unitDir := RecoveryUnitPath(sourceNsRoot, stackName) + appDataDir := AppDataDir(sourceNsRoot, stackName) + if _, err := os.Stat(unitDir); err != nil { + return nil // no recovery unit yet — nothing to copy + } + + unitSize := dirSizeBytes(unitDir) + dirSizeBytes(appDataDir) + + target, err := m.selectTier2Target(stackName, unitSize) + if err != nil { + reason := tier2NoTargetReason(err) + m.recordTier2NoTarget(stackName, reason) + m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason) + return nil + } + // Defense-in-depth off-drive guard (selection already enforced it). + if system.SamePhysicalDevice(sourceDrive, target.NamespaceRoot) { + m.recordTier2NoTarget(stackName, "a kiválasztott cél ugyanazon a fizikai lemezen van") + return nil + } + + destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", stackName) + start := time.Now() + + if err := rsyncMirror(unitDir, filepath.Join(destBase, "recovery-unit")); err != nil { + m.recordTier2Failure(stackName, target, err) + if m.tier2Notify != nil { + m.tier2Notify(stackName, target.Label, time.Since(start), err) + } + return fmt.Errorf("tier2 rsync unit for %s: %w", stackName, err) + } + if _, e := os.Stat(appDataDir); e == nil { + if err := rsyncMirror(appDataDir, filepath.Join(destBase, "appdata")); err != nil { + m.recordTier2Failure(stackName, target, err) + if m.tier2Notify != nil { + m.tier2Notify(stackName, target.Label, time.Since(start), err) + } + return fmt.Errorf("tier2 rsync appdata for %s: %w", stackName, err) + } + } + + dur := time.Since(start) + m.recordTier2Success(stackName, target, unitSize, dur) + if m.tier2Notify != nil { + m.tier2Notify(stackName, target.Label, dur, nil) + } + m.logger.Printf("[INFO] [backup] Tier 2 copied %s → %s (%s, %s)%s", + stackName, destBase, humanizeBytes(unitSize), dur.Round(time.Second), + map[bool]string{true: " [SSD: DB/config only]", false: ""}[target.IsSystemDrive]) + return nil +} + +// RunAllTier2 runs Tier 2 for every deployed HDD app (apps whose data lives on an external drive — +// non-HDD apps live on the rootfs and are already inside the PBS whole-guest snapshot). +func (m *Manager) RunAllTier2() { + if m.stackProvider == nil { + return + } + var n int + for _, stack := range m.stackProvider.ListDeployedStacks() { + if m.stackProvider.GetStackHDDPath(stack.Name) == "" { + continue // not an HDD app — its data is on the rootfs, covered by PBS + } + if m.settings != nil && (m.settings.IsDisconnected(m.GetAppDrivePath(stack.Name)) || + m.settings.IsDecommissioned(m.GetAppDrivePath(stack.Name))) { + continue + } + if err := m.RunTier2(stack.Name); err != nil { + m.logger.Printf("[WARN] [backup] Tier 2 failed for %s: %v", stack.Name, err) + } + n++ + } + m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d HDD app(s) processed", n) +} + +// --- status persistence (drives the "2. mentés" UI card) --- + +func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, dur time.Duration) { + if m.settings == nil { + return + } + _ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{ + Enabled: true, + Method: "rsync", + DestinationPath: target.NamespaceRoot, + Schedule: "daily", + LastRun: time.Now().Format(time.RFC3339), + LastStatus: "ok", + LastDuration: dur.Round(time.Second).String(), + LastSizeHuman: humanizeBytes(sizeBytes), + }) +} + +func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) { + if m.settings == nil { + return + } + _ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{ + Enabled: true, + Method: "rsync", + DestinationPath: target.NamespaceRoot, + Schedule: "daily", + LastRun: time.Now().Format(time.RFC3339), + LastStatus: "error", + LastError: cause.Error(), + }) +} + +func (m *Manager) recordTier2NoTarget(stackName, reason string) { + if m.settings == nil { + return + } + _ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{ + Enabled: false, + Method: "rsync", + Schedule: "daily", + LastStatus: "no_target", + LastError: reason, + }) +} + +func tier2NoTargetReason(err error) string { + switch { + case errors.Is(err, errSSDNoHeadroom): + return "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges" + case errors.Is(err, errNoOffDiskTarget): + return "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges" + default: + return err.Error() + } +} + +// --- helpers --- + +// rsyncMirror mirrors src→dst with rsync -a --delete (exact copy, browsable on disk, no versioning). +func rsyncMirror(src, dst string) error { + if err := os.MkdirAll(dst, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dst, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) + defer cancel() + // Trailing slashes: copy the CONTENTS of src into dst. + cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", strings.TrimRight(src, "/")+"/", strings.TrimRight(dst, "/")+"/") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// dirSizeBytes returns the total size of a directory via `du -sb` (0 if absent/error). +func dirSizeBytes(dir string) int64 { + if _, err := os.Stat(dir); err != nil { + return 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "du", "-sb", dir).Output() + if err != nil { + return 0 + } + fields := strings.Fields(string(out)) + if len(fields) == 0 { + return 0 + } + var size int64 + if _, err := fmt.Sscanf(fields[0], "%d", &size); err != nil { + return 0 + } + return size +} diff --git a/controller/internal/backup/tier2_test.go b/controller/internal/backup/tier2_test.go new file mode 100644 index 0000000..1591433 --- /dev/null +++ b/controller/internal/backup/tier2_test.go @@ -0,0 +1,30 @@ +package backup + +import "testing" + +// TestTier2FitsHeadroom covers the size-aware rootfs-headroom guard that protects the ~8 GB guest +// rootfs from being filled by a Tier 2 SSD copy (reserve = max(2 GB, 20% of total)). +func TestTier2FitsHeadroom(t *testing.T) { + cases := []struct { + name string + availGB, totalGB, unitGB float64 + want bool + }{ + // 8 GB rootfs, ~2.4 GB free: a tiny unit fits (reserve = 2 GB), a 1 GB unit does NOT. + {"8G rootfs, tiny unit fits", 2.4, 8.0, 0.02, true}, + {"8G rootfs, 1G unit refused", 2.4, 8.0, 1.0, false}, + {"8G rootfs, 0.3G unit fits", 2.4, 8.0, 0.3, true}, + // Reserve is the larger of 2 GB and 20%: on 8 GB, 20% = 1.6 GB < 2 GB, so 2 GB applies. + {"8G rootfs exactly at 2G reserve", 2.0, 8.0, 0.0, true}, + {"8G rootfs just under reserve", 2.0, 8.0, 0.01, false}, + // Large drive: 20% reserve dominates (204.8 GB on a 1 TB drive). + {"1TB drive, 50G unit fits", 500.0, 1024.0, 50.0, true}, + {"1TB drive, 320G unit refused (under 20% reserve)", 500.0, 1024.0, 320.0, false}, + } + for _, c := range cases { + if got := tier2FitsHeadroom(c.availGB, c.totalGB, c.unitGB); got != c.want { + t.Errorf("%s: tier2FitsHeadroom(avail=%.2f,total=%.2f,unit=%.2f)=%v want %v", + c.name, c.availGB, c.totalGB, c.unitGB, got, c.want) + } + } +} diff --git a/controller/internal/system/mounts_linux.go b/controller/internal/system/mounts_linux.go index 26d8953..875da34 100644 --- a/controller/internal/system/mounts_linux.go +++ b/controller/internal/system/mounts_linux.go @@ -233,6 +233,17 @@ func isSameBlockDevice(pathA, pathB string) bool { return statA.Dev == statB.Dev } +// SamePhysicalDevice reports whether two paths resolve to the same block device. Used by the Tier 2 +// off-drive guard to refuse copying an app's backup onto the same physical disk as its source (the +// whole point of Tier 2 is to survive that disk failing). Returns false if either path can't be +// stat'd (fail-open to "different" would be unsafe, so callers must also verify the dest separately — +// but in practice an unstattable path fails earlier). NOTE: this is mount/device-granularity; two +// partitions on one physical disk look "different" here — the agent's durable-id is the stronger +// guarantee for that case, but for the felhom layout (external drive vs system rootfs) this suffices. +func SamePhysicalDevice(a, b string) bool { + return isSameBlockDevice(a, b) +} + // stripPartition strips the partition suffix from a device name. // e.g., "sda1" → "sda", "nvme0n1p1" → "nvme0n1", "mmcblk0p1" → "mmcblk0". func stripPartition(base string) string { diff --git a/controller/internal/system/mounts_other.go b/controller/internal/system/mounts_other.go index 85ad46b..64d2ed7 100644 --- a/controller/internal/system/mounts_other.go +++ b/controller/internal/system/mounts_other.go @@ -48,6 +48,9 @@ type DiskUsageInfo struct { // GetDiskUsage returns nil on non-Linux. func GetDiskUsage(_ string) *DiskUsageInfo { return nil } +// SamePhysicalDevice always returns false on non-Linux (dev/testing only — Tier 2 runs on Linux). +func SamePhysicalDevice(_, _ string) bool { return false } + // FSInfo holds filesystem type, device, and disk model info. type FSInfo struct { FSType string diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 1ab7184..8bb2a02 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -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() diff --git a/controller/internal/web/storage_handlers_test.go b/controller/internal/web/storage_handlers_test.go index b11a7e4..af01027 100644 --- a/controller/internal/web/storage_handlers_test.go +++ b/controller/internal/web/storage_handlers_test.go @@ -276,11 +276,13 @@ func TestSortDisksForView(t *testing.T) { } } -// P4 (4B): a drive's cross-drive backup copies (felhom-data/backups/secondary/) 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/) 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) diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 1a00ce5..b8aa26a 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -358,8 +358,10 @@ {{else}} ✓ 1. mentés auto - ⚠ Nincs 2. másolat - Beállítás → + ⚠ Nincs 2. (off-drive) másolat + {{if .Tier2LastError}} + {{.Tier2LastError}} + {{end}} {{end}}