Skip to content

Commit b45a653

Browse files
committed
feat(env): audit events, update cmd, secret redaction, richer plan
Address pre-push review findings before opening PR for #53. Secret hygiene (Legal HIGH): - env get redacts env values by default; --reveal unredacts and emits environment.revealed audit event - values files continue to accept env overrides but their raw contents never leave the CLI without explicit reveal Audit coverage (Legal HIGH): - new audit actions: environment.{created,updated,deleted,revealed} - Store.Create/Update/Delete emit inside the same tx as the write - Store gains Actor field (defaults to "cli") to attribute events User-visible gaps (PM): - add mantle env update subcommand + Store.Update that preserves id and created_at so environment history isn't lost on edits - plan --env now appends a "Resolved configuration" block showing final inputs and env vars with the layer each value came from - env delete requires --yes/-y; refuses to proceed without it - env get shows created/updated timestamps and env IDs - rename env show to env get to match kubectl/gh conventions - relax name regex to ^[a-z0-9][a-z0-9_-]* so production, prod_1, env-v2, 123env all validate Polish (Tech Writer + Reality Checker): - richer Long/Example text across all env subcommands - migration 018 gains header comment explaining purpose and reveal contract - cel.SetValuesEnv godoc reflects that both values and named envs feed it - replace err == sql.ErrNoRows with errors.Is throughout environment store - ErrNotFound sentinel for Get/Update/Delete misses Docs: - CLAUDE.md Key Commands lists mantle env and --values/--env flags with the full override precedence chain Tests: extend store_test with Update, ValidNameRelaxed, InvalidName (broader cases), AuditEvents; add plan_test for writeResolvedOverrides source attribution.
1 parent ea4c966 commit b45a653

10 files changed

