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

This commit is contained in:
2026-10-21 15:46:10 +02:00
parent 8f5bea0148
commit 5929b8e345
34 changed files with 2152 additions and 44 deletions
+3 -1
View File
@@ -38,4 +38,6 @@ var (
// memoryVerdict through DeployStack to the API, which renders it in the request's language (R-575 —
// it used to be rendered here in Hungarian, so an English page showed a Hungarian line). Pinned by
// TestR575_OvercommitWarningFollowsTheLanguage.
const KeyMemoryOvercommitWarning = "err.stacks.memory_overcommit_warning"
// R-1192 (2026-10-21): the key changed with the text — its old second sentence („Normál használat mellett ez nem okoz
// problémát.") was false; the 8 GB box froze under its first uploads.
const KeyMemoryOvercommitWarning = "err.stacks.memory_overcommit_slow"
@@ -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
}
@@ -0,0 +1,67 @@
package stacks
import (
"os"
"path/filepath"
"testing"
)
// R-1191 (proven live on 9202, felhom.eu audits/dataloss-hunt-2026-10-20/C3-R1187e-fab-family-gate/): a `.fab` import of
// a family-only app must come back WITH its door — the gate file before the first start, the record carrying it, and
// the reconcile loop keeping it. The three catalog family apps' stanzas (Glance, MeTube: family_gate only; Grimmory:
// with exceptions) are the fixtures. COMPANION RED-PROOF: make PrepareImportedApp skip prepareFamilyGate → each case
// fails „no door after the import"; and the old bare record (SaveImportedAppConfig without carryLifeRecords) fails the
// installed-app case.
func TestR1191_AnImportedFamilyAppKeepsItsDoor(t *testing.T) {
base := "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
for name, yml := range map[string]string{
"glance": "display_name: Glance\nfamily_gate: true\n" + base,
"metube": "display_name: MeTube\nfamily_gate: true\n" + base,
"grimmory": "display_name: Grimmory\nfamily_gate: true\nfamily_gate_except: [\"/api/v1/opds\", \"/api/v2/opds\", \"/api/kobo\", \"/api/koreader\", \"/komga/api\"]\n" + base,
} {
t.Run(name, func(t *testing.T) {
m := gateManager(t, yml)
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}
// a REMOVED app imported from its bundle (no record before)
fresh, err := m.SaveImportedAppConfig(dir, env)
must(t, err)
if !fresh {
t.Fatal("an app with no record must import as fresh")
}
must(t, m.PrepareImportedApp(dir, env, fresh))
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("no door after the import — the app would start open to anyone (R-1191)")
}
cfg := LoadAppConfig(dir)
if cfg == nil || cfg.FamilyGate == nil || cfg.DesiredState != DesiredStateRunning {
t.Fatalf("record after the import: %+v", cfg)
}
m.SetupGateTick() // the reconcile that removed the file of a gate-less record
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("the reconcile loop removed the imported app's door")
}
})
}
}
// An INSTALLED app re-imported over itself keeps the records it had (the gate's „since", an update's history) — the
// import never replaces them with a bare record.
func TestR1191_AnImportOverAnInstalledAppKeepsItsRecords(t *testing.T) {
m := gateManager(t, familyYml)
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}
prior := &AppConfig{Deployed: true, Env: env, FamilyGate: &FamilyGateRecord{Since: "2026-10-01T00:00:00Z", Hosts: []string{"gapp.example.hu"}},
PinnedImages: map[string]string{"gapp": "example/gapp:1.2.3"}}
must(t, SaveAppConfig(dir, prior, m.encKey, nil))
fresh, err := m.SaveImportedAppConfig(dir, env)
must(t, err)
if fresh {
t.Fatal("an installed app must not import as fresh")
}
must(t, m.PrepareImportedApp(dir, env, fresh))
cfg := LoadAppConfig(dir)
if cfg == nil || cfg.FamilyGate == nil || cfg.FamilyGate.Since != "2026-10-01T00:00:00Z" || cfg.PinnedImages["gapp"] != "example/gapp:1.2.3" {
t.Fatalf("the installed app's records were not kept: %+v", cfg)
}
}