Complete Cross-Drive Backup + Per-Tier UI (v0.12.8)

This commit is contained in:
2026-02-18 11:32:11 +01:00
parent 1c54beb4e0
commit b65ab612f0
2 changed files with 680 additions and 70 deletions
+631 -38
View File
@@ -1,54 +1,647 @@
# TASK.md — Post-deploy fixes (v0.12.7a) # TASK.md — Complete Cross-Drive Backup + Per-Tier UI (v0.12.8)
## Prompt (copy-paste this into Claude Code) ## Prompt (copy-paste this into Claude Code)
``` ```
Read TASK.md for context. The code changes are already applied. Build, deploy, and verify. 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 45, v0.12.8)
3. Commit, build, and deploy following the workflow in CLAUDE.md
``` ```
--- ---
## Context ## Context and Goals
v0.12.7 was deployed with two issues discovered during testing: The cross-drive backup (Tier 2) currently only copies HDD user data (mounts). It does NOT
include DB dumps or app config. If the primary drive fails, the customer loses their database
and config — even though photos are safe on the second drive. **This is not a viable backup.**
### Fix A: Restore showed "Nincs elérhető mentés" for Immich Each tier must be a **complete, self-sufficient backup**:
**Root cause:** `filterSnapshotsByPaths` in `router.go` filtered snapshots by HDD mount | Tier | Contents | Location | Can fully restore? |
paths. Older snapshots (taken before v0.12.7 made HDD backup mandatory) don't contain |------|----------|----------|--------------------|
HDD paths, so they got filtered out — leaving zero snapshots for Immich. | 1. Nightly restic | DB + Config + User data | Same drive | Yes (not against drive failure) |
| 2. Cross-drive | DB + Config + User data | Different drive | **Yes (after this fix)** |
| 3. Remote (future) | Everything | Offsite | Yes |
**Fix applied:** Removed the path filtering entirely (`router.go` line 460-469). All This task also restructures the UI from per-layer (DB/Config/UserData rows) to per-tier
snapshots contain config + DB dumps, so they're useful for any app. The `RestoreApp` (1st backup / 2nd backup rows) — matching the customer mental model.
function extracts whatever paths are available from the snapshot.
Note: `filterSnapshotsByPaths` and `pathCovers` functions are now unused but kept for
potential future use. They won't cause compile errors (Go only errors on unused
variables/imports, not functions).
### Fix B: "Nincs beállítva" warning was misleading
**Root cause:** With mandatory nightly restic backup, user data IS backed up to the local
drive. The missing piece is only the second copy (cross-drive). The old messages implied
no backup at all.
**Changes applied:**
1. `handlers.go` line 601-604: Status changed from `"red"``"yellow"`, StatusText
from `"Felhasználói adatokról nincs mentés"``"Nincs második másolat (csak helyi mentés)"`
2. `backups.html` line 315: Added `✓ Helyi mentés auto` badge before `⚠ Nincs 2. másolat`
3. `style.css`: Added `.layer-auto-ok` class (green text for the auto badge)
--- ---
## Steps ## Fix 1: Include DB dumps + config in cross-drive backup
1. Run `go build ./...` and `go vet ./...` from the controller/ directory — fix any errors ### 1a: Add `dbDumpDir` field to CrossDriveRunner
2. Update CHANGELOG.md: add v0.12.7a entry (session 44):
- Fix: restore dropdown now shows snapshots for all apps (removed HDD path filtering) **File:** `internal/backup/crossdrive.go`
- Fix: user data warning clarified — shows "Helyi mentés auto / Nincs 2. másolat"
instead of the misleading "Nincs beállítva" Add a `dbDumpDir` field to the struct:
3. Commit, build, and deploy following the workflow in CLAUDE.md
4. Verify: ```go
- Immich now shows restore snapshots (should list all available snapshots) type CrossDriveRunner struct {
- Paperless-ngx and RomM show yellow dot (not red) with "✓ Helyi mentés auto · ⚠ Nincs 2. másolat" sett *settings.Settings
- Mealie/Gokapi (no HDD) still show correctly with config+DB restore stackProvider StackDataProvider
dbDumper DBDumper
dbDumpDir string // path to DB dump directory (e.g., /srv/backups/db-dumps)
logger *log.Logger
mu sync.Mutex
running map[string]bool
}
```
Add setter after `SetDBDumper`:
```go
// SetDBDumpDir sets the path to the DB dump directory for cross-drive backups.
func (r *CrossDriveRunner) SetDBDumpDir(dir string) {
r.dbDumpDir = dir
}
```
### 1b: Add helper to copy DB dump files for a stack
**File:** `internal/backup/crossdrive.go`, add helper function:
```go
// copyStackDBDumps copies DB dump files for the given stack to destDir.
// DB dump files are named <stackName>_<dbtype>.sql (e.g., immich_postgres.sql).
// Small files — uses plain file copy, not rsync.
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
if r.dbDumpDir == "" {
return nil
}
entries, err := os.ReadDir(r.dbDumpDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("reading DB dump dir: %w", err)
}
prefix := stackName + "_"
copied := 0
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), prefix) {
continue
}
src := filepath.Join(r.dbDumpDir, e.Name())
dst := filepath.Join(destDir, e.Name())
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("reading %s: %w", e.Name(), err)
}
if err := os.WriteFile(dst, data, 0644); err != nil {
return fmt.Errorf("writing %s: %w", e.Name(), err)
}
copied++
}
if copied > 0 {
r.logger.Printf("[DEBUG] Copied %d DB dump file(s) to %s", copied, destDir)
}
return nil
}
```
### 1c: Update `runRsyncBackup` to include DB + config
**File:** `internal/backup/crossdrive.go`, function `runRsyncBackup`
After the existing HDD mount rsync loop (after line 260 `return nil` of the for loop),
add these blocks BEFORE the final `return nil`:
```go
// --- Copy DB dumps for this stack ---
dbDestDir := filepath.Join(destDir, "_db")
if err := os.MkdirAll(dbDestDir, 0755); err != nil {
return fmt.Errorf("creating DB dump dest dir: %w", err)
}
if err := r.copyStackDBDumps(stackName, dbDestDir); err != nil {
r.logger.Printf("[WARN] Cross-drive DB dump copy failed for %s: %v", stackName, err)
// Non-fatal: user data is the primary concern
}
// --- Rsync app config (compose dir) ---
if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok {
configSrcDir := filepath.Dir(composePath)
configDestDir := filepath.Join(destDir, "_config")
if err := os.MkdirAll(configDestDir, 0755); err != nil {
return fmt.Errorf("creating config dest dir: %w", err)
}
src := strings.TrimRight(configSrcDir, "/") + "/"
dst := strings.TrimRight(configDestDir, "/") + "/"
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", src, dst)
r.logger.Printf("[DEBUG] rsync config: %s → %s", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
r.logger.Printf("[WARN] Cross-drive config rsync failed for %s: %v (%s)", stackName, err, strings.TrimSpace(string(out)))
// Non-fatal
}
}
```
**Resulting rsync destination layout:**
```
backups/rsync/<app>/
_db/ ← stackName_postgres.sql, stackName_mariadb.sql
_config/ ← compose.yml, app.yaml, .felhom.yml
<user data> ← existing HDD mount contents (unchanged)
```
The `_` prefix prevents collision with user data directories (HDD mounts never start with `_`).
### 1d: Update `runResticBackup` to include DB + config
**File:** `internal/backup/crossdrive.go`, function `runResticBackup`
Currently the restic backup only includes `mounts`. Change to also include the config dir
and DB dump dir.
**Current code (around line 294301):**
```go
args := []string{
"backup", "--repo", repoPath,
"--password-file", pwPath,
"--tag", stackName,
"--tag", "cross-drive",
}
args = append(args, mounts...)
```
**Replace with:**
```go
args := []string{
"backup", "--repo", repoPath,
"--password-file", pwPath,
"--tag", stackName,
"--tag", "cross-drive",
}
// Include user data (HDD mounts)
args = append(args, mounts...)
// Include app config dir (compose + app.yaml + .felhom.yml)
if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok {
args = append(args, filepath.Dir(composePath))
}
// Include DB dump dir (all stacks' dumps — restic deduplicates)
if r.dbDumpDir != "" {
if _, err := os.Stat(r.dbDumpDir); err == nil {
args = append(args, r.dbDumpDir)
}
}
```
Note: for restic, including the full DB dump dir is fine — restic deduplicates and it's
shared across apps. The `--tag <stackName>` identifies which app this backup belongs to.
### 1e: Wire up in main.go
**File:** `cmd/controller/main.go`, after the existing `crossDriveRunner.SetDBDumper(backupMgr)` line:
```go
crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir)
```
---
## Fix 2: Restructure UI from per-layer to per-tier
The current UI shows 3 rows per app (DB / Konfiguráció / Felhasználói adatok). This
maps to implementation details, not the customer mental model. Change to 2 rows per app:
- **1. mentés** — nightly restic (mandatory, same drive)
- **2. mentés** — cross-drive (opt-in, different drive)
### 2a: Restructure `AppBackupRow` struct
**File:** `internal/web/handlers.go`
**Replace the current `AppBackupRow` struct (lines 500533) with:**
```go
// AppBackupRow holds per-tier backup information for one app on the backup page.
type AppBackupRow struct {
StackName string
DisplayName string
Status string // "green", "yellow", "red", "auto"
StatusText string // short Hungarian tooltip
// App characteristics
HasHDDData bool
HasDB bool
StorageLabel string
HDDSizeHuman string
// What this app's backup contains (for display)
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
BackupContents string
// Tier 1: Nightly backup (always exists)
Tier1LastRun string // formatted time of last restic snapshot
Tier1LastStatus string // "ok", "error", ""
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
// Tier 2: Cross-drive backup (only for apps with HDD data)
Tier2Configured bool
Tier2Method string // "rsync", "restic"
Tier2MethodLabel string // "rsync", "restic"
Tier2Dest string // destination label
Tier2Schedule string // "Naponta", "Hetente"
Tier2LastRun string
Tier2LastStatus string // "ok", "error", "running", ""
Tier2LastError string
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
Tier2SizeHuman string
Tier2Browsable bool // true for rsync (plain files), false for restic
// Warnings accumulated for this app
Warnings []string
}
```
### 2b: Update `buildAppBackupRows`
**File:** `internal/web/handlers.go`
**Replace the entire `buildAppBackupRows` function** with:
```go
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
func (s *Server) buildAppBackupRows(
status *backup.FullBackupStatus,
crossConfigs map[string]*settings.CrossDriveBackup,
destLabels map[string]string,
) []AppBackupRow {
loc := getTimezone()
// Build DB stack lookup
dbStacks := make(map[string]bool)
for _, db := range status.DiscoveredDBs {
dbStacks[db.StackName] = true
}
for _, f := range status.DumpFiles {
dbStacks[f.StackName] = true
}
// Tier 1 timestamps (shared across all apps — single nightly job)
tier1LastRun := ""
tier1LastStatus := ""
if status.LastBackup != nil {
tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
if status.LastBackup.Success {
tier1LastStatus = "ok"
} else {
tier1LastStatus = "error"
}
}
tier1DBStatus := ""
if status.LastDBDump != nil {
if status.LastDBDump.Success {
tier1DBStatus = "ok"
} else {
tier1DBStatus = "error"
}
}
var rows []AppBackupRow
for _, app := range status.AppDataInfo {
hasDB := dbStacks[app.StackName] || app.HasDBDump
// Build backup contents label
var parts []string
if hasDB {
parts = append(parts, "DB")
}
parts = append(parts, "Konfig")
if app.HasHDDData {
parts = append(parts, "Adatok")
}
contents := strings.Join(parts, " + ")
row := AppBackupRow{
StackName: app.StackName,
DisplayName: app.DisplayName,
HasHDDData: app.HasHDDData,
HasDB: hasDB,
StorageLabel: app.StorageLabel,
HDDSizeHuman: app.HDDSizeHuman,
BackupContents: contents,
Tier1LastRun: tier1LastRun,
Tier1LastStatus: tier1LastStatus,
Tier1DBStatus: tier1DBStatus,
}
// Default status = auto (no user data, just config)
row.Status = "auto"
row.StatusText = "Automatikus mentés"
if app.HasHDDData {
cfg, hasCfg := crossConfigs[app.StackName]
if !hasCfg || cfg == nil || !cfg.Enabled {
// HDD data backed up via nightly restic (mandatory), but no second copy
row.Tier2Configured = false
row.Status = "yellow"
row.StatusText = "Nincs második másolat (csak helyi mentés)"
} else {
row.Tier2Configured = true
row.Tier2Method = cfg.Method
row.Tier2MethodLabel = cfg.Method // "rsync" or "restic"
row.Tier2Browsable = cfg.Method == "rsync"
row.Tier2Dest = destLabels[cfg.DestinationPath]
if row.Tier2Dest == "" {
row.Tier2Dest = cfg.DestinationPath
}
switch cfg.Schedule {
case "daily":
row.Tier2Schedule = "Naponta"
case "weekly":
row.Tier2Schedule = "Hetente"
default:
row.Tier2Schedule = cfg.Schedule
}
if cfg.LastRun != "" {
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
row.Tier2LastRun = t.In(loc).Format("01-02 15:04")
}
}
row.Tier2LastStatus = cfg.LastStatus
row.Tier2LastError = cfg.LastError
row.Tier2SizeHuman = cfg.LastSizeHuman
switch cfg.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
case "error":
row.Tier2StatusBadge = "Hiba"
row.Status = "yellow"
row.StatusText = "Utolsó mentés sikertelen"
case "running":
row.Tier2StatusBadge = "Fut..."
default:
row.Tier2StatusBadge = "—"
}
// Destination health check
if cfg.Enabled && cfg.DestinationPath != "" {
if err := s.crossDrive.ValidateDestination(cfg.DestinationPath); err != nil {
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
row.Status = "red"
row.StatusText = "Mentési cél nem elérhető"
} else {
row.Status = "yellow"
row.StatusText = "Figyelmeztetés"
}
row.Warnings = append(row.Warnings, err.Error())
} else if row.Status != "yellow" {
row.Status = "green"
row.StatusText = "Mentés rendben"
}
}
}
}
// DB dump failure warning (affects Tier 1 quality)
if hasDB && tier1DBStatus == "error" {
if row.Status != "red" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
}
}
rows = append(rows, row)
}
return rows
}
```
**Note:** This requires `strings` import in handlers.go — check it's present.
### 2c: Update template to per-tier display
**File:** `internal/web/templates/backups.html`
Replace the backup-layers section inside the `app-backup-row-detail` div (lines 265322).
The current code has three `backup-layer-row` divs (DB, Konfiguráció, Felhasználói adatok).
**Replace with two tier rows:**
```html
<div class="app-backup-row-detail" style="display:none">
<div class="backup-layers">
<!-- Tier 1: Nightly backup (mandatory, same drive) -->
<div class="backup-layer-row">
<span class="tier-label">1. mentés</span>
<span class="layer-badge">Auto</span>
<span class="tier-location">helyi</span>
{{if .Tier1LastRun}}
<span class="layer-last">Utolsó: {{.Tier1LastRun}}
{{if eq .Tier1LastStatus "ok"}}<span class="text-ok"></span>
{{else if eq .Tier1LastStatus "error"}}<span class="text-error"></span>{{end}}
</span>
{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
{{if and .HasDB (eq .Tier1DBStatus "error")}}
<span class="text-error" style="font-size:.8rem">⚠ DB dump hiba</span>
{{end}}
</div>
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
{{if .HasHDDData}}
<div class="backup-layer-row">
<span class="tier-label">2. mentés</span>
{{if .Tier2Configured}}
<span class="layer-method">{{.Tier2MethodLabel}}</span>
<span class="layer-dest">→ {{.Tier2Dest}}</span>
<span class="layer-schedule">{{.Tier2Schedule}}</span>
{{if .Tier2LastRun}}
<span class="layer-last">Utolsó: {{.Tier2LastRun}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
{{if .Tier2Browsable}}<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>{{end}}
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<button class="btn btn-xs btn-outline"
onclick="triggerCrossDriveBackup('{{.StackName}}', this)">
Futtatás most</button>
</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>
{{end}}
</div>
{{end}}
</div>
{{if .Warnings}}
<div class="layer-warnings">
{{range .Warnings}}
<div class="backup-layer-warning">{{.}}</div>
{{end}}
</div>
{{end}}
</div>
```
### 2d: Add CSS for new tier elements
**File:** `internal/web/templates/style.css`
Add near the existing `.layer-*` styles:
```css
.tier-label {
font-weight: 600;
min-width: 5rem;
color: var(--text);
}
.tier-location {
color: var(--text-muted);
font-size: .85rem;
}
.tier-contents {
color: var(--text-muted);
font-size: .8rem;
font-style: italic;
margin-left: .25rem;
}
.tier-size {
color: var(--text-muted);
font-size: .8rem;
margin-left: .25rem;
}
.tier-browsable {
font-size: .75rem;
margin-left: .15rem;
cursor: help;
}
```
---
## Fix 3: Clean up unused code
### 3a: Remove unused `filterSnapshotsByPaths` and `pathCovers`
**File:** `internal/api/router.go`
Delete the `filterSnapshotsByPaths` function and the `pathCovers` helper — they were
left after v0.12.7a removed the call site. No other code references them.
### 3b: Remove `VolumeLastRun` / `VolumeLastStatus` / `DBLastRun` / `DBLastStatus` fields
These fields no longer exist in the new `AppBackupRow` struct (replaced by Tier1/Tier2
fields). Verify no template references to the old field names remain.
---
## Summary of all changes
| Fix | What | File(s) |
|-----|------|---------|
| 1a | `dbDumpDir` field + setter on CrossDriveRunner | `crossdrive.go` |
| 1b | `copyStackDBDumps` helper for rsync | `crossdrive.go` |
| 1c | rsync includes DB dumps + config | `crossdrive.go` |
| 1d | restic includes config dir + DB dump dir | `crossdrive.go` |
| 1e | Wire `SetDBDumpDir` in main.go | `main.go` |
| 2a | `AppBackupRow` restructured to per-tier | `handlers.go` |
| 2b | `buildAppBackupRows` rewritten for per-tier | `handlers.go` |
| 2c | Template: 2 tier rows instead of 3 layer rows | `backups.html` |
| 2d | CSS for tier elements | `style.css` |
| 3a | Remove unused `filterSnapshotsByPaths` + `pathCovers` | `router.go` |
| 3b | Verify no old field references remain | `backups.html` |
## Files to modify (6)
1. `internal/backup/crossdrive.go` — Fix 1a + 1b + 1c + 1d
2. `cmd/controller/main.go` — Fix 1e
3. `internal/web/handlers.go` — Fix 2a + 2b
4. `internal/web/templates/backups.html` — Fix 2c
5. `internal/web/templates/style.css` — Fix 2d
6. `internal/api/router.go` — Fix 3a
## Architecture after fix
```
Per-app backup tiers:
┌──────────────────────────────────────────────────────┐
│ TIER 1 — Nightly restic (MANDATORY, same drive) │
│ │
│ Contains: │
│ - DB dumps (pg_dump / mariadb-dump) │
│ - Config (compose.yml, app.yaml, .felhom.yml) │
│ - User data (ALL HDD bind mounts — mandatory) │
│ │
│ Protects against: accidental deletion, corruption │
│ Does NOT protect against: drive failure │
│ │
├──────────────────────────────────────────────────────┤
│ TIER 2 — Cross-drive backup (OPT-IN, second device) │
│ │
│ Contains (COMPLETE — same as Tier 1): │
│ - DB dumps (copied to _db/ subfolder) │
│ - Config (rsynced to _config/ subfolder) │
│ - User data (rsync or restic to destination) │
│ │
│ rsync layout: │
│ backups/rsync/<app>/ │
│ _db/ ← DB dump files (browsable) │
│ _config/ ← compose + app.yaml (browsable) │
│ <data> ← user data (browsable) │
│ │
│ restic layout: │
│ backups/restic/ ← encrypted repo (not browsable) │
│ │
│ Protects against: drive failure, drive theft │
│ │
├──────────────────────────────────────────────────────┤
│ TIER 3 — Remote backup (FUTURE) │
│ │
│ Complete offsite copy. Not implemented yet. │
└──────────────────────────────────────────────────────┘
UI per-app display:
┌─────────────────────────────────────────────────────────┐
│ ● Immich Külső tárhely (hdd_1) 63.9 MB │
│ 1. mentés Auto helyi 02-18 03:00 ✓ DB+Konfig+Adat│
│ 2. mentés rsync → hdd_1 Naponta 02-18 10:48 ✓ │
│ DB+Konfig+Adatok 📁 [Beáll][Futtás]│
├─────────────────────────────────────────────────────────┤
│ ● Mealie Auto │
│ 1. mentés Auto helyi 02-18 03:00 ✓ Konfig │
├─────────────────────────────────────────────────────────┤
│ ● Paperless-ngx hdd_placeholder 76 B │
│ 1. mentés Auto helyi 02-18 03:00 ✓ Konfig+Adatok │
│ 2. mentés ✓ 1. mentés auto ⚠ Nincs 2. másolat │
└─────────────────────────────────────────────────────────┘
```
## Post-fix checklist
- [ ] `go build ./...` passes
- [ ] `go vet ./...` passes
- [ ] Verify no references to old fields: `DBLastRun`, `DBLastStatus`, `VolumeLastRun`,
`VolumeLastStatus`, `HasUserData`, `UserDataConfigured`, `UserDataMethod`,
`UserDataDest`, `UserDataSchedule`, `UserDataLastRun`, `UserDataLastStatus`,
`UserDataLastError`, `UserDataStatusBadge`
- [ ] Verify `filterSnapshotsByPaths` and `pathCovers` deleted from router.go
- [ ] Update `CHANGELOG.md` — session 45, version **v0.12.8**:
- Cross-drive backup now includes DB dumps + app config (complete backup)
- rsync layout: `_db/` and `_config/` subdirs alongside user data
- restic cross-drive includes config dir + DB dump dir
- UI: restructured from per-layer to per-tier display (1. mentés / 2. mentés)
- UI: shows backup contents per app (DB + Konfig + Adatok)
- UI: rsync backups show browsable indicator (📁)
- Cleanup: removed unused filterSnapshotsByPaths code
- [ ] Commit, build on 192.168.0.180, deploy on 192.168.0.162
- [ ] Verify with `docker ps` and `docker logs`
- [ ] After deploy, verify:
- Immich card shows "1. mentés" and "2. mentés" rows (not DB/Konfig/User data rows)
- Immich Tier 2 shows "DB + Konfig + Adatok" contents label
- Run manual cross-drive backup for Immich
- Check destination: `ls backups/rsync/immich/` should show `_db/`, `_config/`, and user data
- Verify `_db/` contains `immich_postgres.sql`
- Verify `_config/` contains `docker-compose.yml`, `app.yaml`
- Mealie shows only "1. mentés" row (no Tier 2 — no HDD data)
- Paperless-ngx shows yellow dot with "⚠ Nincs 2. másolat"
+49 -32
View File
@@ -163,19 +163,23 @@ The `/apps/{slug}` page renders hero section, screenshots, setup guide, and opti
### 2. Backup System ### 2. Backup System
The backup system implements a **3-2-1 backup architecture**: The backup system implements a **3-2-1 backup architecture**. Each tier is a **complete,
self-sufficient backup** — any single tier can fully restore an app.
| Rule | What | Where | Status | | Tier | Contents | Location | Can fully restore? |
|------|------|-------|--------| |------|----------|----------|--------------------|
| **1. Nightly backup** | DB dumps + config + ALL user data | Same drive as app | Mandatory, automatic | | **1. Nightly restic** | DB + Config + User data | Same drive as app | Yes (not against drive failure) |
| **2. Cross-drive backup** | User data copy to secondary drive | Different physical device | Opt-in per app | | **2. Cross-drive** | DB + Config + User data | Different physical device | Yes |
| **3. Remote backup** | Offsite copy for disaster recovery | Cloud / remote server | Future | | **3. Remote** | Everything | Cloud / remote server | Future |
**Key principle:** User data backup is **mandatory** — every app with HDD bind mounts **Key principles:**
is included in the nightly restic snapshot automatically. There is no per-app toggle. - User data backup is **mandatory** — every app with HDD bind mounts is included
The `AppBackupPrefs.Enabled` field in settings.json is legacy and not read by any code. automatically. There is no per-app toggle.
- Each tier includes **everything** needed to restore: DB dumps, config, and user data.
No tier depends on another tier's data.
- The `AppBackupPrefs.Enabled` field in settings.json is legacy and not read by any code.
#### Rule 1: Nightly Backup (mandatory, same drive) #### Tier 1: Nightly Backup (mandatory, same drive)
The nightly backup has two phases that run sequentially: The nightly backup has two phases that run sequentially:
@@ -199,18 +203,18 @@ The nightly backup has two phases that run sequentially:
- Weekly prune on Sundays with configurable retention (keep-daily, keep-weekly, keep-monthly) - Weekly prune on Sundays with configurable retention (keep-daily, keep-weekly, keep-monthly)
- Weekly integrity check (`restic check`) on Sunday 04:00 - Weekly integrity check (`restic check`) on Sunday 04:00
**What this protects against:** accidental deletion, data corruption, point-in-time rollback. **Protects against:** accidental deletion, data corruption, point-in-time rollback.
Does NOT protect against drive failure (backup is on the same physical drive). Does NOT protect against drive failure (backup is on the same physical drive).
#### Rule 2: Cross-Drive Backup (opt-in, different device) (`internal/backup/crossdrive.go`) #### Tier 2: Cross-Drive Backup (opt-in, different device) (`internal/backup/crossdrive.go`)
Copies user data to a **different physical drive**, providing the second copy for 3-2-1. **Complete backup** to a different physical drive — DB dumps + config + user data.
- **Two methods:** - **Two methods:**
- **rsync** — Simple mirror with `--delete` (fast, no versioning) - **rsync** — Simple mirror with `--delete` (fast, no versioning, **browsable** on disk)
- **restic** — Versioned, deduplicated, encrypted (shared repo across apps, auto-generated password) - **restic** — Versioned, deduplicated, encrypted (shared repo across apps, not browsable)
- Per-app configuration in settings.json: destination path, method, schedule (daily/weekly/manual) - Per-app configuration in settings.json: destination path, method, schedule (daily/weekly/manual)
- **Pre-backup DB dump:** `DumpStackDB()` runs fresh pg_dump/mariadb-dump before each cross-drive backup to ensure DB state matches user data; non-fatal on failure (wired via `DBDumper` interface to avoid circular imports) - **Pre-backup DB dump:** `DumpStackDB()` runs fresh pg_dump/mariadb-dump before each cross-drive backup; non-fatal on failure (wired via `DBDumper` interface to avoid circular imports)
- **Drive-type-aware validation** (`ValidateDestination`): - **Drive-type-aware validation** (`ValidateDestination`):
| Destination type | Space checks | | Destination type | Space checks |
@@ -218,20 +222,26 @@ Copies user data to a **different physical drive**, providing the second copy fo
| External mount (different device than `/`) | Block if <100 MB free | | External mount (different device than `/`) | Block if <100 MB free |
| System drive (same device as `/`) | Require ≥10 GB free AND <90% used; logged warning | | System drive (same device as `/`) | Require ≥10 GB free AND <90% used; logged warning |
- **Rsync destination layout:** - **Rsync destination layout** (complete — can restore app independently):
- Single mount: `backups/rsync/<app>/` (flat, no extra nesting) ```
- Multiple mounts: `backups/rsync/<app>/<leaf>/` per mount; duplicate leaf names get `_N` suffix backups/rsync/<app>/
- DB dump files excluded (`--exclude backups/*.sql.gz/sql/dump`) — already handled by pg_dump _db/ ← DB dump files (stackName_postgres.sql, etc.)
_config/ ← compose.yml, app.yaml, .felhom.yml
<user data> ← HDD mount contents (single mount: flat; multi-mount: leaf subfolders)
```
- DB dump files excluded from user data rsync (`--exclude backups/*.sql.gz/sql/dump`) to avoid duplicating app-internal dumps
- `_` prefix directories prevent collision with user data
- **Restic backup paths:** includes HDD mounts + config dir + DB dump dir (deduplication handles overlap)
- Safety guards: destination ≠ source, path-overlap check, writable check - Safety guards: destination ≠ source, path-overlap check, writable check
- **Chained execution:** runs immediately after nightly restic — daily apps every night, weekly apps on Sundays - **Chained execution:** runs immediately after nightly restic — daily apps every night, weekly apps on Sundays
- Per-app concurrency lock prevents overlapping runs - Per-app concurrency lock prevents overlapping runs
- Status (last_run, duration, size, error) persisted to settings.json - Status (last_run, duration, size, error) persisted to settings.json
**What this protects against:** primary drive failure, drive theft/damage. **Protects against:** primary drive failure, drive theft/damage.
#### Rule 3: Remote Backup (future) #### Tier 3: Remote Backup (future)
Offsite backup for disaster recovery. Not yet implemented. Complete offsite backup for disaster recovery. Not yet implemented.
#### Restore (`internal/backup/restore.go`) #### Restore (`internal/backup/restore.go`)
@@ -244,7 +254,7 @@ All deployed apps appear in the restore dropdown — every app has restic snapsh
| DB only, no HDD | ✓ | ✓ | n/a | | DB only, no HDD | ✓ | ✓ | n/a |
| No DB, no HDD | ✓ | — | n/a | | No DB, no HDD | ✓ | — | n/a |
- **Snapshot API** returns ALL snapshots unfiltered — older snapshots (pre-mandatory HDD backup) still allow config+DB restore; `RestoreApp` extracts whatever paths are available - **Snapshot API** returns ALL snapshots unfiltered — older snapshots still allow config+DB restore; `RestoreApp` extracts whatever paths are available
- **Restore type info** shown per-app when selected in dropdown (Hungarian banners): - **Restore type info** shown per-app when selected in dropdown (Hungarian banners):
- Has HDD: "Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok" - Has HDD: "Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok"
- Has DB, no HDD: "Adatbázis és konfiguráció visszaállítása" - Has DB, no HDD: "Adatbázis és konfiguráció visszaállítása"
@@ -253,9 +263,12 @@ All deployed apps appear in the restore dropdown — every app has restic snapsh
- Running flag prevents concurrent backup/restore operations - Running flag prevents concurrent backup/restore operations
- Snapshot ID validated (864 lowercase hex) - Snapshot ID validated (864 lowercase hex)
**Note:** Restore currently uses Tier 1 (primary restic repo) only. Restoring from Tier 2
(cross-drive) is a future enhancement.
#### Backup Page UI (`internal/web/templates/backups.html`) #### Backup Page UI (`internal/web/templates/backups.html`)
Unified per-app status table with expandable rows showing 3 backup layers per app: Unified per-app status table with expandable rows showing **per-tier** backup status:
**Status dot per app:** **Status dot per app:**
@@ -266,13 +279,17 @@ Unified per-app status table with expandable rows showing 3 backup layers per ap
| Red | Cross-drive destination blocked or inaccessible | | Red | Cross-drive destination blocked or inaccessible |
| Gray (auto) | No user data — only config/DB backup (automatic) | | Gray (auto) | No user data — only config/DB backup (automatic) |
**Three backup layers per app row:** **Per-app backup tiers:**
1. **Adatbázis mentés** — Auto badge + last run timestamp + status - **1. mentés** (Tier 1, always present) — Auto badge + "helyi" + last run + contents (e.g., "DB + Konfig + Adatok")
2. **Konfiguráció** — Auto badge + last restic snapshot timestamp + status - **2. mentés** (Tier 2, only for apps with HDD data) — one of:
3. **Felhasználói adatok** — one of: - Configured: method (rsync/restic) + destination + schedule + last run + status + contents + browsable indicator (📁 for rsync) + action buttons
- Cross-drive configured: method + destination + schedule + last run + status + "Futtatás most" button - Not configured: "✓ 1. mentés auto" + "⚠ Nincs 2. másolat" + settings link
- HDD data, no cross-drive: "✓ Helyi mentés auto" (green) + "⚠ Nincs 2. másolat" (yellow) + settings link
- No HDD data: "— (nincs HDD adat)" (muted) **Backup contents per app** (shown per tier):
- Apps with DB + HDD: "DB + Konfig + Adatok"
- Apps with DB only: "DB + Konfig"
- Apps with HDD, no DB: "Konfig + Adatok"
- Apps with neither: "Konfig"
**Other sections:** **Other sections:**
- Schedule overview with next run times for DB dump, restic, prune - Schedule overview with next run times for DB dump, restic, prune