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
+57 -3
View File
@@ -69,6 +69,11 @@ type RecoveryManifest struct {
// never claim a coherence it did not establish — it carries the prior stamp forward instead.
OffsiteRunID string `json:"offsite_run_id,omitempty"`
DumpsAt string `json:"dumps_at,omitempty"` // RFC3339 UTC — when this run's dump leg finished
// Data (v0.275.0, R-696) is the account of the DATA this unit holds: its time and the versions that
// wrote it (data_versions.go). compose/ holds the definition THESE pins name; ImagePins above are the
// app's CURRENT pins — the two differ between an update and the next data run. Nil = unknown (a unit
// written before v0.275.0, or one with an unstamped data file): it restores as before, with a WARN.
Data *UnitData `json:"data,omitempty"`
}
// SetVersion records the controller version stamped into recovery-unit manifests.
@@ -92,7 +97,15 @@ func (m *Manager) SetTier2Notifier(fn func(stackName, destLabel string, dur time
// Idempotent: it builds the captured content in memory first and SKIPS all writes when the unit is
// already current (same config checksums, same dump set, same controller version) — so it can run on
// the periodic status refresh without thrashing a spinning USB drive.
//
// v0.275.0 (R-696): CaptureRecoveryUnit is the capture AFTER a data run (it follows the legs in
// RunAppBackupNow). The periodic refresh calls captureRecoveryUnit(…, false), which never moves the
// unit's data time and never rewrites the definition away from the data it belongs to.
func (m *Manager) CaptureRecoveryUnit(stackName string) error {
return m.captureRecoveryUnit(stackName, true)
}
func (m *Manager) captureRecoveryUnit(stackName string, dataRun bool) error {
if m.stackProvider == nil {
return fmt.Errorf("no stack provider")
}
@@ -161,6 +174,38 @@ func (m *Manager) CaptureRecoveryUnit(stackName string) error {
manifestPath := RecoveryUnitManifestPath(nsRoot, stackName)
cur := readManifest(manifestPath)
unitDir := RecoveryUnitPath(nsRoot, stackName)
composeDir := RecoveryUnitComposePath(nsRoot, stackName)
// v0.275.0 (R-696): the data's own account, folded from the stamps the legs wrote.
var prevData *UnitData
if cur != nil {
prevData = cur.Data
}
curPins := definitionPins(info)
data := foldUnitData(unitDir, dbDumps, volDumps, dataRun, time.Now(), curPins, info.InstalledImages, prevData)
// THE DEFINITION STAYS WITH ITS DATA. When the app's pins have moved since the data was written (an
// update between two data runs), compose/ keeps the definition the data belongs to until the next data
// run replaces the data — the refresh used to rewrite it here within five minutes of every update,
// pairing the new version with the old data (A1: a 16 datadir under an 18 definition; the restore left
// the app down). The checksums then describe what compose/ really holds, so the already-current check
// below does not rewrite the manifest on every refresh.
frozen := data != nil && !data.Mixed && !samePins(data.ImagePins, curPins)
if frozen {
files, checksums, configFiles = nil, map[string]string{}, nil
for _, fname := range []string{"docker-compose.yml", ".felhom.yml", "app.yaml"} {
b, err := os.ReadFile(filepath.Join(composeDir, fname))
if err != nil {
continue
}
checksums[fname] = sha256Hex(b)
configFiles = append(configFiles, fname)
}
if held := ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml")); !samePins(held, data.ImagePins) {
m.logger.Printf("[WARN] [backup] %s: the unit's definition %v matches neither its data %v nor the app %v — kept as it is; a restore of this unit will refuse", stackName, held, data.ImagePins, curPins)
}
}
// R-43/R-44: the coherence stamp of the offsite run currently in flight ("" on the periodic
// refresh and on the local dump run). When empty we CARRY THE PRIOR STAMP FORWARD rather than
@@ -193,11 +238,16 @@ func (m *Manager) CaptureRecoveryUnit(stackName string) error {
stringMapEqual(cur.Checksums, checksums) &&
stringSliceEqual(cur.DBDumps, dbDumps) &&
stringSliceEqual(cur.VolumeDumps, volDumps) &&
stringSliceEqual(cur.ImagePins, info.ImagePins) &&
unitDataEqual(cur.Data, data) &&
cur.OffsiteRunID == runID {
return nil
}
composeDir := RecoveryUnitComposePath(nsRoot, stackName)
if frozen && (cur == nil || cur.Data == nil || stringSliceEqual(cur.ImagePins, cur.Data.ImagePins)) {
m.logger.Printf("[INFO] [backup] %s: the app now runs %v; the unit keeps the definition of its data (%v, written %s) until the next backup replaces the data",
stackName, curPins, data.ImagePins, data.At)
}
if err := os.MkdirAll(composeDir, 0755); err != nil {
return fmt.Errorf("creating recovery-unit compose dir: %w", err)
}
@@ -226,6 +276,10 @@ func (m *Manager) CaptureRecoveryUnit(stackName string) error {
Checksums: checksums,
OffsiteRunID: runID,
DumpsAt: dumpsAt,
Data: data,
}
if data == nil && (len(dbDumps)+len(volDumps)) > 0 && m.isDebug() {
m.logger.Printf("[DEBUG] [backup] %s: the unit's data files are not all stamped — its versions are unknown until the next backup", stackName)
}
if err := writeManifest(manifestPath, manifest); err != nil {
return fmt.Errorf("writing manifest: %w", err)
@@ -387,7 +441,7 @@ func (m *Manager) readUnitSpace(stackName string) *UnitSpace {
// volume-dump legs of this run already consulted for this app. When a run is in flight the answer
// here is a memo lookup — an app refused before its first write is refused here too, silently,
// because it was already alerted once. Outside a run (the periodic status refresh) it decides fresh.
func (m *Manager) captureAllRecoveryUnits() {
func (m *Manager) captureAllRecoveryUnits(dataRun bool) {
if m.stackProvider == nil {
return
}
@@ -409,7 +463,7 @@ func (m *Manager) captureAllRecoveryUnits() {
if !m.admitApp(stack.Name) {
continue
}
if err := m.CaptureRecoveryUnit(stack.Name); err != nil {
if err := m.captureRecoveryUnit(stack.Name, dataRun); err != nil {
m.noteFailure(stack.Name, "recovery-unit capture", err.Error())
m.logger.Printf("[WARN] [backup] Recovery unit capture failed for %s: %v", stack.Name, err)
// R-158: per app, and the loop CONTINUES — one app's failure must not silence the