Files
felhom-controller/controller/internal/backup/tier2_restore.go
T
admin fd50a73e65 C9-F1 + C9-F2: a restore that restored nothing, and a crash loop nobody saw (v0.183.0)
Both are the system reporting healthy while the customer is not, and both live in the same
status-derivation code. Neither is fixed by making the system quieter.

C9-F1 (HIGH) — Tier-2 writes recovery-unit/ on EVERY run and RestoreTier2Files has never read
it (tier2_restore.go:101-104 reads hdd/ + userdata/ only). Phase 0 enumerated all 53 catalog
templates against both demo boxes: 43 apps have NO readable subtree, so the button stopped the
app, restored 0 files, restarted it and said "Nincs hiányzó fájl — minden fájl megvan a helyén."
— at the moment the customer pressed it because files were missing, with 156 MB of BookStack's
data unread in the same copy. 9 apps have file legs but never their DB or volumes, so the same
sentence was also a clean bill of health over data never opened (immich: 1.3 GB Postgres unit).

Honesty half shipped: a pre-flight coverage check refuses UP FRONT without stopping the app and
NAMES the action that works; a run that proceeds claims only what it EXAMINED and discloses that
the database and volumes are not covered. Completeness is filed as C9-F1b — routing to the
Tier-1 unit restore puts a destructive operation behind a non-destructive button, so its confirm
copy has to carry that difference. C9-F4 filed: nothing reads the Tier-2 recovery-unit/ mirror,
so the second local copy that exists for drive loss is unreachable by any customer action.

C9-F2 (HIGH) — a crash loop was counted as working. StateRestarting is deliberately NOT added to
IsDownState (that alarms on every deploy fleet-wide, the over-correction F-A1 nearly cost us); a
sustained run becomes down after crashLoopAfter = 5m, set above the 120s deploy timeout, Mealie's
60s start_period and R-97b's 180s grace. The dashboard counter uses the same predicate, so it no
longer contradicts the alarm on the same screen. README's claim that faults "still surface as
restarting" was a wish with no test — corrected in place; it is the seventh such instance.

Six red-proofs observed, including the one that matters most: adding StateRestarting to
IsDownState fails the brief-restart test with "every deploy and update would page the operator".
go test ./... rc=0, 27 packages, run and read separately from this commit.
2026-07-28 18:53:56 +02:00

