package backup import ( "context" "fmt" "os" "os/exec" "strings" "time" ) // RestoreApp restores an app's data from its on-disk app-data backup. // // Disk-tier (restic snapshot) restore has moved to the host agent. This keep-side // restore re-imports the Docker-volume tar dumps that the app-data backup produced // (AppVolumeDumpPath) and relies on the DB dumps already present on the app's drive. // The stack is stopped before the volume import and restarted after. // // snapshotID is retained for API/UI signature compatibility; with restic removed it // is only used for logging (the source of truth is now the on-disk volume tars). func (m *Manager) RestoreApp(stackName, snapshotID string) error { if m.stackProvider == nil { return fmt.Errorf("stack provider not configured") } 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() }() drivePath := m.GetAppDrivePath(stackName) if drivePath == "" { return fmt.Errorf("cannot determine drive path for %s", stackName) } m.logger.Printf("[INFO] [backup] Starting app-data restore for %s (drive=%s)", stackName, drivePath) // Stop the app before restore if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 1/3 — 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) } // F17: surface a data-restore failure instead of swallowing it. We still bring the app back up so it // isn't left dead, but the error is returned at the end so a failed restore can't read as success. var dataErr error // Populate Docker volumes from restored tars if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 2/3 — restoring Docker volumes for %s", stackName) } if err := m.restoreDockerVolumes(stackName, drivePath); err != nil { m.logger.Printf("[ERROR] RESTORE volume restore failed for %s: %v", stackName, err) dataErr = err } // Restart the app if m.isDebug() { m.logger.Printf("[DEBUG] RestoreApp: step 3/3 — restarting app %s after restore", stackName) } if err := m.stackProvider.StartStack(stackName); err != nil { m.logger.Printf("[WARN] RESTORE could not restart %s after restore: %v", stackName, err) } // F17: replay the captured .sql dump into the now-running DB (the legacy path never did this, so // DB-resident data did not come back). Runs after volume restore so the dump WINS over any tar copy. if _, err := m.reimportDBDumpsCtx(stackName, m.namespaceRoot(drivePath)); err != nil { m.logger.Printf("[ERROR] RESTORE DB re-import failed for %s: %v", stackName, err) if dataErr == nil { dataErr = err } } // Verify app started successfully if err := m.waitForHealthy(stackName, 90*time.Second); err != nil { m.logger.Printf("[WARN] [backup] Restore completed but app health check failed: %v", err) } if dataErr != nil { return fmt.Errorf("restore of %s completed with data errors: %w", stackName, dataErr) } m.logger.Printf("[INFO] RESTORE completed: stack=%s", stackName) return nil } // restoreDockerVolumes populates Docker volumes from tar files in the volume dump directory. func (m *Manager) restoreDockerVolumes(stackName, drivePath string) error { dumpDir := AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName) entries, err := os.ReadDir(dumpDir) if err != nil { if os.IsNotExist(err) { return nil // No volume dumps to restore } return fmt.Errorf("reading volume dump dir: %w", err) } var restored int var failed []string for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar") { continue } volName := strings.TrimSuffix(entry.Name(), ".tar") m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName) // Remove existing volume (ignore errors — may not exist) exec.Command("docker", "volume", "rm", "-f", volName).Run() // Create fresh volume if out, err := exec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil { m.logger.Printf("[ERROR] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err) failed = append(failed, volName) continue } // Populate from tar ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) cmd := exec.CommandContext(ctx, "docker", "run", "--rm", "-v", volName+":/vol", "-v", dumpDir+":/in:ro", "alpine", "tar", "xf", "/in/"+entry.Name(), "-C", "/vol") out, err := cmd.CombinedOutput() cancel() if err != nil { m.logger.Printf("[ERROR] [backup] Failed to populate volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err) failed = append(failed, volName) continue } restored++ if m.isDebug() { m.logger.Printf("[DEBUG] [backup] Volume %s restored successfully", volName) } } if restored > 0 { m.logger.Printf("[INFO] [backup] Restored %d Docker volume(s) for %s", restored, stackName) } // F17: a per-volume failure used to be a swallowed WARN; surface it so the restore is reported as // failed rather than silently partial. if len(failed) > 0 { return fmt.Errorf("failed to restore %d volume(s): %v", len(failed), failed) } return nil } // waitForHealthy waits for a stack to reach running state after restore. // Forces a docker ps refresh on each poll to avoid stale state. func (m *Manager) waitForHealthy(stackName string, timeout time.Duration) error { deadline := time.Now().Add(timeout) interval := 5 * time.Second time.Sleep(3 * time.Second) // initial settling time for time.Now().Before(deadline) { if m.stackProvider == nil { return fmt.Errorf("no stack provider") } if m.stackProvider.RefreshAndIsRunning(stackName) { if m.isDebug() { m.logger.Printf("[DEBUG] [backup] Post-restore health check: %s is running", stackName) } return nil } if m.isDebug() { m.logger.Printf("[DEBUG] [backup] Post-restore health check: %s not yet running, waiting...", stackName) } time.Sleep(interval) } return fmt.Errorf("stack %s did not reach running state within %s after restore", stackName, timeout) }