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
}