30b11100e0
Closes the engine half of drill finding F2: user files under appdata/<stack> had no customer recovery path (operator copy-back only). - RestoreTier2Files(stack): single-flight with backup/restore; ALL refusals before any stop (no Tier-2 record / LastRun empty / copy dir absent → "nincs másodlagos fájlmásolat"; Tier-2 drive disconnected; live drive disconnected/decommissioned — Hungarian, flash-ready); source is the RECORDED CrossDriveBackup.DestinationPath (never a fresh selectTier2Target); stop → copy → start → waitForHealthy; copy/restart errors surface (F17). - rsyncRestoreMissing: rsyncMirror's exec shape with the OPPOSITE-direction flags: -a --ignore-existing --itemize-changes — existing live files are never overwritten, nothing is ever deleted (the --delete trap this task exists to avoid). Count = ">f" itemize lines (pure countRestoredFiles). - restoreFilesCopier seam so orchestration tests never shell out; the one FS-level test of the real rsync is LookPath-guarded (runs on the Linux build server + live validation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
148 lines
6.3 KiB
Go
148 lines
6.3 KiB
Go
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/<stack>. 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
|
|
}
|