Skip to content

Commit e73029e

Browse files
feat: env: map in mantle.yaml for workflow expression variables (#74) (#119)
* feat(config): add env map for workflow expression variables (#74) Add Env map[string]string field to Config struct, parsed from the env: section in mantle.yaml. Keys are normalized to uppercase to match the MANTLE_ENV_* convention used by the CEL evaluator. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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> * feat(engine): wire config env map into CEL evaluator (#74) Call eng.CEL.SetConfigEnv(cfg.Env) after engine.New(database) in the run and serve CLI commands so that env: values from mantle.yaml are available in CEL workflow expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: document env: config section for workflow variables (#74) Add Workflow Expression Variables section to configuration docs covering the env: YAML syntax, CEL expression usage, and MANTLE_ENV_* override precedence with info logging on conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct max_concurrent_executions_per_team default to 0 (unlimited) in docs * docs: clarify env key case sensitivity and uppercase normalization --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c609237 commit e73029e

7 files changed

Lines changed: 245 additions & 16 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"
@@ -27,7 +28,8 @@ const maxProgramCacheSize = 10000
2728
// Evaluator evaluates CEL expressions against a runtime context.
2829
type Evaluator struct {
2930
env *cel.Env
30-
envCache map[string]string // cached, filtered environment variables
31+
configEnv map[string]string // config-sourced env values from mantle.yaml
32+
envCache map[string]string // cached, filtered environment variables
3133
programMu sync.Mutex
3234
programCache map[string]cel.Program // expression string -> compiled cel.Program
3335
}
@@ -49,7 +51,7 @@ func NewEvaluator() (*Evaluator, error) {
4951
if err != nil {
5052
return nil, fmt.Errorf("creating CEL environment: %w", err)
5153
}
52-
return &Evaluator{env: env, envCache: envVars(), programCache: make(map[string]cel.Program)}, nil
54+
return &Evaluator{env: env, envCache: mergeEnvVars(nil), programCache: make(map[string]cel.Program)}, nil
5355
}
5456

5557
// Eval evaluates a CEL expression and returns the result as a Go value.
@@ -222,17 +224,37 @@ func (e *Evaluator) resolveValue(v any, ctx *Context) (any, error) {
222224
}
223225
}
224226

225-
// envVars returns only environment variables with the MANTLE_ENV_ prefix,
226-
// stripping the prefix for cleaner access (e.g., MANTLE_ENV_FOO -> env.FOO).
227+
// SetConfigEnv sets the config-sourced env values and rebuilds the env cache.
228+
// This merges config values with MANTLE_ENV_* environment variables, where
229+
// OS env vars take precedence over config values.
230+
func (e *Evaluator) SetConfigEnv(configEnv map[string]string) {
231+
e.configEnv = configEnv
232+
e.envCache = mergeEnvVars(configEnv)
233+
}
234+
235+
// mergeEnvVars builds the env map available to CEL expressions.
236+
// It starts with config-sourced values (from the env: section in mantle.yaml),
237+
// then overlays MANTLE_ENV_* environment variables (stripping the prefix).
238+
// When a key exists in both, the OS env var wins and an info log is emitted.
227239
// This prevents CEL expressions from reading sensitive variables like
228240
// MANTLE_ENCRYPTION_KEY, MANTLE_DATABASE_URL, or AWS_SECRET_ACCESS_KEY.
229-
func envVars() map[string]string {
241+
func mergeEnvVars(configEnv map[string]string) map[string]string {
230242
env := make(map[string]string)
243+
244+
// Start with config values.
245+
for k, v := range configEnv {
246+
env[k] = v
247+
}
248+
249+
// Overlay MANTLE_ENV_* from OS environment (env var wins).
231250
const prefix = "MANTLE_ENV_"
232251
for _, e := range os.Environ() {
233252
parts := strings.SplitN(e, "=", 2)
234253
if len(parts) == 2 && strings.HasPrefix(parts[0], prefix) {
235254
key := strings.TrimPrefix(parts[0], prefix)
255+
if _, exists := env[key]; exists {
256+
slog.Info("env variable overrides config", "key", prefix+key, "config_key", "env."+key)
257+
}
236258
env[key] = parts[1]
237259
}
238260
}

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

packages/engine/internal/cli/run.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ func newRunCommand() *cobra.Command {
6565
return fmt.Errorf("creating engine: %w", err)
6666
}
6767
eng.MaxConcurrentExecutionsPerTeam = cfg.Engine.MaxConcurrentExecutionsPerTeam
68+
eng.CEL.SetConfigEnv(cfg.Env)
6869

6970
// Configure credential resolver with Postgres-backed store when encryption key is set.
7071
if cfg.Encryption.Key != "" {

packages/engine/internal/cli/serve.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func newServeCommand() *cobra.Command {
4949
if err != nil {
5050
return fmt.Errorf("creating engine: %w", err)
5151
}
52+
eng.CEL.SetConfigEnv(cfg.Env)
5253
eng.MaxToolRoundsLimit = cfg.Engine.MaxToolRoundsLimit
5354
eng.MaxConcurrentExecutionsPerTeam = cfg.Engine.MaxConcurrentExecutionsPerTeam
5455

packages/engine/internal/config/config.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"log/slog"
1010
"net/url"
1111
"os"
12+
"strings"
1213
"time"
1314

1415
"github.com/dvflw/mantle/internal/budget"
@@ -32,6 +33,7 @@ type Config struct {
3233
GCP GCPConfig `mapstructure:"gcp"`
3334
Azure AzureConfig `mapstructure:"azure"`
3435
Storage StorageConfig `mapstructure:"storage"`
36+
Env map[string]string `mapstructure:"env"`
3537
}
3638

3739
// RetentionConfig holds data retention settings.
@@ -279,8 +281,6 @@ func Load(cmd *cobra.Command) (*Config, error) {
279281
}
280282

281283
// Validate config version.
282-
// If the user did not set "version" at all, default to 1.
283-
// If they explicitly set it (including to 0), it must be 1.
284284
if !v.IsSet("version") {
285285
cfg.Version = 1
286286
}
@@ -289,7 +289,6 @@ func Load(cmd *cobra.Command) (*Config, error) {
289289
}
290290

291291
// Deprecated: fall back from "tmp" section to "storage" for backward compatibility.
292-
// Field-by-field merge so that env vars / flags on "storage.*" are not clobbered.
293292
if v.IsSet("tmp") {
294293
if sub := v.Sub("tmp"); sub != nil {
295294
var legacy StorageConfig
@@ -314,6 +313,16 @@ func Load(cmd *cobra.Command) (*Config, error) {
314313
}
315314
}
316315

316+
// Viper lowercases all map keys. Normalize env map keys to uppercase
317+
// so they match MANTLE_ENV_* convention and CEL expressions like env.APP_NAME.
318+
if len(cfg.Env) > 0 {
319+
normalized := make(map[string]string, len(cfg.Env))
320+
for k, v := range cfg.Env {
321+
normalized[strings.ToUpper(k)] = v
322+
}
323+
cfg.Env = normalized
324+
}
325+
317326
// Validate budget reset_day range.
318327
if cfg.Engine.Budget.ResetDay < 1 || cfg.Engine.Budget.ResetDay > 28 {
319328
if cfg.Engine.Budget.ResetMode == budget.ResetModeRolling {

packages/engine/internal/config/config_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,43 @@ tmp:
427427
assert.Equal(t, "s3", cfg.Storage.Type)
428428
assert.Equal(t, "new-bucket", cfg.Storage.Bucket)
429429
}
430+
431+
func TestLoad_EnvMap(t *testing.T) {
432+
dir := t.TempDir()
433+
configFile := filepath.Join(dir, "mantle.yaml")
434+
err := os.WriteFile(configFile, []byte(`
435+
env:
436+
APP_NAME: "my-app"
437+
REGION: "us-east-1"
438+
DEBUG: "true"
439+
`), 0644)
440+
require.NoError(t, err)
441+
442+
cmd := newTestCommand()
443+
_ = cmd.Flags().Set("config", configFile)
444+
445+
cfg, err := Load(cmd)
446+
require.NoError(t, err)
447+
448+
assert.Equal(t, "my-app", cfg.Env["APP_NAME"])
449+
assert.Equal(t, "us-east-1", cfg.Env["REGION"])
450+
assert.Equal(t, "true", cfg.Env["DEBUG"])
451+
}
452+
453+
func TestLoad_EnvMapEmpty(t *testing.T) {
454+
dir := t.TempDir()
455+
configFile := filepath.Join(dir, "mantle.yaml")
456+
err := os.WriteFile(configFile, []byte(`
457+
log:
458+
level: "debug"
459+
`), 0644)
460+
require.NoError(t, err)
461+
462+
cmd := newTestCommand()
463+
_ = cmd.Flags().Set("config", configFile)
464+
465+
cfg, err := Load(cmd)
466+
require.NoError(t, err)
467+
468+
assert.Empty(t, cfg.Env)
469+
}

0 commit comments

Comments
 (0)