From 263b58dea0d157989aedf4cb94d1db690493b3c6 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 18 Feb 2026 10:31:16 +0100 Subject: [PATCH] Backup Architecture Overhaul (v0.12.7) --- TASK.md | 756 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 648 insertions(+), 108 deletions(-) diff --git a/TASK.md b/TASK.md index 92dd5da..02eb9d9 100644 --- a/TASK.md +++ b/TASK.md @@ -1,4 +1,4 @@ -# TASK.md — Cross-Drive Backup Improvements (v0.12.6) +# TASK.md — Backup Architecture Overhaul (v0.12.7) ## Prompt (copy-paste this into Claude Code) @@ -6,155 +6,695 @@ Read TASK.md for the full plan. Apply all code changes described, then build and deploy. After all fixes are done: 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 42, v0.12.6) -3. Commit, build, and deploy following the workflow in CLAUDE.md +2. Update CHANGELOG.md with a new entry at the top (session 43, v0.12.7) +3. Update controller/README.md backup section with the new architecture +4. Commit, build, and deploy following the workflow in CLAUDE.md ``` --- -## Context +## Context and Goals -The cross-drive backup for Immich was fixed earlier today (mount-point validation + system-drive -space thresholds). During testing, two more issues were found: +The backup system has accumulated an unnecessary `Enabled` flag that gates user data +inclusion in the nightly restic backup. This was a design mistake — user data backup +must be **mandatory** for all apps that have HDD data. Without it, the service isn't viable. -1. **Redundant destination folder nesting** — rsync creates - `backups/rsync/immich/storage/immich/` instead of `backups/rsync/immich/` -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. +This task removes the `Enabled` gate, makes HDD data backup automatic, and fixes the +restore feature to show all apps. +**Backup architecture (3-2-1 rule):** -## Fix 1: Simplify rsync destination path structure (crossdrive.go) +1. **Nightly restic (mandatory, same drive)** — DB dumps + config + ALL user data (HDD). + Every app with data gets backed up automatically. This protects against accidental + deletion and enables point-in-time restore. The backup lives on the same drive as the + app, so it does NOT protect against drive failure. -**File:** `internal/backup/crossdrive.go`, function `runRsyncBackup`, lines ~206–217 +2. **Cross-drive backup (opt-in, different device)** — rsync or restic to a secondary + physical drive. This is the second copy that protects against drive failure. When not + configured for an app with HDD data, the UI warns that data is only on one drive. -**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: +3. **Remote backup (future)** — offsite copy for disaster recovery. Not implemented yet. +**Current problems:** +- `AppBackupPrefs.Enabled` flag gates HDD inclusion in nightly restic — should be automatic +- The flag gets out of sync with cross-drive config +- Restore dropdown only shows apps with `HasHDDData && BackupEnabled` — excludes most apps +- "Docker kötetek: Auto ✓" in UI implies volumes are separately backed up — they're not +- Cross-drive backup doesn't trigger a DB dump first + +--- + +## Fix 1: Make HDD data backup mandatory + +Remove the `Enabled` flag as a gate. All apps with HDD data are always included in the +nightly restic backup. No toggle, no opt-in. + +### 1a: Rewrite `resolveAppBackupPaths()` — include ALL HDD paths + +**File:** `internal/backup/backup.go`, function `resolveAppBackupPaths` (line 429) + +**Current:** ```go -parts := strings.SplitN(strings.TrimPrefix(srcMount, "/"), "/", 3) -if len(parts) >= 3 { - rel = parts[2] // "storage/immich" — redundant nesting! -} -``` +func (m *Manager) resolveAppBackupPaths() []string { + if m.stackProvider == nil || m.settings == nil { + return nil + } + appBackupMap := m.settings.GetAppBackupMap() + if len(appBackupMap) == 0 { + return nil + } -For source `/mnt/hdd_1/storage/immich`, this creates: -``` -backups/rsync/immich/storage/immich/ ← "storage/immich" repeats context -``` + var paths []string + seen := make(map[string]bool) -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 ~206–219) with: - -```go -for i, srcMount := range mounts { - var dstPath string - if len(mounts) == 1 { - // Single mount: rsync directly into the stack folder - dstPath = destDir - } else { - // Multiple mounts: use the leaf directory name as subfolder - // Disambiguate if needed by appending index - 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)) + for stackName, enabled := range appBackupMap { + if !enabled { + continue + } + hddMounts := m.stackProvider.GetStackHDDMounts(stackName) + for _, mount := range hddMounts { + if seen[mount] { + continue + } + if _, err := os.Stat(mount); err == nil { + paths = append(paths, mount) + seen[mount] = true + m.logger.Printf("[DEBUG] Including app data: %s (from %s)", mount, stackName) } } } - 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))) - } + return paths } ``` -Remove the old `rel` variable and the `SplitN` block entirely. Also remove the -`os.MkdirAll(dstPath, 0755)` that was inside the old loop since it's now in the new block. +**Replace with:** +```go +// resolveAppBackupPaths returns HDD paths for ALL deployed apps. +// User data backup is mandatory — every app with HDD mounts is included. +func (m *Manager) resolveAppBackupPaths() []string { + if m.stackProvider == nil { + return nil + } -**Result after fix:** -``` -/mnt/hdd_placeholder/backups/rsync/immich/ -├── backups/ ← Immich's internal DB dumps (will be excluded in Fix 4) -├── encoded-video/ -├── library/ -├── profile/ -├── thumbs/ -└── upload/ + var paths []string + seen := make(map[string]bool) + + for _, stack := range m.stackProvider.ListDeployedStacks() { + hddMounts := m.stackProvider.GetStackHDDMounts(stack.Name) + for _, mount := range hddMounts { + if seen[mount] { + continue + } + if _, err := os.Stat(mount); err == nil { + paths = append(paths, mount) + seen[mount] = true + m.logger.Printf("[DEBUG] Including app data: %s (from %s)", mount, stack.Name) + } + } + } + return paths +} ``` -**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. +Key change: no longer reads `GetAppBackupMap()`, no longer checks `Enabled`. Iterates +all deployed stacks via `ListDeployedStacks()` and includes every HDD mount. +The `m.settings` dependency is also removed from this method. + +### 1b: Remove `backupPrefs` parameter from `DiscoverAppData()` + +**File:** `internal/backup/appdata.go`, function `DiscoverAppData` (line 61) + +**Current:** +```go +func DiscoverAppData(provider StackDataProvider, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo { +``` +and at line 100: +```go + info.BackupEnabled = backupPrefs[stack.Name] +``` + +**Replace function signature with:** +```go +func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) []AppBackupInfo { +``` + +**Replace line 100 with:** +```go + // All apps with HDD data are backed up automatically (mandatory) + info.BackupEnabled = info.HasHDDData +``` + +### 1c: Update `RefreshCache()` caller + +**File:** `internal/backup/backup.go`, in `RefreshCache` (around line 547) + +**Current:** +```go + if m.stackProvider != nil { + backupPrefs := m.settings.GetAppBackupMap() + status.AppDataInfo = DiscoverAppData(m.stackProvider, backupPrefs, status.DiscoveredDBs) +``` + +**Replace with:** +```go + if m.stackProvider != nil { + status.AppDataInfo = DiscoverAppData(m.stackProvider, status.DiscoveredDBs) +``` + +### 1d: Delete dead settings methods + +**File:** `internal/settings/settings.go` + +Delete these methods entirely — they are no longer called anywhere: + +1. `IsAppBackupEnabled()` (lines 235–243) — was used by restore.go, replaced in Fix 3f +2. `SetAppBackup()` (lines 245–257) — was never called outside settings.go +3. `GetAppBackupMap()` (lines 259–271) — was used by resolveAppBackupPaths + RefreshCache, replaced in 1a + 1c +4. `SetAppBackupBulk()` (lines 273–287) — was never called outside settings.go +5. `GetAppBackupPrefs()` (lines 289–298) — was never called outside settings.go + +**Keep** the `AppBackupPrefs` struct and `AppBackup` field in `Settings` — the JSON field +`"app_backup"` still holds `CrossDrive` configs. The `Enabled` field stays in the struct +for backward compat (existing settings.json won't break on load) but nothing reads it. --- -## Fix 2: Exclude app-internal DB backups from rsync (crossdrive.go) +## Fix 2: DB dump before cross-drive backup -**File:** `internal/backup/crossdrive.go`, function `runRsyncBackup` +When cross-drive backup runs (scheduled or manual), trigger a fresh DB dump for that +app's databases first. This ensures DB state matches the user data being rsynced. -**Problem:** Many apps store their own periodic DB dumps inside their data directory: -- Immich: `storage/immich/backups/` (64 MB of daily postgres dumps) -- Other apps may follow similar patterns +### 2a: Define `DBDumper` interface -The controller already handles DB backups separately via `pg_dump` (the "Adatbázis mentés" -feature). Copying the app's internal DB dumps via rsync is redundant and wastes space. - -Immich's internal backup path: `/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: +**File:** `internal/backup/crossdrive.go`, add near top (after imports): ```go -cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", - "--exclude", "backups/*.sql.gz", - "--exclude", "backups/*.sql", - "--exclude", "backups/*.dump", - src, dst) +// DBDumper can run a database dump for a specific stack. +type DBDumper interface { + DumpStackDB(ctx context.Context, stackName string) error +} ``` -This excludes only DB dump files inside `backups/` subdirectories — not the `backups/` -directory itself (which might contain non-DB files), and not any other `*.sql.gz` files -outside of `backups/`. This is conservative and safe. +### 2b: Add field to `CrossDriveRunner` -**Note:** The `.immich` marker file and the `backups/` directory structure itself are -preserved — only the large dump files are excluded. +**File:** `internal/backup/crossdrive.go` + +Add `dbDumper` field to the struct (do NOT change the constructor signature): + +```go +type CrossDriveRunner struct { + sett *settings.Settings + stackProvider StackDataProvider + dbDumper DBDumper + logger *log.Logger + mu sync.Mutex + running map[string]bool +} +``` + +Add setter method after `NewCrossDriveRunner`: + +```go +// SetDBDumper sets the DB dumper for pre-backup database dumps. +// Called after backup manager is initialized (avoids circular init dependency). +func (r *CrossDriveRunner) SetDBDumper(d DBDumper) { + r.dbDumper = d +} +``` + +### 2c: Implement `DumpStackDB` on backup Manager + +**File:** `internal/backup/backup.go`, add new method: + +```go +// DumpStackDB runs a database dump for containers belonging to a specific stack. +// Used by cross-drive backup to ensure DB state matches user data. +func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error { + dbs, err := DiscoverDatabases(ctx, m.logger) + if err != nil { + return fmt.Errorf("database discovery failed: %w", err) + } + + var stackDBs []DiscoveredDB + for _, db := range dbs { + if db.StackName == stackName { + stackDBs = append(stackDBs, db) + } + } + if len(stackDBs) == 0 { + m.logger.Printf("[DEBUG] No databases found for stack %s — skipping pre-backup dump", stackName) + return nil + } + + m.logger.Printf("[INFO] Running pre-backup DB dump for %s (%d database(s))", stackName, len(stackDBs)) + results := DumpAll(ctx, stackDBs, m.cfg.Paths.DBDumpDir, m.logger) + + for _, r := range results { + if r.Error != nil { + return fmt.Errorf("DB dump failed for %s: %w", r.DB.ContainerName, r.Error) + } + m.logger.Printf("[INFO] Pre-backup DB dump OK: %s (%s)", r.DB.ContainerName, humanizeBytes(r.Size)) + + // Persist validation to settings + if m.settings != nil && r.FilePath != "" { + filename := filepath.Base(r.FilePath) + cache := settings.DBValidationCache{ + ValidatedAt: time.Now().Format(time.RFC3339), + TableCount: r.Validation.TableCount, + HasHeader: r.Validation.Valid, + } + if !r.Validation.Valid { + cache.Error = r.Validation.Error + } + _ = m.settings.SetDBValidation(filename, cache) + } + } + return nil +} +``` + +### 2d: Call DB dump in `RunAppBackup` + +**File:** `internal/backup/crossdrive.go`, in `RunAppBackup`, add BEFORE the +`ValidateDestination` call (around line 67, after the "Mark as running" block): + +```go + // Trigger fresh DB dump for this app before cross-drive backup + if r.dbDumper != nil { + if err := r.dbDumper.DumpStackDB(ctx, stackName); err != nil { + r.logger.Printf("[WARN] Pre-backup DB dump failed for %s: %v — proceeding with user data backup", stackName, err) + // Non-fatal: user data backup is still valuable without fresh dump + } + } +``` + +### 2e: Wire up in main.go + +**File:** `cmd/controller/main.go`, after both `crossDriveRunner` and `backupMgr` are created +(after line 135): + +```go + // Wire cross-drive → backup manager for pre-backup DB dumps + if backupMgr != nil { + crossDriveRunner.SetDBDumper(backupMgr) + } +``` --- -## Files to modify +## Fix 3: Restore dropdown shows ALL deployed apps -1. `internal/backup/crossdrive.go` — `runRsyncBackup()` (Fix 3 + Fix 4) +The restore section should show every deployed app. All apps have restic snapshots +(stacks dir + DB dumps are always backed up). Apps with HDD data get full restore. + +### 3a: Update template filter + +**File:** `internal/web/templates/backups.html`, lines 450–456 + +**Current:** +```html +{{range .Backup.AppDataInfo}} +{{if and .HasHDDData .BackupEnabled}} + +{{end}} +{{end}} +``` + +**Replace with:** +```html +{{range .Backup.AppDataInfo}} + +{{end}} +``` + +Note: no `data-backup-enabled` attribute — user data is always backed up when present. + +### 3b: Show restore type info when app is selected + +**File:** `internal/web/templates/backups.html` + +Add a new info div after the snapshot selector (after line 464, before the warning div): + +```html + +``` + +### 3c: Update `onRestoreAppChange()` JavaScript + +**File:** `internal/web/templates/backups.html`, replace the `onRestoreAppChange` function +(lines 604–637) with: + +```javascript +function onRestoreAppChange() { + var sel = document.getElementById('restore-app'); + var appName = sel.value; + var snapSel = document.getElementById('restore-snapshot'); + var noSnaps = document.getElementById('restore-no-snapshots'); + var typeInfo = document.getElementById('restore-type-info'); + + document.getElementById('restore-confirm-cb').checked = false; + document.getElementById('restore-btn').disabled = true; + noSnaps.style.display = 'none'; + typeInfo.style.display = 'none'; + + if (!appName) { + snapSel.innerHTML = ''; + return; + } + + // Determine restore type from data attributes + var opt = sel.options[sel.selectedIndex]; + var hasHDD = opt.getAttribute('data-has-hdd') === 'true'; + var hasDB = opt.getAttribute('data-has-db') === 'true'; + + if (hasHDD) { + typeInfo.innerHTML = '🔄 Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok a kiválasztott pillanatképből.'; + typeInfo.className = 'restore-info'; + } else if (hasDB) { + typeInfo.innerHTML = 'ℹ Adatbázis és konfiguráció visszaállítása — az alkalmazásnak nincs külön felhasználói adata.'; + typeInfo.className = 'restore-info restore-info-partial'; + } else { + typeInfo.innerHTML = 'ℹ Csak konfiguráció visszaállítása (compose fájlok, beállítások).'; + typeInfo.className = 'restore-info restore-info-partial'; + } + typeInfo.style.display = 'block'; + + snapSel.innerHTML = ''; + + fetch('/api/backup/snapshots?stack=' + encodeURIComponent(appName)) + .then(function(r) { return r.json(); }) + .then(function(data) { + snapSel.innerHTML = ''; + if (data.ok && data.data && data.data.length > 0) { + data.data.forEach(function(s) { + var o = document.createElement('option'); + o.value = s.short_id; + o.textContent = formatSnapshot(s); + snapSel.appendChild(o); + }); + } else { + snapSel.innerHTML = ''; + noSnaps.style.display = 'block'; + } + }); +} +``` + +Note: the JS is simpler than before — no `backupEnabled` check. If `hasHDD` is true, the +data IS backed up (it's mandatory). So: hasHDD → full restore, hasDB → config+DB, else → config only. + +### 3d: Add CSS for restore info + +**File:** `internal/web/templates/style.css`, add: + +```css +.restore-info { + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + color: #93c5fd; +} +.restore-info-partial { + background: rgba(251, 191, 36, 0.1); + border-color: rgba(251, 191, 36, 0.3); + color: #fcd34d; +} +``` + +### 3e: Update snapshot filtering API for non-HDD apps + +**File:** `internal/api/router.go`, function `backupSnapshots` (line 448) + +**Current code (lines 460–466):** +```go + if stackName := req.URL.Query().Get("stack"); stackName != "" { + mounts := r.backupMgr.GetStackHDDMounts(stackName) + if len(mounts) > 0 { + snapshots = filterSnapshotsByPaths(snapshots, mounts) + } + } +``` + +**Replace with:** +```go + if stackName := req.URL.Query().Get("stack"); stackName != "" { + mounts := r.backupMgr.GetStackHDDMounts(stackName) + if len(mounts) > 0 { + // App has HDD data — filter to snapshots containing those paths + snapshots = filterSnapshotsByPaths(snapshots, mounts) + } + // Apps without HDD mounts: return all snapshots (they all contain + // the stacks dir + DB dumps which cover this app's config and database) + } +``` + +This is effectively a no-op change (just adding a comment), since `if len(mounts) > 0` +already skips filtering for non-HDD apps. The comment clarifies intent. + +### 3f: Update `RestoreApp` to handle all apps + +**File:** `internal/backup/restore.go` + +**Replace the entire file** with: + +```go +package backup + +import ( + "fmt" + "path/filepath" + "regexp" +) + +// snapshotIDRe validates restic snapshot IDs: 8-64 lowercase hex characters. +var snapshotIDRe = regexp.MustCompile(`^[0-9a-f]{8,64}$`) + +// RestoreApp restores an app from a restic snapshot. +// All apps get config + DB dump restored. Apps with HDD data also get user data restored. +func (m *Manager) RestoreApp(stackName, snapshotID string) error { + if m.stackProvider == nil { + return fmt.Errorf("stack provider not configured") + } + + // Validate snapshot ID format + if !snapshotIDRe.MatchString(snapshotID) { + return fmt.Errorf("invalid snapshot ID: must be 8-64 lowercase hex characters") + } + + // Prevent concurrent operations + m.mu.Lock() + if m.running { + m.mu.Unlock() + return fmt.Errorf("backup or restore already in progress") + } + m.running = true + m.mu.Unlock() + defer func() { + m.mu.Lock() + m.running = false + m.mu.Unlock() + }() + + // Determine what to restore + hddMounts := m.stackProvider.GetStackHDDMounts(stackName) + hasHDD := len(hddMounts) > 0 + + // Build list of paths to restore from the snapshot + var restorePaths []string + + // Always restore the stack's config dir (compose + app.yaml + .felhom.yml) + composePath, ok := m.stackProvider.GetStackComposePath(stackName) + if ok { + stackDir := filepath.Dir(composePath) + restorePaths = append(restorePaths, stackDir) + } + + // Restore DB dump files for this stack + if m.cfg.Paths.DBDumpDir != "" { + restorePaths = append(restorePaths, m.cfg.Paths.DBDumpDir) + } + + // Restore HDD data (always included for apps that have it — backup is mandatory) + if hasHDD { + restorePaths = append(restorePaths, hddMounts...) + } + + if len(restorePaths) == 0 { + return fmt.Errorf("no restorable paths found for %s", stackName) + } + + m.logger.Printf("[WARN] RESTORE starting: stack=%s, snapshot=%s, paths=%v, hasHDD=%v", + stackName, snapshotID, restorePaths, hasHDD) + + // Stop the app before restore + if err := m.stackProvider.StopStack(stackName); err != nil { + m.logger.Printf("[WARN] RESTORE could not stop %s: %v (proceeding anyway)", stackName, err) + } + + // Execute restore via restic + if err := m.restic.RestoreAppData(snapshotID, restorePaths); err != nil { + m.logger.Printf("[ERROR] RESTORE failed for %s: %v", stackName, err) + if startErr := m.stackProvider.StartStack(stackName); startErr != nil { + m.logger.Printf("[WARN] RESTORE could not restart %s after failure: %v", stackName, startErr) + } + return err + } + + // Restart the app + if err := m.stackProvider.StartStack(stackName); err != nil { + m.logger.Printf("[WARN] RESTORE could not restart %s after restore: %v", stackName, err) + } + + restoreType := "config+DB" + if hasHDD { + restoreType = "full (config+DB+userdata)" + } + m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s, type=%s", stackName, snapshotID, restoreType) + return nil +} +``` + +Key difference from previous version: no `IsAppBackupEnabled()` check — HDD data is always +backed up and always restorable. + +--- + +## Fix 4: Honest Docker volume UI label + +### 4a: Change label in template + +**File:** `internal/web/templates/backups.html`, lines 283–292 + +**Current:** +```html +
+ Docker kötetek + Auto +``` + +**Replace** `Docker kötetek` with `Konfiguráció`: +```html +
+ Konfiguráció + Auto +``` + +Why: Docker named volumes (`immich_postgres_data`) live at `/var/lib/docker/volumes/` +which is NOT in the restic backup paths. DB data is protected via pg_dump (separate row). +What's actually backed up is compose files + app.yaml + .felhom.yml = configuration. + +--- + +## Summary of all changes + +| Fix | What | File(s) | +|-----|------|---------| +| 1a | `resolveAppBackupPaths()` includes ALL deployed stacks' HDD | `backup.go` | +| 1b | Remove `backupPrefs` parameter from `DiscoverAppData()` | `appdata.go` | +| 1c | Update `RefreshCache()` caller to match new signature | `backup.go` | +| 1d | Delete 5 dead settings methods | `settings.go` | +| 2a | `DBDumper` interface | `crossdrive.go` | +| 2b | `dbDumper` field + `SetDBDumper` on CrossDriveRunner | `crossdrive.go` | +| 2c | `DumpStackDB` method on backup Manager | `backup.go` | +| 2d | Call DB dump before cross-drive backup | `crossdrive.go` | +| 2e | Wire dbDumper in main.go | `main.go` | +| 3a | Remove filter from restore dropdown | `backups.html` | +| 3b | Add restore type info div | `backups.html` | +| 3c | Update JS to show restore type per app | `backups.html` | +| 3d | CSS for restore info banners | `style.css` | +| 3e | Comment clarifying snapshot API for non-HDD apps | `router.go` | +| 3f | `RestoreApp` handles config+DB+HDD restore | `restore.go` | +| 4a | Rename "Docker kötetek" → "Konfiguráció" | `backups.html` | + +## Files to modify (8) + +1. `internal/backup/backup.go` — Fix 1a + 1c + 2c +2. `internal/backup/appdata.go` — Fix 1b +3. `internal/settings/settings.go` — Fix 1d (delete 5 methods) +4. `internal/backup/crossdrive.go` — Fix 2a + 2b + 2d +5. `internal/backup/restore.go` — Fix 3f (full rewrite) +6. `internal/web/templates/backups.html` — Fix 3a + 3b + 3c + 4a +7. `internal/web/templates/style.css` — Fix 3d +8. `internal/api/router.go` — Fix 3e +9. `cmd/controller/main.go` — Fix 2e + +## Architecture after fix + +``` +Backup layers per app (3-2-1 rule): +┌──────────────────────────────────────────────────────┐ +│ RULE 1 — Nightly restic (MANDATORY, same drive) │ +│ │ +│ 1. Adatbázis mentés (Auto) │ +│ - pg_dump / mysqldump → /srv/backups/db-dumps/ │ +│ - Runs nightly 02:30 (all discovered DBs) │ +│ - Also runs before cross-drive backup (per-app) │ +│ │ +│ 2. Konfiguráció (Auto) │ +│ - compose.yml, app.yaml, .felhom.yml │ +│ - In nightly restic snapshot (always) │ +│ │ +│ 3. Felhasználói adatok (Auto — if app has HDD data) │ +│ - HDD bind mounts (photos, documents, etc.) │ +│ - Always included in nightly restic │ +│ - No toggle — backup is mandatory │ +│ │ +├──────────────────────────────────────────────────────┤ +│ RULE 2 — Cross-drive backup (OPT-IN, second device) │ +│ │ +│ - rsync or restic to a secondary physical drive │ +│ - Protects against primary drive failure │ +│ - When NOT configured: UI warns "nincs 2. másolat" │ +│ - Triggers fresh DB dump before backup │ +│ │ +├──────────────────────────────────────────────────────┤ +│ RULE 3 — Remote backup (FUTURE) │ +│ │ +│ - Offsite copy for disaster recovery │ +│ - Not implemented yet │ +└──────────────────────────────────────────────────────┘ + +Restore capabilities: +┌─────────────────┬────────────┬──────────┬────────────┐ +│ App type │ DB restore │ Config │ User data │ +├─────────────────┼────────────┼──────────┼────────────┤ +│ Has HDD data │ ✓ │ ✓ │ ✓ (always) │ +│ DB only, no HDD │ ✓ │ ✓ │ n/a │ +│ No DB, no HDD │ — │ ✓ │ n/a │ +└─────────────────┴────────────┴──────────┴────────────┘ +``` + +Note: the "DB + HDD, no backup" row from the previous version is GONE. All apps with +HDD data are always backed up. The restore table is simpler. ## Post-fix checklist - [ ] `go build ./...` passes - [ ] `go vet ./...` passes -- [ ] 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 +- [ ] Verify no remaining references to deleted methods (`IsAppBackupEnabled`, `GetAppBackupMap`, + `SetAppBackup`, `SetAppBackupBulk`, `GetAppBackupPrefs`) — use `grep -r` to confirm +- [ ] Update `CHANGELOG.md` — session 43, version **v0.12.7**: + - HDD data backup is now mandatory for all apps (no opt-in toggle) + - Removed dead `Enabled` flag gating from settings + - Cross-drive: triggers pre-backup DB dump for consistency + - Restore: all deployed apps now appear in dropdown + - Restore: shows restore type info (full / config+DB / config only) + - Restore: supports config+DB restore for apps without user data + - UI: "Docker kötetek" renamed to "Konfiguráció" (accuracy) +- [ ] Update `controller/README.md` — backup architecture section: + - 3-2-1 backup rule (nightly mandatory, cross-drive opt-in, remote future) + - Restore capabilities matrix + - Cross-drive pre-backup DB dump flow - [ ] Commit, build on 192.168.0.180, deploy on 192.168.0.162 - [ ] Verify with `docker ps` and `docker logs` -- [ ] After deploy, run manual Immich backup and verify new folder structure +- [ ] After deploy, verify: + - Immich shows in restore dropdown (HDD data → full restore) + - Gokapi/Mealie also show (DB+config restore) + - Selecting Immich shows "Teljes visszaállítás" banner + - Selecting Gokapi shows "Adatbázis és konfiguráció" banner + - Manual cross-drive backup triggers DB dump first (check logs) + - Backup status page shows "Konfiguráció" instead of "Docker kötetek" + - Nightly restic backup includes Immich HDD paths without any toggle