v0.275.0: a backup's data and its version travel together (R-696, 07 §6.6, D4 option A); R-695, R-691, R-694
gates / gates (push) Successful in 23s

The unit's data files are stamped with the versions that wrote them; the capture keeps the
definition the data belongs to; a restore never starts data under another version's
definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's
definition); every tier's time is its data's; the conversion-copy release needs a dump on
the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept
view joins the folder's owning group, language switch resyncs (R-691); a restore-generated
login is not shown as the password (R-694). Red-proofs in
felhom.eu/documentation/audits/version-travel-2026-09-26/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-26 10:35:22 +02:00
parent fb2bcdd5d6
commit b6810f14ff
47 changed files with 3771 additions and 135 deletions
@@ -91,6 +91,13 @@ type OffsiteReconstituteResult struct {
// than reporting a bare success — a warning beside a success is read as a success, so the
// difference has to survive into the message.
Placement PlacementCheck
// v0.275.0 (R-696, `07` §6.6) — the versions the snapshot's data belongs to (UnitRestoreResult's
// fields, same meaning). VersionChanged: the app came back at the SNAPSHOT's version, its definition
// written from the snapshot's unit, because the live one was another version.
DataPins []string
DataAt time.Time
VersionChanged bool
VersionsUnknown bool
}
// fullPlaceCopier returns the FULL-restore file copier (nil seam → rsyncRestoreOverwrite).
@@ -693,12 +700,50 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stack)
}
// --- WHICH VERSION COMES BACK (v0.275.0, R-696, `07` §6.6) ---------------------------------
// Until v0.275.0 this path never wrote the definition, so after any update the snapshot's data
// (last night's version) was started by the app's NEW definition — for an engine step that is the
// measured A1 failure (a PostgreSQL 16 datadir under 18: refused, the app left down). Now the
// snapshot's data comes back with the definition it belongs to: when the snapshot records its data's
// versions and they differ from what runs, the snapshot unit's definition is written into the stack
// dir (and pinned) before anything is started, and the normal guarded update climbs from there. A
// snapshot without that record restores as before, WARNed. Decided before the first mutation.
scratchCompose := UnitComposeDir(scratchUnit)
dataPins, verr := unitVersionCheck(stack, man, scratchCompose)
if verr != nil {
m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: the snapshot's definition does not belong to its data — nothing was touched", stack)
return res, verr
}
var snapEnv map[string]string
defineFromSnapshot := false
if dataPins == nil {
res.VersionsUnknown = true
m.logger.Printf("[WARN] [offbox] %s: snapshot %s does not record which versions wrote its data (taken before v0.275.0) — restoring into the app's current definition, as before", stack, id)
} else {
res.DataPins = dataPins
res.DataAt, _ = man.Data.DataTime()
if info, ok := m.stackProvider.GetStackRecoveryInfo(stack); ok && len(definitionPins(info)) > 0 && !samePins(definitionPins(info), dataPins) {
env, _, eerr := m.unitRestoreEnv(stack, scratchCompose, man)
if eerr != nil {
return res, eerr
}
snapEnv, defineFromSnapshot, res.VersionChanged = env, true, true
m.logger.Printf("[INFO] [offbox] %s: snapshot %s holds data of %v (written %s); the app runs %v — it comes back at the snapshot's version", stack, id, dataPins, man.Data.At, definitionPins(info))
}
}
// --- WHICH SERVICE HOLDS THE DATABASE (R-47) ------------------------------------------------
// Read from the LIVE compose, not the scratch one: reconstitution never overwrites the stack dir,
// so the live file is what `docker compose up` will actually act on. Resolved BEFORE the first
// mutation so the refusal below costs nothing.
// Read from the compose that will RUN: the live one, or — when the app comes back at the snapshot's
// version — the snapshot unit's, which is written into the stack dir before the first start. Resolved
// BEFORE the first mutation so the refusal below costs nothing.
var dbServices []string
if composePath, cOK := m.stackProvider.GetStackComposePath(stack); cOK && composePath != "" {
if defineFromSnapshot {
svcs, dsErr := DBServiceNames(filepath.Join(scratchCompose, "docker-compose.yml"))
if dsErr != nil {
m.logger.Printf("[WARN] [offbox] %s: could not read the snapshot's compose services: %v", stack, dsErr)
}
dbServices = svcs
} else if composePath, cOK := m.stackProvider.GetStackComposePath(stack); cOK && composePath != "" {
svcs, dsErr := DBServiceNames(composePath)
if dsErr != nil {
// "cannot tell" is not "no database" — leave dbServices empty and let the gate refuse.
@@ -754,6 +799,16 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if err := m.stackProvider.StopStack(stack); err != nil {
m.logger.Printf("[WARN] [offbox] could not stop %s before reconstitution: %v (continuing)", stack, err)
}
// v0.275.0: the snapshot's own definition, written while nothing of the app's data has been touched
// yet — a failure here restarts the app as it was.
if defineFromSnapshot {
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stack, scratchCompose, snapEnv); err != nil {
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed definition write also failed: %v", stack, sErr)
}
return res, fmt.Errorf("restoring %s: writing the snapshot's definition failed: %w", stack, err)
}
}
copier := m.fullPlaceCopier()
for _, pl := range placements {
if pl.isUnit {