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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user