v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s

This commit is contained in:
2026-10-16 22:46:00 +02:00
parent 00a0d91976
commit f75b3aa778
212 changed files with 2414 additions and 398 deletions
@@ -828,12 +828,31 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
}
}
// --- THE DATABASE PASSWORD COMES BACK WITH THE DATA (R-1106, `07` §7.4, decision 1A) ---------
// When the app keeps its own definition (same version), its app.yaml still holds the CURRENT install's database
// password — after a remove + reinstall, a freshly generated one that cannot open the data directory this restore
// is about to put back (measured 2026-10-16: Rallly crash-looped on auth, Grimmory was held). So when the snapshot
// replays a database service's data VOLUME, the snapshot's password is written into the app's settings before
// anything starts. A snapshot that replays only a logical dump leaves the current password alone — the dump goes
// into the current data directory, which the current password opens. Decided before the first mutation; a
// refusal touches nothing. (The other branch already gets this through unitRestoreEnv.)
var credOverlay map[string]string
if !defineFromSnapshot {
ov, oErr := m.offsiteCredentialOverlay(stack, scratchUnit, scratchCompose, man)
if oErr != nil {
return res, oErr
}
credOverlay = ov
}
definitionMoved := defineFromSnapshot || len(credOverlay) > 0
// --- THE LIVE DEFINITION, KEPT FOR A FAILED REPLAY (R-893, `09` §3 decision 192) -------------
// When the snapshot's definition is about to be written, keep the LIVE one first, so a failed
// replay below can write it back before the app is held. Taken before the first mutation; a
// failure here refuses with nothing touched — without it the failure branch could not be undone.
// R-1106: the same when only the database password is about to move.
var liveDef *liveDefinition
if defineFromSnapshot {
if definitionMoved {
ld, ldErr := m.captureLiveDefinition(stack)
if ldErr != nil {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: could not keep the live definition: %v — nothing was touched", stack, ldErr)
@@ -919,6 +938,14 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
}
return res, fmt.Errorf("restoring %s: writing the snapshot's definition failed: %w", stack, err)
}
} else if len(credOverlay) > 0 {
if err := m.stackProvider.(secretValueSetter).SetStackSecretValues(stack, credOverlay); err != nil {
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed password write also failed: %v", stack, sErr)
}
return res, fmt.Errorf("restoring %s: writing the backup's database password failed: %w", stack, err)
}
m.logger.Printf("[INFO] [offbox] %s: the backup's database password(s) %v written into the app's settings before the data (R-1106)", stack, credNames(credOverlay))
}
copier := m.fullPlaceCopier()
for _, pl := range placements {
@@ -943,7 +970,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if cErr != nil {
// R-893 option C (security review 2026-10-08): once the snapshot's (possibly older) definition
// is written, a start would run that version on the live data — HOLD the app instead.
if defineFromSnapshot {
if definitionMoved {
m.holdAppAfterMixedRestore(stack, cErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -977,7 +1004,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// already be the snapshot's — HOLD the app rather than start it on a mix.
// R-893 option C (security review 2026-10-08): a volume already replaced, or the snapshot's
// definition written → HOLD (the volume detail reaches the operator through the hold notice).
if defineFromSnapshot || nVols > 0 {
if definitionMoved || nVols > 0 {
m.holdAppAfterMixedRestore(stack, vErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -997,7 +1024,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if hasDB {
if err := m.stackProvider.StartStackServices(stack, dbServices); err != nil {
// R-893 option C (security review 2026-10-08): volumes or the definition already moved → HOLD.
if defineFromSnapshot || res.VolumesReplayed > 0 {
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, err, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
}
@@ -1044,7 +1071,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// Pinned by r893_mixed_restore_test.go; the plain case keeps TestR379_ScenarioA.
// Placed FILES alone do not hold (option C as ruled): that mix is what „put back exactly as it
// was" (option A, the next slice) removes.
if defineFromSnapshot || res.VolumesReplayed > 0 {
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, iErr, liveDef)
return res, util.MsgError("err.backup.db_restore_failed_held_mixed", stack)
}