diff --git a/api/v1alpha1/feature_types.go b/api/v1alpha1/feature_types.go index dc0426b74..1a087e5ac 100644 --- a/api/v1alpha1/feature_types.go +++ b/api/v1alpha1/feature_types.go @@ -38,6 +38,11 @@ type ConditionalTerraformComponent struct { // When is a CEL expression that determines if this terraform component should be applied. // If empty, the component is always applied when the parent feature matches. When string `yaml:"when,omitempty"` + + // Inputs contains input values for the terraform module. + // Values can be expressions using ${} syntax (e.g., "${cluster.workers.count + 2}") or literals (e.g., "us-east-1"). + // Values with ${} are evaluated as expressions, plain values are passed through as literals. + Inputs map[string]any `yaml:"inputs,omitempty"` } // ConditionalKustomization extends Kustomization with conditional logic support. @@ -65,6 +70,9 @@ func (f *Feature) DeepCopy() *Feature { valuesCopy := make(map[string]any, len(component.Values)) maps.Copy(valuesCopy, component.Values) + inputsCopy := make(map[string]any, len(component.Inputs)) + maps.Copy(inputsCopy, component.Inputs) + dependsOnCopy := append([]string{}, component.DependsOn...) terraformComponentsCopy[i] = ConditionalTerraformComponent{ @@ -77,7 +85,8 @@ func (f *Feature) DeepCopy() *Feature { Destroy: component.Destroy, Parallelism: component.Parallelism, }, - When: component.When, + When: component.When, + Inputs: inputsCopy, } } @@ -108,6 +117,9 @@ func (c *ConditionalTerraformComponent) DeepCopy() *ConditionalTerraformComponen valuesCopy := make(map[string]any, len(c.Values)) maps.Copy(valuesCopy, c.Values) + inputsCopy := make(map[string]any, len(c.Inputs)) + maps.Copy(inputsCopy, c.Inputs) + dependsOnCopy := append([]string{}, c.DependsOn...) return &ConditionalTerraformComponent{ @@ -120,7 +132,8 @@ func (c *ConditionalTerraformComponent) DeepCopy() *ConditionalTerraformComponen Destroy: c.Destroy, Parallelism: c.Parallelism, }, - When: c.When, + When: c.When, + Inputs: inputsCopy, } } diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 829d4ceb5..5c0c5dc79 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -797,8 +797,33 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c continue } } + + component := terraformComponent.TerraformComponent + + if len(terraformComponent.Inputs) > 0 { + evaluatedInputs, err := evaluator.EvaluateDefaults(terraformComponent.Inputs, config) + if err != nil { + return fmt.Errorf("failed to evaluate inputs for component '%s': %w", component.Path, err) + } + + filteredInputs := make(map[string]any) + for k, v := range evaluatedInputs { + if v != nil { + filteredInputs[k] = v + } + } + + if len(filteredInputs) > 0 { + if component.Values == nil { + component.Values = make(map[string]any) + } + + component.Values = b.deepMergeMaps(component.Values, filteredInputs) + } + } + tempBlueprint := &blueprintv1alpha1.Blueprint{ - TerraformComponents: []blueprintv1alpha1.TerraformComponent{terraformComponent.TerraformComponent}, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{component}, } if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { return fmt.Errorf("failed to merge terraform component: %w", err) diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go index b6a6e1585..5ac5216bd 100644 --- a/pkg/blueprint/blueprint_handler_private_test.go +++ b/pkg/blueprint/blueprint_handler_private_test.go @@ -3491,4 +3491,145 @@ terraform: t.Errorf("Expected condition evaluation error, got %v", err) } }) + + t.Run("EvaluatesAndMergesInputs", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + featureWithInputs := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: aws-eks +when: provider == "aws" +terraform: + - path: cluster/aws-eks + values: + cluster_name: my-cluster + inputs: + node_groups: + default: + instance_types: + - ${cluster.workers.instance_type} + min_size: ${cluster.workers.count} + max_size: ${cluster.workers.count + 2} + desired_size: ${cluster.workers.count} + region: us-east-1 + literal_string: my-literal-value +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/eks.yaml": featureWithInputs, + } + + config := map[string]any{ + "provider": "aws", + "cluster": map[string]any{ + "workers": map[string]any{ + "instance_type": "t3.medium", + "count": 3, + }, + }, + } + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.TerraformComponents) != 1 { + t.Fatalf("Expected 1 terraform component, got %d", len(handler.blueprint.TerraformComponents)) + } + + component := handler.blueprint.TerraformComponents[0] + + if component.Values["cluster_name"] != "my-cluster" { + t.Errorf("Expected cluster_name to be 'my-cluster', got %v", component.Values["cluster_name"]) + } + + nodeGroups, ok := component.Values["node_groups"].(map[string]any) + if !ok { + t.Fatalf("Expected node_groups to be a map, got %T", component.Values["node_groups"]) + } + + defaultGroup, ok := nodeGroups["default"].(map[string]any) + if !ok { + t.Fatalf("Expected default group to be a map, got %T", nodeGroups["default"]) + } + + instanceTypes, ok := defaultGroup["instance_types"].([]any) + if !ok { + t.Fatalf("Expected instance_types to be an array, got %T", defaultGroup["instance_types"]) + } + if len(instanceTypes) != 1 || instanceTypes[0] != "t3.medium" { + t.Errorf("Expected instance_types to be ['t3.medium'], got %v", instanceTypes) + } + + if defaultGroup["min_size"] != 3 { + t.Errorf("Expected min_size to be 3, got %v", defaultGroup["min_size"]) + } + + if defaultGroup["max_size"] != 5 { + t.Errorf("Expected max_size to be 5 (3+2), got %v", defaultGroup["max_size"]) + } + + if defaultGroup["desired_size"] != 3 { + t.Errorf("Expected desired_size to be 3, got %v", defaultGroup["desired_size"]) + } + + if component.Values["region"] != "us-east-1" { + t.Errorf("Expected region to be literal 'us-east-1', got %v", component.Values["region"]) + } + + if component.Values["literal_string"] != "my-literal-value" { + t.Errorf("Expected literal_string to be 'my-literal-value', got %v", component.Values["literal_string"]) + } + }) + + t.Run("FailsOnInvalidExpressions", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +`) + + featureWithBadExpression := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: test +terraform: + - path: test/module + inputs: + bad_path: ${cluster.workrs.count} +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/test.yaml": featureWithBadExpression, + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + err := handler.processFeatures(templateData, config) + + if err == nil { + t.Fatal("Expected error for invalid expression, got nil") + } + if !strings.Contains(err.Error(), "failed to evaluate inputs") { + t.Errorf("Expected inputs evaluation error, got %v", err) + } + }) } diff --git a/pkg/blueprint/feature_evaluator.go b/pkg/blueprint/feature_evaluator.go index e91664bd6..e747e7960 100644 --- a/pkg/blueprint/feature_evaluator.go +++ b/pkg/blueprint/feature_evaluator.go @@ -2,6 +2,7 @@ package blueprint import ( "fmt" + "strings" "github.com/expr-lang/expr" ) @@ -61,109 +62,102 @@ func (e *FeatureEvaluator) EvaluateExpression(expression string, config map[stri 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 +// EvaluateValue evaluates an expression and returns the result as any type. +// Supports arithmetic, string operations, array construction, and nested object access. +// Returns the evaluated value or an error if evaluation fails. +func (e *FeatureEvaluator) EvaluateValue(expression string, config map[string]any) (any, error) { + if expression == "" { + return nil, fmt.Errorf("expression cannot be empty") } - for key, expectedValue := range conditions { - if !e.matchCondition(key, expectedValue, config) { - return false - } + program, err := expr.Compile(expression) + if err != nil { + return nil, fmt.Errorf("failed to compile expression '%s': %w", expression, err) } - return true -} + result, err := expr.Run(program, config) + if err != nil { + return nil, fmt.Errorf("failed to evaluate expression '%s': %w", expression, err) + } -// ============================================================================= -// Private Methods -// ============================================================================= + return result, nil +} -// 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) +// EvaluateDefaults recursively evaluates default values, treating quoted strings as literals +// and unquoted values as expressions. Supports nested maps and arrays. +func (e *FeatureEvaluator) EvaluateDefaults(defaults map[string]any, config map[string]any) (map[string]any, error) { + result := make(map[string]any) - if expectedArray, ok := expectedValue.([]any); ok { - for _, expected := range expectedArray { - if e.valuesEqual(actualValue, expected) { - return true - } + for key, value := range defaults { + evaluated, err := e.evaluateDefaultValue(value, config) + if err != nil { + return nil, fmt.Errorf("failed to evaluate default for key '%s': %w", key, err) } - return false + result[key] = evaluated } - return e.valuesEqual(actualValue, expectedValue) + return result, nil } -// 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 +// ============================================================================= +// Private Methods +// ============================================================================= - for i, k := range keys { - if current == nil { - return nil +// evaluateDefaultValue recursively evaluates a single default value. +func (e *FeatureEvaluator) evaluateDefaultValue(value any, config map[string]any) (any, error) { + switch v := value.(type) { + case string: + if expr := e.extractExpression(v); expr != "" { + return e.EvaluateValue(expr, config) } - - value, exists := current[k] - if !exists { - return nil + return v, nil + + case map[string]any: + result := make(map[string]any) + for k, val := range v { + evaluated, err := e.evaluateDefaultValue(val, config) + if err != nil { + return nil, err + } + result[k] = evaluated } - - if i == len(keys)-1 { - return value + return result, nil + + case []any: + result := make([]any, len(v)) + for i, val := range v { + evaluated, err := e.evaluateDefaultValue(val, config) + if err != nil { + return nil, err + } + result[i] = evaluated } + return result, nil - if nextMap, ok := value.(map[string]any); ok { - current = nextMap - } else { - return nil - } + default: + return value, nil } - - return nil } -// splitKey splits a dot-notation key into its component parts. -func (e *FeatureEvaluator) splitKey(key string) []string { - if key == "" { - return []string{} +// extractExpression checks if a string contains an expression in ${} syntax. +// If found, returns the expression content. Otherwise returns empty string. +func (e *FeatureEvaluator) extractExpression(s string) string { + if !strings.Contains(s, "${") { + return "" } - var parts []string - current := "" + start := strings.Index(s, "${") + end := strings.Index(s[start:], "}") - for _, char := range key { - if char == '.' { - if current != "" { - parts = append(parts, current) - current = "" - } - } else { - current += string(char) - } + if end == -1 { + return "" } - if current != "" { - parts = append(parts, current) - } + end += start - 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 + if start == 0 && end == len(s)-1 { + return s[start+2 : end] } - return fmt.Sprintf("%v", actual) == fmt.Sprintf("%v", expected) + return "" } diff --git a/pkg/blueprint/feature_evaluator_test.go b/pkg/blueprint/feature_evaluator_test.go index 7ebcb288d..c493b3884 100644 --- a/pkg/blueprint/feature_evaluator_test.go +++ b/pkg/blueprint/feature_evaluator_test.go @@ -21,7 +21,7 @@ func TestNewFeatureEvaluator(t *testing.T) { // Test Public Methods // ============================================================================= -func TestEvaluateExpression(t *testing.T) { +func TestFeatureEvaluator_EvaluateExpression(t *testing.T) { evaluator := NewFeatureEvaluator() tests := []struct { @@ -162,125 +162,586 @@ func TestEvaluateExpression(t *testing.T) { } } -func TestMatchConditions(t *testing.T) { +func TestFeatureEvaluator_EvaluateValue(t *testing.T) { evaluator := NewFeatureEvaluator() tests := []struct { - name string - conditions map[string]any - config map[string]any - expected bool + name string + expression string + config map[string]any + expected any + shouldError bool }{ { - name: "EmptyConditionsAlwaysMatch", - conditions: map[string]any{}, - config: map[string]any{"provider": "aws"}, - expected: true, + name: "EmptyExpressionFails", + expression: "", + config: map[string]any{}, + shouldError: true, }, { - name: "SimpleStringEqualityTrue", - conditions: map[string]any{"provider": "aws"}, + name: "StringValue", + expression: "provider", config: map[string]any{"provider": "aws"}, - expected: true, + expected: "aws", }, { - name: "SimpleStringEqualityFalse", - conditions: map[string]any{"provider": "aws"}, - config: map[string]any{"provider": "generic"}, - expected: false, + name: "IntegerValue", + expression: "cluster.workers.count", + config: map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + }, + expected: 3, }, { - name: "BooleanEqualityTrue", - conditions: map[string]any{"observability.enabled": true}, + name: "ArithmeticExpression", + expression: "cluster.workers.count + 2", config: map[string]any{ - "observability": map[string]any{ - "enabled": true, + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, }, }, - expected: true, + expected: 5, }, { - name: "BooleanEqualityFalse", - conditions: map[string]any{"observability.enabled": true}, + name: "NestedMapAccess", + expression: "cluster.controlplanes.nodes", config: map[string]any{ - "observability": map[string]any{ - "enabled": false, + "cluster": map[string]any{ + "controlplanes": map[string]any{ + "nodes": map[string]any{ + "node1": "value1", + }, + }, }, }, - expected: false, + expected: map[string]any{"node1": "value1"}, }, { - name: "MultipleConditionsAllMatch", - conditions: map[string]any{ - "provider": "generic", - "observability.enabled": true, - "observability.backend": "quickwit", + name: "ArrayAccess", + expression: "cluster.workers.instance_types", + config: map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "instance_types": []any{"t3.medium", "t3.large"}, + }, + }, }, + expected: []any{"t3.medium", "t3.large"}, + }, + { + name: "UndefinedVariableReturnsNil", + expression: "cluster.undefined", config: map[string]any{ - "provider": "generic", - "observability": map[string]any{ - "enabled": true, - "backend": "quickwit", + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, }, }, - expected: true, + expected: nil, }, { - name: "MultipleConditionsOneDoesNotMatch", - conditions: map[string]any{ - "provider": "generic", - "observability.enabled": true, - "observability.backend": "elk", + name: "InvalidExpressionFails", + expression: "cluster.workers.count +", + config: map[string]any{}, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := evaluator.EvaluateValue(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 !deepEqual(result, tt.expected) { + t.Errorf("Expected %v (type: %T) for expression '%s', got %v (type: %T)", + tt.expected, tt.expected, tt.expression, result, result) + } + }) + } +} + +func TestFeatureEvaluator_EvaluateDefaults(t *testing.T) { + evaluator := NewFeatureEvaluator() + + t.Run("EvaluatesLiteralValues", func(t *testing.T) { + defaults := map[string]any{ + "cluster_name": "talos", + "region": "us-east-1", + } + + config := map[string]any{} + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["cluster_name"] != "talos" { + t.Errorf("Expected cluster_name to be 'talos', got %v", result["cluster_name"]) + } + if result["region"] != "us-east-1" { + t.Errorf("Expected region to be 'us-east-1', got %v", result["region"]) + } + }) + + t.Run("EvaluatesSimpleExpressions", func(t *testing.T) { + defaults := map[string]any{ + "count": "${cluster.workers.count}", + "endpoint": "${cluster.endpoint}", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + "endpoint": "https://localhost:6443", }, - config: map[string]any{ - "provider": "generic", - "observability": map[string]any{ - "enabled": true, - "backend": "quickwit", + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["count"] != 3 { + t.Errorf("Expected count to be 3, got %v", result["count"]) + } + if result["endpoint"] != "https://localhost:6443" { + t.Errorf("Expected endpoint to be 'https://localhost:6443', got %v", result["endpoint"]) + } + }) + + t.Run("EvaluatesArithmeticExpressions", func(t *testing.T) { + defaults := map[string]any{ + "min_size": "${cluster.workers.count}", + "max_size": "${cluster.workers.count + 2}", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, }, }, - expected: false, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["min_size"] != 3 { + t.Errorf("Expected min_size to be 3, got %v", result["min_size"]) + } + if result["max_size"] != 5 { + t.Errorf("Expected max_size to be 5, got %v", result["max_size"]) + } + }) + + t.Run("EvaluatesNestedMaps", func(t *testing.T) { + defaults := map[string]any{ + "node_groups": map[string]any{ + "default": map[string]any{ + "min_size": "${cluster.workers.count}", + "max_size": "${cluster.workers.count + 2}", + }, + }, + "region": "us-east-1", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + nodeGroups, ok := result["node_groups"].(map[string]any) + if !ok { + t.Fatalf("Expected node_groups to be a map, got %T", result["node_groups"]) + } + + defaultGroup, ok := nodeGroups["default"].(map[string]any) + if !ok { + t.Fatalf("Expected default group to be a map, got %T", nodeGroups["default"]) + } + + if defaultGroup["min_size"] != 3 { + t.Errorf("Expected min_size to be 3, got %v", defaultGroup["min_size"]) + } + if defaultGroup["max_size"] != 5 { + t.Errorf("Expected max_size to be 5, got %v", defaultGroup["max_size"]) + } + }) + + t.Run("EvaluatesArraysWithExpressions", func(t *testing.T) { + defaults := map[string]any{ + "instance_types": []any{ + "${cluster.workers.instance_type}", + }, + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "instance_type": "t3.medium", + }, + }, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + instanceTypes, ok := result["instance_types"].([]any) + if !ok { + t.Fatalf("Expected instance_types to be an array, got %T", result["instance_types"]) + } + + if len(instanceTypes) != 1 || instanceTypes[0] != "t3.medium" { + t.Errorf("Expected instance_types to be ['t3.medium'], got %v", instanceTypes) + } + }) + + t.Run("UndefinedVariablesReturnNil", func(t *testing.T) { + defaults := map[string]any{ + "endpoint": "${cluster.endpoint}", + "defined": "${cluster.workers.count}", + "undefined": "${cluster.undefined}", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["endpoint"] != nil { + t.Errorf("Expected endpoint to be nil, got %v", result["endpoint"]) + } + if result["defined"] != 3 { + t.Errorf("Expected defined to be 3, got %v", result["defined"]) + } + if result["undefined"] != nil { + t.Errorf("Expected undefined to be nil, got %v", result["undefined"]) + } + }) + + t.Run("InvalidExpressionFails", func(t *testing.T) { + defaults := map[string]any{ + "bad_expr": "${cluster.workers.count +}", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + _, err := evaluator.EvaluateDefaults(defaults, config) + if err == nil { + t.Fatal("Expected error for invalid expression, got nil") + } + }) + + t.Run("MixesLiteralsAndExpressions", func(t *testing.T) { + defaults := map[string]any{ + "cluster_name": "talos", + "region": "us-east-1", + "count": "${cluster.workers.count}", + "max": "${cluster.workers.count + 2}", + "literal_number": 42, + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + result, err := evaluator.EvaluateDefaults(defaults, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result["cluster_name"] != "talos" { + t.Errorf("Expected cluster_name to be 'talos', got %v", result["cluster_name"]) + } + if result["region"] != "us-east-1" { + t.Errorf("Expected region to be 'us-east-1', got %v", result["region"]) + } + if result["count"] != 3 { + t.Errorf("Expected count to be 3, got %v", result["count"]) + } + if result["max"] != 5 { + t.Errorf("Expected max to be 5, got %v", result["max"]) + } + if result["literal_number"] != 42 { + t.Errorf("Expected literal_number to be 42, got %v", result["literal_number"]) + } + }) +} + +// ============================================================================= +// Test Private Methods +// ============================================================================= + +func TestFeatureEvaluator_extractExpression(t *testing.T) { + evaluator := NewFeatureEvaluator() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "SimpleExpression", + input: "${cluster.endpoint}", + expected: "cluster.endpoint", }, { - name: "ArrayConditionMatchesFirst", - conditions: map[string]any{"storage.provider": []any{"auto", "openebs"}}, - config: map[string]any{"storage": map[string]any{"provider": "auto"}}, - expected: true, + name: "ArithmeticExpression", + input: "${cluster.workers.count + 2}", + expected: "cluster.workers.count + 2", }, { - name: "ArrayConditionMatchesSecond", - conditions: map[string]any{"storage.provider": []any{"auto", "openebs"}}, - config: map[string]any{"storage": map[string]any{"provider": "openebs"}}, - expected: true, + name: "LiteralString", + input: "talos", + expected: "", }, { - name: "ArrayConditionNoMatch", - conditions: map[string]any{"storage.provider": []any{"auto", "openebs"}}, - config: map[string]any{"storage": map[string]any{"provider": "ebs"}}, - expected: false, + name: "EmptyString", + input: "", + expected: "", }, { - name: "MissingFieldDoesNotMatch", - conditions: map[string]any{"missing.field": "value"}, - config: map[string]any{"other": "data"}, - expected: false, + name: "PartialExpressionNotExtracted", + input: "${cluster.endpoint} additional text", + expected: "", }, { - name: "NilValueDoesNotMatch", - conditions: map[string]any{"provider": "aws"}, - config: map[string]any{"provider": nil}, - expected: false, + name: "MissingClosingBrace", + input: "${cluster.endpoint", + expected: "", + }, + { + name: "EmptyExpression", + input: "${}", + expected: "", + }, + { + name: "ComplexExpression", + input: "${cluster.workers.count * 2 + 1}", + expected: "cluster.workers.count * 2 + 1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := evaluator.MatchConditions(tt.conditions, tt.config) - + result := evaluator.extractExpression(tt.input) if result != tt.expected { - t.Errorf("Expected %v for conditions %v with config %v, got %v", - tt.expected, tt.conditions, tt.config, result) + t.Errorf("Expected '%s', got '%s' for input '%s'", tt.expected, result, tt.input) } }) } } + +func TestFeatureEvaluator_evaluateDefaultValue(t *testing.T) { + evaluator := NewFeatureEvaluator() + + t.Run("LiteralStringPassesThrough", func(t *testing.T) { + result, err := evaluator.evaluateDefaultValue("talos", map[string]any{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result != "talos" { + t.Errorf("Expected 'talos', got %v", result) + } + }) + + t.Run("ExpressionEvaluates", func(t *testing.T) { + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + result, err := evaluator.evaluateDefaultValue("${cluster.workers.count}", config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result != 3 { + t.Errorf("Expected 3, got %v", result) + } + }) + + t.Run("NestedMapEvaluates", func(t *testing.T) { + input := map[string]any{ + "literal": "value", + "expr": "${cluster.workers.count}", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + result, err := evaluator.evaluateDefaultValue(input, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("Expected map, got %T", result) + } + + if resultMap["literal"] != "value" { + t.Errorf("Expected literal to be 'value', got %v", resultMap["literal"]) + } + if resultMap["expr"] != 3 { + t.Errorf("Expected expr to be 3, got %v", resultMap["expr"]) + } + }) + + t.Run("ArrayEvaluates", func(t *testing.T) { + input := []any{ + "literal", + "${cluster.workers.count}", + } + + config := map[string]any{ + "cluster": map[string]any{ + "workers": map[string]any{ + "count": 3, + }, + }, + } + + result, err := evaluator.evaluateDefaultValue(input, config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + resultArray, ok := result.([]any) + if !ok { + t.Fatalf("Expected array, got %T", result) + } + + if len(resultArray) != 2 { + t.Fatalf("Expected 2 elements, got %d", len(resultArray)) + } + if resultArray[0] != "literal" { + t.Errorf("Expected first element to be 'literal', got %v", resultArray[0]) + } + if resultArray[1] != 3 { + t.Errorf("Expected second element to be 3, got %v", resultArray[1]) + } + }) + + t.Run("NonStringTypesPassThrough", func(t *testing.T) { + result, err := evaluator.evaluateDefaultValue(42, map[string]any{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result != 42 { + t.Errorf("Expected 42, got %v", result) + } + + result, err = evaluator.evaluateDefaultValue(true, map[string]any{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result != true { + t.Errorf("Expected true, got %v", result) + } + }) +} + +// ============================================================================= +// Test Helpers +// ============================================================================= + +func deepEqual(a, b any) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + + switch aVal := a.(type) { + case map[string]any: + bVal, ok := b.(map[string]any) + if !ok { + return false + } + if len(aVal) != len(bVal) { + return false + } + for k, v := range aVal { + if !deepEqual(v, bVal[k]) { + return false + } + } + return true + case []any: + bVal, ok := b.([]any) + if !ok { + return false + } + if len(aVal) != len(bVal) { + return false + } + for i := range aVal { + if !deepEqual(aVal[i], bVal[i]) { + return false + } + } + return true + default: + return a == b + } +} diff --git a/pkg/pipelines/init.go b/pkg/pipelines/init.go index fcc7c4d7a..7d09befa6 100644 --- a/pkg/pipelines/init.go +++ b/pkg/pipelines/init.go @@ -281,11 +281,9 @@ func (p *InitPipeline) Execute(ctx context.Context) error { } // Phase 5: Final file generation - if len(renderedData) > 0 { - for _, generator := range p.generators { - if err := generator.Generate(renderedData, reset); err != nil { - return fmt.Errorf("failed to generate from template data: %w", err) - } + for _, generator := range p.generators { + if err := generator.Generate(renderedData, reset); err != nil { + return fmt.Errorf("failed to generate from template data: %w", err) } } diff --git a/pkg/pipelines/install.go b/pkg/pipelines/install.go index 35ede5934..87decd843 100644 --- a/pkg/pipelines/install.go +++ b/pkg/pipelines/install.go @@ -116,11 +116,9 @@ func (p *InstallPipeline) Execute(ctx context.Context) error { } // Phase 3: Generate kustomize data using generators - if len(renderedData) > 0 { - for _, generator := range p.generators { - if err := generator.Generate(renderedData, false); err != nil { - return fmt.Errorf("failed to generate from template data: %w", err) - } + for _, generator := range p.generators { + if err := generator.Generate(renderedData, false); err != nil { + return fmt.Errorf("failed to generate from template data: %w", err) } } diff --git a/pkg/pipelines/install_test.go b/pkg/pipelines/install_test.go index e93bfcb39..52f1485b5 100644 --- a/pkg/pipelines/install_test.go +++ b/pkg/pipelines/install_test.go @@ -571,7 +571,7 @@ func TestInstallPipeline_Execute(t *testing.T) { } }) - t.Run("SkipsGeneratorWhenNoRenderedData", func(t *testing.T) { + t.Run("CallsGeneratorsEvenWithNoRenderedData", func(t *testing.T) { // Given a pipeline with no rendered data pipeline, mocks := setup(t) @@ -615,9 +615,9 @@ func TestInstallPipeline_Execute(t *testing.T) { t.Errorf("Expected no error, got %v", err) } - // And generators should not be called - if generatorCalled { - t.Error("Expected generators to not be called when no rendered data") + // And generators should be called even with empty rendered data + if !generatorCalled { + t.Error("Expected generators to be called even when no rendered data") } })