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
+122 -49
View File
@@ -1,6 +1,7 @@
package backup
import (
"errors"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"os"
@@ -166,6 +167,40 @@ type UnitRestoreResult struct {
// statement, and precisely the R-88 failure direction (degrade to NO DATA rather than to UNKNOWN)
// this whole change exists to remove. An unknown must be carried, never drawn as a zero.
CountsUnknown bool
// DataPins / DataAt (v0.275.0, R-696) — the versions the restored DATA belongs to and when it was
// written, from the unit's `data` block; the definition started with it is the one they name. Empty
// when the unit's versions are unknown (VersionsUnknown).
DataPins []string
DataAt time.Time
// VersionChanged — the app now runs DataPins, which differ from what it ran before the restore (a
// restore of an older version). The page then says which version came back (`07` §6.6).
VersionChanged bool
// VersionsUnknown — a unit written before v0.275.0 (or with an unstamped data file): restored as
// before, with a WARN naming it.
VersionsUnknown bool
}
// ErrUnitVersionMismatch is the KIND of the refusal of a restore whose unit would start its data with a
// definition the data does not belong to (v0.275.0, R-696): a unit whose files were written under different
// pins (Mixed), or whose compose/ names other pins than its data. Raised BEFORE anything is touched;
// branch with errors.Is. The customer sentence is the bundle's.
var ErrUnitVersionMismatch = errors.New("the recovery unit's definition is not the one its data belongs to")
// unitVersionCheck is A3's rule, as a pure function of the manifest and the unit's own compose file:
// known and matching → the pins to report; unknown → (nil, nil) and the caller WARNs; mixed or
// mismatched → the refusal.
func unitVersionCheck(stackName string, man *RecoveryManifest, composeDir string) ([]string, error) {
if man == nil || man.Data == nil {
return nil, nil
}
if man.Data.Mixed {
return nil, util.MsgErrorf(ErrUnitVersionMismatch, "err.backup.unit_versions_mixed", stackName)
}
def := ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml"))
if !samePins(def, man.Data.ImagePins) {
return nil, util.MsgErrorf(ErrUnitVersionMismatch, "err.backup.unit_version_mismatch", stackName, PinsVersion(def), PinsVersion(man.Data.ImagePins))
}
return man.Data.ImagePins, nil
}
// RestoreFromRecoveryUnit recreates an app from its on-drive recovery unit.
@@ -323,60 +358,35 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
res.ManifestVolumes, res.ManifestDBs = len(manifest.VolumeDumps), len(manifest.DBDumps)
composeDir := UnitComposeDir(unitDir)
nonSecretEnv, unitSecrets := readUnitEnv(filepath.Join(composeDir, "app.yaml"), manifest.PortableSecretEnvVars)
// D5: the unit carries the portable class, so this is the leg that no longer needs the guest. The
// guest is still consulted for the WITHHELD class (internet-reachable admin logins) and as the
// fallback for a schema-1 unit — it returns an empty map when the guest is gone, which is the whole
// point: a Tier-1/2 restore must survive that. Precedence is unit-over-guest (see
// reconcileRestoreSecrets), then the fail-closed gate.
guestSecrets := m.stackProvider.RecoverStackSecrets(stackName, manifest.SecretEnvVars)
fullEnv, missing, err := reconcileRestoreSecrets(nonSecretEnv, unitSecrets, guestSecrets, manifest.SecretEnvVars, manifest.DataKeyEnvVars)
if err != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
return res, err
// v0.275.0 (R-696, `07` §6.6) — A RESTORE NEVER STARTS DATA WITH A DEFINITION IT DOES NOT BELONG TO.
// The unit keeps the definition of its data (data_versions.go); this refuses, before anything is
// touched, a unit where the two disagree. Measured before the fix (A1, 9202): the unit's PostgreSQL 18
// definition over a 16 datadir tar — the tar replaced the live volume, 18 refused it, the app stayed down.
dataPins, verr := unitVersionCheck(stackName, manifest, composeDir)
if verr != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: the unit's definition %v, its data %v (mixed=%v) — a restore never starts data with a definition it does not belong to; nothing was touched",
stackName, ParseComposeImages(filepath.Join(composeDir, "docker-compose.yml")), manifest.Data.ImagePins, manifest.Data.Mixed)
return res, verr
}
// O4: a missing RESETTABLE secret used to redeploy blank (compose "Defaulting to a blank
// string" → exit 1). Generate a replacement via the deploy flow's generator instead —
// RecreateStackFromUnit persists fullEnv through SaveAppConfig, so the new value lands
// encrypted in the guest app.yaml and round-trips on the next backup/restore. Data-keys are
// never generated: the fail-closed gate above already refused if one was missing, and the
// generator itself refuses data-key fields (defense-in-depth). Values are never logged.
//
// D5 shrinks this path to the rare case: the portable class now comes from the unit, so a
// generator run means the secret was empty at capture AND absent from the guest.
//
// It does NOT claim the reset is harmless. R-127: for a DB password it is not — a restored data
// directory keeps the OLD role hash (POSTGRES_PASSWORD is ignored once PGDATA is non-empty), so a
// regenerated value leaves the app unable to authenticate against its own restored rows while the
// dump replay, which uses the container's local trust socket, still reports success. The old wording
// here asserted "stored data is unaffected" for every non-data-key secret; that is false for the 18
// DB/root-password fields and is now scoped to what is actually true.
if len(missing) > 0 {
dataKeySet := make(map[string]bool, len(manifest.DataKeyEnvVars))
for _, dk := range manifest.DataKeyEnvVars {
dataKeySet[dk] = true
}
var generated, unresolved []string
for _, name := range missing {
if !dataKeySet[name] && m.generateSecret != nil {
if v, ok := m.generateSecret(stackName, name); ok && v != "" {
fullEnv[name] = v
generated = append(generated, name)
continue
}
if dataPins == nil {
res.VersionsUnknown = true
m.logger.Printf("[WARN] [backup] Restore %s from %s: the unit does not record which versions wrote its data (written before v0.275.0, or an unstamped file) — restoring its definition as captured, as before", stackName, unitDir)
} else {
res.DataPins = dataPins
res.DataAt, _ = manifest.Data.DataTime()
if info, ok := m.stackProvider.GetStackRecoveryInfo(stackName); ok {
if live := definitionPins(info); len(live) > 0 && !samePins(live, dataPins) {
res.VersionChanged = true
m.logger.Printf("[INFO] [backup] Restore %s: the data belongs to %v (written %s); the app ran %v — it comes back at its data's version, and the update climbs from there",
stackName, dataPins, manifest.Data.At, live)
}
unresolved = append(unresolved, name)
}
if len(generated) > 0 {
m.logger.Printf("[WARN] [backup] Restore %s: generated replacement for %v — the credential was reset (old value unrecoverable); no data-encrypting key was involved, but a regenerated DATABASE password will not match the restored data directory's stored hash (R-127) — check the app can reach its data",
stackName, generated)
}
if len(unresolved) > 0 {
m.logger.Printf("[WARN] [backup] Restore %s: %d resettable secret(s) unrecoverable and have no generator %v — proceeding, but the app may fail to start until the credential is set manually",
stackName, len(unresolved), unresolved)
}
}
fullEnv, missing, err := m.unitRestoreEnv(stackName, composeDir, manifest)
if err != nil {
return res, err
}
// R-102: the unit DIRECTORY is logged. Which copy a restore read from is now a real question with
// two answers, and "an absent log line is not evidence" — the drill reads this line to prove the
// secondary mirror, not the primary unit, was the source. It is a path, never a secret.
@@ -472,3 +482,66 @@ func (m *Manager) RestoreFromRecoveryUnitAtWith(stackName, unitDir string, opt U
m.clearUpdateHoldAfterRestore(stackName)
return res, nil
}
// unitRestoreEnv rebuilds the env a restore starts the unit's definition with: the unit's plain config,
// its portable secrets (D5), the guest's for the withheld class, the fail-closed data-key gate, and a
// generated replacement for a missing RESETTABLE secret (O4). ONE implementation for the unit restore and,
// since v0.275.0, the off-site restore that brings an app back at its snapshot's version. Values are
// never logged.
func (m *Manager) unitRestoreEnv(stackName, composeDir string, manifest *RecoveryManifest) (fullEnv map[string]string, missing []string, err error) {
nonSecretEnv, unitSecrets := readUnitEnv(filepath.Join(composeDir, "app.yaml"), manifest.PortableSecretEnvVars)
// D5: the unit carries the portable class, so this is the leg that no longer needs the guest. The
// guest is still consulted for the WITHHELD class (internet-reachable admin logins) and as the
// fallback for a schema-1 unit — it returns an empty map when the guest is gone, which is the whole
// point: a Tier-1/2 restore must survive that. Precedence is unit-over-guest (see
// reconcileRestoreSecrets), then the fail-closed gate.
guestSecrets := m.stackProvider.RecoverStackSecrets(stackName, manifest.SecretEnvVars)
fullEnv, missing, err = reconcileRestoreSecrets(nonSecretEnv, unitSecrets, guestSecrets, manifest.SecretEnvVars, manifest.DataKeyEnvVars)
if err != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
return nil, missing, err
}
// O4: a missing RESETTABLE secret used to redeploy blank (compose "Defaulting to a blank
// string" → exit 1). Generate a replacement via the deploy flow's generator instead —
// RecreateStackFromUnit persists fullEnv through SaveAppConfig, so the new value lands
// encrypted in the guest app.yaml and round-trips on the next backup/restore. Data-keys are
// never generated: the fail-closed gate above already refused if one was missing, and the
// generator itself refuses data-key fields (defense-in-depth). Values are never logged.
//
// D5 shrinks this path to the rare case: the portable class now comes from the unit, so a
// generator run means the secret was empty at capture AND absent from the guest.
//
// It does NOT claim the reset is harmless. R-127: for a DB password it is not — a restored data
// directory keeps the OLD role hash (POSTGRES_PASSWORD is ignored once PGDATA is non-empty), so a
// regenerated value leaves the app unable to authenticate against its own restored rows while the
// dump replay, which uses the container's local trust socket, still reports success. The old wording
// here asserted "stored data is unaffected" for every non-data-key secret; that is false for the 18
// DB/root-password fields and is now scoped to what is actually true.
if len(missing) > 0 {
dataKeySet := make(map[string]bool, len(manifest.DataKeyEnvVars))
for _, dk := range manifest.DataKeyEnvVars {
dataKeySet[dk] = true
}
var generated, unresolved []string
for _, name := range missing {
if !dataKeySet[name] && m.generateSecret != nil {
if v, ok := m.generateSecret(stackName, name); ok && v != "" {
fullEnv[name] = v
generated = append(generated, name)
continue
}
}
unresolved = append(unresolved, name)
}
if len(generated) > 0 {
m.logger.Printf("[WARN] [backup] Restore %s: generated replacement for %v — the credential was reset (old value unrecoverable); no data-encrypting key was involved, but a regenerated DATABASE password will not match the restored data directory's stored hash (R-127) — check the app can reach its data",
stackName, generated)
}
if len(unresolved) > 0 {
m.logger.Printf("[WARN] [backup] Restore %s: %d resettable secret(s) unrecoverable and have no generator %v — proceeding, but the app may fail to start until the credential is set manually",
stackName, len(unresolved), unresolved)
}
}
return fullEnv, missing, nil
}