From 6036278cc7e20f093ffa7f1af5723d8d87d95e5d Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Wed, 17 Sep 2025 22:51:52 -0400 Subject: [PATCH 1/2] feat(blueprint): Add CEL feature evaluator Adds the feature evaluator to the blueprint package. This leverages the CEL system to evaluate conditionals for eventual feature composition of blueprints. --- go.mod | 4 + go.sum | 1 + pkg/blueprint/feature_evaluator.go | 156 ++++++++++ pkg/blueprint/feature_evaluator_test.go | 378 ++++++++++++++++++++++++ 4 files changed, 539 insertions(+) create mode 100644 pkg/blueprint/feature_evaluator.go create mode 100644 pkg/blueprint/feature_evaluator_test.go diff --git a/go.mod b/go.mod index cfb230614..5c2478ad5 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/fluxcd/source-controller/api v1.6.2 github.com/getsops/sops/v3 v3.10.2 github.com/goccy/go-yaml v1.18.0 + github.com/google/cel-go v0.26.0 github.com/google/go-containerregistry v0.20.6 github.com/google/go-jsonnet v0.21.0 github.com/hashicorp/hcl/v2 v2.24.0 @@ -57,6 +58,7 @@ require ( github.com/ProtonMail/gopenpgp/v2 v2.8.3 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect @@ -172,6 +174,7 @@ require ( github.com/siderolabs/protoenc v0.2.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/urfave/cli v1.22.16 // indirect @@ -194,6 +197,7 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect diff --git a/go.sum b/go.sum index 4e7d83480..01259eb4e 100644 --- a/go.sum +++ b/go.sum @@ -439,6 +439,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/pkg/blueprint/feature_evaluator.go b/pkg/blueprint/feature_evaluator.go new file mode 100644 index 000000000..9ab5bd41c --- /dev/null +++ b/pkg/blueprint/feature_evaluator.go @@ -0,0 +1,156 @@ +package blueprint + +import ( + "fmt" + "reflect" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// The FeatureEvaluator is a CEL-based expression evaluator for blueprint feature conditions. +// It provides GitHub Actions-style conditional expression evaluation capabilities with support +// for nested object access, logical operators, and type-safe variable declarations. +// The FeatureEvaluator enables dynamic feature activation based on user configuration values. + +// ============================================================================= +// Types +// ============================================================================= + +// FeatureEvaluator provides CEL expression evaluation capabilities for feature conditions. +type FeatureEvaluator struct { + env *cel.Env +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewFeatureEvaluator creates a new CEL-based feature evaluator configured for evaluating feature conditions. +// The evaluator is pre-configured with standard libraries and custom functions needed +// for blueprint feature evaluation. +func NewFeatureEvaluator() (*FeatureEvaluator, error) { + env, err := cel.NewEnv( + cel.HomogeneousAggregateLiterals(), + cel.EagerlyValidateDeclarations(true), + ) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment: %w", err) + } + + return &FeatureEvaluator{ + env: env, + }, nil +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// CompileExpression compiles a CEL expression string with variable declarations derived from the config structure. +// The expression should follow GitHub Actions-style syntax with support for: +// - Equality/inequality: ==, != +// - Logical operators: &&, || +// - Parentheses for grouping: (expression) +// - Nested object access: provider, observability.enabled, vm.driver +// Returns a compiled program that can be evaluated against configuration data. +func (e *FeatureEvaluator) CompileExpression(expression string, config map[string]any) (cel.Program, error) { + if expression == "" { + return nil, fmt.Errorf("expression cannot be empty") + } + + var envOptions []cel.EnvOption + envOptions = append(envOptions, cel.HomogeneousAggregateLiterals()) + envOptions = append(envOptions, cel.EagerlyValidateDeclarations(true)) + + for key, value := range config { + envOptions = append(envOptions, cel.Variable(key, e.getCELType(value))) + } + + env, err := cel.NewEnv(envOptions...) + if err != nil { + return nil, fmt.Errorf("failed to create CEL environment with config: %w", err) + } + + ast, issues := env.Compile(expression) + if issues.Err() != nil { + return nil, fmt.Errorf("failed to compile expression '%s': %w", expression, issues.Err()) + } + + program, err := env.Program(ast) + if err != nil { + return nil, fmt.Errorf("failed to create program for expression '%s': %w", expression, err) + } + + return program, nil +} + +// EvaluateProgram executes a compiled CEL program against the provided configuration data. +// The configuration data should be a map containing the user's configuration values +// that the expression will be evaluated against. +// Returns true if the expression evaluates to true, false otherwise. +func (e *FeatureEvaluator) EvaluateProgram(program cel.Program, config map[string]any) (bool, error) { + if config == nil { + config = make(map[string]any) + } + + result, _, err := program.Eval(config) + if err != nil { + return false, fmt.Errorf("failed to evaluate expression: %w", err) + } + + return e.convertToBool(result) +} + +// EvaluateExpression is a convenience method that compiles and evaluates an expression in one call. +// This is useful for one-time evaluations where the compiled program won't be reused. +func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[string]any) (bool, error) { + program, err := e.CompileExpression(expression, config) + if err != nil { + return false, err + } + + return e.EvaluateProgram(program, config) +} + +// ============================================================================= +// Private Methods +// ============================================================================= + +// convertToBool converts a CEL result value to a boolean. +// CEL expressions should evaluate to boolean values for feature conditions. +func (e *FeatureEvaluator) convertToBool(result ref.Val) (bool, error) { + if result.Type() == types.BoolType { + return result.Value().(bool), nil + } + + return false, fmt.Errorf("expression must evaluate to boolean, got %s", result.Type()) +} + +// getCELType determines the appropriate CEL type for a Go value. +// This is used to create variable declarations for the CEL environment. +func (e *FeatureEvaluator) getCELType(value any) *cel.Type { + if value == nil { + return cel.DynType + } + + switch reflect.TypeOf(value).Kind() { + case reflect.String: + return cel.StringType + case reflect.Bool: + return cel.BoolType + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return cel.IntType + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return cel.UintType + case reflect.Float32, reflect.Float64: + return cel.DoubleType + case reflect.Map: + return cel.MapType(cel.StringType, cel.DynType) + case reflect.Slice, reflect.Array: + return cel.ListType(cel.DynType) + default: + return cel.DynType + } +} diff --git a/pkg/blueprint/feature_evaluator_test.go b/pkg/blueprint/feature_evaluator_test.go new file mode 100644 index 000000000..dbe236937 --- /dev/null +++ b/pkg/blueprint/feature_evaluator_test.go @@ -0,0 +1,378 @@ +package blueprint + +import ( + "testing" +) + +// ============================================================================= +// Test Constructor +// ============================================================================= + +func TestNewFeatureEvaluator(t *testing.T) { + t.Run("CreatesNewFeatureEvaluatorSuccessfully", func(t *testing.T) { + evaluator, err := NewFeatureEvaluator() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if evaluator == nil { + t.Fatal("Expected evaluator, got nil") + } + if evaluator.env == nil { + t.Fatal("Expected CEL env to be initialized") + } + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestCompileExpression(t *testing.T) { + evaluator, err := NewFeatureEvaluator() + if err != nil { + t.Fatalf("Failed to create evaluator: %v", err) + } + + tests := []struct { + name string + expression string + shouldError bool + }{ + { + name: "EmptyExpressionFails", + expression: "", + shouldError: true, + }, + { + name: "SimpleEqualityExpression", + expression: "provider == 'aws'", + shouldError: false, + }, + { + name: "SimpleInequalityExpression", + expression: "provider != 'local'", + shouldError: false, + }, + { + name: "LogicalAndExpression", + expression: "provider == 'local' && observability.enabled == true", + shouldError: false, + }, + { + name: "LogicalOrExpression", + expression: "provider == 'aws' || provider == 'azure'", + shouldError: false, + }, + { + name: "ParenthesesGrouping", + expression: "provider == 'local' && (vm.driver != 'docker-desktop' || loadbalancer.enabled == true)", + shouldError: false, + }, + { + name: "NestedObjectAccess", + expression: "observability.enabled == true && observability.backend == 'quickwit'", + shouldError: false, + }, + { + name: "BooleanComparison", + expression: "dns.enabled == true", + shouldError: false, + }, + { + name: "InvalidSyntaxFails", + expression: "provider ===", + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := map[string]any{ + "provider": "aws", + "observability": map[string]any{ + "enabled": true, + "backend": "quickwit", + }, + "vm": map[string]any{ + "driver": "virtualbox", + }, + "loadbalancer": map[string]any{ + "enabled": true, + }, + "dns": map[string]any{ + "enabled": true, + }, + } + + program, err := evaluator.CompileExpression(tt.expression, config) + + if tt.shouldError { + if err == nil { + t.Errorf("Expected error for expression '%s', got none", tt.expression) + } + } else { + if err != nil { + t.Errorf("Expected no error for expression '%s', got %v", tt.expression, err) + } + if program == nil { + t.Errorf("Expected program for expression '%s', got nil", tt.expression) + } + } + }) + } +} + +func TestEvaluateProgram(t *testing.T) { + evaluator, err := NewFeatureEvaluator() + if err != nil { + t.Fatalf("Failed to create evaluator: %v", err) + } + + tests := []struct { + name string + expression string + config map[string]any + expected bool + shouldErr bool + }{ + { + name: "SimpleStringEqualityTrue", + expression: "provider == 'aws'", + config: map[string]any{"provider": "aws"}, + expected: true, + }, + { + name: "SimpleStringEqualityFalse", + expression: "provider == 'aws'", + config: map[string]any{"provider": "local"}, + expected: false, + }, + { + name: "StringInequalityTrue", + expression: "provider != 'local'", + config: map[string]any{"provider": "aws"}, + expected: true, + }, + { + name: "StringInequalityFalse", + expression: "provider != 'local'", + config: map[string]any{"provider": "local"}, + expected: false, + }, + { + name: "BooleanEqualityTrue", + expression: "observability.enabled == true", + config: map[string]any{ + "observability": map[string]any{ + "enabled": true, + }, + }, + expected: true, + }, + { + name: "BooleanEqualityFalse", + expression: "observability.enabled == true", + config: map[string]any{ + "observability": map[string]any{ + "enabled": false, + }, + }, + expected: false, + }, + { + name: "LogicalAndBothTrue", + expression: "provider == 'local' && observability.enabled == true", + config: map[string]any{ + "provider": "local", + "observability": map[string]any{ + "enabled": true, + }, + }, + expected: true, + }, + { + name: "LogicalAndFirstFalse", + expression: "provider == 'local' && observability.enabled == true", + config: map[string]any{ + "provider": "aws", + "observability": map[string]any{ + "enabled": true, + }, + }, + expected: false, + }, + { + name: "LogicalOrFirstTrue", + expression: "provider == 'aws' || provider == 'azure'", + config: map[string]any{"provider": "aws"}, + expected: true, + }, + { + name: "LogicalOrSecondTrue", + expression: "provider == 'aws' || provider == 'azure'", + config: map[string]any{"provider": "azure"}, + expected: true, + }, + { + name: "LogicalOrBothFalse", + expression: "provider == 'aws' || provider == 'azure'", + config: map[string]any{"provider": "local"}, + expected: false, + }, + { + name: "ParenthesesGroupingComplexExpressionTrue", + expression: "provider == 'local' && (vm.driver != 'docker-desktop' || loadbalancer.enabled == true)", + config: map[string]any{ + "provider": "local", + "vm": map[string]any{ + "driver": "virtualbox", + }, + "loadbalancer": map[string]any{ + "enabled": false, + }, + }, + expected: true, + }, + { + name: "NestedObjectAccessMultipleLevels", + expression: "observability.enabled == true && observability.backend == 'quickwit'", + config: map[string]any{ + "observability": map[string]any{ + "enabled": true, + "backend": "quickwit", + }, + }, + expected: true, + }, + { + name: "MissingFieldEvaluatesToNullFalseComparison", + expression: "missing.field == 'value'", + config: map[string]any{ + "missing": map[string]any{ + "field": nil, + }, + }, + expected: false, + }, + { + name: "NilConfigHandledGracefully", + expression: "provider == 'aws'", + config: map[string]any{ + "provider": nil, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + program, err := evaluator.CompileExpression(tt.expression, tt.config) + if err != nil { + t.Fatalf("Failed to compile expression '%s': %v", tt.expression, err) + } + + result, err := evaluator.EvaluateProgram(program, tt.config) + if tt.shouldErr { + if err == nil { + t.Errorf("Expected error for expression '%s', got none", tt.expression) + } + return + } + + if err != nil { + t.Errorf("Expected no error for expression '%s', got %v", tt.expression, err) + return + } + + if result != tt.expected { + t.Errorf("Expected %v for expression '%s' with config %v, got %v", + tt.expected, tt.expression, tt.config, result) + } + }) + } +} + +func TestEvaluateExpression(t *testing.T) { + evaluator, err := NewFeatureEvaluator() + if err != nil { + t.Fatalf("Failed to create evaluator: %v", err) + } + + t.Run("ConvenienceMethodWorksCorrectly", func(t *testing.T) { + config := map[string]any{ + "provider": "aws", + "observability": map[string]any{ + "enabled": true, + "backend": "quickwit", + }, + } + + result, err := evaluator.EvaluateExpression("provider == 'aws' && observability.enabled == true", config) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !result { + t.Errorf("Expected true, got false") + } + }) + + t.Run("InvalidExpressionReturnsError", func(t *testing.T) { + _, err := evaluator.EvaluateExpression("invalid === syntax", map[string]any{}) + if err == nil { + t.Error("Expected error for invalid expression, got none") + } + }) +} + +func TestConvertToBool(t *testing.T) { + evaluator, err := NewFeatureEvaluator() + if err != nil { + t.Fatalf("Failed to create evaluator: %v", err) + } + + tests := []struct { + name string + expression string + config map[string]any + shouldErr bool + }{ + { + name: "BooleanResultConvertsSuccessfully", + expression: "provider == 'aws'", + config: map[string]any{"provider": "aws"}, + shouldErr: false, + }, + { + name: "StringResultShouldError", + expression: "provider", + config: map[string]any{"provider": "aws"}, + shouldErr: true, + }, + { + name: "NumberResultShouldError", + expression: "count", + config: map[string]any{"count": 5}, + shouldErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + program, err := evaluator.CompileExpression(tt.expression, tt.config) + if err != nil { + t.Fatalf("Failed to compile expression '%s': %v", tt.expression, err) + } + + _, err = evaluator.EvaluateProgram(program, tt.config) + if tt.shouldErr { + if err == nil { + t.Errorf("Expected error for non-boolean result, got none") + } + } else { + if err != nil { + t.Errorf("Expected no error for boolean result, got %v", err) + } + } + }) + } +} From ea4ad2b700ce1b32902595b3911aaea43baefb38 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Wed, 17 Sep 2025 23:08:42 -0400 Subject: [PATCH 2/2] feat(blueprint): Add feature evaluator Adds the feature evaluator to the blueprint package. This uses the simple Go expr language to evaluate simple conditionals based on context data. This will ultimately be used for blueprint Feature composition. --- go.mod | 5 +- go.sum | 3 +- pkg/blueprint/feature_evaluator.go | 209 +++++++------- pkg/blueprint/feature_evaluator_test.go | 346 +++++++++--------------- 4 files changed, 241 insertions(+), 322 deletions(-) diff --git a/go.mod b/go.mod index 5c2478ad5..23fa30ccb 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/abiosoft/colima v0.8.4 github.com/briandowns/spinner v1.23.2 github.com/compose-spec/compose-go/v2 v2.8.2 + github.com/expr-lang/expr v1.17.6 github.com/fluxcd/helm-controller/api v1.3.0 github.com/fluxcd/kustomize-controller/api v1.6.1 github.com/fluxcd/pkg/apis/kustomize v1.11.0 @@ -14,7 +15,6 @@ require ( github.com/fluxcd/source-controller/api v1.6.2 github.com/getsops/sops/v3 v3.10.2 github.com/goccy/go-yaml v1.18.0 - github.com/google/cel-go v0.26.0 github.com/google/go-containerregistry v0.20.6 github.com/google/go-jsonnet v0.21.0 github.com/hashicorp/hcl/v2 v2.24.0 @@ -58,7 +58,6 @@ require ( github.com/ProtonMail/gopenpgp/v2 v2.8.3 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect @@ -174,7 +173,6 @@ require ( github.com/siderolabs/protoenc v0.2.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect - github.com/stoewer/go-strcase v1.3.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/urfave/cli v1.22.16 // indirect @@ -197,7 +195,6 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect diff --git a/go.sum b/go.sum index 01259eb4e..1dd7c81d6 100644 --- a/go.sum +++ b/go.sum @@ -189,6 +189,8 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfU github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= +github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -439,7 +441,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/pkg/blueprint/feature_evaluator.go b/pkg/blueprint/feature_evaluator.go index 9ab5bd41c..e91664bd6 100644 --- a/pkg/blueprint/feature_evaluator.go +++ b/pkg/blueprint/feature_evaluator.go @@ -2,155 +2,168 @@ package blueprint import ( "fmt" - "reflect" - "github.com/google/cel-go/cel" - "github.com/google/cel-go/common/types" - "github.com/google/cel-go/common/types/ref" + "github.com/expr-lang/expr" ) -// The FeatureEvaluator is a CEL-based expression evaluator for blueprint feature conditions. -// It provides GitHub Actions-style conditional expression evaluation capabilities with support -// for nested object access, logical operators, and type-safe variable declarations. -// The FeatureEvaluator enables dynamic feature activation based on user configuration values. +// FeatureEvaluator provides lightweight expression evaluation for blueprint feature conditions. +// It uses the expr library for fast compilation and evaluation of simple comparison expressions +// without the overhead of a full expression language like CEL for basic equality checks. +// The FeatureEvaluator enables efficient feature activation based on user configuration values. // ============================================================================= // Types // ============================================================================= -// FeatureEvaluator provides CEL expression evaluation capabilities for feature conditions. -type FeatureEvaluator struct { - env *cel.Env -} +// FeatureEvaluator provides lightweight expression evaluation for feature conditions. +type FeatureEvaluator struct{} // ============================================================================= // Constructor // ============================================================================= -// NewFeatureEvaluator creates a new CEL-based feature evaluator configured for evaluating feature conditions. -// The evaluator is pre-configured with standard libraries and custom functions needed -// for blueprint feature evaluation. -func NewFeatureEvaluator() (*FeatureEvaluator, error) { - env, err := cel.NewEnv( - cel.HomogeneousAggregateLiterals(), - cel.EagerlyValidateDeclarations(true), - ) - if err != nil { - return nil, fmt.Errorf("failed to create CEL environment: %w", err) - } - - return &FeatureEvaluator{ - env: env, - }, nil +// NewFeatureEvaluator creates a new lightweight feature evaluator for expression evaluation. +func NewFeatureEvaluator() *FeatureEvaluator { + return &FeatureEvaluator{} } // ============================================================================= // Public Methods // ============================================================================= -// CompileExpression compiles a CEL expression string with variable declarations derived from the config structure. -// The expression should follow GitHub Actions-style syntax with support for: +// EvaluateExpression evaluates an expression string against the provided configuration data. +// The expression should use simple comparison syntax supported by expr: // - Equality/inequality: ==, != // - Logical operators: &&, || // - Parentheses for grouping: (expression) // - Nested object access: provider, observability.enabled, vm.driver -// Returns a compiled program that can be evaluated against configuration data. -func (e *FeatureEvaluator) CompileExpression(expression string, config map[string]any) (cel.Program, error) { +// Returns true if the expression evaluates to true, false otherwise. +func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[string]any) (bool, error) { if expression == "" { - return nil, fmt.Errorf("expression cannot be empty") + return false, fmt.Errorf("expression cannot be empty") } - var envOptions []cel.EnvOption - envOptions = append(envOptions, cel.HomogeneousAggregateLiterals()) - envOptions = append(envOptions, cel.EagerlyValidateDeclarations(true)) - - for key, value := range config { - envOptions = append(envOptions, cel.Variable(key, e.getCELType(value))) + program, err := expr.Compile(expression) + if err != nil { + return false, fmt.Errorf("failed to compile expression '%s': %w", expression, err) } - env, err := cel.NewEnv(envOptions...) + result, err := expr.Run(program, config) if err != nil { - return nil, fmt.Errorf("failed to create CEL environment with config: %w", err) + return false, fmt.Errorf("failed to evaluate expression '%s': %w", expression, err) } - ast, issues := env.Compile(expression) - if issues.Err() != nil { - return nil, fmt.Errorf("failed to compile expression '%s': %w", expression, issues.Err()) + boolResult, ok := result.(bool) + if !ok { + return false, fmt.Errorf("expression '%s' must evaluate to boolean, got %T", expression, result) } - program, err := env.Program(ast) - if err != nil { - return nil, fmt.Errorf("failed to create program for expression '%s': %w", expression, err) + return boolResult, nil +} + +// MatchConditions evaluates whether all conditions in a map match the provided configuration. +// This provides a simple key-value matching interface for basic feature conditions. +// Each condition key-value pair must match for the overall result to be true. +func (e *FeatureEvaluator) MatchConditions(conditions map[string]any, config map[string]any) bool { + if len(conditions) == 0 { + return true } - return program, nil + for key, expectedValue := range conditions { + if !e.matchCondition(key, expectedValue, config) { + return false + } + } + + return true } -// EvaluateProgram executes a compiled CEL program against the provided configuration data. -// The configuration data should be a map containing the user's configuration values -// that the expression will be evaluated against. -// Returns true if the expression evaluates to true, false otherwise. -func (e *FeatureEvaluator) EvaluateProgram(program cel.Program, config map[string]any) (bool, error) { - if config == nil { - config = make(map[string]any) - } +// ============================================================================= +// Private Methods +// ============================================================================= - result, _, err := program.Eval(config) - if err != nil { - return false, fmt.Errorf("failed to evaluate expression: %w", err) +// matchCondition checks if a single condition matches the configuration. +// Supports dot notation for nested field access and array matching for OR logic. +func (e *FeatureEvaluator) matchCondition(key string, expectedValue any, config map[string]any) bool { + actualValue := e.getNestedValue(key, config) + + if expectedArray, ok := expectedValue.([]any); ok { + for _, expected := range expectedArray { + if e.valuesEqual(actualValue, expected) { + return true + } + } + return false } - return e.convertToBool(result) + return e.valuesEqual(actualValue, expectedValue) } -// EvaluateExpression is a convenience method that compiles and evaluates an expression in one call. -// This is useful for one-time evaluations where the compiled program won't be reused. -func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[string]any) (bool, error) { - program, err := e.CompileExpression(expression, config) - if err != nil { - return false, err +// getNestedValue retrieves a value from a nested map using dot notation. +// For example, "observability.enabled" retrieves config["observability"]["enabled"]. +func (e *FeatureEvaluator) getNestedValue(key string, config map[string]any) any { + keys := e.splitKey(key) + current := config + + for i, k := range keys { + if current == nil { + return nil + } + + value, exists := current[k] + if !exists { + return nil + } + + if i == len(keys)-1 { + return value + } + + if nextMap, ok := value.(map[string]any); ok { + current = nextMap + } else { + return nil + } } - return e.EvaluateProgram(program, config) + return nil } -// ============================================================================= -// Private Methods -// ============================================================================= +// splitKey splits a dot-notation key into its component parts. +func (e *FeatureEvaluator) splitKey(key string) []string { + if key == "" { + return []string{} + } -// convertToBool converts a CEL result value to a boolean. -// CEL expressions should evaluate to boolean values for feature conditions. -func (e *FeatureEvaluator) convertToBool(result ref.Val) (bool, error) { - if result.Type() == types.BoolType { - return result.Value().(bool), nil + var parts []string + current := "" + + for _, char := range key { + if char == '.' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(char) + } } - return false, fmt.Errorf("expression must evaluate to boolean, got %s", result.Type()) + if current != "" { + parts = append(parts, current) + } + + return parts } -// getCELType determines the appropriate CEL type for a Go value. -// This is used to create variable declarations for the CEL environment. -func (e *FeatureEvaluator) getCELType(value any) *cel.Type { - if value == nil { - return cel.DynType +// valuesEqual compares two values for equality, handling type conversion. +func (e *FeatureEvaluator) valuesEqual(actual, expected any) bool { + if actual == nil && expected == nil { + return true } - - switch reflect.TypeOf(value).Kind() { - case reflect.String: - return cel.StringType - case reflect.Bool: - return cel.BoolType - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return cel.IntType - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return cel.UintType - case reflect.Float32, reflect.Float64: - return cel.DoubleType - case reflect.Map: - return cel.MapType(cel.StringType, cel.DynType) - case reflect.Slice, reflect.Array: - return cel.ListType(cel.DynType) - default: - return cel.DynType + if actual == nil || expected == nil { + return false } + + return fmt.Sprintf("%v", actual) == fmt.Sprintf("%v", expected) } diff --git a/pkg/blueprint/feature_evaluator_test.go b/pkg/blueprint/feature_evaluator_test.go index dbe236937..c771b3498 100644 --- a/pkg/blueprint/feature_evaluator_test.go +++ b/pkg/blueprint/feature_evaluator_test.go @@ -10,16 +10,10 @@ import ( func TestNewFeatureEvaluator(t *testing.T) { t.Run("CreatesNewFeatureEvaluatorSuccessfully", func(t *testing.T) { - evaluator, err := NewFeatureEvaluator() - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } + evaluator := NewFeatureEvaluator() if evaluator == nil { t.Fatal("Expected evaluator, got nil") } - if evaluator.env == nil { - t.Fatal("Expected CEL env to be initialized") - } }) } @@ -27,160 +21,48 @@ func TestNewFeatureEvaluator(t *testing.T) { // Test Public Methods // ============================================================================= -func TestCompileExpression(t *testing.T) { - evaluator, err := NewFeatureEvaluator() - if err != nil { - t.Fatalf("Failed to create evaluator: %v", err) - } +func TestEvaluateExpression(t *testing.T) { + evaluator := NewFeatureEvaluator() tests := []struct { name string expression string + config map[string]any + expected bool shouldError bool }{ { name: "EmptyExpressionFails", expression: "", + config: map[string]any{}, shouldError: true, }, { - name: "SimpleEqualityExpression", - expression: "provider == 'aws'", - shouldError: false, - }, - { - name: "SimpleInequalityExpression", - expression: "provider != 'local'", - shouldError: false, - }, - { - name: "LogicalAndExpression", - expression: "provider == 'local' && observability.enabled == true", - shouldError: false, - }, - { - name: "LogicalOrExpression", - expression: "provider == 'aws' || provider == 'azure'", - shouldError: false, - }, - { - name: "ParenthesesGrouping", - expression: "provider == 'local' && (vm.driver != 'docker-desktop' || loadbalancer.enabled == true)", - shouldError: false, - }, - { - name: "NestedObjectAccess", - expression: "observability.enabled == true && observability.backend == 'quickwit'", - shouldError: false, - }, - { - name: "BooleanComparison", - expression: "dns.enabled == true", - shouldError: false, - }, - { - name: "InvalidSyntaxFails", - expression: "provider ===", - shouldError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := map[string]any{ - "provider": "aws", - "observability": map[string]any{ - "enabled": true, - "backend": "quickwit", - }, - "vm": map[string]any{ - "driver": "virtualbox", - }, - "loadbalancer": map[string]any{ - "enabled": true, - }, - "dns": map[string]any{ - "enabled": true, - }, - } - - program, err := evaluator.CompileExpression(tt.expression, config) - - if tt.shouldError { - if err == nil { - t.Errorf("Expected error for expression '%s', got none", tt.expression) - } - } else { - if err != nil { - t.Errorf("Expected no error for expression '%s', got %v", tt.expression, err) - } - if program == nil { - t.Errorf("Expected program for expression '%s', got nil", tt.expression) - } - } - }) - } -} - -func TestEvaluateProgram(t *testing.T) { - evaluator, err := NewFeatureEvaluator() - if err != nil { - t.Fatalf("Failed to create evaluator: %v", err) - } - - tests := []struct { - name string - expression string - config map[string]any - expected bool - shouldErr bool - }{ - { - name: "SimpleStringEqualityTrue", + name: "SimpleEqualityExpressionTrue", expression: "provider == 'aws'", config: map[string]any{"provider": "aws"}, expected: true, }, { - name: "SimpleStringEqualityFalse", + name: "SimpleEqualityExpressionFalse", expression: "provider == 'aws'", config: map[string]any{"provider": "local"}, expected: false, }, { - name: "StringInequalityTrue", + name: "SimpleInequalityExpressionTrue", expression: "provider != 'local'", config: map[string]any{"provider": "aws"}, expected: true, }, { - name: "StringInequalityFalse", + name: "SimpleInequalityExpressionFalse", expression: "provider != 'local'", config: map[string]any{"provider": "local"}, expected: false, }, { - name: "BooleanEqualityTrue", - expression: "observability.enabled == true", - config: map[string]any{ - "observability": map[string]any{ - "enabled": true, - }, - }, - expected: true, - }, - { - name: "BooleanEqualityFalse", - expression: "observability.enabled == true", - config: map[string]any{ - "observability": map[string]any{ - "enabled": false, - }, - }, - expected: false, - }, - { - name: "LogicalAndBothTrue", + name: "LogicalAndExpressionTrue", expression: "provider == 'local' && observability.enabled == true", config: map[string]any{ "provider": "local", @@ -191,7 +73,7 @@ func TestEvaluateProgram(t *testing.T) { expected: true, }, { - name: "LogicalAndFirstFalse", + name: "LogicalAndExpressionFalse", expression: "provider == 'local' && observability.enabled == true", config: map[string]any{ "provider": "aws", @@ -202,25 +84,19 @@ func TestEvaluateProgram(t *testing.T) { expected: false, }, { - name: "LogicalOrFirstTrue", + name: "LogicalOrExpressionTrue", expression: "provider == 'aws' || provider == 'azure'", config: map[string]any{"provider": "aws"}, expected: true, }, { - name: "LogicalOrSecondTrue", - expression: "provider == 'aws' || provider == 'azure'", - config: map[string]any{"provider": "azure"}, - expected: true, - }, - { - name: "LogicalOrBothFalse", + name: "LogicalOrExpressionFalse", expression: "provider == 'aws' || provider == 'azure'", config: map[string]any{"provider": "local"}, expected: false, }, { - name: "ParenthesesGroupingComplexExpressionTrue", + name: "ParenthesesGrouping", expression: "provider == 'local' && (vm.driver != 'docker-desktop' || loadbalancer.enabled == true)", config: map[string]any{ "provider": "local", @@ -234,7 +110,7 @@ func TestEvaluateProgram(t *testing.T) { expected: true, }, { - name: "NestedObjectAccessMultipleLevels", + name: "NestedObjectAccess", expression: "observability.enabled == true && observability.backend == 'quickwit'", config: map[string]any{ "observability": map[string]any{ @@ -245,34 +121,28 @@ func TestEvaluateProgram(t *testing.T) { expected: true, }, { - name: "MissingFieldEvaluatesToNullFalseComparison", - expression: "missing.field == 'value'", + name: "BooleanComparison", + expression: "dns.enabled == true", config: map[string]any{ - "missing": map[string]any{ - "field": nil, + "dns": map[string]any{ + "enabled": true, }, }, - expected: false, + expected: true, }, { - name: "NilConfigHandledGracefully", - expression: "provider == 'aws'", - config: map[string]any{ - "provider": nil, - }, - expected: false, + name: "InvalidSyntaxFails", + expression: "provider ===", + config: map[string]any{"provider": "aws"}, + shouldError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - program, err := evaluator.CompileExpression(tt.expression, tt.config) - if err != nil { - t.Fatalf("Failed to compile expression '%s': %v", tt.expression, err) - } + result, err := evaluator.EvaluateExpression(tt.expression, tt.config) - result, err := evaluator.EvaluateProgram(program, tt.config) - if tt.shouldErr { + if tt.shouldError { if err == nil { t.Errorf("Expected error for expression '%s', got none", tt.expression) } @@ -292,86 +162,124 @@ func TestEvaluateProgram(t *testing.T) { } } -func TestEvaluateExpression(t *testing.T) { - evaluator, err := NewFeatureEvaluator() - if err != nil { - t.Fatalf("Failed to create evaluator: %v", err) - } - - t.Run("ConvenienceMethodWorksCorrectly", func(t *testing.T) { - config := map[string]any{ - "provider": "aws", - "observability": map[string]any{ - "enabled": true, - "backend": "quickwit", - }, - } - - result, err := evaluator.EvaluateExpression("provider == 'aws' && observability.enabled == true", config) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - if !result { - t.Errorf("Expected true, got false") - } - }) - - t.Run("InvalidExpressionReturnsError", func(t *testing.T) { - _, err := evaluator.EvaluateExpression("invalid === syntax", map[string]any{}) - if err == nil { - t.Error("Expected error for invalid expression, got none") - } - }) -} - -func TestConvertToBool(t *testing.T) { - evaluator, err := NewFeatureEvaluator() - if err != nil { - t.Fatalf("Failed to create evaluator: %v", err) - } +func TestMatchConditions(t *testing.T) { + evaluator := NewFeatureEvaluator() tests := []struct { name string - expression string + conditions map[string]any config map[string]any - shouldErr bool + expected bool }{ { - name: "BooleanResultConvertsSuccessfully", - expression: "provider == 'aws'", + name: "EmptyConditionsAlwaysMatch", + conditions: map[string]any{}, config: map[string]any{"provider": "aws"}, - shouldErr: false, + expected: true, }, { - name: "StringResultShouldError", - expression: "provider", + name: "SimpleStringEqualityTrue", + conditions: map[string]any{"provider": "aws"}, config: map[string]any{"provider": "aws"}, - shouldErr: true, + expected: true, + }, + { + name: "SimpleStringEqualityFalse", + conditions: map[string]any{"provider": "aws"}, + config: map[string]any{"provider": "local"}, + expected: false, + }, + { + name: "BooleanEqualityTrue", + conditions: map[string]any{"observability.enabled": true}, + config: map[string]any{ + "observability": map[string]any{ + "enabled": true, + }, + }, + expected: true, + }, + { + name: "BooleanEqualityFalse", + conditions: map[string]any{"observability.enabled": true}, + config: map[string]any{ + "observability": map[string]any{ + "enabled": false, + }, + }, + expected: false, + }, + { + name: "MultipleConditionsAllMatch", + conditions: map[string]any{ + "provider": "local", + "observability.enabled": true, + "observability.backend": "quickwit", + }, + config: map[string]any{ + "provider": "local", + "observability": map[string]any{ + "enabled": true, + "backend": "quickwit", + }, + }, + expected: true, + }, + { + name: "MultipleConditionsOneDoesNotMatch", + conditions: map[string]any{ + "provider": "local", + "observability.enabled": true, + "observability.backend": "elk", + }, + config: map[string]any{ + "provider": "local", + "observability": map[string]any{ + "enabled": true, + "backend": "quickwit", + }, + }, + expected: false, + }, + { + name: "ArrayConditionMatchesFirst", + conditions: map[string]any{"storage.provider": []any{"auto", "openebs"}}, + config: map[string]any{"storage": map[string]any{"provider": "auto"}}, + expected: true, + }, + { + name: "ArrayConditionMatchesSecond", + conditions: map[string]any{"storage.provider": []any{"auto", "openebs"}}, + config: map[string]any{"storage": map[string]any{"provider": "openebs"}}, + expected: true, }, { - name: "NumberResultShouldError", - expression: "count", - config: map[string]any{"count": 5}, - shouldErr: true, + name: "ArrayConditionNoMatch", + conditions: map[string]any{"storage.provider": []any{"auto", "openebs"}}, + config: map[string]any{"storage": map[string]any{"provider": "ebs"}}, + expected: false, + }, + { + name: "MissingFieldDoesNotMatch", + conditions: map[string]any{"missing.field": "value"}, + config: map[string]any{"other": "data"}, + expected: false, + }, + { + name: "NilValueDoesNotMatch", + conditions: map[string]any{"provider": "aws"}, + config: map[string]any{"provider": nil}, + expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - program, err := evaluator.CompileExpression(tt.expression, tt.config) - if err != nil { - t.Fatalf("Failed to compile expression '%s': %v", tt.expression, err) - } + result := evaluator.MatchConditions(tt.conditions, tt.config) - _, err = evaluator.EvaluateProgram(program, tt.config) - if tt.shouldErr { - if err == nil { - t.Errorf("Expected error for non-boolean result, got none") - } - } else { - if err != nil { - t.Errorf("Expected no error for boolean result, got %v", err) - } + if result != tt.expected { + t.Errorf("Expected %v for conditions %v with config %v, got %v", + tt.expected, tt.conditions, tt.config, result) } }) }