-
Notifications
You must be signed in to change notification settings - Fork 0
feat: environment values and named environments (#53) #127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
18d6e21
e841242
764e707
1a5770a
d11c7bc
c9cba05
8eab6cf
f2943f6
767999a
e00dfd4
e37f3fc
e3b03fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package cel | ||
|
|
||
| import ( | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestSetValuesEnv_LayersBetweenConfigAndOS(t *testing.T) { | ||
| eval, err := NewEvaluator() | ||
| if err != nil { | ||
| t.Fatalf("NewEvaluator() error: %v", err) | ||
| } | ||
|
|
||
| eval.SetConfigEnv(map[string]string{ | ||
| "LOG_LEVEL": "info", | ||
| "REGION": "us-west-2", | ||
| "APP_NAME": "config-app", | ||
| }) | ||
|
|
||
| eval.SetValuesEnv(map[string]string{ | ||
| "LOG_LEVEL": "error", | ||
| "DB_HOST": "prod-db.example.com", | ||
| }) | ||
|
|
||
| ctx := &Context{ | ||
| Steps: make(map[string]map[string]any), | ||
| Inputs: make(map[string]any), | ||
| } | ||
|
|
||
| // Values override config. | ||
| result, err := eval.Eval(`env.LOG_LEVEL`, ctx) | ||
| if err != nil { | ||
| t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err) | ||
| } | ||
| if result != "error" { | ||
| t.Errorf("env.LOG_LEVEL = %v, want %q (values overrides config)", result, "error") | ||
| } | ||
|
|
||
| // Config value preserved when values doesn't override. | ||
| result, err = eval.Eval(`env.REGION`, ctx) | ||
| if err != nil { | ||
| t.Fatalf("Eval(env.REGION) error: %v", err) | ||
| } | ||
| if result != "us-west-2" { | ||
| t.Errorf("env.REGION = %v, want %q (config preserved)", result, "us-west-2") | ||
| } | ||
|
|
||
| // Values-only key is accessible. | ||
| result, err = eval.Eval(`env.DB_HOST`, ctx) | ||
| if err != nil { | ||
| t.Fatalf("Eval(env.DB_HOST) error: %v", err) | ||
| } | ||
| if result != "prod-db.example.com" { | ||
| t.Errorf("env.DB_HOST = %v, want %q", result, "prod-db.example.com") | ||
| } | ||
| } | ||
|
|
||
| func TestSetValuesEnv_OSEnvTakesPrecedence(t *testing.T) { | ||
| eval, err := NewEvaluator() | ||
| if err != nil { | ||
| t.Fatalf("NewEvaluator() error: %v", err) | ||
| } | ||
|
|
||
| t.Setenv("MANTLE_ENV_LOG_LEVEL", "debug") | ||
|
|
||
| eval.SetConfigEnv(map[string]string{ | ||
| "LOG_LEVEL": "info", | ||
| }) | ||
| eval.SetValuesEnv(map[string]string{ | ||
| "LOG_LEVEL": "error", | ||
| }) | ||
|
|
||
| ctx := &Context{ | ||
| Steps: make(map[string]map[string]any), | ||
| Inputs: make(map[string]any), | ||
| } | ||
|
|
||
| result, err := eval.Eval(`env.LOG_LEVEL`, ctx) | ||
| if err != nil { | ||
| t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err) | ||
| } | ||
| if result != "debug" { | ||
| t.Errorf("env.LOG_LEVEL = %v, want %q (OS env wins)", result, "debug") | ||
| } | ||
| } | ||
|
|
||
| func TestSetValuesEnv_NilClearsValuesLayer(t *testing.T) { | ||
| eval, err := NewEvaluator() | ||
| if err != nil { | ||
| t.Fatalf("NewEvaluator() error: %v", err) | ||
| } | ||
|
|
||
| eval.SetConfigEnv(map[string]string{ | ||
| "LOG_LEVEL": "info", | ||
| }) | ||
| eval.SetValuesEnv(map[string]string{ | ||
| "LOG_LEVEL": "error", | ||
| }) | ||
|
|
||
| eval.SetValuesEnv(nil) | ||
|
|
||
| ctx := &Context{ | ||
| Steps: make(map[string]map[string]any), | ||
| Inputs: make(map[string]any), | ||
| } | ||
|
|
||
| result, err := eval.Eval(`env.LOG_LEVEL`, ctx) | ||
| if err != nil { | ||
| t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err) | ||
| } | ||
| if result != "info" { | ||
| t.Errorf("env.LOG_LEVEL = %v, want %q (back to config after clear)", result, "info") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "text/tabwriter" | ||
|
|
||
| "github.com/dvflw/mantle/internal/config" | ||
| "github.com/dvflw/mantle/internal/db" | ||
| "github.com/dvflw/mantle/internal/environment" | ||
| "github.com/dvflw/mantle/internal/workflow" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func newEnvCommand() *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "env", | ||
| Short: "Manage named environments", | ||
| Long: "Create, list, show, and delete named environments for parameterized workflow execution.", | ||
| } | ||
|
|
||
| cmd.AddCommand(newEnvCreateCommand()) | ||
| cmd.AddCommand(newEnvListCommand()) | ||
| cmd.AddCommand(newEnvShowCommand()) | ||
| cmd.AddCommand(newEnvDeleteCommand()) | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| func newEnvCreateCommand() *cobra.Command { | ||
| var fromFile string | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "create <name>", | ||
| Short: "Create a named environment", | ||
| Long: "Creates a named environment from a values file.", | ||
| Example: ` mantle env create production --from prod.values.yaml | ||
| mantle env create staging --from staging.values.yaml`, | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| name := args[0] | ||
|
|
||
| vals, err := workflow.LoadValues(fromFile) | ||
| if err != nil { | ||
| return fmt.Errorf("loading values file: %w", err) | ||
| } | ||
|
|
||
| store, cleanup, err := newEnvStore(cmd) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer cleanup() | ||
|
|
||
| env, err := store.Create(cmd.Context(), name, vals.Inputs, vals.Env) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| fmt.Fprintf(cmd.OutOrStdout(), "Created environment %q\n", env.Name) | ||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringVar(&fromFile, "from", "", "Values file to load inputs and env from (required)") | ||
| _ = cmd.MarkFlagRequired("from") | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| func newEnvListCommand() *cobra.Command { | ||
| return &cobra.Command{ | ||
| Use: "list", | ||
| Short: "List all environments", | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| store, cleanup, err := newEnvStore(cmd) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer cleanup() | ||
|
|
||
| envs, err := store.List(cmd.Context()) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| if len(envs) == 0 { | ||
| fmt.Fprintln(cmd.OutOrStdout(), "(no environments)") | ||
| return nil | ||
| } | ||
|
|
||
| w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) | ||
| fmt.Fprintln(w, "NAME\tCREATED") | ||
| for _, e := range envs { | ||
| fmt.Fprintf(w, "%s\t%s\n", e.Name, e.CreatedAt.Format("2006-01-02 15:04:05")) | ||
| } | ||
| return w.Flush() | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func newEnvShowCommand() *cobra.Command { | ||
| return &cobra.Command{ | ||
| Use: "show <name>", | ||
| Short: "Show environment details", | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| store, cleanup, err := newEnvStore(cmd) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer cleanup() | ||
|
|
||
| env, err := store.Get(cmd.Context(), args[0]) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", env.Name) | ||
| if len(env.Inputs) > 0 { | ||
| fmt.Fprintln(cmd.OutOrStdout(), "\nInputs:") | ||
| for k, v := range env.Inputs { | ||
| fmt.Fprintf(cmd.OutOrStdout(), " %s: %v\n", k, v) | ||
| } | ||
| } | ||
| if len(env.Env) > 0 { | ||
| fmt.Fprintln(cmd.OutOrStdout(), "\nEnv:") | ||
| for k, v := range env.Env { | ||
| fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", k, v) | ||
| } | ||
| } | ||
| return nil | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func newEnvDeleteCommand() *cobra.Command { | ||
| return &cobra.Command{ | ||
| Use: "delete <name>", | ||
| Short: "Delete an environment", | ||
| Args: cobra.ExactArgs(1), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| store, cleanup, err := newEnvStore(cmd) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer cleanup() | ||
|
|
||
| if err := store.Delete(cmd.Context(), args[0]); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| fmt.Fprintf(cmd.OutOrStdout(), "Deleted environment %q\n", args[0]) | ||
| return nil | ||
| }, | ||
| } | ||
| } | ||
|
Comment on lines
+156
to
+176
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if environment.Store.Delete emits audit events
rg -n -A 15 'func.*Delete.*context' packages/engine/internal/environment/store.go | head -40Repository: dvflw/mantle Length of output: 554 🏁 Script executed: cat -n packages/engine/internal/environment/store.go | sed -n '125,160p'Repository: dvflw/mantle Length of output: 709 🏁 Script executed: rg -n -B 5 -A 10 'Audit.*Delete|EmitAudit.*Delete' packages/engine/internal/environment/Repository: dvflw/mantle Length of output: 38 🏁 Script executed: rg -n -B 2 -A 20 'func.*Store.*Create.*ctx context' packages/engine/internal/environment/store.go | head -50Repository: dvflw/mantle Length of output: 843 🏁 Script executed: rg -n -B 5 -A 15 'type Store struct' packages/engine/internal/environment/store.goRepository: dvflw/mantle Length of output: 750 🏁 Script executed: rg -n -A 35 'func.*Store.*Create.*ctx context' packages/engine/internal/environment/store.go | head -70Repository: dvflw/mantle Length of output: 1245 🏁 Script executed: rg -n 'AuditEmitter|EmitAudit' packages/engine/internal/environment/store.goRepository: dvflw/mantle Length of output: 38 🏁 Script executed: rg -n 'type AuditEmitter|interface.*AuditEmitter' packages/engine/internal/Repository: dvflw/mantle Length of output: 38 🏁 Script executed: rg -n 'AuditEmitter' packages/engine/internal/ | head -20Repository: dvflw/mantle Length of output: 38 🏁 Script executed: rg -r 'AuditEmitter' packages/ --type goRepository: dvflw/mantle Length of output: 38 🏁 Script executed: cat -n CLAUDE.md | head -50Repository: dvflw/mantle Length of output: 2632 Emit audit event for delete operation. The 🤖 Prompt for AI Agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pushing back on this one. If we want audit coverage for all CRUD operations (secrets, environments, etc.), that should be a separate cross-cutting concern. Happy to file an issue for it.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If you'd like, I can open a GitHub issue to track cross-cutting audit coverage for all CRUD stores (secrets, environments, etc.) as a follow-up. Just say the word. ✏️ Learnings added
🧠 Learnings used |
||
|
|
||
| // newEnvStore builds an environment.Store from the current command context. | ||
| func newEnvStore(cmd *cobra.Command) (*environment.Store, func(), error) { | ||
| cfg := config.FromContext(cmd.Context()) | ||
| if cfg == nil { | ||
| return nil, nil, fmt.Errorf("config not loaded") | ||
| } | ||
|
|
||
| database, err := db.Open(cfg.Database) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("failed to connect to database: %w", err) | ||
| } | ||
|
|
||
| store := &environment.Store{DB: database} | ||
| cleanup := func() { database.Close() } | ||
| return store, cleanup, nil | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.