diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1ea7c..b5e4d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ ## Changelog +### What was just completed (2026-02-18 session 41) +- **v0.12.5 — Cross-Drive Backup Validation Fix:** + + **Root cause:** Immich cross-drive backup failed with `destination /mnt/hdd_placeholder is not a mount point` because `ValidateDestination()` hard-blocked non-mount-point destinations. The `/mnt/hdd_placeholder` folder is on the internal SSD (not a separate mount), so the device-ID check returned false. + + **Fix 1: Drive-type-aware space checks in `ValidateDestination` (`internal/backup/crossdrive.go`)** + - `onSystemDrive` flag replaces the previous boolean-only mount-point check + - System-drive destinations: require **≥10 GB free** and **<90% usage** to protect OS stability + - External-drive destinations: require **≥100 MB free** (original threshold) + - Updated function comment to reflect the new tiered logic + + **Fix 2: Aligned `CheckBackupDestination` UI thresholds for system drives (`internal/system/mounts_linux.go`)** + - Tier 4 disk checks now branch on `h.SystemDrive` flag (set in Tier 3) + - System drive: block at <10 GB free OR ≥90% used (matches runner enforcement); Hungarian warning messages + - External drive: warn at ≥90% used, block at ≥95% used (unchanged) + - Removed the `&& h.Severity == "ok"` guard that prevented system-drive warnings from being overridden properly + + **Files modified (2):** `internal/backup/crossdrive.go`, `internal/system/mounts_linux.go` + ### What was just completed (2026-02-18 session 40) - **v0.12.4 — Correctness & Robustness Bug Fixes (TASK.md — 15 bugs fixed):** diff --git a/controller/README.md b/controller/README.md index f1ee41c..c66defe 100644 --- a/controller/README.md +++ b/controller/README.md @@ -4,7 +4,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware. -**Current version: v0.12.2** +**Current version: v0.12.5** --- @@ -192,7 +192,11 @@ Implements the 3-2-1 backup rule by copying data to a different physical drive. - **rsync** — Simple mirror with `--delete` (fast, no versioning) - **restic** — Versioned, deduplicated, encrypted (shared repo across apps) - Per-app configuration: destination path, method, schedule (daily/weekly/manual) -- Safety guards: destination != source, mount point check, writable check +- **Drive-type-aware validation** (`ValidateDestination` / `CheckBackupDestination`): + - External mount: block if <100 MB free; warn/block at 90%/95% usage + - System drive (same block device as `/`): require ≥10 GB free AND <90% usage to protect OS stability; allowed with a logged warning (no hard block for non-mount-point destinations) + - Web UI `CheckBackupDestination` matches runner thresholds — no surprise divergence between UI and actual enforcement +- Safety guards: destination ≠ source, path-overlap check, writable check - **Chained execution**: cross-drive runs immediately after nightly restic backup (daily apps every night, weekly apps on Sundays) for DB/file consistency - Per-app concurrency lock prevents overlapping runs - Status tracking (last_run, duration, size, error) persisted to settings.json diff --git a/controller/internal/backup/crossdrive.go b/controller/internal/backup/crossdrive.go index 31d3b4a..5ca0649 100644 --- a/controller/internal/backup/crossdrive.go +++ b/controller/internal/backup/crossdrive.go @@ -159,9 +159,9 @@ func (r *CrossDriveRunner) IsRunning(stackName string) bool { } // ValidateDestination checks that the destination path exists, is writable, -// and has sufficient free space (at least 100MB). -// A non-mount-point destination (e.g. a folder on the system drive) is allowed -// with a logged warning — consistent with the web UI's CheckBackupDestination. +// and has sufficient free space. System-drive destinations get stricter limits +// (≥10 GB free, <90% used) to protect OS stability; external drives just need +// ≥100 MB. Non-mount-point destinations are allowed with a logged warning. func (r *CrossDriveRunner) ValidateDestination(path string) error { if path == "" { return fmt.Errorf("destination path is empty") @@ -169,15 +169,28 @@ func (r *CrossDriveRunner) ValidateDestination(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return fmt.Errorf("destination %s does not exist", path) } - if !system.IsMountPoint(path) { + onSystemDrive := !system.IsMountPoint(path) + if onSystemDrive { r.logger.Printf("[WARN] Destination %s is not a separate mount point (system drive) — backup will proceed but data is not protected against drive failure", path) } if !system.IsWritable(path) { return fmt.Errorf("destination %s is not writable", path) } - di := system.GetDiskUsage(path) - if di != nil && di.AvailGB < 0.1 { - return fmt.Errorf("destination %s has insufficient free space (%.1f GB)", path, di.AvailGB) + if di := system.GetDiskUsage(path); di != nil { + if onSystemDrive { + // System drive: protect OS stability — require ≥10 GB free and <90% used + if di.AvailGB < 10 { + return fmt.Errorf("destination %s is on the system drive with only %.1f GB free — at least 10 GB required to protect OS stability", path, di.AvailGB) + } + if di.UsedPercent >= 90 { + return fmt.Errorf("destination %s is on the system drive at %.0f%% capacity — maximum 90%% allowed", path, di.UsedPercent) + } + } else { + // External drive: just ensure it's not completely full + if di.AvailGB < 0.1 { + return fmt.Errorf("destination %s has insufficient free space (%.1f GB free)", path, di.AvailGB) + } + } } return nil } diff --git a/controller/internal/system/mounts_linux.go b/controller/internal/system/mounts_linux.go index fbcefd1..c4d384f 100644 --- a/controller/internal/system/mounts_linux.go +++ b/controller/internal/system/mounts_linux.go @@ -172,13 +172,28 @@ func CheckBackupDestination(path string) DestinationHealth { if di := GetDiskUsage(path); di != nil { h.UsedPercent = di.UsedPercent h.FreeGB = di.AvailGB - if di.UsedPercent >= 95 { - h.Warning = fmt.Sprintf("A mentési meghajtó megtelt (%.0f%% használt)!", di.UsedPercent) - h.Blocked = true - h.Severity = "critical" - } else if di.UsedPercent >= 90 && h.Severity == "ok" { - h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent) - h.Severity = "warning" + if h.SystemDrive { + // System drive: stricter limits to protect OS stability + if di.AvailGB < 10 { + h.Warning = fmt.Sprintf("A rendszermeghajtón csak %.1f GB szabad — legalább 10 GB szükséges a rendszer stabilitásához!", di.AvailGB) + h.Blocked = true + h.Severity = "critical" + } else if di.UsedPercent >= 90 { + h.Warning = fmt.Sprintf("A rendszermeghajtó %.0f%%-ban megtelt — maximum 90%% megengedett.", di.UsedPercent) + h.Blocked = true + h.Severity = "critical" + } + // If neither triggers, keep the Tier 3 system-drive warning + } else { + // External drive: original thresholds + if di.UsedPercent >= 95 { + h.Warning = fmt.Sprintf("A mentési meghajtó megtelt (%.0f%% használt)!", di.UsedPercent) + h.Blocked = true + h.Severity = "critical" + } else if di.UsedPercent >= 90 { + h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent) + h.Severity = "warning" + } } }