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
+16 -4
View File
@@ -43,6 +43,9 @@ type Tier2RestorePoint struct {
PackagePreserved bool
// CopyLastSuccess — the RFC3339 time of the last Tier-2 copy that succeeded.
CopyLastSuccess string
// DataDate (v0.275.0, R-696) — the mirrored unit's DATA time (unitNewestArtifact on the mirror): when
// the data the copy holds was written, which a mirror run copies but never makes newer. "" = unknown.
DataDate string
}
// restorePointFromCoverage is the pure half of the predicate.
@@ -54,6 +57,7 @@ func restorePointFromCoverage(cov Tier2Coverage) Tier2RestorePoint {
CopyDateProven: cov.CopyLastSuccess != "",
PackagePreserved: preserved,
CopyLastSuccess: cov.CopyLastSuccess,
DataDate: cov.UnitDataDate,
}
}
@@ -93,6 +97,12 @@ func (p Tier2RestorePoint) ProvenCopyTime() (time.Time, bool) {
if err != nil {
return time.Time{}, false
}
// v0.275.0 (R-696): a mirror run copies the unit's data; it never makes the data newer. When the
// mirror's data time is known and older than the copy, the data time is the copy's age — a mirror
// taken right after an update, of a unit whose dump is from before it, is as old as that dump.
if d, derr := time.Parse(time.RFC3339, p.DataDate); derr == nil && d.Before(t) {
return d, true
}
return t, true
}
@@ -209,8 +219,9 @@ var updateOffsiteCheckTimeout = 15 * time.Second
// UpdateTierPoint is one proven, restorable copy of an app on one tier.
type UpdateTierPoint struct {
Tier int
// At is when the data in that copy was last proven written: Tier 2 ProvenCopyTime, Tier 1 the
// newest artifact of the unit (ListRestorePoints), Tier 3 the newest snapshot for the app.
// At is when the data in that copy was last proven written: Tier 2 ProvenCopyTime (capped by the
// mirror's data time), Tier 1 the unit's DATA time (ListRestorePoints → unitNewestArtifact), Tier 3
// the newest snapshot, capped by the data time the box recorded when it pushed it (v0.275.0, R-696).
At time.Time
}
@@ -281,7 +292,7 @@ func (m *Manager) updateTierPoint(ctx context.Context, stackName string, tier in
return UpdateTierPoint{}, false
}
if at, ok := got[stackName]; ok && !at.IsZero() {
return UpdateTierPoint{Tier: tier, At: at}, true
return UpdateTierPoint{Tier: tier, At: m.offsiteDataTime(stackName, at)}, true
}
}
return UpdateTierPoint{}, false
@@ -391,11 +402,12 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error {
if db.StackName != stackName {
continue
}
res := DumpOne(ctx, db, AppDBDumpPath(nsRoot, stackName), m.logger, m.isDebug())
res := m.dumpOneOrDefault(ctx, db, AppDBDumpPath(nsRoot, stackName))
if res.Error != nil {
return util.MsgError("err.backup.adatbazis_mentes_sikertelen", db.ContainerName, res.Error)
}
dumped++
m.stampDataFile(stackName, RecoveryUnitPath(nsRoot, stackName), "db-dumps/"+filepath.Base(res.FilePath))
m.logger.Printf("[INFO] [backup] update pre-backup for %s: database dump OK (%s, %s)", stackName, db.ContainerName, humanizeBytes(res.Size))
}