diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index a5ca9c5..a213cac 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -1337,6 +1337,14 @@ func (m *Manager) InjectMissingFields(stackNames []string) { switch field.Type { case "secret": + // R-757: a value the app must have been GIVEN — read only by the one-time after_install + // command, never by the compose file — cannot be invented for an app already installed: + // after_install does not run again, so the app never received it, and the reveal would + // then answer a login (calibre-web's ADMIN_USER, measured 2026-10-01) the app does not have. + if onlyGivenAtInstall(&meta, stackDir, field.EnvVar) { + m.logger.Printf("[INFO] [stacks] Stack %s: new field %s is read only by after_install, which ran (or not) at install — not invented for an installed app (R-757)", name, field.EnvVar) + continue + } if field.Generate == "" { m.logger.Printf("[WARN] [stacks] Stack %s: new secret field %s has no generator — skipping", name, field.EnvVar) continue @@ -1391,6 +1399,24 @@ func (m *Manager) InjectMissingFields(stackNames []string) { m.logger.Printf("[INFO] [stacks] InjectMissingFields: processed %d stacks", count) } +// onlyGivenAtInstall reports whether envVar reaches the app ONLY through the one-time after_install +// command: it is in after_install's env list and no compose definition the app runs from (the catalog +// mirror, or the pinned applied copy) names it. Unreadable compose files count as NOT naming it, which is +// the side that skips the injection — inventing nothing is the safe error. Pinned by +// TestR757_AfterInstallOnlyFieldIsNotInvented. +func onlyGivenAtInstall(meta *Metadata, stackDir, envVar string) bool { + if meta == nil || meta.AfterInstall == nil || !containsStr(meta.AfterInstall.Env, envVar) { + return false + } + ref := regexp.MustCompile(`\$\{?` + regexp.QuoteMeta(envVar) + `\b`) + for _, p := range []string{filepath.Join(stackDir, "docker-compose.yml"), AppliedComposePath(stackDir)} { + if b, err := os.ReadFile(p); err == nil && ref.Match(b) { + return false + } + } + return true +} + func containsStr(slice []string, s string) bool { for _, v := range slice { if v == s { diff --git a/controller/internal/stacks/r757_inject_test.go b/controller/internal/stacks/r757_inject_test.go new file mode 100644 index 0000000..a786764 --- /dev/null +++ b/controller/internal/stacks/r757_inject_test.go @@ -0,0 +1,48 @@ +package stacks + +import ( + "os" + "path/filepath" + "testing" +) + +// R-757 — the consequence: after a template gains a generated secret that only after_install reads, +// an INSTALLED app does not get an invented value (calibre-web's ADMIN_USER, 2026-10-01); a new secret +// the compose file reads is still generated as before. +func TestR757_AfterInstallOnlyFieldIsNotInvented(t *testing.T) { + compose := "services:\n app:\n image: nginx:1.27\n environment:\n - NEW_KEY=${NEW_KEY}\n" + m, dir, _ := newR442Manager(t, "app", compose, "deployed: true\nenv:\n KEEP: x\n", "") + meta := "display_name: App\nslug: app\n" + + "deploy_fields:\n" + + " - env_var: ADMIN_USER\n label: U\n type: secret\n generate: \"hex:5\"\n" + + " - env_var: NEW_KEY\n label: K\n type: secret\n generate: \"hex:8\"\n" + + "after_install:\n service: app\n env: [ADMIN_USER]\n command: [\"true\", \"${ADMIN_USER}\"]\n success: OK\n" + if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + if md := LoadMetadata(dir); md.AfterInstall == nil || len(md.DeployFields) != 2 { + t.Fatalf("fixture metadata did not parse: after_install=%v fields=%d", md.AfterInstall, len(md.DeployFields)) + } + + m.InjectMissingFields([]string{"app"}) + + cfg := LoadAppConfig(dir) + if cfg == nil { + t.Fatal("app.yaml unreadable after injection") + } + env := cfg.Env + if _, ok := env["ADMIN_USER"]; ok { + t.Errorf("ADMIN_USER was INVENTED for an installed app (only after_install reads it): %v", keysOf(env)) + } + if v := env["NEW_KEY"]; v == "" { + t.Errorf("a new secret the compose file reads must still be generated: %v", keysOf(env)) + } +} + +func keysOf(m map[string]string) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +}