R-757: a secret only after_install reads is not invented for an installed app
InjectMissingFields skips a new generated secret that is in after_install's env list and named by no compose definition: after_install does not run again, so the app never received the value and the reveal would answer a login the app does not have (calibre-web ADMIN_USER, 2026-10-01). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user