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
+32 -5
View File
@@ -172,6 +172,14 @@ type Manager struct {
// disconnected) can be unit-tested without Docker. Nil → the real DumpAppVolumesSafe.
dumpVolumesSafe func(stackName string) error
// dumpOne (v0.275.0) — the per-database dump seam, nil → the real DumpOne. It lets a test drive the
// REAL legs (and so the stamps they write) without a database container.
dumpOne func(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.Logger, debug bool) DumpResult
// stampMu serialises writes of a unit's data-stamps.json (v0.275.0, data_versions.go). The legs run
// under the running flag already; this keeps a stray concurrent caller from losing a stamp.
stampMu sync.Mutex
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
updatingCheck func(stackName string) bool
// undoCopyRemover (R-671, v0.272.0) deletes an app's leftover undo copies — stacks.Manager.RemoveUndoCopies,
@@ -539,7 +547,13 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
defer m.beginRunSummary(kind, newRunID())()
defer m.emitRunSummary()
dbs, err := DiscoverDatabases(ctx, m.logger, m.isDebug(), m.knownStackNames())
discover := m.discoverDBs
if discover == nil {
discover = func(ctx context.Context) ([]DiscoveredDB, error) {
return DiscoverDatabases(ctx, m.logger, m.isDebug(), m.knownStackNames())
}
}
dbs, err := discover(ctx)
if err != nil {
m.logger.Printf("[ERROR] [backup] Database discovery failed: %v", err)
return err
@@ -586,7 +600,7 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), db.StackName)
result := DumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
result := m.dumpOneOrDefault(ctx, db, dumpDir)
results = append(results, result)
if result.Error != nil {
@@ -597,6 +611,8 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
} else {
totalSize += result.Size
summary = append(summary, fmt.Sprintf("OK %s (%s)", result.DB.ContainerName, humanizeBytes(result.Size)))
// v0.275.0 (R-696): the dump records the versions that wrote it, at the moment it is written.
m.stampDataFile(db.StackName, RecoveryUnitPath(m.namespaceRoot(drivePath), db.StackName), "db-dumps/"+filepath.Base(result.FilePath))
// Persist validation result to settings.json
if m.settings != nil && result.FilePath != "" {
@@ -644,8 +660,9 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
strings.Join(failedSummaryLines(summary), "; "))
}
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest).
m.captureAllRecoveryUnits()
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest). A DATA run:
// the capture folds the stamps the legs above just wrote (v0.275.0).
m.captureAllRecoveryUnits(true)
// F5 (CAMPAIGN-3): after the units are fresh on the CURRENT drives, prune any orphaned
// backups/primary/<app> dir an app left on an OLD drive when its HDD_PATH moved — pure disk
@@ -823,6 +840,8 @@ func (m *Manager) DumpAppVolumes(stackName string) error {
if info, _ := os.Stat(tarPath); info != nil {
m.logger.Printf("[INFO] [backup] Volume dump: %s/%s → %s", stackName, volName, humanizeBytes(info.Size()))
}
// v0.275.0 (R-696): the tar records the versions that wrote it.
m.stampDataFile(stackName, RecoveryUnitPath(m.namespaceRoot(drivePath), stackName), "volume-dumps/"+volName+".tar")
}
// Clean up tars (and any orphan `.tar.tmp` from a killed run) for volumes that no longer exist.
@@ -1158,7 +1177,7 @@ func (m *Manager) RefreshCache(nextDBDump time.Time) {
func() {
defer m.beginRunSummary(runKindRefresh, "")()
defer m.emitRunSummary()
m.captureAllRecoveryUnits()
m.captureAllRecoveryUnits(false) // a REFRESH: never moves the unit's data time or its definition away from its data
}()
}
@@ -1439,3 +1458,11 @@ func (m *Manager) stackIsDeploying(name string) bool {
}
return false
}
// dumpOneOrDefault runs the dumpOne seam, or the real DumpOne.
func (m *Manager) dumpOneOrDefault(ctx context.Context, db DiscoveredDB, dumpDir string) DumpResult {
if m.dumpOne != nil {
return m.dumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
}
return DumpOne(ctx, db, dumpDir, m.logger, m.isDebug())
}