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
+87 -14
View File
@@ -487,7 +487,17 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
data["AutoFieldValues"] = autoFieldValues
// For deployed apps, pass stored field values (decrypted) so fields show current values
if alreadyDeployed && decryptedEnv != nil {
// R-694 (v0.275.0): a login a restore GENERATED is not the app's login (its login came back with
// the data) — its value is never rendered, and the field says to use the old password.
restored := map[string]bool{}
if appCfg != nil {
for _, n := range appCfg.RestoredLogins {
restored[n] = true
delete(decryptedEnv, n)
}
}
data["DeployedFieldValues"] = decryptedEnv
data["RestoredLogins"] = restored
}
// R-351 SCENARIO A — an app being reinstalled so its data can come back should not ask the
// customer to remember what their own backup already recorded. The address and the data folder
@@ -1723,7 +1733,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
// of it, which is the question the sentence exists to answer.
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res))
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(stackName, res.VersionChanged, res.DataPins, res.DataAt)+s.unitRestoreOutcomeMsg(stackName, res))
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
@@ -1780,6 +1790,39 @@ func (s *Server) unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult)
return s.note(unitRestoreSettingsOnlyKey, app)
}
// restoredVersionPrefix is the FIRST sentence after a restore that brought an app back at an older
// version (v0.275.0, `07` §6.6 "Which version a restore brings back", D4 option A): which backup, which
// version, and what happens next — the box climbs it one tested step at a time, or, when no tested step
// leads on from that version, that the box will not update it by itself (a person's press would jump to
// the catalog's current definition, which is not a tested step). "" when the version did not change or
// the app is not behind. Pinned by TestA3_RestoredVersionSentence.
func (s *Server) restoredVersionPrefix(app string, changed bool, pins []string, at time.Time) string {
if !changed || len(pins) == 0 {
return ""
}
pos := s.versionPosition
if pos == nil {
if s.stackMgr == nil {
return ""
}
pos = s.stackMgr.RestoredVersionPosition
}
behind, climbable := pos(app)
if !behind {
return ""
}
key := restoredOlderVersionKey
if !climbable {
key = restoredOlderVersionNoStepKey
}
return s.note(key, app, at.In(getTimezone()).Format("2006-01-02 15:04"), backup.PinsVersion(pins)) + " "
}
const (
restoredOlderVersionKey = "note.restore.older_version"
restoredOlderVersionNoStepKey = "note.restore.older_version_no_step"
)
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
// r353_unit_outcome_test.go — a silent edit to any of them is how an honest message drifts back into a
// comforting one, which is the exact history of the sentence they replace.
@@ -2082,7 +2125,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
s.logger.Printf("[INFO] [web] Tier-2 whole restore completed (async): stack=%s in %s (files restored %d, replaced %d, kept-newer %d, unchanged %d; volumes %d/%d, dbs %d/%d)",
stackName, time.Since(start), res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged,
res.Unit.VolumesReplayed, res.Unit.ManifestVolumes, res.Unit.DBsReplayed, res.Unit.ManifestDBs)
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files)
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(stackName, res.Unit.VersionChanged, res.Unit.DataPins, res.Unit.DataAt)+s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files)
}()
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.full_started"), http.StatusFound)
return
@@ -2120,7 +2163,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
// second thing to keep honest. What IS added is which copy it came from and how old that copy
// is: this action overwrote the customer's live data, and the sentence they are left with has
// to say what it overwrote it with (Scenario E).
msg := s.unitRestoreOutcomeMsg(stackName, res)
msg := s.restoredVersionPrefix(stackName, res.VersionChanged, res.DataPins, res.DataAt) + s.unitRestoreOutcomeMsg(stackName, res)
if src := s.tier2UnitSourceMsg(cov); src != "" {
msg += " " + src
}
@@ -3350,10 +3393,43 @@ func skipFileBrowserPath(path string, isMount func(string) bool) bool {
}
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// R-695 (v0.275.0): SINGLE-FLIGHT. A request is covered by any sync that STARTS reading the state
// after the request was made: a caller that waited behind a running sync returns as soon as one
// such sync has finished, instead of each queued caller restarting the file browser again. Measured
// 2026-09-25 on 9202: two kept-data Deletes in the same second each ran a sync; one restarted the
// file browser with a bind list read before the second Delete, Docker recreated the deleted folder
// empty, and the next sync listed and bound it again.
s.fbReqMu.Lock()
s.fbReqGen++
mine := s.fbReqGen
s.fbReqMu.Unlock()
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
s.fileBrowserMu.Lock()
defer s.fileBrowserMu.Unlock()
s.fbReqMu.Lock()
if !resetDBOnChange && s.fbDoneGen >= mine {
s.fbReqMu.Unlock()
if s.cfg != nil && s.isDebug() {
s.logger.Printf("[DEBUG] [web] FileBrowser sync request %d already covered by a sync that read the state after it", mine)
}
return
}
covers := s.fbReqGen // every request made before THIS read of the state is covered by this sync
s.fbReqMu.Unlock()
defer func() {
s.fbReqMu.Lock()
if covers > s.fbDoneGen {
s.fbDoneGen = covers
}
s.fbReqMu.Unlock()
}()
if s.syncFileBrowserHook != nil {
s.syncFileBrowserHook()
return
}
stackDir := "/opt/docker/stacks/filebrowser"
composePath := stackDir + "/docker-compose.yml"
@@ -3407,9 +3483,13 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// `09` §3 decision 36: the read-only „Megőrzött adatok" source — one `:ro` bind per kept item, and
// the source only when there is at least one (a source with no mount is a broken sidebar entry, R-67).
keptLabel := ""
if kb := s.keptFileBrowserBinds(); len(kb) > 0 {
storageMounts = append(storageMounts, kb...)
keptLabel = s.msgLang(s.boxLang(), "kept.fb_source")
var keptGroups []int
if s.stackMgr != nil {
if items := s.stackMgr.ListKept(s.keptDrives()); len(items) > 0 {
storageMounts = append(storageMounts, keptBindLines(items)...)
keptLabel = s.msgLang(s.boxLang(), "kept.fb_source")
keptGroups = keptReadGroups(items, statOwner)
}
}
configPath := stackDir + "/config.yaml"
@@ -3436,7 +3516,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
}
// Generate and write compose (includes config.yaml mount)
compose := generateFileBrowserCompose(domain, storageMounts)
compose := infra.RenderFileBrowserCompose(domain, storageMounts, keptGroups...)
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
return
@@ -3574,13 +3654,6 @@ func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
}
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
// pinned image and the base-infra bring-up path can never diverge).
func generateFileBrowserCompose(domain string, storageMounts []string) string {
return infra.RenderFileBrowserCompose(domain, storageMounts)
}
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
// registered storage path. Delegates to internal/infra (single source of truth).
func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {