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
+39
View File
@@ -169,6 +169,11 @@ type AppConfig struct {
// ConversionCopy (v0.273.0, `09` §6.4 part 10) is the OLD datadir's copy kept after a successful
// PostgreSQL major conversion, until a backup of the converted app is proven (ReleaseConversionCopies).
ConversionCopy *ConversionCopy `yaml:"conversion_copy,omitempty" json:"conversion_copy,omitempty"`
// RestoredLogins (v0.275.0, R-694) are the `type: password` fields whose stored value was GENERATED by a
// restore (the unit never carries an admin login, D5, and the guest had none — a load of kept data, a
// removed app, a rebuilt guest) while the app's own login came back with its data. The page then shows
// no value for them and says the old password is the one that works (restoredLoginFields).
RestoredLogins []string `yaml:"restored_logins,omitempty" json:"restored_logins,omitempty"`
}
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
@@ -684,6 +689,9 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
// R-694: which admin logins did the restore have to GENERATE? Exactly the `type: password` fields the
// guest held no value for before this write (the unit never carries one) — read BEFORE it is replaced.
prior := LoadAppConfigDecrypted(stackDir, m.encKey)
cfg := &AppConfig{
Deployed: true,
DeployedAt: time.Now().UTC().Format(time.RFC3339),
@@ -694,6 +702,10 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
cfg.LockedFields = append(cfg.LockedFields, f.EnvVar)
}
}
cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env)
if len(cfg.RestoredLogins) > 0 {
m.logger.Printf("[INFO] [stacks] %s: the restore generated %v — the app's own login came back with its data; the page will not show the new value as the password", name, cfg.RestoredLogins)
}
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving app config: %w", err)
}
@@ -1332,3 +1344,30 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim
}
return nil, warning
}
// loginAppliedEveryStart is the register of `type: password` fields whose app APPLIES the env value at
// EVERY start, so a value generated at a restore IS the login afterwards (R-694, measured 2026-09-26 from
// each image's entrypoint at the catalog's tag, `audits/version-travel-2026-09-26/D4/`): code-server's
// s6 run script passes $PASSWORD to `code-server --auth password` on every start and stores none. The six
// other apps with such a field (crafty-controller, gokapi, grafana, kimai, nextcloud, paperless-ngx) use it
// only at first initialisation — the restored data's login wins. Code, not a catalog flag, like
// nonPortableSecrets: it decides what a household is told about how to get into its own app.
var loginAppliedEveryStart = map[string]map[string]bool{
"code-server": {"PASSWORD": true},
}
// restoredLoginFields names the `type: password` fields a restore GENERATED — no value in the guest's
// app.yaml before, a value now — for an app whose login lives in its data. Pinned by TestR694_*.
func restoredLoginFields(app string, meta Metadata, prior *AppConfig, env map[string]string) []string {
var out []string
for _, f := range meta.DeployFields {
if f.Type != "password" || env[f.EnvVar] == "" || loginAppliedEveryStart[app][f.EnvVar] {
continue
}
if prior != nil && prior.Env[f.EnvVar] != "" {
continue // the guest kept the household's own value — not generated
}
out = append(out, f.EnvVar)
}
return out
}
+6
View File
@@ -304,6 +304,12 @@ func (m *Manager) ListKept(drives []string) []KeptItem {
continue
}
dir := filepath.Join(d, KeptDirName, a.Name(), s.Name())
// R-695 (v0.275.0): an EMPTY dated folder is not kept data — it is what Docker leaves when a
// file-browser bind outlives a Delete (it recreates the missing source, empty, as root).
// Listed, it was bound again, and the bind recreated it: a loop. Never listed, never bound.
if !dirHasEntries(dir) {
continue
}
it := KeptItem{App: a.Name(), DisplayName: a.Name(), Path: dir, Drive: d, Kind: KeptKindDated,
Marker: readKeptMarker(dir), SizeBytes: sizeFn(dir)}
if st, ok := m.GetStack(a.Name()); ok && st.Meta.DisplayName != "" {
+24
View File
@@ -295,3 +295,27 @@ func TestKept_OwnerIsNeverTheFileBrowser(t *testing.T) {
t.Fatalf("the leftovers must be named by the app that binds them through HDD_PATH, never the file browser: %v", got)
}
}
// R-695 (v0.275.0) — an EMPTY dated kept folder (what Docker recreates when a file-browser bind outlives
// a Delete) is never listed, so it is never bound and cannot recreate itself. A dated folder that holds
// something is still listed.
//
// COMPANION RED-PROOF (REPORT.md): drop the dirHasEntries check in ListKept's dated loop — the empty
// folder is then listed and this fails.
func TestR695_AnEmptyDatedKeptFolderIsNeverListed(t *testing.T) {
m, drive := keptManager(t)
m.mu.Lock()
s := m.stacks["cloudapp"]
s.Deployed = true
s.AppConfig = &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive}}
m.mu.Unlock()
empty := filepath.Join(drive, KeptDirName, "nextcloud", "2026-09-25_141014")
must(t, os.MkdirAll(empty, 0o755))
full := filepath.Join(drive, KeptDirName, "nextcloud", "2026-09-24_101010")
must(t, os.MkdirAll(full, 0o755))
must(t, os.WriteFile(filepath.Join(full, "f"), []byte("kept"), 0o644))
items := m.ListKept([]string{drive})
if len(items) != 1 || items[0].Path != full {
t.Fatalf("listing = %+v — want only the folder that holds something", items)
}
}
+24
View File
@@ -234,3 +234,27 @@ func loadMetadataFile(path string) (Metadata, error) {
}
return LoadProbeMetadata(tmp), nil // R-670
}
// RestoredVersionPosition says where an app stands after a restore brought it back at an older version
// (v0.275.0, `07` §6.6, the page's first sentence): behind — its pin is not the catalog's; climbable — a
// tested ladder step leads on from its pin, so the automatic leg climbs it (legCandidate refuses an app
// older than the ladder, LegSkipOlderThanLadder, and a template with no ladder, LegSkipNoTestRecord).
// Digests are ignored on both sides: this is about versions, not about which bytes a tag names today.
func (m *Manager) RestoredVersionPosition(name string) (behind, climbable bool) {
st, ok := m.GetStack(name)
if !ok || st.AppConfig == nil || len(st.AppConfig.PinnedImages) == 0 || len(st.CatalogImages) == 0 {
return false, false
}
strip := func(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = StripDigest(v)
}
return out
}
if sameRefs(strip(st.AppConfig.PinnedImages), strip(st.CatalogImages)) {
return false, false
}
tpl := filepath.Dir(m.CatalogTemplatePath(name, "docker-compose.yml"))
return true, ladderStepsLeft(tpl, st.AppConfig.PinnedImages) > 0
}
+31
View File
@@ -214,3 +214,34 @@ func mustWriteMk(t *testing.T, p, body string) {
}
mustWrite(t, p, body)
}
// TestA3_RestoredVersionPosition — after a restore brought an app back at an older pin: behind, and
// climbable only when a tested ladder step leads on from that pin (the automatic leg's own rule:
// LegSkipOlderThanLadder). A pin at the catalog's version is not behind; digests do not count.
func TestA3_RestoredVersionPosition(t *testing.T) {
m, _, _, _, _ := ladderManager(t, true)
set := func(pin string) {
m.mu.Lock()
st := m.stacks["nextcloud"]
st.CatalogImages = map[string]string{"web": ladderC}
if st.AppConfig == nil {
st.AppConfig = &AppConfig{}
}
st.AppConfig.PinnedImages = map[string]string{"web": pin}
m.mu.Unlock()
}
for _, c := range []struct {
pin string
behind, climbable bool
}{
{ladderA, true, true}, // on the ladder: the leg climbs it
{"nextcloud:20.0.0-apache", true, false}, // older than the ladder: the leg will not
{ladderC, false, false}, // at the catalog's version
{ladderC + "@sha256:0123", false, false}, // a digest is not a version
} {
set(c.pin)
if b, cl := m.RestoredVersionPosition("nextcloud"); b != c.behind || cl != c.climbable {
t.Errorf("pin %s: behind=%v climbable=%v, want %v %v", c.pin, b, cl, c.behind, c.climbable)
}
}
}
+68 -1
View File
@@ -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
@@ -423,6 +423,8 @@ func TestConvert_ReleaseAfterAProvenBackup(t *testing.T) {
}
g.mu.Lock()
g.points = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: slice4T0.Add(time.Hour)}}
// v0.275.0 (A4): and the dump in that backup was written by the converted engine.
g.stamps = []DataDumpStamp{{File: "db-dumps/nextcloud-postgres.sql", At: slice4T0.Add(time.Hour), Images: map[string]string{"db": "postgres:18-alpine@sha256:18"}}}
g.mu.Unlock()
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 1 || fc.nCopies() != 0 {
t.Fatalf("released %v after a proven backup; copies=%d", got, fc.nCopies())
@@ -505,3 +507,68 @@ func TestConvert_MarkDoesNotChangeOldLadderPrints(t *testing.T) {
t.Fatalf("an entry without the mark prints it: %s", b)
}
}
// A5 red-proof 2 — the release refuses a PRE-CONVERSION dump (R-696). The measured demo-hp night: a copy
// "proven" after the conversion (the unit's refresh time) while its dump was written by PostgreSQL 16
// before the conversion; v0.274.0 removed the 16 datadir copy on that. Now the copy stays until a dump
// written after the conversion BY THE NEW ENGINE is recorded — and each half of that is needed.
func TestA5_TheReleaseRefusesAPreConversionDump(t *testing.T) {
m, dir, g, _, fc, _ := convManager(t, goodMark)
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
t.Fatal(err)
}
if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone {
t.Fatalf("setup: %q", st.UpdatePhase)
}
m.mu.Lock()
m.stacks["nextcloud"].AppConfig = LoadAppConfig(dir)
m.mu.Unlock()
cc := LoadAppConfig(dir).ConversionCopy
if cc == nil || cc.Service != "db" {
t.Fatalf("conversion_copy = %+v, want one naming the converted service", cc)
}
at, _ := time.Parse(time.RFC3339, cc.At)
g.mu.Lock()
g.points = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: at.Add(10 * time.Minute)}} // "proven" after it
g.mu.Unlock()
for _, c := range []struct {
why string
stamp DataDumpStamp
}{
{"a dump written BEFORE the conversion, by 16", DataDumpStamp{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(-5 * time.Minute), Images: map[string]string{"db": "postgres:16-alpine@sha256:16"}}},
{"a dump after the conversion, but recorded as 16", DataDumpStamp{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(5 * time.Minute), Images: map[string]string{"db": "postgres:16-alpine@sha256:16"}}},
{"a dump after the conversion with no recorded images", DataDumpStamp{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(5 * time.Minute)}},
} {
g.mu.Lock()
g.stamps = []DataDumpStamp{c.stamp}
g.mu.Unlock()
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 0 || fc.nCopies() != 1 {
t.Fatalf("%s: released %v; copies=%d — the 16 datadir copy went on a backup that holds no 18 dump", c.why, got, fc.nCopies())
}
}
g.mu.Lock()
g.stamps = []DataDumpStamp{{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(5 * time.Minute), Images: map[string]string{"db": "postgres:18-alpine@sha256:18"}}}
g.mu.Unlock()
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 1 || fc.nCopies() != 0 {
t.Fatalf("released %v with an 18 dump after the conversion; copies=%d", got, fc.nCopies())
}
}
// A record written before v0.275.0 carries no service: every Postgres-family image in the dump's
// recorded set must then be at the new major.
func TestA4_AnOldRecordWithoutAServiceChecksEveryPostgresImage(t *testing.T) {
cc := &ConversionCopy{From: 16, To: 18}
after := slice4T0
st := func(imgs map[string]string) []DataDumpStamp {
return []DataDumpStamp{{File: "db-dumps/x-postgres.sql", At: after.Add(time.Minute), Images: imgs}}
}
if _, ok := convertedDumpAt(st(map[string]string{"app": "x/app:1", "db": "postgres:18-alpine"}), cc, after); !ok {
t.Fatal("an 18 dump was not accepted")
}
if _, ok := convertedDumpAt(st(map[string]string{"app": "x/app:1", "db": "postgres:16-alpine"}), cc, after); ok {
t.Fatal("a 16 dump was accepted")
}
if _, ok := convertedDumpAt(st(map[string]string{"app": "x/app:1"}), cc, after); ok {
t.Fatal("a dump with no Postgres image recorded was accepted")
}
}
@@ -0,0 +1,57 @@
package stacks
import (
"io"
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-694 (v0.275.0) — a restore with no guest app.yaml (a load of kept data, a removed app, a rebuilt
// guest) GENERATES the withheld admin login (D5: the unit never carries it), while the app's own login
// comes back with its data. For six of the seven catalog apps with such a field the old password is the
// one that works (measured from each entrypoint, `audits/version-travel-2026-09-26/D4/`); the page used
// to show the new value as "the first password set at install".
//
// Through the PRODUCTION write (PersistUnitRedeployConfig), not the helper alone.
//
// COMPANION RED-PROOF (REPORT.md): make restoredLoginFields return nil — the nextcloud case then records
// nothing and this fails at "restored_logins".
func TestR694_ARestoreThatGeneratesTheLoginRecordsIt(t *testing.T) {
for _, c := range []struct {
name string
app, env string
guestHad bool
wantNoted bool
}{
{"nextcloud, guest app.yaml gone", "nextcloud", "NEXTCLOUD_ADMIN_PASSWORD", false, true},
{"nextcloud, guest kept the household's value", "nextcloud", "NEXTCLOUD_ADMIN_PASSWORD", true, false},
{"code-server applies the env at every start", "code-server", "PASSWORD", false, false},
} {
t.Run(c.name, func(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, c.app)
must(t, os.MkdirAll(app, 0o755))
must(t, os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n web:\n image: x/y:1\n"), 0o644))
must(t, os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: X\ndeploy_fields:\n - env_var: "+c.env+"\n label: Admin\n type: password\n"), 0o644))
if c.guestHad {
must(t, os.WriteFile(filepath.Join(app, "app.yaml"), []byte("deployed: true\nenv:\n "+c.env+": households-own\n"), 0o600))
}
m, err := NewManager(cfg, log.New(io.Discard, "", 0))
must(t, err)
must(t, m.ScanStacks())
must(t, m.PersistUnitRedeployConfig(c.app, map[string]string{c.env: "generated-at-restore"}))
got := LoadAppConfig(app)
noted := got != nil && len(got.RestoredLogins) == 1 && got.RestoredLogins[0] == c.env
if noted != c.wantNoted {
t.Fatalf("restored_logins = %v, want noted=%v", got.RestoredLogins, c.wantNoted)
}
})
}
}
+1 -1
View File
@@ -1011,7 +1011,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
}
m.removeUndoCopies(name, rest)
if keep != nil {
m.recordConversionCopy(name, dir, &ConversionCopy{Volume: keep.Volume, Copy: keep.Copy, At: m.now().UTC().Format(time.RFC3339), From: entry.Convert.From, To: entry.Convert.To})
m.recordConversionCopy(name, dir, &ConversionCopy{Volume: keep.Volume, Copy: keep.Copy, At: m.now().UTC().Format(time.RFC3339), From: entry.Convert.From, To: entry.Convert.To, Service: entry.Convert.Service})
m.logger.Printf("[INFO] [stacks] update %s: KEEPING the pre-conversion datadir copy %s (PostgreSQL %d) until a backup of the converted app is proven", name, keep.Copy, entry.Convert.From)
}
_ = os.RemoveAll(filepath.Join(dir, preUpdateConvertDir))
@@ -37,6 +37,14 @@ type fakeGuards struct {
pinAtDump string
stackDir string
undoState string // what the last hold said a failed undo left (v0.263.0)
// stamps are the app's own unit's recorded database dumps (v0.275.0, DumpStampSource).
stamps []DataDumpStamp
}
func (f *fakeGuards) DumpStamps(string) []DataDumpStamp {
f.mu.Lock()
defer f.mu.Unlock()
return append([]DataDumpStamp(nil), f.stamps...)
}
func (f *fakeGuards) note(c string) { f.mu.Lock(); f.calls = append(f.calls, c); f.mu.Unlock() }