kept second-drive copies (D25: R-1175, R-1181, R-1179); .fab import keeps the family gate (R-1191); stale restore temp (R-1188 c); the overcommit sentence made true (R-1192); escrow before the floor (R-1176); unreleased
gates / gates (push) Successful in 1m20s
gates / gates (push) Successful in 1m20s
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── R-1191 (2026-10-21): a `.fab` import builds the app record the way the unit restore does ─────────────────────
|
||||
//
|
||||
// The import used to write a bare record (Deployed, DeployedAt, Env, DesiredState) and start the app; the family-gate
|
||||
// reconcile then removed the gate file of an app with no FamilyGate record, so a family-only app (Glance, Grimmory,
|
||||
// MeTube) came back open to anyone (proven live on 9202, felhom.eu audits/dataloss-hunt-2026-10-20/C3-R1187e-…). The
|
||||
// sign-up lock (R-773) and the locked fields were dropped the same way.
|
||||
//
|
||||
// Now, in two steps around the import's own order (the bundle's compose file is in place only after the config step):
|
||||
// - SaveImportedAppConfig — the record: locked fields, desired state running, and the PRIOR record's life records
|
||||
// (carryLifeRecords: an installed app keeps its gates, pins and update history; an import never un-gates it);
|
||||
// - PrepareImportedApp — right before the start: the family gate (when the template is a family app and the record
|
||||
// has none) and, for an app that had no record before, the sign-up lock — the same helpers the unit restore calls
|
||||
// (prepareFamilyGate, restoreSignupLock), never a second copy of them.
|
||||
|
||||
// SaveImportedAppConfig writes an imported app's record. Returns whether the app had NO record before (a fresh import),
|
||||
// which PrepareImportedApp needs for the sign-up lock rule (decision 49: a restore never adds a lock to an installed
|
||||
// app the household has not closed).
|
||||
func (m *Manager) SaveImportedAppConfig(stackDir string, env map[string]string) (fresh bool, err error) {
|
||||
name := filepath.Base(stackDir)
|
||||
meta := LoadMetadata(stackDir)
|
||||
priorRaw := LoadAppConfig(stackDir)
|
||||
cfg := &AppConfig{
|
||||
Deployed: true,
|
||||
DeployedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Env: env,
|
||||
// R-166: importing a bundle is the household installing that app — it is meant to run.
|
||||
DesiredState: DesiredStateRunning,
|
||||
}
|
||||
for _, f := range meta.DeployFields {
|
||||
if f.LockedAfterDeploy {
|
||||
cfg.LockedFields = append(cfg.LockedFields, f.EnvVar)
|
||||
}
|
||||
}
|
||||
carryLifeRecords(m.logger, name, priorRaw, cfg)
|
||||
if priorRaw != nil {
|
||||
cfg.PinnedImages = priorRaw.PinnedImages
|
||||
cfg.InstalledImages = priorRaw.InstalledImages
|
||||
cfg.PreviousImages = priorRaw.PreviousImages
|
||||
}
|
||||
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] %s: import record saved (fresh=%v, family gate carried=%v, sign-up lock carried=%v)",
|
||||
name, priorRaw == nil, cfg.FamilyGate != nil, cfg.SetupGate != nil)
|
||||
return priorRaw == nil, nil
|
||||
}
|
||||
|
||||
// PrepareImportedApp gives an imported app its doors BEFORE it starts. Called by the import after the bundle's
|
||||
// config files (the compose file among them) are in place and before StartStack. An error means „do not start".
|
||||
func (m *Manager) PrepareImportedApp(stackDir string, env map[string]string, fresh bool) error {
|
||||
name := filepath.Base(stackDir)
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
meta := LoadMetadata(stackDir)
|
||||
cfg := LoadAppConfigDecrypted(stackDir, m.encKey)
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("the imported app's record could not be read")
|
||||
}
|
||||
changed := false
|
||||
if cfg.FamilyGate == nil && meta.FamilyGate {
|
||||
rec, err := m.prepareFamilyGate(name, composePath, env, &meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the family gate could not be prepared (the app was not started): %w", err)
|
||||
}
|
||||
cfg.FamilyGate, changed = rec, true
|
||||
}
|
||||
if fresh && cfg.SetupGate == nil {
|
||||
rec, err := m.restoreSignupLock(name, composePath, env, &meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the sign-up lock could not be prepared (the app was not started): %w", err)
|
||||
}
|
||||
if rec != nil {
|
||||
cfg.SetupGate, changed = rec, true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
||||
return fmt.Errorf("saving app config: %w", err)
|
||||
}
|
||||
}
|
||||
// The in-memory record is what the gate reconcile reads: without this the next tick would remove the file again.
|
||||
m.mu.Lock()
|
||||
if s, ok := m.stacks[name]; ok {
|
||||
s.Deployed = true
|
||||
s.AppConfig = cfg
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.logger.Printf("[INFO] [stacks] %s: import prepared before the start (family gate=%v, sign-up lock=%v)", name, cfg.FamilyGate != nil, cfg.SetupGate != nil)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user