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