Skip to content

Commit 8b652eb

Browse files
feat: environment values and named environments (#53) (#127)
* feat(workflow): add Values type, LoadValues, and MergeInputs helpers Implements Task 1-3 for issue #53 (environment variables and promotion). Adds values file parsing with unknown-key validation and env key normalization, plus a three-layer MergeInputs helper (defaults < values file < inline flags). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(workflow): use valid input types in MergeInputs test fixtures * feat: add valuesEnv layer to CEL evaluator for values-file env overrides Inserts a values-file precedence layer between config env and MANTLE_ENV_* OS variables (config < values < MANTLE_ENV_*), exposed via SetValuesEnv. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: wire --values flag into CLI run and plan commands Add --values <file> flag to both `mantle run` and `mantle plan`. The run command merges values file inputs (lower priority than --input flags) and applies env overrides via CEL.SetValuesEnv. The plan command validates the values file early for fast failure feedback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add environments table migration and EnvironmentStore CRUD Adds migration 018 creating the environments table with team-scoped unique name constraint, and implements Store with Create/Get/List/Delete backed by testcontainers integration tests (all 5 passing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add env subcommands and wire --env flag into run/plan commands Implements `mantle env` (create/list/show/delete) backed by the environment.Store, and adds --env to `mantle run` (4-source input merge: named env < values file < --input flags) and `mantle plan` (existence validation only). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: wire MergeInputs 4-layer support, add env name validation, clarify plan help text - Extend MergeInputs to 4 layers (defaults < env < values < inline) and update all call sites in values_test.go; add TestMergeInputs_FourLayerPrecedence - Refactor run.go to use workflow.MergeInputs instead of manual merge loops - Add environment name validation (^[a-z][a-z0-9-]*$) in Store.Create with test - Clarify plan --values and --env flag descriptions to note they do not affect diff Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address code review feedback - Sort map keys in `env show` output for deterministic display - Move values file loading before DB open in run.go (fail-fast) - Detect env key collisions during uppercase normalization in values.go Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: rename TestMergeInputs_FullPrecedence to ThreeLayerPrecedence The test passes nil for envInputs, exercising only 3 of the 4 merge layers. Rename to accurately reflect actual coverage; the full 4-layer test already exists as TestMergeInputs_FourLayerPrecedence. https://claude.ai/code/session_01ScMbDdk1f4m7HGyjfTrmv1 * docs: add docstrings to env CLI command builder functions Resolves CodeRabbit docstring coverage warning on PR #127. All six functions in cli/env.go now have Go doc comments, bringing coverage from ~17% to 100% for that file. https://claude.ai/code/session_01E2yrXXhJW7Jt7ZPt7JYMWF * fix: address coderabbitai review feedback on PR #127 - Add UTC indicator to env list timestamp output - Add missing godoc comments to credentials, health, db, and metrics packages https://claude.ai/code/session_01KiE3FJpfF6XE2y3Tyth2r3 * fix: address coderabbitai review feedback on PR #127 - Add cobra.NoArgs to env list command to reject accidental extra args - Add package doc comments to health, db, metrics, and cli packages https://claude.ai/code/session_01Kt2cz2tqC28r9b6bM1c7LE --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 821a97c commit 8b652eb

17 files changed

Lines changed: 1248 additions & 25 deletions

File tree

go.work.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,12 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
321321
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
322322
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
323323
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
324+
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
324325
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
325326
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
326327
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
327328
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
329+
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
328330
github.com/vertica/vertica-sql-go v1.3.5 h1:IrfH2WIgzZ45yDHyjVFrXU2LuKNIjF5Nwi90a6cfgUI=
329331
github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
330332
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
@@ -340,10 +342,7 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
340342
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
341343
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
342344
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
343-
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
344-
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
345-
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
346-
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
345+
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
347346
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
348347
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
349348
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -355,3 +354,4 @@ google.golang.org/genproto/googleapis/bytestream v0.0.0-20250804133106-a7a43d27e
355354
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y=
356355
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
357356
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
357+
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=

packages/engine/internal/api/health/health.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Package health provides HTTP health-check endpoints for the Mantle API server.
12
package health
23

34
import (
@@ -17,6 +18,8 @@ type LivenessChecker interface {
1718
Name() string
1819
}
1920

21+
// HealthzHandler returns an HTTP handler that always responds 200 OK, used as
22+
// a liveness probe.
2023
func HealthzHandler() http.HandlerFunc {
2124
return func(w http.ResponseWriter, r *http.Request) {
2225
w.Header().Set("Content-Type", "application/json")
@@ -25,6 +28,9 @@ func HealthzHandler() http.HandlerFunc {
2528
}
2629
}
2730

31+
// ReadyzHandler returns an HTTP handler used as a readiness probe. It pings
32+
// the database and checks each LivenessChecker; it responds 503 if any check
33+
// fails.
2834
func ReadyzHandler(database *sql.DB, checkers ...LivenessChecker) http.HandlerFunc {
2935
return func(w http.ResponseWriter, r *http.Request) {
3036
w.Header().Set("Content-Type", "application/json")

packages/engine/internal/cel/cel.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const maxProgramCacheSize = 10000
2929
type Evaluator struct {
3030
env *cel.Env
3131
configEnv map[string]string // config-sourced env values from mantle.yaml
32+
valuesEnv map[string]string // values-file env overrides (between config and OS)
3233
envCache map[string]string // cached, filtered environment variables
3334
programMu sync.Mutex
3435
programCache map[string]cel.Program // expression string -> compiled cel.Program
@@ -51,7 +52,7 @@ func NewEvaluator() (*Evaluator, error) {
5152
if err != nil {
5253
return nil, fmt.Errorf("creating CEL environment: %w", err)
5354
}
54-
return &Evaluator{env: env, envCache: mergeEnvVars(nil), programCache: make(map[string]cel.Program)}, nil
55+
return &Evaluator{env: env, envCache: mergeEnvVars(nil, nil), programCache: make(map[string]cel.Program)}, nil
5556
}
5657

5758
// Eval evaluates a CEL expression and returns the result as a Go value.
@@ -229,24 +230,38 @@ func (e *Evaluator) resolveValue(v any, ctx *Context) (any, error) {
229230
// OS env vars take precedence over config values.
230231
func (e *Evaluator) SetConfigEnv(configEnv map[string]string) {
231232
e.configEnv = configEnv
232-
e.envCache = mergeEnvVars(configEnv)
233+
e.envCache = mergeEnvVars(configEnv, e.valuesEnv)
234+
}
235+
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_*.
239+
func (e *Evaluator) SetValuesEnv(valuesEnv map[string]string) {
240+
e.valuesEnv = valuesEnv
241+
e.envCache = mergeEnvVars(e.configEnv, e.valuesEnv)
233242
}
234243

235244
// mergeEnvVars builds the env map available to CEL expressions.
236245
// 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.
246+
// then overlays values-file overrides, then overlays MANTLE_ENV_* environment
247+
// variables (stripping the prefix). When a key exists in both config/values and
248+
// the OS env, the OS env var wins and an info log is emitted.
239249
// This prevents CEL expressions from reading sensitive variables like
240250
// MANTLE_ENCRYPTION_KEY, MANTLE_DATABASE_URL, or AWS_SECRET_ACCESS_KEY.
241-
func mergeEnvVars(configEnv map[string]string) map[string]string {
251+
func mergeEnvVars(configEnv map[string]string, valuesEnv map[string]string) map[string]string {
242252
env := make(map[string]string)
243253

244-
// Start with config values.
254+
// Layer 1: config values (lowest precedence).
245255
for k, v := range configEnv {
246256
env[k] = v
247257
}
248258

249-
// Overlay MANTLE_ENV_* from OS environment (env var wins).
259+
// Layer 2: values-file overrides.
260+
for k, v := range valuesEnv {
261+
env[k] = v
262+
}
263+
264+
// Layer 3: MANTLE_ENV_* from OS environment (highest precedence).
250265
const prefix = "MANTLE_ENV_"
251266
for _, e := range os.Environ() {
252267
parts := strings.SplitN(e, "=", 2)

packages/engine/internal/cel/cel_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ func TestEnvVars_PrefixStripping(t *testing.T) {
303303
os.Setenv("MANTLE_DATABASE_URL", "should-not-appear")
304304
defer os.Unsetenv("MANTLE_DATABASE_URL")
305305

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

308308
if v, ok := result["TEST_KEY"]; !ok || v != "test-value" {
309309
t.Errorf("mergeEnvVars()[TEST_KEY] = %q, %v; want %q, true", v, ok, "test-value")
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package cel
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestSetValuesEnv_LayersBetweenConfigAndOS(t *testing.T) {
8+
eval, err := NewEvaluator()
9+
if err != nil {
10+
t.Fatalf("NewEvaluator() error: %v", err)
11+
}
12+
13+
eval.SetConfigEnv(map[string]string{
14+
"LOG_LEVEL": "info",
15+
"REGION": "us-west-2",
16+
"APP_NAME": "config-app",
17+
})
18+
19+
eval.SetValuesEnv(map[string]string{
20+
"LOG_LEVEL": "error",
21+
"DB_HOST": "prod-db.example.com",
22+
})
23+
24+
ctx := &Context{
25+
Steps: make(map[string]map[string]any),
26+
Inputs: make(map[string]any),
27+
}
28+
29+
// Values override config.
30+
result, err := eval.Eval(`env.LOG_LEVEL`, ctx)
31+
if err != nil {
32+
t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err)
33+
}
34+
if result != "error" {
35+
t.Errorf("env.LOG_LEVEL = %v, want %q (values overrides config)", result, "error")
36+
}
37+
38+
// Config value preserved when values doesn't override.
39+
result, err = eval.Eval(`env.REGION`, ctx)
40+
if err != nil {
41+
t.Fatalf("Eval(env.REGION) error: %v", err)
42+
}
43+
if result != "us-west-2" {
44+
t.Errorf("env.REGION = %v, want %q (config preserved)", result, "us-west-2")
45+
}
46+
47+
// Values-only key is accessible.
48+
result, err = eval.Eval(`env.DB_HOST`, ctx)
49+
if err != nil {
50+
t.Fatalf("Eval(env.DB_HOST) error: %v", err)
51+
}
52+
if result != "prod-db.example.com" {
53+
t.Errorf("env.DB_HOST = %v, want %q", result, "prod-db.example.com")
54+
}
55+
}
56+
57+
func TestSetValuesEnv_OSEnvTakesPrecedence(t *testing.T) {
58+
eval, err := NewEvaluator()
59+
if err != nil {
60+
t.Fatalf("NewEvaluator() error: %v", err)
61+
}
62+
63+
t.Setenv("MANTLE_ENV_LOG_LEVEL", "debug")
64+
65+
eval.SetConfigEnv(map[string]string{
66+
"LOG_LEVEL": "info",
67+
})
68+
eval.SetValuesEnv(map[string]string{
69+
"LOG_LEVEL": "error",
70+
})
71+
72+
ctx := &Context{
73+
Steps: make(map[string]map[string]any),
74+
Inputs: make(map[string]any),
75+
}
76+
77+
result, err := eval.Eval(`env.LOG_LEVEL`, ctx)
78+
if err != nil {
79+
t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err)
80+
}
81+
if result != "debug" {
82+
t.Errorf("env.LOG_LEVEL = %v, want %q (OS env wins)", result, "debug")
83+
}
84+
}
85+
86+
func TestSetValuesEnv_NilClearsValuesLayer(t *testing.T) {
87+
eval, err := NewEvaluator()
88+
if err != nil {
89+
t.Fatalf("NewEvaluator() error: %v", err)
90+
}
91+
92+
eval.SetConfigEnv(map[string]string{
93+
"LOG_LEVEL": "info",
94+
})
95+
eval.SetValuesEnv(map[string]string{
96+
"LOG_LEVEL": "error",
97+
})
98+
99+
eval.SetValuesEnv(nil)
100+
101+
ctx := &Context{
102+
Steps: make(map[string]map[string]any),
103+
Inputs: make(map[string]any),
104+
}
105+
106+
result, err := eval.Eval(`env.LOG_LEVEL`, ctx)
107+
if err != nil {
108+
t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err)
109+
}
110+
if result != "info" {
111+
t.Errorf("env.LOG_LEVEL = %v, want %q (back to config after clear)", result, "info")
112+
}
113+
}

packages/engine/internal/cli/credentials.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Package cli implements the Mantle command-line interface.
12
package cli
23

34
import (
@@ -15,6 +16,7 @@ const (
1516
CredTypeOIDC = "oidc"
1617
)
1718

19+
// Credentials holds the authentication material persisted to disk.
1820
type Credentials struct {
1921
Type string `json:"type"`
2022
APIKey string `json:"api_key,omitempty"`
@@ -23,11 +25,15 @@ type Credentials struct {
2325
ExpiresAt time.Time `json:"expires_at,omitempty"`
2426
}
2527

28+
// DefaultCredentialsPath returns ~/.mantle/credentials, the default location
29+
// where authentication material is stored.
2630
func DefaultCredentialsPath() string {
2731
home, _ := os.UserHomeDir()
2832
return filepath.Join(home, ".mantle", "credentials")
2933
}
3034

35+
// SaveCredentials marshals cred to JSON and writes it to path with
36+
// 0600 permissions, creating parent directories as needed.
3137
func SaveCredentials(path string, cred *Credentials) error {
3238
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
3339
return fmt.Errorf("creating credentials directory: %w", err)
@@ -42,6 +48,7 @@ func SaveCredentials(path string, cred *Credentials) error {
4248
return nil
4349
}
4450

51+
// LoadCredentials reads and parses the JSON credentials file at path.
4552
func LoadCredentials(path string) (*Credentials, error) {
4653
data, err := os.ReadFile(path)
4754
if err != nil {
@@ -54,6 +61,8 @@ func LoadCredentials(path string) (*Credentials, error) {
5461
return &cred, nil
5562
}
5663

64+
// DeleteCredentials removes the credentials file at path. It is not an error
65+
// if the file does not exist.
5766
func DeleteCredentials(path string) error {
5867
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
5968
return fmt.Errorf("removing credentials: %w", err)

0 commit comments

Comments
 (0)