v0.14.0: Per-drive backup architecture + storage path overhaul

Major refactor of backup and storage paths:

- Per-drive restic repos at <drive>/backups/primary/restic/
- Per-app DB dumps at <drive>/backups/primary/<app>/db-dumps/
- Remove global BackupDir, DBDumpDir, ResticRepo config fields
- Add SystemDataPath config (fallback for apps without HDD)
- New backup/paths.go with pure path computation helpers
- Add GetStackHDDPath to StackDataProvider interface
- Restic methods now accept repoPath as parameter
- Cross-drive backup uses new secondary path structure
- Rename storage/ to appdata/ in scripts and compose templates
- Update protected HDD paths (storage → appdata + backups)
- Simplify backup UI (remove global path displays)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 18:47:39 +01:00
parent 05f6095e6b
commit 563c9515d9
15 changed files with 937 additions and 570 deletions
+37 -35
View File
@@ -22,22 +22,23 @@ type DBDumper interface {
// CrossDriveRunner handles per-app backup to secondary storage.
type CrossDriveRunner struct {
sett *settings.Settings
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 // per-app running state
sett *settings.Settings
stackProvider StackDataProvider
dbDumper DBDumper
systemDataPath string // fallback drive for SSD-only apps
logger *log.Logger
mu sync.Mutex
running map[string]bool // per-app running state
}
// NewCrossDriveRunner creates a new CrossDriveRunner.
func NewCrossDriveRunner(sett *settings.Settings, provider StackDataProvider, logger *log.Logger) *CrossDriveRunner {
func NewCrossDriveRunner(sett *settings.Settings, provider StackDataProvider, systemDataPath string, logger *log.Logger) *CrossDriveRunner {
return &CrossDriveRunner{
sett: sett,
stackProvider: provider,
logger: logger,
running: make(map[string]bool),
sett: sett,
stackProvider: provider,
systemDataPath: systemDataPath,
logger: logger,
running: make(map[string]bool),
}
}
@@ -47,9 +48,12 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) {
r.dbDumper = d
}
// SetDBDumpDir sets the path to the DB dump directory for cross-drive backups.
func (r *CrossDriveRunner) SetDBDumpDir(dir string) {
r.dbDumpDir = dir
// getAppDrivePath returns the drive path for an app.
func (r *CrossDriveRunner) getAppDrivePath(stackName string) string {
if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" {
return hddPath
}
return r.systemDataPath
}
// RunAppBackup runs cross-drive backup for a single app.
@@ -128,7 +132,7 @@ func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) e
// Calculate backup size
var sizeHuman string
if cfg.Method == "rsync" {
destDir := filepath.Join(cfg.DestinationPath, "backups", "rsync", stackName)
destDir := AppSecondaryRsyncPath(cfg.DestinationPath, stackName)
if sz, err := dirSizeBytes(destDir); err == nil {
sizeHuman = humanizeBytes(sz)
}
@@ -220,7 +224,7 @@ func (r *CrossDriveRunner) ValidateDestination(path string) error {
// --- rsync ---
func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBase string, mounts []string) error {
destDir := filepath.Join(destBase, "backups", "rsync", stackName)
destDir := AppSecondaryRsyncPath(destBase, stackName)
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("creating rsync dest dir: %w", err)
}
@@ -261,7 +265,7 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
}
}
// --- Copy DB dumps for this stack ---
// --- Copy DB dumps for this stack from its home drive ---
dbDestDir := filepath.Join(destDir, "_db")
if err := os.MkdirAll(dbDestDir, 0755); err != nil {
return fmt.Errorf("creating DB dump dest dir: %w", err)
@@ -294,7 +298,7 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
// --- restic ---
func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destBase string, mounts []string) error {
repoPath := filepath.Join(destBase, "backups", "restic")
repoPath := SecondaryResticRepoPath(destBase)
// Get or create the cross-drive restic password
password, err := r.sett.GetOrCreateCrossDrivePassword()
@@ -334,11 +338,11 @@ func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destB
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)
}
// Include DB dump dir for this app (from its home drive)
appDrive := r.getAppDrivePath(stackName)
dumpDir := AppDBDumpPath(appDrive, stackName)
if _, err := os.Stat(dumpDir); err == nil {
args = append(args, dumpDir)
}
cmd := exec.CommandContext(ctx, "restic", args...)
@@ -387,27 +391,26 @@ func (r *CrossDriveRunner) ensureResticRepo(ctx context.Context, repoPath, pwFil
return nil
}
// 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.
// copyStackDBDumps copies DB dump files for the given stack from its home drive.
// DB dumps are at <drive>/backups/primary/<stack>/db-dumps/<stack>_<dbtype>.sql.
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
if r.dbDumpDir == "" {
return nil
}
entries, err := os.ReadDir(r.dbDumpDir)
appDrive := r.getAppDrivePath(stackName)
dumpDir := AppDBDumpPath(appDrive, stackName)
entries, err := os.ReadDir(dumpDir)
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) {
if e.IsDir() {
continue
}
src := filepath.Join(r.dbDumpDir, e.Name())
src := filepath.Join(dumpDir, e.Name())
dst := filepath.Join(destDir, e.Name())
data, err := os.ReadFile(src)
if err != nil {
@@ -453,4 +456,3 @@ func dirSizeBytes(path string) (int64, error) {
})
return total, err
}