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") } if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: stack=%s, snapshotID=%s", stackName, snapshotID) } // 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 if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: %s has %d HDD mount(s), hasHDD=%v", stackName, len(hddMounts), hasHDD) } // 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) if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: will restore config dir: %s", stackDir) } } // Restore DB dump files for this stack (per-drive path) drivePath := m.GetAppDrivePath(stackName) dumpDir := AppDBDumpPath(drivePath, stackName) restorePaths = append(restorePaths, dumpDir) if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: will restore DB dump dir: %s", dumpDir) } // Restore HDD data (always included for apps that have it — backup is mandatory) if hasHDD { restorePaths = append(restorePaths, hddMounts...) if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: will restore HDD data: %v", hddMounts) } } if len(restorePaths) == 0 { return fmt.Errorf("no restorable paths found for %s", stackName) } // Use the app's primary restic repo repoPath := PrimaryResticRepoPath(drivePath) if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: using repo=%s, %d restore path(s)", repoPath, len(restorePaths)) } m.logger.Printf("[WARN] RESTORE starting: stack=%s, snapshot=%s, repo=%s, paths=%v, hasHDD=%v", stackName, snapshotID, repoPath, restorePaths, hasHDD) // Stop the app before restore if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 1/4 — stopping app %s", stackName) } 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 m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 2/4 — restoring data from snapshot %s", snapshotID) } if err := m.restic.RestoreAppData(repoPath, snapshotID, restorePaths); err != nil { m.logger.Printf("[ERROR] RESTORE failed for %s: %v", stackName, err) if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 3/4 — restarting app %s after failure", stackName) } 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 m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 3/4 — restarting app %s after successful restore", stackName) } 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)" } if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 4/4 — restore completed, type=%s", restoreType) } m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s, type=%s", stackName, snapshotID, restoreType) return nil }