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:
@@ -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/<app>/`) and the app's `appdata/<app>/` to `<target>/backups/secondary/<app>/`.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
Reference in New Issue
Block a user