Skip to content

Commit 2dfc5d4

Browse files
michaelmcneesclaude
andcommitted
feat(cel): merge config env with MANTLE_ENV_, log overrides (#74)
Rename envVars() to mergeEnvVars(configEnv) which starts with config values from mantle.yaml env: section, then overlays MANTLE_ENV_* OS environment variables. When a key exists in both sources, the OS env var wins and an info log is emitted. Add SetConfigEnv setter to avoid changing the NewEvaluator signature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4fa8033 commit 2dfc5d4

2 files changed

Lines changed: 122 additions & 10 deletions

File tree

packages/engine/internal/cel/cel.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cel
22

33
import (
44
"fmt"
5+
"log/slog"
56
"os"
67
"reflect"
78
"strings"
@@ -25,7 +26,8 @@ const maxProgramCacheSize = 10000
2526
// Evaluator evaluates CEL expressions against a runtime context.
2627
type Evaluator struct {
2728
env *cel.Env
28-
envCache map[string]string // cached, filtered environment variables
29+
configEnv map[string]string // config-sourced env values from mantle.yaml
30+
envCache map[string]string // cached, filtered environment variables
2931
programMu sync.Mutex
3032
programCache map[string]cel.Program // expression string -> compiled cel.Program
3133
}
@@ -45,7 +47,7 @@ func NewEvaluator() (*Evaluator, error) {
4547
if err != nil {
4648
return nil, fmt.Errorf("creating CEL environment: %w", err)
4749
}
48-
return &Evaluator{env: env, envCache: envVars(), programCache: make(map[string]cel.Program)}, nil
50+
return &Evaluator{env: env, envCache: mergeEnvVars(nil), programCache: make(map[string]cel.Program)}, nil
4951
}
5052

5153
// Eval evaluates a CEL expression and returns the result as a Go value.
@@ -206,17 +208,37 @@ func (e *Evaluator) resolveValue(v any, ctx *Context) (any, error) {
206208
}
207209
}
208210

209-
// envVars returns only environment variables with the MANTLE_ENV_ prefix,
210-
// stripping the prefix for cleaner access (e.g., MANTLE_ENV_FOO -> env.FOO).
211+
// SetConfigEnv sets the config-sourced env values and rebuilds the env cache.
212+
// This merges config values with MANTLE_ENV_* environment variables, where
213+
// OS env vars take precedence over config values.
214+
func (e *Evaluator) SetConfigEnv(configEnv map[string]string) {
215+
e.configEnv = configEnv
216+
e.envCache = mergeEnvVars(configEnv)
217+
}
218+
219+
// mergeEnvVars builds the env map available to CEL expressions.
220+
// It starts with config-sourced values (from the env: section in mantle.yaml),
221+
// then overlays MANTLE_ENV_* environment variables (stripping the prefix).
222+
// When a key exists in both, the OS env var wins and an info log is emitted.
211223
// This prevents CEL expressions from reading sensitive variables like
212224
// MANTLE_ENCRYPTION_KEY, MANTLE_DATABASE_URL, or AWS_SECRET_ACCESS_KEY.
213-
func envVars() map[string]string {
225+
func mergeEnvVars(configEnv map[string]string) map[string]string {
214226
env := make(map[string]string)
227+
228+
// Start with config values.
229+
for k, v := range configEnv {
230+
env[k] = v
231+
}
232+
233+
// Overlay MANTLE_ENV_* from OS environment (env var wins).
215234
const prefix = "MANTLE_ENV_"
216235
for _, e := range os.Environ() {
217236
parts := strings.SplitN(e, "=", 2)
218237
if len(parts) == 2 && strings.HasPrefix(parts[0], prefix) {
219238
key := strings.TrimPrefix(parts[0], prefix)
239+
if _, exists := env[key]; exists {
240+
slog.Info("env variable overrides config", "key", prefix+key, "config_key", "env."+key)
241+
}
220242
env[key] = parts[1]
221243
}
222244
}

packages/engine/internal/cel/cel_test.go

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -296,25 +296,115 @@ func TestEval_EnvVarBlocksSensitive(t *testing.T) {
296296
}
297297

298298
func TestEnvVars_PrefixStripping(t *testing.T) {
299-
// Directly test the envVars function.
299+
// Directly test the mergeEnvVars function.
300300
os.Setenv("MANTLE_ENV_TEST_KEY", "test-value")
301301
defer os.Unsetenv("MANTLE_ENV_TEST_KEY")
302302

303303
os.Setenv("MANTLE_DATABASE_URL", "should-not-appear")
304304
defer os.Unsetenv("MANTLE_DATABASE_URL")
305305

306-
result := envVars()
306+
result := mergeEnvVars(nil)
307307

308308
if v, ok := result["TEST_KEY"]; !ok || v != "test-value" {
309-
t.Errorf("envVars()[TEST_KEY] = %q, %v; want %q, true", v, ok, "test-value")
309+
t.Errorf("mergeEnvVars()[TEST_KEY] = %q, %v; want %q, true", v, ok, "test-value")
310310
}
311311

312312
if _, ok := result["MANTLE_DATABASE_URL"]; ok {
313-
t.Error("envVars() should not contain MANTLE_DATABASE_URL")
313+
t.Error("mergeEnvVars() should not contain MANTLE_DATABASE_URL")
314314
}
315315

316316
if _, ok := result["DATABASE_URL"]; ok {
317-
t.Error("envVars() should not contain DATABASE_URL (MANTLE_ prefix without ENV_)")
317+
t.Error("mergeEnvVars() should not contain DATABASE_URL (MANTLE_ prefix without ENV_)")
318+
}
319+
}
320+
321+
func TestEnvVars_ConfigEnvMerge(t *testing.T) {
322+
configEnv := map[string]string{
323+
"APP_NAME": "my-workflow-app",
324+
"REGION": "us-east-1",
325+
}
326+
327+
eval, err := NewEvaluator()
328+
if err != nil {
329+
t.Fatalf("NewEvaluator() error: %v", err)
330+
}
331+
eval.SetConfigEnv(configEnv)
332+
333+
ctx := newTestContext()
334+
335+
result, err := eval.Eval(`env.APP_NAME`, ctx)
336+
if err != nil {
337+
t.Fatalf("Eval(env.APP_NAME) error: %v", err)
338+
}
339+
if result != "my-workflow-app" {
340+
t.Errorf("env.APP_NAME = %v, want %q", result, "my-workflow-app")
341+
}
342+
343+
result, err = eval.Eval(`env.REGION`, ctx)
344+
if err != nil {
345+
t.Fatalf("Eval(env.REGION) error: %v", err)
346+
}
347+
if result != "us-east-1" {
348+
t.Errorf("env.REGION = %v, want %q", result, "us-east-1")
349+
}
350+
}
351+
352+
func TestEnvVars_OsEnvOverridesConfig(t *testing.T) {
353+
t.Setenv("MANTLE_ENV_REGION", "eu-west-1")
354+
355+
configEnv := map[string]string{
356+
"REGION": "us-east-1",
357+
"APP_NAME": "my-app",
358+
}
359+
360+
eval, err := NewEvaluator()
361+
if err != nil {
362+
t.Fatalf("NewEvaluator() error: %v", err)
363+
}
364+
eval.SetConfigEnv(configEnv)
365+
366+
ctx := newTestContext()
367+
368+
// MANTLE_ENV_REGION should override the config value.
369+
result, err := eval.Eval(`env.REGION`, ctx)
370+
if err != nil {
371+
t.Fatalf("Eval(env.REGION) error: %v", err)
372+
}
373+
if result != "eu-west-1" {
374+
t.Errorf("env.REGION = %v, want %q (OS env override)", result, "eu-west-1")
375+
}
376+
377+
// APP_NAME should still come from config (no OS env override).
378+
result, err = eval.Eval(`env.APP_NAME`, ctx)
379+
if err != nil {
380+
t.Fatalf("Eval(env.APP_NAME) error: %v", err)
381+
}
382+
if result != "my-app" {
383+
t.Errorf("env.APP_NAME = %v, want %q", result, "my-app")
384+
}
385+
}
386+
387+
func TestEnvVars_NilConfigEnv(t *testing.T) {
388+
eval, err := NewEvaluator()
389+
if err != nil {
390+
t.Fatalf("NewEvaluator() error: %v", err)
391+
}
392+
393+
// SetConfigEnv(nil) should not panic.
394+
eval.SetConfigEnv(nil)
395+
396+
ctx := newTestContext()
397+
398+
// env map should still work (just no config values).
399+
t.Setenv("MANTLE_ENV_SAFE_KEY", "safe-value")
400+
eval.SetConfigEnv(nil)
401+
402+
result, err := eval.Eval(`env.SAFE_KEY`, ctx)
403+
if err != nil {
404+
t.Fatalf("Eval(env.SAFE_KEY) error: %v", err)
405+
}
406+
if result != "safe-value" {
407+
t.Errorf("env.SAFE_KEY = %v, want %q", result, "safe-value")
318408
}
319409
}
320410

0 commit comments

Comments
 (0)