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
gates / gates (push) Successful in 1m40s
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user