diff --git a/internal/generate/compose.go b/internal/generate/compose.go index 1dc12c0..d823b7c 100644 --- a/internal/generate/compose.go +++ b/internal/generate/compose.go @@ -13,6 +13,7 @@ import ( "github.com/open-source-cloud/devstack/internal/config" "github.com/open-source-cloud/devstack/internal/proxy" + "github.com/open-source-cloud/devstack/internal/secrets" "github.com/open-source-cloud/devstack/internal/template" ) @@ -161,8 +162,14 @@ func (b *serviceBuilder) projectEnv(svc config.Service, fragment map[string]any) // 1. Template-provided environment (already rendered), in either map or list // (["KEY=VALUE"]) form — both are valid compose and must not be dropped. env := envFromFragment(fragment["environment"]) - // 2. env.raw — interpolated, emitted verbatim. + // 2. env.raw — interpolated, emitted verbatim. A secret:// value becomes a + // VALUELESS key (the §7.5 coupling): its resolved value is injected at runtime + // via the compose-up process env, never written to the generated file. for _, k := range sortedKeys(svc.Env.Raw) { + if secrets.IsRef(svc.Env.Raw[k]) { + env[k] = nil + continue + } val, err := config.Interpolate(svc.Env.Raw[k], b.res) if err != nil { return nil, fmt.Errorf("%s.%s env.raw[%s]: %w", b.project, b.service, k, err) @@ -171,11 +178,16 @@ func (b *serviceBuilder) projectEnv(svc config.Service, fragment map[string]any) } // 3. env.prefixed — same, but namespaced under the service name. for _, k := range sortedKeys(svc.Env.Prefixed) { + key := envPrefix(b.service) + "_" + k + if secrets.IsRef(svc.Env.Prefixed[k]) { + env[key] = nil + continue + } val, err := config.Interpolate(svc.Env.Prefixed[k], b.res) if err != nil { return nil, fmt.Errorf("%s.%s env.prefixed[%s]: %w", b.project, b.service, k, err) } - env[envPrefix(b.service)+"_"+k] = val + env[key] = val } // 4. env.import — connection vars pulled from a shared service / another service. for i, imp := range svc.Env.Import { diff --git a/internal/generate/health_test.go b/internal/generate/health_test.go index cc0fef6..72573a0 100644 --- a/internal/generate/health_test.go +++ b/internal/generate/health_test.go @@ -196,3 +196,51 @@ func TestProxyLabelsEmitted(t *testing.T) { } } } + +// TestSecretRefEnvValueless verifies a secret:// value in env.raw/prefixed is +// emitted as a VALUELESS key — the ref string (and any resolved value) never +// reaches the generated compose (§7.5 / S6). +func TestSecretRefEnvValueless(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n") + write("app/devstack.yaml", `apiVersion: devstack/v1 +kind: Project +name: app +services: + web: + template: node.vite + env: + raw: { DB_PASSWORD: "secret://sops/secrets.enc.yaml#pw", APP_ENV: "dev" } +`) + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + g, err := New(m, template.NewFSSource(templates.FS), WithEnv(map[string]string{})) + if err != nil { + t.Fatalf("New: %v", err) + } + st, err := g.GenerateProject("app") + if err != nil { + t.Fatalf("GenerateProject: %v", err) + } + compose := string(st.Compose) + if strings.Contains(compose, "secret://") { + t.Errorf("secret ref leaked into generated compose:\n%s", compose) + } + if !strings.Contains(compose, "DB_PASSWORD: null") { + t.Errorf("DB_PASSWORD should be a valueless (null) key:\n%s", compose) + } + if !strings.Contains(compose, "APP_ENV: dev") { + t.Errorf("non-secret env should still be emitted verbatim:\n%s", compose) + } +}