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
gates / gates (push) Successful in 1m33s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user