TASK.md — Cross-Drive Backup Improvements (v0.12.6)

This commit is contained in:
2026-02-18 08:53:29 +01:00
parent 1a5e96e998
commit 2f08306770
+103 -123
View File
@@ -1,4 +1,4 @@
# TASK.md — Cross-Drive Backup Validation Fix (v0.12.5) # TASK.md — Cross-Drive Backup Improvements (v0.12.6)
## Prompt (copy-paste this into Claude Code) ## Prompt (copy-paste this into Claude Code)
@@ -6,7 +6,7 @@
Read TASK.md for the full plan. Apply all code changes described, then build and deploy. Read TASK.md for the full plan. Apply all code changes described, then build and deploy.
After all fixes are done: After all fixes are done:
1. Run `go build ./...` and `go vet ./...` from the controller/ directory — fix any errors 1. Run `go build ./...` and `go vet ./...` from the controller/ directory — fix any errors
2. Update CHANGELOG.md with a new entry at the top (session 41, v0.12.5) 2. Update CHANGELOG.md with a new entry at the top (session 42, v0.12.6)
3. Commit, build, and deploy following the workflow in CLAUDE.md 3. Commit, build, and deploy following the workflow in CLAUDE.md
``` ```
@@ -14,167 +14,147 @@ After all fixes are done:
## Context ## Context
The cross-drive backup for Immich failed last night with: The cross-drive backup for Immich was fixed earlier today (mount-point validation + system-drive
``` space thresholds). During testing, two more issues were found:
Hiba: destination /mnt/hdd_placeholder is not a mount point (0s)
```
**Root cause:** `ValidateDestination()` in `crossdrive.go` hard-blocked non-mount-point 1. **Redundant destination folder nesting** — rsync creates
destinations. The `/mnt/hdd_placeholder` folder is on the internal SSD (not a separate mount), `backups/rsync/immich/storage/immich/<data>` instead of `backups/rsync/immich/<data>`
so the device-ID check in `IsMountPoint()` returned false. 2. **DB backups backed up twice** — Immich stores its own DB dumps in
`/mnt/hdd_1/storage/immich/backups/` (~16 MB each). The cross-drive rsync copies these as part of the user data, but the controller already handles DB backups separately via pg_dump.
**Already fixed (in current working tree):** The mount-point check was changed from a hard
block to a logged warning (lines 172174 of crossdrive.go). The backup will now proceed
for system-drive destinations.
**Remaining work:** Improve the disk space validation to be smarter about system-drive ## Fix 1: Simplify rsync destination path structure (crossdrive.go)
destinations (don't fill up the OS drive).
--- **File:** `internal/backup/crossdrive.go`, function `runRsyncBackup`, lines ~206217
## Fix 1: Smarter space checks in `ValidateDestination` (crossdrive.go) **Problem:** The path-stripping logic strips only the first 2 segments of the source path
(e.g., `mnt/hdd_1`) and keeps everything else as a relative subpath:
**File:** `internal/backup/crossdrive.go`, lines 161183
**Current code (already patched with the mount-point warning):**
```go ```go
func (r *CrossDriveRunner) ValidateDestination(path string) error { parts := strings.SplitN(strings.TrimPrefix(srcMount, "/"), "/", 3)
if path == "" { if len(parts) >= 3 {
return fmt.Errorf("destination path is empty") rel = parts[2] // "storage/immich" — redundant nesting!
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("destination %s does not exist", path)
}
if !system.IsMountPoint(path) {
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)
}
return nil
} }
``` ```
**Required change:** Replace the flat 100MB space check (lines 178181) with drive-type-aware logic: For source `/mnt/hdd_1/storage/immich`, this creates:
```
backups/rsync/immich/storage/immich/ ← "storage/immich" repeats context
```
Expected:
```
backups/rsync/immich/ ← data goes directly here (single mount)
```
**Fix:** Use `filepath.Base()` as the subdirectory name. If the app has only one mount,
rsync directly into the stack folder; if multiple, use basenames to keep them separate.
Replace the path-stripping block (lines ~206219) with:
```go ```go
func (r *CrossDriveRunner) ValidateDestination(path string) error { for i, srcMount := range mounts {
if path == "" { var dstPath string
return fmt.Errorf("destination path is empty") if len(mounts) == 1 {
} // Single mount: rsync directly into the stack folder
if _, err := os.Stat(path); os.IsNotExist(err) { dstPath = destDir
return fmt.Errorf("destination %s does not exist", 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)
}
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 { } else {
// External drive: just ensure it's not completely full // Multiple mounts: use the leaf directory name as subfolder
if di.AvailGB < 0.1 { // Disambiguate if needed by appending index
return fmt.Errorf("destination %s has insufficient free space (%.1f GB free)", path, di.AvailGB) leaf := filepath.Base(srcMount)
dstPath = filepath.Join(destDir, leaf)
// Check for duplicate leaf names (unlikely but safe)
if i > 0 {
if _, err := os.Stat(dstPath); err == nil {
dstPath = filepath.Join(destDir, fmt.Sprintf("%s_%d", leaf, i))
} }
} }
} }
return nil if err := os.MkdirAll(dstPath, 0755); err != nil {
return fmt.Errorf("creating rsync destination: %w", err)
}
// Ensure trailing slash on source for rsync semantics (copy contents, not the dir itself)
src := strings.TrimRight(srcMount, "/") + "/"
dst := strings.TrimRight(dstPath, "/") + "/"
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", src, dst)
r.logger.Printf("[DEBUG] rsync: %s → %s", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, strings.TrimSpace(string(out)))
}
} }
``` ```
**Update the function comment (lines 161164) to match:** Remove the old `rel` variable and the `SplitN` block entirely. Also remove the
```go `os.MkdirAll(dstPath, 0755)` that was inside the old loop since it's now in the new block.
// ValidateDestination checks that the destination path exists, is writable,
// and has sufficient free space. System-drive destinations get stricter limits **Result after fix:**
// (≥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.
``` ```
/mnt/hdd_placeholder/backups/rsync/immich/
├── backups/ ← Immich's internal DB dumps (will be excluded in Fix 4)
├── encoded-video/
├── library/
├── profile/
├── thumbs/
└── upload/
```
**Impact on existing backups:** The first rsync after this change will create the new
flat structure. The old nested `storage/immich/` subfolder inside `backups/rsync/immich/`
will remain orphaned (rsync `--delete` only deletes within the target, not sibling dirs).
This is fine — no data loss, and the old folder can be cleaned up manually.
--- ---
## Fix 2: Align `CheckBackupDestination` thresholds for system drives (mounts_linux.go) ## Fix 2: Exclude app-internal DB backups from rsync (crossdrive.go)
**File:** `internal/system/mounts_linux.go`, lines 134186 **File:** `internal/backup/crossdrive.go`, function `runRsyncBackup`
The web UI's `CheckBackupDestination` currently applies the same disk thresholds (90% warn, **Problem:** Many apps store their own periodic DB dumps inside their data directory:
95% block) regardless of drive type. For system drives, it should use the same stricter - Immich: `storage/immich/backups/` (64 MB of daily postgres dumps)
thresholds as the runner (90% block, 10 GB minimum) so the UI warning matches what the - Other apps may follow similar patterns
runner will actually enforce.
**Required change:** In the Tier 4 block (lines 171183), add system-drive-specific checks The controller already handles DB backups separately via `pg_dump` (the "Adatbázis mentés"
BEFORE the generic percentage checks. The logic should be: feature). Copying the app's internal DB dumps via rsync is redundant and wastes space.
Immich's internal backup path: `<data>/backups/*.sql.gz` (created by Immich itself daily).
**Fix:** Add `--exclude` flags to the rsync command for common app-internal backup patterns.
In the rsync `exec.CommandContext` call, add excludes:
```go ```go
// Tier 4: disk usage checks cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete",
if di := GetDiskUsage(path); di != nil { "--exclude", "backups/*.sql.gz",
h.UsedPercent = di.UsedPercent "--exclude", "backups/*.sql",
h.FreeGB = di.AvailGB "--exclude", "backups/*.dump",
if h.SystemDrive { src, dst)
// 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"
}
}
}
``` ```
Note: the `else if di.UsedPercent >= 90 && h.Severity == "ok"` condition in the original This excludes only DB dump files inside `backups/` subdirectories — not the `backups/`
was preventing the 90% warning from overriding the system-drive warning. The new code directory itself (which might contain non-DB files), and not any other `*.sql.gz` files
separates the branches cleanly — system drive gets its own block, external drive gets its own. outside of `backups/`. This is conservative and safe.
--- **Note:** The `.immich` marker file and the `backups/` directory structure itself are
preserved — only the large dump files are excluded.
## Summary of thresholds
| Condition | System drive | External drive |
|-----------|-------------|----------------|
| Free space < 10 GB | **Block** | — |
| Usage ≥ 90% | **Block** | Warning |
| Usage ≥ 95% | (caught by 90%) | **Block** |
| Free space < 100 MB | (caught by 10GB) | **Block** |
--- ---
## Files to modify ## Files to modify
1. `internal/backup/crossdrive.go``ValidateDestination()` (Fix 1) 1. `internal/backup/crossdrive.go``runRsyncBackup()` (Fix 3 + Fix 4)
2. `internal/system/mounts_linux.go``CheckBackupDestination()` (Fix 2)
## Post-fix checklist ## Post-fix checklist
- [ ] `go build ./...` passes - [ ] `go build ./...` passes
- [ ] `go vet ./...` passes - [ ] `go vet ./...` passes
- [ ] Update `CHANGELOG.md` — session 41, version **v0.12.5**, describe both fixes - [ ] Update `CHANGELOG.md` — session 42, version **v0.12.6**, describe ALL fixes:
- Fix 1: ValidateDestination allows non-mount-point destinations with warning
- Fix 2: System-drive space thresholds (10 GB / 90%) in both runner and web UI
- Fix 3: Simplified rsync destination path (flat structure per app)
- Fix 4: Exclude app-internal DB dumps from rsync
- [ ] Update `controller/README.md` backup section with the new architecture
- [ ] Commit, build on 192.168.0.180, deploy on 192.168.0.162 - [ ] Commit, build on 192.168.0.180, deploy on 192.168.0.162
- [ ] Verify with `docker ps` and `docker logs` - [ ] Verify with `docker ps` and `docker logs`
- [ ] After deploy, run manual Immich backup and verify new folder structure