Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 15 additions & 2 deletions api/v1alpha1/feature_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand All @@ -77,7 +85,8 @@ func (f *Feature) DeepCopy() *Feature {
Destroy: component.Destroy,
Parallelism: component.Parallelism,
},
When: component.When,
When: component.When,
Inputs: inputsCopy,
}
}

Expand Down Expand Up @@ -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{
Expand All @@ -120,7 +132,8 @@ func (c *ConditionalTerraformComponent) DeepCopy() *ConditionalTerraformComponen
Destroy: c.Destroy,
Parallelism: c.Parallelism,
},
When: c.When,
When: c.When,
Inputs: inputsCopy,
}
}

Expand Down
27 changes: 26 additions & 1 deletion pkg/blueprint/blueprint_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
141 changes: 141 additions & 0 deletions pkg/blueprint/blueprint_handler_private_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Loading
Loading