Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -321,10 +321,12 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/vertica/vertica-sql-go v1.3.5 h1:IrfH2WIgzZ45yDHyjVFrXU2LuKNIjF5Nwi90a6cfgUI=
github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
Expand All @@ -340,10 +342,7 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
Expand All @@ -355,3 +354,4 @@ google.golang.org/genproto/googleapis/bytestream v0.0.0-20250804133106-a7a43d27e
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
5 changes: 5 additions & 0 deletions packages/engine/internal/api/health/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type LivenessChecker interface {
Name() string
}

// HealthzHandler returns an HTTP handler that always responds 200 OK, used as
// a liveness probe.
func HealthzHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand All @@ -25,6 +27,9 @@ func HealthzHandler() http.HandlerFunc {
}
}

// ReadyzHandler returns an HTTP handler used as a readiness probe. It pings
// the database and checks each LivenessChecker; it responds 503 if any check
// fails.
func ReadyzHandler(database *sql.DB, checkers ...LivenessChecker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand Down
29 changes: 22 additions & 7 deletions packages/engine/internal/cel/cel.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const maxProgramCacheSize = 10000
type Evaluator struct {
env *cel.Env
configEnv map[string]string // config-sourced env values from mantle.yaml
valuesEnv map[string]string // values-file env overrides (between config and OS)
envCache map[string]string // cached, filtered environment variables
programMu sync.Mutex
programCache map[string]cel.Program // expression string -> compiled cel.Program
Expand All @@ -51,7 +52,7 @@ func NewEvaluator() (*Evaluator, error) {
if err != nil {
return nil, fmt.Errorf("creating CEL environment: %w", err)
}
return &Evaluator{env: env, envCache: mergeEnvVars(nil), programCache: make(map[string]cel.Program)}, nil
return &Evaluator{env: env, envCache: mergeEnvVars(nil, nil), programCache: make(map[string]cel.Program)}, nil
}

// Eval evaluates a CEL expression and returns the result as a Go value.
Expand Down Expand Up @@ -229,24 +230,38 @@ func (e *Evaluator) resolveValue(v any, ctx *Context) (any, error) {
// OS env vars take precedence over config values.
func (e *Evaluator) SetConfigEnv(configEnv map[string]string) {
e.configEnv = configEnv
e.envCache = mergeEnvVars(configEnv)
e.envCache = mergeEnvVars(configEnv, e.valuesEnv)
}

// SetValuesEnv sets the values-file env overrides and rebuilds the env cache.
// Values-file env sits between config env and MANTLE_ENV_* OS variables in
// the precedence stack: config < values < MANTLE_ENV_*.
func (e *Evaluator) SetValuesEnv(valuesEnv map[string]string) {
e.valuesEnv = valuesEnv
e.envCache = mergeEnvVars(e.configEnv, e.valuesEnv)
}

// mergeEnvVars builds the env map available to CEL expressions.
// It starts with config-sourced values (from the env: section in mantle.yaml),
// then overlays MANTLE_ENV_* environment variables (stripping the prefix).
// When a key exists in both, the OS env var wins and an info log is emitted.
// then overlays values-file overrides, then overlays MANTLE_ENV_* environment
// variables (stripping the prefix). When a key exists in both config/values and
// the OS env, the OS env var wins and an info log is emitted.
// This prevents CEL expressions from reading sensitive variables like
// MANTLE_ENCRYPTION_KEY, MANTLE_DATABASE_URL, or AWS_SECRET_ACCESS_KEY.
func mergeEnvVars(configEnv map[string]string) map[string]string {
func mergeEnvVars(configEnv map[string]string, valuesEnv map[string]string) map[string]string {
env := make(map[string]string)

// Start with config values.
// Layer 1: config values (lowest precedence).
for k, v := range configEnv {
env[k] = v
}

// Overlay MANTLE_ENV_* from OS environment (env var wins).
// Layer 2: values-file overrides.
for k, v := range valuesEnv {
env[k] = v
}

// Layer 3: MANTLE_ENV_* from OS environment (highest precedence).
const prefix = "MANTLE_ENV_"
for _, e := range os.Environ() {
parts := strings.SplitN(e, "=", 2)
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/internal/cel/cel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ func TestEnvVars_PrefixStripping(t *testing.T) {
os.Setenv("MANTLE_DATABASE_URL", "should-not-appear")
defer os.Unsetenv("MANTLE_DATABASE_URL")

result := mergeEnvVars(nil)
result := mergeEnvVars(nil, nil)

if v, ok := result["TEST_KEY"]; !ok || v != "test-value" {
t.Errorf("mergeEnvVars()[TEST_KEY] = %q, %v; want %q, true", v, ok, "test-value")
Expand Down
113 changes: 113 additions & 0 deletions packages/engine/internal/cel/cel_values_test.go
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")
}
}
8 changes: 8 additions & 0 deletions packages/engine/internal/cli/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
CredTypeOIDC = "oidc"
)

// Credentials holds the authentication material persisted to disk.
type Credentials struct {
Type string `json:"type"`
APIKey string `json:"api_key,omitempty"`
Expand All @@ -23,11 +24,15 @@ type Credentials struct {
ExpiresAt time.Time `json:"expires_at,omitempty"`
}

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

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

// LoadCredentials reads and parses the JSON credentials file at path.
func LoadCredentials(path string) (*Credentials, error) {
data, err := os.ReadFile(path)
if err != nil {
Expand All @@ -54,6 +60,8 @@ func LoadCredentials(path string) (*Credentials, error) {
return &cred, nil
}

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