D22: every restore puts the data back exactly on failure and after a power cut (R-1177, R-1195); whole restore asks the unit first (R-1178); no mirror during a restore (R-1180); W22 restore refusals in Hungarian; unreleased
gates / gates (push) Successful in 1m40s

This commit is contained in:
2026-10-21 21:19:46 +02:00
parent 7848d4aaac
commit d582feb245
25 changed files with 1065 additions and 71 deletions
+113 -36
View File
@@ -149,6 +149,8 @@ func hasReplayableDump(dumpDir string) bool {
type UnitRestoreResult struct {
// VolumesReplayed is how many named-volume tars were unpacked into live Docker volumes.
VolumesReplayed int
// Stopped (R-1178) is set the moment the restore stops the app: false means it refused before touching anything.
Stopped bool
// DBsReplayed is how many .sql dumps were imported. Never inferred from the presence of a database
// service — only a completed import increments it.
DBsReplayed int
@@ -186,6 +188,10 @@ type UnitRestoreResult struct {
// branch with errors.Is. The customer sentence is the bundle's.
var ErrUnitVersionMismatch = errors.New("the recovery unit's definition is not the one its data belongs to")
// ErrRestoreBusy is the KIND of the refusal while another backup or restore holds the single-writer flag (W22: the
// household reads err.backup.restore_busy, never the English text).
var ErrRestoreBusy = errors.New("backup or restore already in progress")
// unitVersionCheck is A3's rule, as a pure function of the manifest and the unit's own compose file:
// known and matching → the pins to report; unknown → (nil, nil) and the caller WARNs; mixed or
// mismatched → the refusal.
@@ -278,6 +284,13 @@ type UnitRestoreOptions struct {
// It is a per-call argument and never a field on the Manager: a consent that outlives the act it
// was given for is not consent.
AcceptMissingFiles bool
// CheckOnly (R-1178) runs every refusal — the definition/data versions, the secrets, the database copy, the
// allow-list, the room for the live copy (D22) — and returns before anything is stopped or changed. The second
// drive's whole restore asks it BEFORE its file half, so a refusal never follows changed files.
CheckOnly bool
// FilesPlaced (R-1178) is how many files the caller already put back before this unit restore: a put-back then
// says those files stay, never „nothing changed".
FilesPlaced int
}
// ErrUnitLacksFileLegs is the refusal R-538 asks for. It names the app and the paths that are NOT in
@@ -321,7 +334,7 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
m.mu.Lock()
if m.running {
m.mu.Unlock()
return res, fmt.Errorf("backup or restore already in progress")
return res, util.MsgErrorf(ErrRestoreBusy, "err.backup.restore_busy", stackName)
}
m.running = true
m.mu.Unlock()
@@ -337,10 +350,14 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
// SOURCE path — that is what unitDir is for.
drivePath := m.GetAppDrivePath(stackName)
if drivePath == "" || !filepath.IsAbs(drivePath) {
return res, fmt.Errorf("cannot determine drive path for %s", stackName)
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: cannot determine its drive path", stackName)
return res, util.MsgError("err.backup.restore_no_drive", stackName)
}
manifest := readManifest(UnitManifestFile(unitDir))
if manifest == nil && opt.CheckOnly {
return res, fmt.Errorf("no readable recovery unit for %s at %s", stackName, unitDir)
}
if manifest == nil {
m.logger.Printf("[WARN] [backup] No readable recovery unit for %s at %s — falling back to volume-only restore", stackName, unitDir)
m.mu.Lock()
@@ -422,13 +439,84 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
return res, util.MsgError("err.stacks.definition_refused", stackName, err.Error())
}
// Stop, restore named-volume data, recreate the definition, replay the DB with ONLY the database
// service running, and only then start the whole stack.
// F17: surface a data-restore failure instead of swallowing it (we still bring the app back up).
var dataErr error
// --- D22 (operator ruling 2026-10-21 20:05; R-1177): PUT BACK EXACTLY ON ANY FAILURE ------------------------
// The off-site restore's put-back (R-893 option A, offbox_restore_undo.go) now covers the local restore too.
// MEASURED before (audits/dataloss-hunt-2026-10-20/C3-R1-local-restore-9202/): a damaged volume tar → the live
// volume removed, `tar: short read`, the app started → Postgres crash-looped and the household's newest row was
// gone, with no copy anywhere. Now, BEFORE anything stops: the live volumes the unit will replace are measured and
// must fit beside the unit (the same rule as the off-site restore); the live definition is kept. RIGHT AFTER THE
// STOP they are copied; any failure after that pours them back, writes the live definition back and starts the
// app. A put-back that fails HOLDS the app and keeps the copy (unchanged R-893 hold). Pinned by
// r1177_local_putback_test.go.
nsRoot := m.namespaceRoot(drivePath)
undoVols := snapshotVolumeNames(UnitVolumeDumpDir(unitDir))
var undoExists map[string]bool
if len(undoVols) > 0 {
volNeed, ex, pErr := m.planLiveVolumeCopy(undoVols)
if pErr != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: the live volumes could not be measured for their copy: %v — the app was NOT stopped", stackName, pErr)
return res, util.MsgError("err.backup.restore_undo_unmeasured", stackName)
}
if free := m.freeBytes(nsRoot); free >= 0 && free < volNeed {
m.logger.Printf("[WARN] [backup] Restore REFUSED for %s: the copy of the live volumes needs %d B, %d B is free on %s — the app was NOT stopped", stackName, volNeed, free, nsRoot)
return res, util.MsgError("err.backup.restore_undo_no_space", humanizeBytes(volNeed), humanizeBytes(free))
}
undoExists = ex
}
if opt.CheckOnly {
return res, nil
}
liveDef, ldErr := m.captureLiveDefinition(stackName)
if ldErr != nil {
// No live definition to keep (an app being brought back from a kept unit has none yet): a put-back then
// pours the volumes back under the unit's definition. Logged, never a refusal — the restore is its route back.
m.logger.Printf("[WARN] [backup] %s: no live definition to keep for a put-back (%v) — a failed restore would put back the volumes only", stackName, ldErr)
liveDef = nil
} else {
defer liveDef.cleanup()
}
// R-166: the stop→restore→start window is marked BEFORE the stop, so a controller killed inside it is owed a
// restart — and, once the copy is whole, a put-back (R-1195, restore_crash_putback.go).
if err := m.appStop.Begin("unit-restore:"+stackName, ReasonUnitRestore, []string{stackName}); err != nil {
return res, util.MsgError("err.backup.a_z_leallitasa_elotti_jelolo_nem", stackName, err)
}
restartStack := func() error {
err := m.stackProvider.StartStack(stackName)
if err == nil {
m.appStop.End()
} else {
m.appStop.ReleaseFailed(stackName)
}
return err
}
// Stop, copy the live volumes, restore named-volume data, recreate the definition, replay the DB with ONLY the
// database service running, and only then start the whole stack.
res.Stopped = true
if err := m.stackProvider.StopStack(stackName); err != nil {
m.logger.Printf("[WARN] [backup] could not stop %s before restore: %v (continuing)", stackName, err)
}
var undo *liveVolumeCopy
if len(undoVols) > 0 {
c, cErr := m.copyLiveVolumes(stackName, nsRoot, undoVols, undoExists)
if cErr != nil {
m.logger.Printf("[ERROR] [backup] %s: the copy of the live volumes failed: %v — nothing was restored, starting the app as it was", stackName, cErr)
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [backup] %s: restart after a failed volume copy also failed: %v", stackName, sErr)
}
return res, util.MsgError("err.backup.restore_undo_copy_failed", stackName)
}
undo = c
defer undo.drop(m, stackName)
m.persistUndoForCrash(stackName, undo) // R-1195: from here a power cut puts the app back on start
}
// moved: a live volume may have changed. With a copy that is always so (the replay removes before it recreates);
// without one (the unit holds no volume) only a database replay that began can have changed data — and with no
// copy to pour back that is the R-379 half state: held, never started (operator ruling 2026-08-22).
putBack := func(cause error, holdKey string, moved bool) (UnitRestoreResult, error) {
return res, m.putBackAfterFailedRestore(stackName, cause, liveDef, undo, moved || undo != nil, opt.FilesPlaced, restartStack, holdKey)
}
// R-353: the count is captured even when the replay errors — a partial replay is a fact the
// customer's sentence has to be built from, and discarding it on the error path is how Scenario C
// would end up wearing Scenario B's wording.
@@ -445,52 +533,41 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
replayed, volErr := volReplay(stackName, UnitVolumeDumpDir(unitDir))
res.VolumesReplayed = replayed
if volErr != nil {
m.logger.Printf("[ERROR] [backup] volume restore for %s: %v", stackName, volErr)
dataErr = volErr
// R-638 (option A, 09 §3 decision 154) still holds — the database copy is never replayed over a volume that is
// not known to be the copy's own — and since D22 nothing else runs on it either: the live volumes go back.
// Pinned by TestR638_UnitVolumeFailureNeverCallsTheImporter and TestR1177_*.
m.logger.Printf("[ERROR] [backup] volume restore for %s: %v — NOT replaying the database copy; putting the live data back", stackName, volErr)
return putBack(volErr, "err.backup.restore_failed_held_mixed", replayed > 0)
}
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stackName, composeDir, fullEnv); err != nil {
return res, fmt.Errorf("recreating %s from unit: %w", stackName, err)
m.logger.Printf("[ERROR] [backup] recreating %s from unit: %v — putting the live data back", stackName, err)
return putBack(err, "err.backup.restore_failed_held_mixed", false)
}
// F17: the captured .sql dump is the authoritative logical DB state — replay it AFTER the volume
// restore, so the dump WINS over any volume-tar copy of the database.
// R-47: the replay happens with ONLY the database service up. This used to run after
// RecreateStackFromUnit had already brought the WHOLE stack up, letting the application rebuild
// schema objects underneath the replay (H4, DIAG-immich-restore-round2-2026-07-19).
//
// R-638 (option A, 09 §3 decision 154): NOT when the volume leg failed. The replay overlays the copy
// and removes only what the copy knows about, so it is safe only over the copy's OWN database files
// — which a failed volume leg does not guarantee (the volume may still be the live, newer one, or
// half-filled). The failure is already in dataErr and returned below; the definition is still the
// unit's and the app is still started, exactly as before. Pinned by
// TestR638_UnitVolumeFailureNeverCallsTheImporter.
if hasDumps && volErr != nil {
m.logger.Printf("[ERROR] [backup] %s: NOT replaying the database copy — the volume restore failed, so the database is not known to be the copy's own", stackName)
} else if hasDumps {
if hasDumps {
if err := m.stackProvider.StartStackServices(stackName, dbServices); err != nil {
m.logger.Printf("[ERROR] [backup] DB-only start for %s: %v", stackName, err)
if dataErr == nil {
dataErr = err
}
} else if n, err := m.reimportDBDumpsAtCtx(stackName, dbDumpDir); err != nil {
res.DBsReplayed = n // partial credit: whatever imported before the failure really did import
m.logger.Printf("[ERROR] [backup] DB re-import for %s: %v", stackName, err)
if dataErr == nil {
dataErr = err
}
} else {
res.DBsReplayed = n
m.logger.Printf("[ERROR] [backup] DB-only start for %s: %v — putting the live data back", stackName, err)
return putBack(err, "err.backup.restore_failed_held_mixed", false)
}
n, err := m.reimportDBDumpsAtCtx(stackName, dbDumpDir)
res.DBsReplayed = n // partial credit: whatever imported before the failure really did import
if err != nil {
m.logger.Printf("[ERROR] [backup] DB re-import for %s: %v — putting the live data back", stackName, err)
return putBack(err, "err.backup.db_restore_failed_held_mixed", true)
}
}
if err := m.stackProvider.StartStack(stackName); err != nil {
return res, fmt.Errorf("starting %s after restore from unit: %w", stackName, err)
if err := restartStack(); err != nil {
m.logger.Printf("[ERROR] [backup] starting %s after restore from unit: %v — putting the live data back", stackName, err)
return putBack(err, "err.backup.restore_failed_held_mixed", false)
}
if err := m.waitForHealthy(stackName, healthTimeout); err != nil {
m.logger.Printf("[WARN] [backup] %s restored but health check failed: %v", stackName, err)
}
if dataErr != nil {
return res, fmt.Errorf("restore of %s from unit completed with data errors: %w", stackName, dataErr)
}
m.logger.Printf("[INFO] [backup] Restore-from-unit completed: %s — %d volume(s) of %d listed, %d database(s) of %d listed",
stackName, res.VolumesReplayed, res.ManifestVolumes, res.DBsReplayed, res.ManifestDBs)
// Slice 4: the app is back on its unit's definition and data and was started — the route back an