diff --git a/go.mod b/go.mod index cfb230614..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 diff --git a/go.sum b/go.sum index 4e7d83480..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= diff --git a/pkg/blueprint/feature_evaluator.go b/pkg/blueprint/feature_evaluator.go new file mode 100644 index 000000000..e91664bd6 --- /dev/null +++ b/pkg/blueprint/feature_evaluator.go @@ -0,0 +1,169 @@ +package blueprint + +import ( + "fmt" + + "github.com/expr-lang/expr" +) + +// 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 lightweight expression evaluation for feature conditions. +type FeatureEvaluator struct{} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewFeatureEvaluator creates a new lightweight feature evaluator for expression evaluation. +func NewFeatureEvaluator() *FeatureEvaluator { + return &FeatureEvaluator{} +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// 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 true if the expression evaluates to true, false otherwise. +func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[string]any) (bool, error) { + if expression == "" { + return false, fmt.Errorf("expression cannot be empty") + } + + program, err := expr.Compile(expression) + if err != nil { + return false, fmt.Errorf("failed to compile expression '%s': %w", expression, err) + } + + result, err := expr.Run(program, config) + if err != nil { + return false, fmt.Errorf("failed to evaluate expression '%s': %w", expression, err) + } + + boolResult, ok := result.(bool) + if !ok { + return false, fmt.Errorf("expression '%s' must evaluate to boolean, got %T", expression, result) + } + + 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 + } + + for key, expectedValue := range conditions { + if !e.matchCondition(key, expectedValue, config) { + return false + } + } + + return true +} + +// ============================================================================= +// Private Methods +// ============================================================================= + +// 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.valuesEqual(actualValue, expectedValue) +} + +// 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 nil +} + +// splitKey splits a dot-notation key into its component parts. +func (e *FeatureEvaluator) splitKey(key string) []string { + if key == "" { + return []string{} + } + + var parts []string + current := "" + + for _, char := range key { + if char == '.' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(char) + } + } + + if current != "" { + parts = append(parts, current) + } + + return parts +} + +// valuesEqual compares two values for equality, handling type conversion. +func (e *FeatureEvaluator) valuesEqual(actual, expected any) bool { + if actual == nil && expected == nil { + return true + } + 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 new file mode 100644 index 000000000..c771b3498 --- /dev/null +++ b/pkg/blueprint/feature_evaluator_test.go @@ -0,0 +1,286 @@ +package blueprint + +import ( + "testing" +) + +// ============================================================================= +// Test Constructor +// ============================================================================= + +func TestNewFeatureEvaluator(t *testing.T) { + t.Run("CreatesNewFeatureEvaluatorSuccessfully", func(t *testing.T) { + evaluator := NewFeatureEvaluator() + if evaluator == nil { + t.Fatal("Expected evaluator, got nil") + } + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +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: "SimpleEqualityExpressionTrue", + expression: "provider == 'aws'", + config: map[string]any{"provider": "aws"}, + expected: true, + }, + { + name: "SimpleEqualityExpressionFalse", + expression: "provider == 'aws'", + config: map[string]any{"provider": "local"}, + expected: false, + }, + { + name: "SimpleInequalityExpressionTrue", + expression: "provider != 'local'", + config: map[string]any{"provider": "aws"}, + expected: true, + }, + { + name: "SimpleInequalityExpressionFalse", + expression: "provider != 'local'", + config: map[string]any{"provider": "local"}, + expected: false, + }, + { + name: "LogicalAndExpressionTrue", + expression: "provider == 'local' && observability.enabled == true", + config: map[string]any{ + "provider": "local", + "observability": map[string]any{ + "enabled": true, + }, + }, + expected: true, + }, + { + name: "LogicalAndExpressionFalse", + expression: "provider == 'local' && observability.enabled == true", + config: map[string]any{ + "provider": "aws", + "observability": map[string]any{ + "enabled": true, + }, + }, + expected: false, + }, + { + name: "LogicalOrExpressionTrue", + expression: "provider == 'aws' || provider == 'azure'", + config: map[string]any{"provider": "aws"}, + expected: true, + }, + { + name: "LogicalOrExpressionFalse", + expression: "provider == 'aws' || provider == 'azure'", + config: map[string]any{"provider": "local"}, + expected: false, + }, + { + name: "ParenthesesGrouping", + 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: "NestedObjectAccess", + expression: "observability.enabled == true && observability.backend == 'quickwit'", + config: map[string]any{ + "observability": map[string]any{ + "enabled": true, + "backend": "quickwit", + }, + }, + expected: true, + }, + { + name: "BooleanComparison", + expression: "dns.enabled == true", + config: map[string]any{ + "dns": map[string]any{ + "enabled": true, + }, + }, + expected: true, + }, + { + name: "InvalidSyntaxFails", + expression: "provider ===", + config: map[string]any{"provider": "aws"}, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := evaluator.EvaluateExpression(tt.expression, tt.config) + + if tt.shouldError { + 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 TestMatchConditions(t *testing.T) { + evaluator := NewFeatureEvaluator() + + tests := []struct { + name string + conditions map[string]any + config map[string]any + expected bool + }{ + { + name: "EmptyConditionsAlwaysMatch", + conditions: map[string]any{}, + config: map[string]any{"provider": "aws"}, + expected: true, + }, + { + name: "SimpleStringEqualityTrue", + conditions: map[string]any{"provider": "aws"}, + config: map[string]any{"provider": "aws"}, + 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: "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) { + result := evaluator.MatchConditions(tt.conditions, tt.config) + + if result != tt.expected { + t.Errorf("Expected %v for conditions %v with config %v, got %v", + tt.expected, tt.conditions, tt.config, result) + } + }) + } +}