D47 / R-1269 (P2): a new install never writes into an earlier install's backup — every install has an identity (install_id), the unit records it, every unit writer asks first; another install's unit is set aside whole (backups/set-aside/<app>/<stamp>, a D25 kept copy, „Visszaállítás” + „Eltávolítás” on the restore page); a move that fails refuses the write. Red-proved. Commit only.
gates / gates (push) Successful in 1m33s

This commit is contained in:
2026-10-25 09:27:48 +01:00
parent e1613aea8d
commit 02daebe9c5
30 changed files with 1990 additions and 17 deletions
+60
View File
@@ -201,6 +201,21 @@ type AppConfig struct {
AfterSetup *AfterInstallRecord `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
// AfterUncleanBoot (R-1007) is what the template's after_unclean_boot last found and did (after_unclean_boot.go).
AfterUncleanBoot *AfterUncleanBootRecord `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"`
// InstallID (D47, operator ruling 2026-10-25; R-1269) names the install the app's DATA belongs to: minted at a
// fresh install and at an import, taken from the unit at a restore (the data is that unit's now), and carried by
// every load-then-save. The recovery unit records it (its app.yaml `install_id:`), and a backup of another install
// never writes into that unit (internal/backup/unit_owner.go). Absent = an app installed before D47 (minted at
// its first backup).
InstallID string `yaml:"install_id,omitempty" json:"install_id,omitempty"`
}
// NewInstallID mints an install identity (D47): 16 random hex characters. Never a secret, never derived from one.
func NewInstallID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("t%015x", time.Now().UnixNano()&0xfffffffffffffff) // a clock fallback, still unique per box
}
return hex.EncodeToString(b)
}
// DefaultLoginRecord is app.yaml's `default_login:`.
@@ -541,6 +556,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
SetupGate: gate,
InstallHold: hold,
FamilyGate: family,
InstallID: NewInstallID(), // D47: a fresh install is a new install — its backups are its own
}
diskCfg := *appCfg
@@ -841,6 +857,18 @@ func (m *Manager) PersistUnitRedeployConfigGate(name string, env map[string]stri
cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env)
priorRaw := LoadAppConfig(stackDir)
carryLifeRecords(m.logger, name, priorRaw, cfg)
// D47 (R-1269): the app now holds the data of the unit it was restored from, so it IS that unit's install — a
// later backup then writes into that unit, and a unit of the install it replaced is set aside, never rewritten.
// A unit that records no install (written before D47) keeps the app's own (carried above), else a new one.
switch {
case gate.InstallID != "":
if priorRaw != nil && priorRaw.InstallID != "" && priorRaw.InstallID != gate.InstallID {
m.logger.Printf("[INFO] [stacks] %s: the restored data belongs to install %s (was %s) — its backups follow it (D47)", name, gate.InstallID, priorRaw.InstallID)
}
cfg.InstallID = gate.InstallID
case cfg.InstallID == "":
cfg.InstallID = NewInstallID()
}
reopen, err := m.applyGateAtBackup(name, stack.ComposePath, env, &meta, cfg, priorRaw == nil, gate)
if err != nil {
return err
@@ -1623,6 +1651,38 @@ func restoredLoginFields(app string, meta Metadata, prior *AppConfig, env map[st
return out
}
// EnsureInstallID (D47) returns the app's install identity, minting and saving one (load-then-save, nothing else
// changes) for an app installed before D47. "" with an error when the record cannot be read or written — the caller
// (the backup) then refuses to decide whose unit it would write.
func (m *Manager) EnsureInstallID(name string) (string, error) {
stack, ok := m.GetStack(name)
if !ok {
return "", fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
m.installIDMu.Lock()
defer m.installIDMu.Unlock()
cfg := LoadAppConfig(stackDir)
if cfg == nil {
return "", fmt.Errorf("app.yaml of %s not readable", name)
}
if cfg.InstallID != "" {
return cfg.InstallID, nil
}
meta := LoadMetadata(stackDir)
cfg.InstallID = NewInstallID()
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return "", fmt.Errorf("saving the install id of %s: %w", name, err)
}
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.AppConfig = LoadAppConfig(stackDir)
}
m.mu.Unlock()
m.logger.Printf("[INFO] [stacks] %s: install id %s recorded (an app installed before D47)", name, cfg.InstallID)
return cfg.InstallID, nil
}
// SetStackSecretValues (R-1106) writes the named values into an installed app's app.yaml and NOTHING else: load the
// file as it is (records, pin, gates, locked fields all kept — the R-697 lesson: a non-install writer loads, then
// saves), replace the named env values, save with the sensitive fields encrypted. Used by the off-site restore to put
@@ -40,6 +40,7 @@ func (m *Manager) SaveImportedAppConfig(stackDir string, env map[string]string)
}
}
carryLifeRecords(m.logger, name, priorRaw, cfg)
cfg.InstallID = NewInstallID() // D47: the bundle's data is no existing unit's data — a new install (after the carry)
if priorRaw != nil {
cfg.PinnedImages = priorRaw.PinnedImages
cfg.InstalledImages = priorRaw.InstalledImages
@@ -0,0 +1,71 @@
package stacks
import (
"os"
"path/filepath"
"testing"
)
// D47 (operator ruling 2026-10-25; R-1269) — the app's record names the install its data belongs to. Through the
// PRODUCTION writers: the restore write (PersistUnitRedeployConfigGate), a later load-then-save, EnsureInstallID.
// COMPANION RED-PROOF: drop the `case gate.InstallID != ""` branch in PersistUnitRedeployConfigGate →
// TestD47_ARestoreTakesTheUnitsInstall fails (the app keeps the replaced install's id, and its next backup would
// set aside the very unit it was restored from).
// A restore from a unit that records an install: the app now holds that unit's data, so it IS that install.
func TestD47_ARestoreTakesTheUnitsInstall(t *testing.T) {
m := gateManager(t, gatedYml)
writeAppYaml(t, gappDir(m), "deployed: true\ninstall_id: bbbb000000000002\nenv:\n SUBDOMAIN: gapp\n")
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{InstallID: "aaaa000000000001"}))
if got := LoadAppConfig(gappDir(m)); got == nil || got.InstallID != "aaaa000000000001" {
t.Fatalf("the restored app must be the unit's install, got %+v", got)
}
}
// A unit that records no install (before D47): the app keeps its own identity — a restore is not a new install.
func TestD47_ARestoreOfAnUnrecordedUnitKeepsTheAppsInstall(t *testing.T) {
m := gateManager(t, gatedYml)
writeAppYaml(t, gappDir(m), "deployed: true\ninstall_id: bbbb000000000002\nenv:\n SUBDOMAIN: gapp\n")
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
if got := LoadAppConfig(gappDir(m)); got == nil || got.InstallID != "bbbb000000000002" {
t.Fatalf("an unrecorded unit's restore keeps the app's own install, got %+v", got)
}
}
// A removed app restored from an unrecorded unit gets an identity (never empty after a restore).
func TestD47_ARemovedAppRestoredGetsAnInstall(t *testing.T) {
m := gateManager(t, gatedYml)
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
if got := LoadAppConfig(gappDir(m)); got == nil || len(got.InstallID) != 16 {
t.Fatalf("a restored app must have an install id, got %+v", got)
}
}
// An app installed before D47 gets one identity once; a second call returns the same one.
func TestD47_EnsureInstallIDMintsOnce(t *testing.T) {
m := gateManager(t, gatedYml)
writeAppYaml(t, gappDir(m), "deployed: true\nenv:\n SUBDOMAIN: gapp\ndesired_state: running\n")
m.mu.Lock()
m.stacks["gapp"].Deployed = true
m.mu.Unlock()
a, err := m.EnsureInstallID("gapp")
must(t, err)
b, err := m.EnsureInstallID("gapp")
must(t, err)
if len(a) != 16 || a != b {
t.Fatalf("one identity, minted once: %q then %q", a, b)
}
if got := LoadAppConfig(gappDir(m)); got.DesiredState != DesiredStateRunning {
t.Fatalf("minting the identity must change nothing else: %+v", got)
}
}
func writeAppYaml(t *testing.T, dir, body string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
@@ -40,6 +40,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced
cfg.DefaultLogin = prior.DefaultLogin
cfg.AfterSetup = prior.AfterSetup
cfg.InstallID = prior.InstallID // D47: a rewrite of the record is not a new install (a restore then sets the unit's)
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
cur := ""
if prior.ConversionCopy != nil {
+1
View File
@@ -259,6 +259,7 @@ type Manager struct {
// Migration engine (B1): single-flight + live job + deps wired via SetMigrationDeps.
migrateMu sync.Mutex
installIDMu sync.Mutex // D47: one EnsureInstallID at a time (two backups racing must not mint two ids)
migrating bool
migJob *MigrationJob
settings *settings.Settings
+9 -2
View File
@@ -29,6 +29,9 @@ type GateAtBackup struct {
Known bool // the backup carries a record
Closed bool // the gate was closed (the first setup not done)
OpenedBy string // who opened it, when open
// InstallID (D47) is the install the backup's data belongs to — the unit's app.yaml `install_id:`. "" = not
// recorded (a unit written before D47). Independent of Known (which is about the gate).
InstallID string
}
// GateCapture is the record a backup writes for an app: nil for a template with no gate. An app whose template gates
@@ -53,11 +56,15 @@ func ReadGateAtBackup(appYamlPath string) GateAtBackup {
}
var doc struct {
SetupGate *appbackup.GateState `yaml:"setup_gate"`
InstallID string `yaml:"install_id"`
}
if yaml.Unmarshal(b, &doc) != nil || doc.SetupGate == nil {
if yaml.Unmarshal(b, &doc) != nil {
return GateAtBackup{}
}
return GateAtBackup{Known: true, Closed: doc.SetupGate.State != SetupGateOpen, OpenedBy: doc.SetupGate.OpenedBy}
if doc.SetupGate == nil {
return GateAtBackup{InstallID: doc.InstallID}
}
return GateAtBackup{Known: true, Closed: doc.SetupGate.State != SetupGateOpen, OpenedBy: doc.SetupGate.OpenedBy, InstallID: doc.InstallID}
}
// applyGateAtBackup decides the restored app's gate (R-1057). removed = the app had no app.yaml before this restore.