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:
@@ -79,6 +79,60 @@ type ConversionCopy struct {
|
||||
At string `yaml:"at" json:"at"` // RFC3339 — a backup proven after this releases the copy
|
||||
From int `yaml:"from" json:"from"`
|
||||
To int `yaml:"to" json:"to"`
|
||||
// Service (v0.275.0) is the converted compose service — the release looks for a dump written by ITS
|
||||
// engine at major To. "" on a record written before v0.275.0: then every Postgres-family image in the
|
||||
// dump's recorded set must be at To.
|
||||
Service string `yaml:"service,omitempty" json:"service,omitempty"`
|
||||
}
|
||||
|
||||
// DataDumpStamp is one database dump in the app's own recovery unit, with what the backup side recorded
|
||||
// when it was WRITTEN (v0.275.0, R-696): its time and the running images (service -> ref@digest).
|
||||
type DataDumpStamp struct {
|
||||
File string
|
||||
At time.Time
|
||||
Images map[string]string
|
||||
}
|
||||
|
||||
// DumpStampSource is the backup side's record of the app's own unit's database dumps (v0.275.0). An
|
||||
// OPTIONAL extension of UpdateGuards: guards without it never release a conversion copy (fail closed —
|
||||
// a copy outliving its backup costs disk, never data).
|
||||
type DumpStampSource interface {
|
||||
DumpStamps(name string) []DataDumpStamp
|
||||
}
|
||||
|
||||
// convertedDumpAt is the release's second condition (A4): a database dump written AFTER the conversion
|
||||
// whose recorded engine is the NEW major. The first condition — a copy of the app proven after the
|
||||
// conversion on any tier — says the DATA is newer; this one says it was written by the converted engine,
|
||||
// which a unit's refresh time or a snapshot's time cannot say (R-696: the demo-hp release cited a unit
|
||||
// re-captured over a PostgreSQL 16 dump).
|
||||
func convertedDumpAt(stamps []DataDumpStamp, cc *ConversionCopy, after time.Time) (DataDumpStamp, bool) {
|
||||
for _, st := range stamps {
|
||||
if !st.At.After(after) || len(st.Images) == 0 {
|
||||
continue
|
||||
}
|
||||
if cc.Service != "" {
|
||||
if ref, ok := st.Images[cc.Service]; ok {
|
||||
if mj, ok := postgresMajor(ref); ok && mj == cc.To {
|
||||
return st, true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen, all := 0, true
|
||||
for _, ref := range st.Images {
|
||||
if !isPostgresImage(ref) {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if mj, ok := postgresMajor(ref); !ok || mj != cc.To {
|
||||
all = false
|
||||
}
|
||||
}
|
||||
if seen > 0 && all {
|
||||
return st, true
|
||||
}
|
||||
}
|
||||
return DataDumpStamp{}, false
|
||||
}
|
||||
|
||||
// conversionDumpMargin is A5's margin on the dump's bound (the DB volume's own size).
|
||||
@@ -576,12 +630,25 @@ func (m *Manager) ReleaseConversionCopies(ctx context.Context) []string {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// v0.275.0 (A4): AND a dump the converted engine wrote. Without the stamps (older guards, or a unit
|
||||
// whose data is unstamped) the copy is KEPT — logged, retried at the next pass.
|
||||
src, hasStamps := g.(DumpStampSource)
|
||||
var dump DataDumpStamp
|
||||
if hasStamps {
|
||||
dump, ok = convertedDumpAt(src.DumpStamps(st.Name), cc, at)
|
||||
}
|
||||
if !hasStamps || !ok {
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [stacks] %s: the pre-conversion copy %s is KEPT — a copy proven at %s exists, but no database dump written after %s by PostgreSQL %d is recorded yet", st.Name, cc.Copy, rp.ProvenAt.UTC().Format(time.RFC3339), cc.At, cc.To)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := m.copier().Remove(cc.Copy); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] %s: could not remove the pre-conversion copy %s: %v — kept, tried again later", st.Name, cc.Copy, err)
|
||||
continue
|
||||
}
|
||||
m.recordConversionCopy(st.Name, filepath.Dir(st.ComposePath), nil)
|
||||
m.logger.Printf("[INFO] [stacks] %s: REMOVED the pre-conversion datadir copy %s (PostgreSQL %d) — the converted app has a backup proven on %d: %s at %s", st.Name, cc.Copy, cc.From, cc.To, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339))
|
||||
m.logger.Printf("[INFO] [stacks] %s: REMOVED the pre-conversion datadir copy %s (PostgreSQL %d) — the converted app has a backup proven on %d: %s at %s, its dump %s written %s by %v", st.Name, cc.Copy, cc.From, cc.To, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339), dump.File, dump.At.UTC().Format(time.RFC3339), dump.Images)
|
||||
released = append(released, st.Name)
|
||||
}
|
||||
return released
|
||||
|
||||
Reference in New Issue
Block a user