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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user