package backup import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // Tier-2 in-place file restore (TASK C2, closes drill finding F2): the customer-facing recovery for // class-C data — HDD bind-mount user files under appdata/. Restores MISSING files from the // recorded Tier-2 copy back into the live appdata dir, and touches NOTHING else: // // - a file that exists live is NEVER overwritten (a customer edit after the last Tier-2 run wins); // - a live file absent from the backup is NEVER deleted (that is what rsyncMirror's --delete would // do in this direction — the catastrophic trap this helper exists to avoid); // - only files present in the copy and missing live are copied back (attrs preserved). // // This exactly serves the "I deleted my files" scenario. Corruption / point-in-time rollback stays // with the offbox restore-to-verify + operator paths — deliberately out of scope. // Refusal reasons (customer-readable — they surface verbatim in the flash message). var ( errNoTier2Copy = errors.New("nincs másodlagos fájlmásolat ehhez az alkalmazáshoz") errTier2DriveGone = errors.New("a másodlagos meghajtó nincs csatlakoztatva") errLiveDriveGone = errors.New("az alkalmazás meghajtója nincs csatlakoztatva") errLiveDriveDecommed = errors.New("az alkalmazás meghajtója le van szerelve") ) // RestoreTier2Files restores the app's MISSING user files in place from its recorded Tier-2 copy // (additive-only; see the package comment above). Returns how many regular files were copied back. // // The source is the RECORDED Tier-2 destination (settings.CrossDriveBackup.DestinationPath) — never // a fresh selectTier2Target, which could re-pick a different (empty) drive and "restore" nothing. // All refusals happen BEFORE the app is stopped. Stop-first is the locked consistency policy: the // app must not be reorganizing its data dir mid-copy. func (m *Manager) RestoreTier2Files(stackName string) (filesRestored int, err error) { if m.stackProvider == nil { return 0, fmt.Errorf("stack provider not configured") } if err := m.acquireRunning(); err != nil { return 0, err // shares the backup/restore single-flight — must not race a running backup } defer m.releaseRunning() // Live side: the app's drive must be present and in service. drive := m.GetAppDrivePath(stackName) if drive == "" || !filepath.IsAbs(drive) { return 0, fmt.Errorf("cannot determine drive path for %s", stackName) } if m.settings != nil { if m.settings.IsDisconnected(drive) { return 0, fmt.Errorf("%w (%s)", errLiveDriveGone, drive) } if m.settings.IsDecommissioned(drive) { return 0, fmt.Errorf("%w (%s)", errLiveDriveDecommed, drive) } } liveDir := AppDataDir(m.namespaceRoot(drive), stackName) // Source side: the RECORDED Tier-2 copy must exist and its drive must be connected. var srcDir string if m.settings != nil { if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil && cfg.LastRun != "" && cfg.DestinationPath != "" { if m.settings.IsDisconnected(cfg.DestinationPath) { return 0, errTier2DriveGone } // Same layout literals as RunTier2's destBase + the appdata leg. srcDir = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName, "appdata") } } if srcDir == "" { return 0, errNoTier2Copy } if _, statErr := os.Stat(srcDir); statErr != nil { return 0, errNoTier2Copy // recorded but the copy dir is gone — same honest refusal } m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s → %s (additive-only)", stackName, srcDir, liveDir) copier := m.restoreFilesCopier if copier == nil { copier = rsyncRestoreMissing } // Stop → copy → start → health (the standard restore shape; F17: errors surface, never swallowed). if stopErr := m.stackProvider.StopStack(stackName); stopErr != nil { m.logger.Printf("[WARN] [backup] could not stop %s before Tier-2 file restore: %v (continuing)", stackName, stopErr) } start := time.Now() filesRestored, copyErr := copier(srcDir, liveDir) startErr := m.stackProvider.StartStack(stackName) if startErr != nil { m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr) } if healthErr := m.waitForHealthy(stackName, 90*time.Second); healthErr != nil { m.logger.Printf("[WARN] [backup] %s Tier-2 file restore done but health check failed: %v", stackName, healthErr) } if copyErr != nil { return filesRestored, fmt.Errorf("fájlmásolás sikertelen: %w", copyErr) } if startErr != nil { return filesRestored, fmt.Errorf("%d fájl visszaállítva, de az alkalmazás újraindítása sikertelen: %w", filesRestored, startErr) } // Privacy: count + duration only — customer file names never at INFO. m.logger.Printf("[INFO] [backup] Tier-2 file restore completed for %s: %d file(s) restored (%s)", stackName, filesRestored, time.Since(start).Round(time.Second)) return filesRestored, nil } // rsyncRestoreMissing copies the files MISSING from dst back from src, and nothing else: // `rsync -a --ignore-existing` — existing dst files are never overwritten, and (unlike rsyncMirror, // which carries --delete for the backup direction) nothing at dst is ever deleted. Returns the // number of regular files transferred, counted from --itemize-changes output. func rsyncRestoreMissing(src, dst string) (int, error) { if err := os.MkdirAll(dst, 0755); err != nil { return 0, fmt.Errorf("mkdir %s: %w", dst, err) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) defer cancel() // Trailing slashes: copy the CONTENTS of src into dst (same shape as rsyncMirror). cmd := exec.CommandContext(ctx, "rsync", "-a", "--ignore-existing", "--itemize-changes", strings.TrimRight(src, "/")+"/", strings.TrimRight(dst, "/")+"/") out, err := cmd.CombinedOutput() if err != nil { return 0, fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) } return countRestoredFiles(string(out)), nil } // countRestoredFiles counts the itemize-changes lines that mark a TRANSFERRED regular file (">f…"). // Created dirs ("cd…") and symlinks ("cL…") are not counted — the flash reports files. Pure // (unit-tested without rsync). func countRestoredFiles(itemizedOut string) int { n := 0 for _, line := range strings.Split(itemizedOut, "\n") { if strings.HasPrefix(line, ">f") { n++ } } return n }