Lines changed: 682 additions & 109 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,20 @@ mantle logs <execution-id> # View execution logs
7575
mantle status <execution-id> # View execution state
7676
mantle secrets create # Create typed credential
7777
mantle secrets rotate-key # Re-encrypt credentials with new key
78+
mantle env create <name> # Create a named environment from a values file
79+
mantle env update <name> # Replace inputs/env on an existing environment
80+
mantle env list # List named environments
81+
mantle env get <name> # Show environment details (env values redacted; --reveal to unredact, audited)
82+
mantle env delete <name> -y # Delete a named environment (requires --yes)
83+
mantle run <wf> --values f.yaml # Run with a values file (inputs + env overrides)
84+
mantle run <wf> --env <name> # Run against a stored named environment
85+
mantle plan <wf> --env <name> # Plan; appends resolved inputs/env with source
7886
mantle serve # Start persistent server
7987
```
8088

89+
**Override precedence (highest wins):**
90+
`MANTLE_ENV_*` OS vars > `--input` flags > `--values` file > `--env` named environment > workflow `default` values > config `env:` section
91+
8192
## License
8293

8394
BSL/SSPL-style — source available, no commercial resale of forks.

packages/engine/internal/audit/audit.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ const (
5555

5656
// Rollback operations.
5757
ActionWorkflowRolledBack Action = "workflow.rolled_back"
58+
59+
// Environment operations.
60+
ActionEnvironmentCreated Action = "environment.created"
61+
ActionEnvironmentUpdated Action = "environment.updated"
62+
ActionEnvironmentDeleted Action = "environment.deleted"
63+
ActionEnvironmentRevealed Action = "environment.revealed"
5864
)
5965

6066
// Resource identifies the target of an audit event.

packages/engine/internal/cel/cel.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,10 @@ func (e *Evaluator) SetConfigEnv(configEnv map[string]string) {
233233
e.envCache = mergeEnvVars(configEnv, e.valuesEnv)
234234
}
235235

236-
// SetValuesEnv sets the values-file env overrides and rebuilds the env cache.
237-
// Values-file env sits between config env and MANTLE_ENV_* OS variables in
238-
// the precedence stack: config < values < MANTLE_ENV_*.
236+
// SetValuesEnv sets the values-file and named-environment env overrides
237+
// and rebuilds the env cache. Overlays them on top of config env; MANTLE_ENV_*
238+
// OS vars still win. The layered precedence is config < values/named-env <
239+
// MANTLE_ENV_*.
239240
func (e *Evaluator) SetValuesEnv(valuesEnv map[string]string) {
240241
e.valuesEnv = valuesEnv
241242
e.envCache = mergeEnvVars(e.configEnv, e.valuesEnv)

packages/engine/internal/cli/env.go

Lines changed: 174 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,30 @@ import (
1212
"github.com/spf13/cobra"
1313
)
1414

15+
// redactedPlaceholder is printed in place of env values in `env get` output
16+
// unless --reveal is passed. Values files commonly contain secret-shaped
17+
// material (API keys, connection strings, tokens) and must not leak to logs
18+
// or terminal scrollback by default. Revealing is always audited.
19+
const redactedPlaceholder = "<redacted>"
20+
1521
// newEnvCommand returns the "env" subcommand for managing named environments.
1622
func newEnvCommand() *cobra.Command {
1723
cmd := &cobra.Command{
1824
Use: "env",
1925
Short: "Manage named environments",
20-
Long: "Create, list, show, and delete named environments for parameterized workflow execution.",
26+
Long: `Named environments (e.g., "production", "staging") store reusable input
27+
and env variable sets for parameterized workflow runs. Combine with
28+
` + "`mantle run --env <name>`" + ` and ` + "`mantle plan --env <name>`" + ` to promote the
29+
same workflow across environments without rewriting CLI flags.
30+
31+
Override precedence (highest wins):
32+
--input flags > --values file > --env named environment > workflow defaults`,
2133
}
2234

2335
cmd.AddCommand(newEnvCreateCommand())
36+
cmd.AddCommand(newEnvUpdateCommand())
2437
cmd.AddCommand(newEnvListCommand())
25-
cmd.AddCommand(newEnvShowCommand())
38+
cmd.AddCommand(newEnvGetCommand())
2639
cmd.AddCommand(newEnvDeleteCommand())
2740

2841
return cmd
@@ -36,31 +49,39 @@ func newEnvCreateCommand() *cobra.Command {
3649
cmd := &cobra.Command{
3750
Use: "create <name>",
3851
Short: "Create a named environment",
39-
Long: "Creates a named environment from a values file.",
52+
Long: `Creates a named environment from a YAML values file. The file must
53+
contain top-level ` + "`inputs:`" + ` and/or ` + "`env:`" + ` keys. Names must match
54+
[a-z0-9][a-z0-9_-]*. Fails if an environment with the same name already
55+
exists in the team scope; use ` + "`mantle env update`" + ` to replace it.`,
4056
Example: ` mantle env create production --from prod.values.yaml
4157
mantle env create staging --from staging.values.yaml`,
4258
Args: cobra.ExactArgs(1),
4359
RunE: func(cmd *cobra.Command, args []string) error {
44-
name := args[0]
60+
return runEnvWrite(cmd, args[0], fromFile, writeModeCreate)
61+
},
62+
}
4563

46-
vals, err := workflow.LoadValues(fromFile)
47-
if err != nil {
48-
return fmt.Errorf("loading values file: %w", err)
49-
}
64+
cmd.Flags().StringVar(&fromFile, "from", "", "Values file to load inputs and env from (required)")
65+
_ = cmd.MarkFlagRequired("from")
5066

51-
store, cleanup, err := newEnvStore(cmd)
52-
if err != nil {
53-
return err
54-
}
55-
defer cleanup()
67+
return cmd
68+
}
5669

57-
env, err := store.Create(cmd.Context(), name, vals.Inputs, vals.Env)
58-
if err != nil {
59-
return err
60-
}
70+
// newEnvUpdateCommand returns the "env update" subcommand, which replaces the
71+
// stored inputs and env on an existing named environment.
72+
func newEnvUpdateCommand() *cobra.Command {
73+
var fromFile string
6174

62-
fmt.Fprintf(cmd.OutOrStdout(), "Created environment %q\n", env.Name)
63-
return nil
75+
cmd := &cobra.Command{
76+
Use: "update <name>",
77+
Short: "Update an existing named environment",
78+
Long: `Replaces the inputs and env of an existing environment with the contents
79+
of a values file. Preserves the environment ID, creation timestamp, and audit
80+
history. Fails if the environment does not exist.`,
81+
Example: ` mantle env update production --from prod.values.yaml`,
82+
Args: cobra.ExactArgs(1),
83+
RunE: func(cmd *cobra.Command, args []string) error {
84+
return runEnvWrite(cmd, args[0], fromFile, writeModeUpdate)
6485
},
6586
}
6687

@@ -70,13 +91,56 @@ func newEnvCreateCommand() *cobra.Command {
7091
return cmd
7192
}
7293

94+
type envWriteMode int
95+
96+
const (
97+
writeModeCreate envWriteMode = iota
98+
writeModeUpdate
99+
)
100+
101+
// runEnvWrite is the shared body for create/update: load a values file,
102+
// dispatch to the matching Store method, and print a single-line confirmation.
103+
func runEnvWrite(cmd *cobra.Command, name, fromFile string, mode envWriteMode) error {
104+
vals, err := workflow.LoadValues(fromFile)
105+
if err != nil {
106+
return fmt.Errorf("loading values file: %w", err)
107+
}
108+
109+
store, cleanup, err := newEnvStore(cmd)
110+
if err != nil {
111+
return err
112+
}
113+
defer cleanup()
114+
115+
var e *environment.Environment
116+
var verb string
117+
switch mode {
118+
case writeModeCreate:
119+
e, err = store.Create(cmd.Context(), name, vals.Inputs, vals.Env)
120+
verb = "Created"
121+
case writeModeUpdate:
122+
e, err = store.Update(cmd.Context(), name, vals.Inputs, vals.Env)
123+
verb = "Updated"
124+
}
125+
if err != nil {
126+
return err
127+
}
128+
129+
fmt.Fprintf(cmd.OutOrStdout(), "%s environment %q\n", verb, e.Name)
130+
return nil
131+
}
132+
73133
// newEnvListCommand returns the "env list" subcommand, which prints all named
74-
// environments for the current team.
134+
// environments for the current team with their creation and update timestamps.
75135
func newEnvListCommand() *cobra.Command {
76136
return &cobra.Command{
77137
Use: "list",
78-
Short: "List all environments",
79-
Args: cobra.NoArgs,
138+
Short: "List all named environments",
139+
Long: `Lists every environment in the current team scope with creation and
140+
update timestamps. Raw input and env values are never shown here — use
141+
` + "`mantle env get <name> --reveal`" + ` to view them.`,
142+
Example: ` mantle env list`,
143+
Args: cobra.NoArgs,
80144
RunE: func(cmd *cobra.Command, args []string) error {
81145
store, cleanup, err := newEnvStore(cmd)
82146
if err != nil {
@@ -95,22 +159,35 @@ func newEnvListCommand() *cobra.Command {
95159
}
96160

97161
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
98-
fmt.Fprintln(w, "NAME\tCREATED")
162+
fmt.Fprintln(w, "NAME\tCREATED\tUPDATED")
99163
for _, e := range envs {
100-
fmt.Fprintf(w, "%s\t%s\n", e.Name, e.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"))
164+
fmt.Fprintf(w, "%s\t%s\t%s\n",
165+
e.Name,
166+
e.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"),
167+
e.UpdatedAt.UTC().Format("2006-01-02 15:04:05 UTC"),
168+
)
101169
}
102170
return w.Flush()
103171
},
104172
}
105173
}
106174

107-
// newEnvShowCommand returns the "env show" subcommand, which prints the inputs
108-
// and env vars stored in a named environment.
109-
func newEnvShowCommand() *cobra.Command {
110-
return &cobra.Command{
111-
Use: "show <name>",
175+
// newEnvGetCommand returns the "env get" subcommand, which prints the stored
176+
// inputs and env for a named environment. Env values are redacted unless the
177+
// caller passes --reveal, which emits an environment.revealed audit event.
178+
func newEnvGetCommand() *cobra.Command {
179+
var reveal bool
180+
181+
cmd := &cobra.Command{
182+
Use: "get <name>",
112183
Short: "Show environment details",
113-
Args: cobra.ExactArgs(1),
184+
Long: `Shows the stored inputs and env for a named environment along with its
185+
timestamps. Env values are redacted by default because values files often
186+
contain secret-shaped material. Pass --reveal to print raw values; every
187+
reveal emits an ` + "`environment.revealed`" + ` audit event.`,
188+
Example: ` mantle env get production
189+
mantle env get production --reveal`,
190+
Args: cobra.ExactArgs(1),
114191
RunE: func(cmd *cobra.Command, args []string) error {
115192
store, cleanup, err := newEnvStore(cmd)
116193
if err != nil {
@@ -123,42 +200,63 @@ func newEnvShowCommand() *cobra.Command {
123200
return err
124201
}
125202

126-
fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", env.Name)
127-
if len(env.Inputs) > 0 {
128-
fmt.Fprintln(cmd.OutOrStdout(), "\nInputs:")
129-
keys := make([]string, 0, len(env.Inputs))
130-
for k := range env.Inputs {
131-
keys = append(keys, k)
203+
if reveal {
204+
if revealErr := store.EmitReveal(cmd.Context(), env); revealErr != nil {
205+
return fmt.Errorf("recording reveal audit event: %w", revealErr)
132206
}
133-
sort.Strings(keys)
134-
for _, k := range keys {
135-
fmt.Fprintf(cmd.OutOrStdout(), " %s: %v\n", k, env.Inputs[k])
207+
}
208+
209+
out := cmd.OutOrStdout()
210+
fmt.Fprintf(out, "Name: %s\n", env.Name)
211+
fmt.Fprintf(out, "ID: %s\n", env.ID)
212+
fmt.Fprintf(out, "Created: %s\n", env.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"))
213+
fmt.Fprintf(out, "Updated: %s\n", env.UpdatedAt.UTC().Format("2006-01-02 15:04:05 UTC"))
214+
215+
if len(env.Inputs) > 0 {
216+
fmt.Fprintln(out, "\nInputs:")
217+
for _, k := range sortedKeys(env.Inputs) {
218+
fmt.Fprintf(out, " %s: %v\n", k, env.Inputs[k])
136219
}
137220
}
138221
if len(env.Env) > 0 {
139-
fmt.Fprintln(cmd.OutOrStdout(), "\nEnv:")
140-
keys := make([]string, 0, len(env.Env))
141-
for k := range env.Env {
142-
keys = append(keys, k)
222+
fmt.Fprintln(out, "\nEnv:")
223+
for _, k := range sortedStringKeys(env.Env) {
224+
if reveal {
225+
fmt.Fprintf(out, " %s: %s\n", k, env.Env[k])
226+
} else {
227+
fmt.Fprintf(out, " %s: %s\n", k, redactedPlaceholder)
228+
}
143229
}
144-
sort.Strings(keys)
145-
for _, k := range keys {
146-
fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", k, env.Env[k])
230+
if !reveal {
231+
fmt.Fprintln(out, "\n(Env values redacted. Pass --reveal to display; reveals are audited.)")
147232
}
148233
}
149234
return nil
150235
},
151236
}
237+
238+
cmd.Flags().BoolVar(&reveal, "reveal", false, "Print raw env values (emits an environment.revealed audit event)")
239+
return cmd
152240
}
153241

154-
// newEnvDeleteCommand returns the "env delete" subcommand, which removes a
155-
// named environment from the database.
242+
// newEnvDeleteCommand returns the "env delete" subcommand, which permanently
243+
// removes a named environment. Requires --yes to confirm.
156244
func newEnvDeleteCommand() *cobra.Command {
157-
return &cobra.Command{
245+
var yes bool
246+
247+
cmd := &cobra.Command{
158248
Use: "delete <name>",
159249
Short: "Delete an environment",
160-
Args: cobra.ExactArgs(1),
250+
Long: `Permanently deletes a named environment. Requires --yes to confirm
251+
because deletion cannot be undone from the CLI — referring workflow runs will
252+
fail until the environment is recreated.`,
253+
Example: ` mantle env delete staging --yes`,
254+
Args: cobra.ExactArgs(1),
161255
RunE: func(cmd *cobra.Command, args []string) error {
256+
if !yes {
257+
return fmt.Errorf("refusing to delete %q without --yes", args[0])
258+
}
259+
162260
store, cleanup, err := newEnvStore(cmd)
163261
if err != nil {
164262
return err
@@ -173,6 +271,9 @@ func newEnvDeleteCommand() *cobra.Command {
173271
return nil
174272
},
175273
}
274+
275+
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Confirm deletion (required)")
276+
return cmd
176277
}
177278

178279
// newEnvStore builds an environment.Store from the current command context.
@@ -187,7 +288,29 @@ func newEnvStore(cmd *cobra.Command) (*environment.Store, func(), error) {
187288
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
188289
}
189290

190-
store := &environment.Store{DB: database}
291+
store := &environment.Store{DB: database, Actor: "cli"}
191292
cleanup := func() { database.Close() }
192293
return store, cleanup, nil
193294
}
295+
296+
// sortedKeys returns the keys of m in ascending order. Used for deterministic
297+
// output in env get.
298+
func sortedKeys(m map[string]any) []string {
299+
keys := make([]string, 0, len(m))
300+
for k := range m {
301+
keys = append(keys, k)
302+
}
303+
sort.Strings(keys)
304+
return keys
305+
}
306+
307+
// sortedStringKeys returns the keys of m in ascending order. Used for
308+
// deterministic output in env get.
309+
func sortedStringKeys(m map[string]string) []string {
310+
keys := make([]string, 0, len(m))
311+
for k := range m {
312+
keys = append(keys, k)
313+
}
314+
sort.Strings(keys)
315+
return keys
316+
}

0 commit comments

Comments
 (0)