245 lines
11 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")
// errTier2OldLayout (3b, §7-G2): the recorded copy predates the v2 relpath-mirroring layout (no
// marker). Refuse rather than read a flat layout we no longer understand — safe, because tier-2
// restore is missing-file recovery and the live data still exists in that scenario.
errTier2OldLayout = errors.New("A 2. mentés régi formátumú — futtass előbb egy új másodlagos mentést.")
// ErrTier2NoRestorableData (C9-F1) — this app HAS a Tier-2 copy, but that copy contains no subtree
// this restore can read: its data lives entirely in Docker named volumes, which are captured into
// recovery-unit/ (db-dumps + volume-dumps) and NEVER read by this path. 43 of the 53 catalog apps
// are in this class. Exported so the handler can refuse BEFORE stopping the app and name the action
// that does work, instead of taking an outage and reporting "no missing files".
ErrTier2NoRestorableData = errors.New("ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza")
)
// Tier2Coverage says what a Tier-2 restore can and cannot return for one app — the asymmetry C9-F1
// is about. Computed from the RECORDED copy on disk, never guessed from the catalog, so an app whose
// template changed is judged by what its actual copy holds.
//
// The distinction that matters: Legs are the subtrees RestoreTier2Files reads (hdd/, userdata/);
// HasUnit means the copy ALSO holds a full recovery unit — the app's database dumps and named-volume
// tarballs — which this restore path never opens. An app can have HasUnit && no Legs (43 of 53), in
// which case the restore is a guaranteed no-op no matter how much data was lost.
type Tier2Coverage struct {
Legs []string // subtrees this restore reads and that exist in the copy: "hdd", "userdata"
HasUnit bool // recovery-unit/ present — captured, but NOT restorable by this path
}
// CanRestore reports whether the restore has any subtree to read at all.
func (c Tier2Coverage) CanRestore() bool { return len(c.Legs) > 0 }
// tier2CoverageAt inspects a resolved copy directory. Pure filesystem stat — no side effects.
func tier2CoverageAt(destBase string) Tier2Coverage {
var c Tier2Coverage
for _, leg := range []string{"hdd", "userdata"} {
if fi, err := os.Stat(filepath.Join(destBase, leg)); err == nil && fi.IsDir() {
c.Legs = append(c.Legs, leg)
}
}
if fi, err := os.Stat(filepath.Join(destBase, "recovery-unit")); err == nil && fi.IsDir() {
c.HasUnit = true
}
return c
}
// Tier2RestoreCoverage resolves the app's RECORDED Tier-2 copy and reports what a restore could
// return from it. Errors are the same refusals RestoreTier2Files itself would raise, so the caller
// can surface them before starting anything — this is what lets the handler refuse without an outage.
func (m *Manager) Tier2RestoreCoverage(stackName string) (Tier2Coverage, error) {
destBase, err := m.tier2RecordedCopyDir(stackName)
if err != nil {
return Tier2Coverage{}, err
}
return tier2CoverageAt(destBase), nil
}
// tier2RecordedCopyDir resolves the RECORDED Tier-2 copy dir for a stack, applying every
// source-side refusal in one place so the pre-flight check and the restore itself cannot drift.
func (m *Manager) tier2RecordedCopyDir(stackName string) (string, error) {
var destBase string
if m.settings != nil {
if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil && cfg.LastRun != "" && cfg.DestinationPath != "" {
if m.settings.IsDisconnected(cfg.DestinationPath) {
return "", errTier2DriveGone
}
destBase = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName)
}
}
if destBase == "" {
return "", errNoTier2Copy
}
if _, statErr := os.Stat(destBase); statErr != nil {
return "", errNoTier2Copy // recorded but the copy dir is gone — same honest refusal
}
// §7-G2 marker gate: a pre-v2 (flat) copy has no marker → refuse rather than read a layout we no
// longer understand (live data still exists for missing-file recovery).
if _, mErr := os.Stat(filepath.Join(destBase, tier2LayoutMarker)); mErr != nil {
return "", errTier2OldLayout
}
return destBase, nil
}
// 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)
}
}
// v2 relpath-mirroring: liveNsRoot == the app's HDD_PATH (Model A). The dest hdd/ and userdata/
// subtrees mirror the live relpath structure exactly, so restore is two whole-subtree merges (N>1
// dirs + nested binds handled natively — no per-appdata-dir resolution, no N>1 refusal).
liveNsRoot := m.namespaceRoot(drive)
// Source side: the RECORDED Tier-2 copy must exist, its drive connected, and it must be v2.
destBase, err := m.tier2RecordedCopyDir(stackName)
if err != nil {
return 0, err
}
// C9-F1: refuse BEFORE the app is stopped if this copy holds nothing this path can read. Without
// this the app was stopped, zero files were copied, it was restarted, and the customer was told
// "Nincs hiányzó fájl — minden fájl megvan a helyén." — an outage plus a claim about data the
// restore never looked at. Placed with the other source-side refusals, all of which precede the
// stop, so the promise "all refusals happen BEFORE the app is stopped" stays true.
cov := tier2CoverageAt(destBase)
if !cov.CanRestore() {
m.logger.Printf("[WARN] [backup] Tier-2 file restore refused for %s: the recorded copy has no restorable subtree (unit_present=%v) — the app was NOT stopped",
stackName, cov.HasUnit)
return 0, ErrTier2NoRestorableData
}
copier := m.restoreFilesCopier
if copier == nil {
copier = rsyncRestoreMissing
}
// The two v2 subtree merges: destBase/hdd/<rel> ↔ liveNsRoot/<rel>;
// destBase/userdata/<rel> ↔ liveNsRoot/userdata/<rel>. Each missing-only, additive.
merges := []struct{ src, dst string }{
{filepath.Join(destBase, "hdd"), liveNsRoot},
{filepath.Join(destBase, "userdata"), filepath.Join(liveNsRoot, "userdata")},
}
m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s (v2) → %s (additive-only)", stackName, destBase, liveNsRoot)
// 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()
var copyErr error
for _, mg := range merges {
if _, err := os.Stat(mg.src); err != nil {
continue // that subtree is absent in this copy (e.g. no userdata legs) — skip
}
n, err := copier(mg.src, mg.dst)
filesRestored += n
if err != nil {
copyErr = err
break
}
}
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
}