diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index f15ac656a..b1f41b437 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -14,6 +14,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// ============================================================================= +// Types +// ============================================================================= + // Blueprint is a configuration blueprint for initializing a project. type Blueprint struct { // Kind is the blueprint type, following Kubernetes conventions. @@ -128,6 +132,10 @@ type TerraformComponent struct { Parallelism *int `yaml:"parallelism,omitempty"` } +// ============================================================================= +// Public Methods +// ============================================================================= + // DeepCopy creates a deep copy of the TerraformComponent object. func (t *TerraformComponent) DeepCopy() *TerraformComponent { if t == nil { @@ -237,6 +245,10 @@ type SubstituteReference struct { Optional bool `yaml:"optional,omitempty"` } +// ============================================================================= +// Public Methods +// ============================================================================= + // DeepCopy creates a deep copy of the Blueprint object. func (b *Blueprint) DeepCopy() *Blueprint { if b == nil { @@ -390,6 +402,196 @@ func (b *Blueprint) StrategicMerge(overlays ...*Blueprint) error { return nil } +// ReplaceTerraformComponent replaces an existing TerraformComponent with the provided component. +// If a component with the same Path and Source exists, it is completely replaced. Otherwise, the component is appended. +// Returns an error if a dependency cycle is detected during sorting. +func (b *Blueprint) ReplaceTerraformComponent(component TerraformComponent) error { + for i, existing := range b.TerraformComponents { + if existing.Path == component.Path && existing.Source == component.Source { + b.TerraformComponents[i] = component + return b.sortTerraform() + } + } + b.TerraformComponents = append(b.TerraformComponents, component) + return b.sortTerraform() +} + +// ReplaceKustomization replaces an existing Kustomization with the provided kustomization. +// If a kustomization with the same Name exists, it is completely replaced. Otherwise, the kustomization is appended. +// Returns an error if a dependency cycle is detected during sorting. +func (b *Blueprint) ReplaceKustomization(kustomization Kustomization) error { + for i, existing := range b.Kustomizations { + if existing.Name == kustomization.Name { + b.Kustomizations[i] = kustomization + return b.sortKustomize() + } + } + b.Kustomizations = append(b.Kustomizations, kustomization) + return b.sortKustomize() +} + +// DeepCopy creates a deep copy of the Kustomization object. +func (k *Kustomization) DeepCopy() *Kustomization { + if k == nil { + return nil + } + + return &Kustomization{ + Name: k.Name, + Path: k.Path, + Source: k.Source, + DependsOn: slices.Clone(k.DependsOn), + Interval: k.Interval, + RetryInterval: k.RetryInterval, + Timeout: k.Timeout, + Patches: slices.Clone(k.Patches), + Wait: k.Wait, + Force: k.Force, + Prune: k.Prune, + Components: slices.Clone(k.Components), + Cleanup: slices.Clone(k.Cleanup), + Destroy: k.Destroy, + Substitutions: maps.Clone(k.Substitutions), + } +} + +// ToFluxKustomization converts a blueprint Kustomization to a Flux Kustomization. +// It takes the namespace for the kustomization, the default source name to use if no source is specified, +// and the list of sources to determine the source kind (GitRepository or OCIRepository). +// PostBuild is constructed based on the kustomization's Substitutions field. +func (k *Kustomization) ToFluxKustomization(namespace string, defaultSourceName string, sources []Source) kustomizev1.Kustomization { + dependsOn := make([]kustomizev1.DependencyReference, len(k.DependsOn)) + for idx, dep := range k.DependsOn { + dependsOn[idx] = kustomizev1.DependencyReference{ + Name: dep, + Namespace: namespace, + } + } + + sourceName := k.Source + if sourceName == "" { + sourceName = defaultSourceName + } + + sourceKind := "GitRepository" + for _, source := range sources { + if source.Name == sourceName && strings.HasPrefix(source.Url, "oci://") { + sourceKind = "OCIRepository" + break + } + } + + path := k.Path + if path == "" { + path = "kustomize" + } else { + path = strings.ReplaceAll(path, "\\", "/") + if path != "kustomize" && !strings.HasPrefix(path, "kustomize/") { + path = "kustomize/" + path + } + } + + interval := metav1.Duration{Duration: constants.DefaultFluxKustomizationInterval} + if k.Interval != nil && k.Interval.Duration != 0 { + interval = *k.Interval + } + + retryInterval := metav1.Duration{Duration: constants.DefaultFluxKustomizationRetryInterval} + if k.RetryInterval != nil && k.RetryInterval.Duration != 0 { + retryInterval = *k.RetryInterval + } + + timeout := metav1.Duration{Duration: constants.DefaultFluxKustomizationTimeout} + if k.Timeout != nil && k.Timeout.Duration != 0 { + timeout = *k.Timeout + } + + wait := constants.DefaultFluxKustomizationWait + if k.Wait != nil { + wait = *k.Wait + } + + force := constants.DefaultFluxKustomizationForce + if k.Force != nil { + force = *k.Force + } + + prune := true + if k.Prune != nil { + prune = *k.Prune + } + + deletionPolicy := "MirrorPrune" + if k.Destroy == nil || *k.Destroy { + deletionPolicy = "WaitForTermination" + } + + patches := make([]kustomize.Patch, 0, len(k.Patches)) + for _, p := range k.Patches { + if p.Patch != "" { + var target *kustomize.Selector + if p.Target != nil { + target = &kustomize.Selector{ + Kind: p.Target.Kind, + Name: p.Target.Name, + Namespace: p.Target.Namespace, + } + } + patches = append(patches, kustomize.Patch{ + Patch: p.Patch, + Target: target, + }) + } + } + + var postBuild *kustomizev1.PostBuild + if len(k.Substitutions) > 0 { + configMapName := fmt.Sprintf("values-%s", k.Name) + postBuild = &kustomizev1.PostBuild{ + SubstituteFrom: []kustomizev1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: configMapName, + Optional: false, + }, + }, + } + } + + return kustomizev1.Kustomization{ + TypeMeta: metav1.TypeMeta{ + Kind: "Kustomization", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: k.Name, + Namespace: namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: sourceKind, + Name: sourceName, + }, + Path: path, + DependsOn: dependsOn, + Interval: interval, + RetryInterval: &retryInterval, + Timeout: &timeout, + Wait: wait, + Force: force, + Prune: prune, + DeletionPolicy: deletionPolicy, + Patches: patches, + Components: k.Components, + PostBuild: postBuild, + }, + } +} + +// ============================================================================= +// Private Methods +// ============================================================================= + // strategicMergeTerraformComponent performs a strategic merge of the provided TerraformComponent into the Blueprint. // It merges values, appends unique dependencies, updates fields if provided, and maintains dependency order. // Returns an error if a dependency cycle is detected during sorting. @@ -593,164 +795,6 @@ func (b *Blueprint) calculateDependencyDepth(componentIndex int, nameToIndex map return maxDepth } -// DeepCopy creates a deep copy of the Kustomization object. -func (k *Kustomization) DeepCopy() *Kustomization { - if k == nil { - return nil - } - - return &Kustomization{ - Name: k.Name, - Path: k.Path, - Source: k.Source, - DependsOn: slices.Clone(k.DependsOn), - Interval: k.Interval, - RetryInterval: k.RetryInterval, - Timeout: k.Timeout, - Patches: slices.Clone(k.Patches), - Wait: k.Wait, - Force: k.Force, - Prune: k.Prune, - Components: slices.Clone(k.Components), - Cleanup: slices.Clone(k.Cleanup), - Destroy: k.Destroy, - Substitutions: maps.Clone(k.Substitutions), - } -} - -// ToFluxKustomization converts a blueprint Kustomization to a Flux Kustomization. -// It takes the namespace for the kustomization, the default source name to use if no source is specified, -// and the list of sources to determine the source kind (GitRepository or OCIRepository). -// PostBuild is constructed based on the kustomization's Substitutions field. -func (k *Kustomization) ToFluxKustomization(namespace string, defaultSourceName string, sources []Source) kustomizev1.Kustomization { - dependsOn := make([]kustomizev1.DependencyReference, len(k.DependsOn)) - for idx, dep := range k.DependsOn { - dependsOn[idx] = kustomizev1.DependencyReference{ - Name: dep, - Namespace: namespace, - } - } - - sourceName := k.Source - if sourceName == "" { - sourceName = defaultSourceName - } - - sourceKind := "GitRepository" - for _, source := range sources { - if source.Name == sourceName && strings.HasPrefix(source.Url, "oci://") { - sourceKind = "OCIRepository" - break - } - } - - path := k.Path - if path == "" { - path = "kustomize" - } else { - path = strings.ReplaceAll(path, "\\", "/") - if path != "kustomize" && !strings.HasPrefix(path, "kustomize/") { - path = "kustomize/" + path - } - } - - interval := metav1.Duration{Duration: constants.DefaultFluxKustomizationInterval} - if k.Interval != nil && k.Interval.Duration != 0 { - interval = *k.Interval - } - - retryInterval := metav1.Duration{Duration: constants.DefaultFluxKustomizationRetryInterval} - if k.RetryInterval != nil && k.RetryInterval.Duration != 0 { - retryInterval = *k.RetryInterval - } - - timeout := metav1.Duration{Duration: constants.DefaultFluxKustomizationTimeout} - if k.Timeout != nil && k.Timeout.Duration != 0 { - timeout = *k.Timeout - } - - wait := constants.DefaultFluxKustomizationWait - if k.Wait != nil { - wait = *k.Wait - } - - force := constants.DefaultFluxKustomizationForce - if k.Force != nil { - force = *k.Force - } - - prune := true - if k.Prune != nil { - prune = *k.Prune - } - - deletionPolicy := "MirrorPrune" - if k.Destroy == nil || *k.Destroy { - deletionPolicy = "WaitForTermination" - } - - patches := make([]kustomize.Patch, 0, len(k.Patches)) - for _, p := range k.Patches { - if p.Patch != "" { - var target *kustomize.Selector - if p.Target != nil { - target = &kustomize.Selector{ - Kind: p.Target.Kind, - Name: p.Target.Name, - Namespace: p.Target.Namespace, - } - } - patches = append(patches, kustomize.Patch{ - Patch: p.Patch, - Target: target, - }) - } - } - - var postBuild *kustomizev1.PostBuild - if len(k.Substitutions) > 0 { - configMapName := fmt.Sprintf("values-%s", k.Name) - postBuild = &kustomizev1.PostBuild{ - SubstituteFrom: []kustomizev1.SubstituteReference{ - { - Kind: "ConfigMap", - Name: configMapName, - Optional: false, - }, - }, - } - } - - return kustomizev1.Kustomization{ - TypeMeta: metav1.TypeMeta{ - Kind: "Kustomization", - APIVersion: "kustomize.toolkit.fluxcd.io/v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: k.Name, - Namespace: namespace, - }, - Spec: kustomizev1.KustomizationSpec{ - SourceRef: kustomizev1.CrossNamespaceSourceReference{ - Kind: sourceKind, - Name: sourceName, - }, - Path: path, - DependsOn: dependsOn, - Interval: interval, - RetryInterval: &retryInterval, - Timeout: &timeout, - Wait: wait, - Force: force, - Prune: prune, - DeletionPolicy: deletionPolicy, - Patches: patches, - Components: k.Components, - PostBuild: postBuild, - }, - } -} - // sortTerraform reorders the Blueprint's TerraformComponents so that dependencies precede dependents. // It applies a topological sort to ensure dependency order. Components without dependencies come first. // Returns an error if a dependency cycle is detected. diff --git a/api/v1alpha1/blueprint_types_test.go b/api/v1alpha1/blueprint_types_test.go index 4fcc56f10..017bd8c8b 100644 --- a/api/v1alpha1/blueprint_types_test.go +++ b/api/v1alpha1/blueprint_types_test.go @@ -725,6 +725,302 @@ func TestBlueprint_StrategicMerge(t *testing.T) { }) } +func TestBlueprint_ReplaceTerraformComponent(t *testing.T) { + t.Run("ReplacesExistingComponent", func(t *testing.T) { + // Given a base blueprint with a terraform component + base := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Path: "network/vpc", + Source: "core", + Inputs: map[string]any{"cidr": "10.0.0.0/16", "enable_dns": false}, + DependsOn: []string{"backend", "security"}, + Destroy: ptrBool(true), + }, + }, + } + + // When replacing with a new component + replacement := TerraformComponent{ + Path: "network/vpc", + Source: "core", + Inputs: map[string]any{"cidr": "172.16.0.0/16"}, + DependsOn: []string{"new-dependency"}, + Destroy: ptrBool(false), + Parallelism: intPtr(5), + } + + err := base.ReplaceTerraformComponent(replacement) + + // Then should replace the component entirely + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if len(base.TerraformComponents) != 1 { + t.Errorf("Expected 1 component, got %d", len(base.TerraformComponents)) + } + + replaced := base.TerraformComponents[0] + if replaced.Path != "network/vpc" { + t.Errorf("Expected path 'network/vpc', got '%s'", replaced.Path) + } + if len(replaced.Inputs) != 1 { + t.Errorf("Expected 1 input (replaced), got %d", len(replaced.Inputs)) + } + if replaced.Inputs["cidr"] != "172.16.0.0/16" { + t.Errorf("Expected new cidr value, got %v", replaced.Inputs["cidr"]) + } + if replaced.Inputs["enable_dns"] != nil { + t.Errorf("Expected old enable_dns to be removed, got %v", replaced.Inputs["enable_dns"]) + } + if len(replaced.DependsOn) != 1 { + t.Errorf("Expected 1 dependency (replaced), got %d", len(replaced.DependsOn)) + } + if replaced.DependsOn[0] != "new-dependency" { + t.Errorf("Expected new dependency, got %v", replaced.DependsOn) + } + if replaced.Destroy == nil || *replaced.Destroy != false { + t.Errorf("Expected destroy=false, got %v", replaced.Destroy) + } + if replaced.Parallelism == nil || *replaced.Parallelism != 5 { + t.Errorf("Expected parallelism=5, got %v", replaced.Parallelism) + } + }) + + t.Run("AppendsNewComponent", func(t *testing.T) { + // Given a base blueprint with existing components + base := &Blueprint{ + TerraformComponents: []TerraformComponent{ + {Path: "existing", Source: "core"}, + }, + } + + // When replacing with a component that doesn't exist + newComponent := TerraformComponent{ + Path: "new-component", + Source: "core", + Inputs: map[string]any{"key": "value"}, + } + + err := base.ReplaceTerraformComponent(newComponent) + + // Then should append the new component + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if len(base.TerraformComponents) != 2 { + t.Errorf("Expected 2 components, got %d", len(base.TerraformComponents)) + } + + found := false + for _, comp := range base.TerraformComponents { + if comp.Path == "new-component" { + found = true + if comp.Inputs["key"] != "value" { + t.Errorf("Expected new component inputs to be preserved") + } + } + } + if !found { + t.Errorf("Expected new component to be added") + } + }) + + t.Run("MaintainsDependencyOrder", func(t *testing.T) { + // Given a base blueprint with ordered components + base := &Blueprint{ + TerraformComponents: []TerraformComponent{ + {Path: "backend", Source: "core"}, + {Path: "network", Source: "core"}, + {Path: "cluster", Source: "core", DependsOn: []string{"network"}}, + }, + } + + // When replacing network component with new dependencies + replacement := TerraformComponent{ + Path: "network", + Source: "core", + DependsOn: []string{"backend", "new-dep"}, + } + + err := base.ReplaceTerraformComponent(replacement) + + // Then should maintain dependency order + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + networkIndex := -1 + clusterIndex := -1 + for i, comp := range base.TerraformComponents { + if comp.Path == "network" { + networkIndex = i + } + if comp.Path == "cluster" { + clusterIndex = i + } + } + + if networkIndex == -1 || clusterIndex == -1 { + t.Fatal("Expected both network and cluster components") + } + + if networkIndex >= clusterIndex { + t.Errorf("Expected network (index %d) to come before cluster (index %d) due to dependency", networkIndex, clusterIndex) + } + }) +} + +func TestBlueprint_ReplaceKustomization(t *testing.T) { + t.Run("ReplacesExistingKustomization", func(t *testing.T) { + // Given a base blueprint with a kustomization + base := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "ingress", + Path: "original-path", + Source: "original-source", + Components: []string{"nginx", "cert-manager"}, + DependsOn: []string{"pki", "dns"}, + Destroy: ptrBool(true), + }, + }, + } + + // When replacing with a new kustomization + replacement := Kustomization{ + Name: "ingress", + Path: "new-path", + Source: "new-source", + Components: []string{"traefik"}, + DependsOn: []string{"new-dependency"}, + Destroy: ptrBool(false), + } + + err := base.ReplaceKustomization(replacement) + + // Then should replace the kustomization entirely + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if len(base.Kustomizations) != 1 { + t.Errorf("Expected 1 kustomization, got %d", len(base.Kustomizations)) + } + + replaced := base.Kustomizations[0] + if replaced.Name != "ingress" { + t.Errorf("Expected name 'ingress', got '%s'", replaced.Name) + } + if replaced.Path != "new-path" { + t.Errorf("Expected path 'new-path', got '%s'", replaced.Path) + } + if replaced.Source != "new-source" { + t.Errorf("Expected source 'new-source', got '%s'", replaced.Source) + } + if len(replaced.Components) != 1 { + t.Errorf("Expected 1 component (replaced), got %d", len(replaced.Components)) + } + if replaced.Components[0] != "traefik" { + t.Errorf("Expected component 'traefik', got %v", replaced.Components) + } + if len(replaced.DependsOn) != 1 { + t.Errorf("Expected 1 dependency (replaced), got %d", len(replaced.DependsOn)) + } + if replaced.DependsOn[0] != "new-dependency" { + t.Errorf("Expected new dependency, got %v", replaced.DependsOn) + } + if replaced.Destroy == nil || *replaced.Destroy != false { + t.Errorf("Expected destroy=false, got %v", replaced.Destroy) + } + }) + + t.Run("AppendsNewKustomization", func(t *testing.T) { + // Given a base blueprint with existing kustomizations + base := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "existing", Path: "existing-path"}, + }, + } + + // When replacing with a kustomization that doesn't exist + newKustomization := Kustomization{ + Name: "new-kustomization", + Path: "new-path", + Components: []string{"component1"}, + } + + err := base.ReplaceKustomization(newKustomization) + + // Then should append the new kustomization + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if len(base.Kustomizations) != 2 { + t.Errorf("Expected 2 kustomizations, got %d", len(base.Kustomizations)) + } + + found := false + for i := range base.Kustomizations { + k := base.Kustomizations[i] + if k.Name == "new-kustomization" { + found = true + if k.Path != "new-path" { + t.Errorf("Expected new kustomization path to be preserved") + } + if len(k.Components) != 1 || k.Components[0] != "component1" { + t.Errorf("Expected new kustomization components to be preserved") + } + } + } + if !found { + t.Errorf("Expected new kustomization to be added") + } + }) + + t.Run("MaintainsDependencyOrder", func(t *testing.T) { + // Given a base blueprint with ordered kustomizations + base := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "pki", Path: "pki"}, + {Name: "ingress", Path: "ingress", DependsOn: []string{"pki"}}, + }, + } + + // When replacing pki with new dependencies + replacement := Kustomization{ + Name: "pki", + Path: "pki", + DependsOn: []string{"base"}, + } + + err := base.ReplaceKustomization(replacement) + + // Then should maintain dependency order + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + pkiIndex := -1 + ingressIndex := -1 + for i, k := range base.Kustomizations { + if k.Name == "pki" { + pkiIndex = i + } + if k.Name == "ingress" { + ingressIndex = i + } + } + + if pkiIndex == -1 || ingressIndex == -1 { + t.Fatal("Expected both pki and ingress kustomizations") + } + + if pkiIndex >= ingressIndex { + t.Errorf("Expected pki (index %d) to come before ingress (index %d) due to dependency", pkiIndex, ingressIndex) + } + }) +} + func TestBlueprint_DeepCopy(t *testing.T) { t.Run("Success", func(t *testing.T) { blueprint := &Blueprint{ diff --git a/api/v1alpha1/feature_types.go b/api/v1alpha1/feature_types.go index c8ca07ffa..982ac11b7 100644 --- a/api/v1alpha1/feature_types.go +++ b/api/v1alpha1/feature_types.go @@ -38,15 +38,27 @@ 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"` + + // Strategy determines how this component is merged into the blueprint. + // Valid values are "merge" (default, strategic merge) and "replace" (replaces existing component entirely). + // If empty or "merge", the component is merged with existing components matching the same Path and Source. + // If "replace", the component completely replaces any existing component with the same Path and Source. + Strategy string `yaml:"strategy,omitempty"` } // ConditionalKustomization extends Kustomization with conditional logic support. type ConditionalKustomization struct { Kustomization `yaml:",inline"` - // When is a CEL expression that determines if this kustomization should be applied. + // When is an expression that determines if this kustomization should be applied. // If empty, the kustomization is always applied when the parent feature matches. When string `yaml:"when,omitempty"` + + // Strategy determines how this kustomization is merged into the blueprint. + // Valid values are "merge" (default, strategic merge) and "replace" (replaces existing kustomization entirely). + // If empty or "merge", the kustomization is merged with existing kustomizations matching the same Name. + // If "replace", the kustomization completely replaces any existing kustomization with the same Name. + Strategy string `yaml:"strategy,omitempty"` } // DeepCopy creates a deep copy of the Feature object. @@ -90,6 +102,7 @@ func (c *ConditionalTerraformComponent) DeepCopy() *ConditionalTerraformComponen return &ConditionalTerraformComponent{ TerraformComponent: *c.TerraformComponent.DeepCopy(), When: c.When, + Strategy: c.Strategy, } } @@ -102,5 +115,6 @@ func (c *ConditionalKustomization) DeepCopy() *ConditionalKustomization { return &ConditionalKustomization{ Kustomization: *c.Kustomization.DeepCopy(), When: c.When, + Strategy: c.Strategy, } } diff --git a/pkg/composer/blueprint/blueprint_handler.go b/pkg/composer/blueprint/blueprint_handler.go index 3ca5348f5..0342455cf 100644 --- a/pkg/composer/blueprint/blueprint_handler.go +++ b/pkg/composer/blueprint/blueprint_handler.go @@ -645,15 +645,30 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c component.Inputs = make(map[string]any) } - component.Inputs = b.deepMergeMaps(component.Inputs, filteredInputs) + if terraformComponent.Strategy == "replace" { + component.Inputs = filteredInputs + } else { + component.Inputs = b.deepMergeMaps(component.Inputs, filteredInputs) + } } } - tempBlueprint := &blueprintv1alpha1.Blueprint{ - TerraformComponents: []blueprintv1alpha1.TerraformComponent{component}, + strategy := terraformComponent.Strategy + if strategy == "" { + strategy = "merge" } - if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { - return fmt.Errorf("failed to merge terraform component: %w", err) + + if strategy == "replace" { + if err := b.blueprint.ReplaceTerraformComponent(component); err != nil { + return fmt.Errorf("failed to replace terraform component: %w", err) + } + } else { + tempBlueprint := &blueprintv1alpha1.Blueprint{ + TerraformComponents: []blueprintv1alpha1.TerraformComponent{component}, + } + if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { + return fmt.Errorf("failed to merge terraform component: %w", err) + } } } @@ -686,11 +701,22 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c // Clear substitutions as they are used for ConfigMap generation and should not appear in the final blueprint kustomizationCopy.Substitutions = nil - tempBlueprint := &blueprintv1alpha1.Blueprint{ - Kustomizations: []blueprintv1alpha1.Kustomization{kustomizationCopy}, + strategy := kustomization.Strategy + if strategy == "" { + strategy = "merge" } - if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { - return fmt.Errorf("failed to merge kustomization: %w", err) + + if strategy == "replace" { + if err := b.blueprint.ReplaceKustomization(kustomizationCopy); err != nil { + return fmt.Errorf("failed to replace kustomization: %w", err) + } + } else { + tempBlueprint := &blueprintv1alpha1.Blueprint{ + Kustomizations: []blueprintv1alpha1.Kustomization{kustomizationCopy}, + } + if err := b.blueprint.StrategicMerge(tempBlueprint); err != nil { + return fmt.Errorf("failed to merge kustomization: %w", err) + } } } } diff --git a/pkg/composer/blueprint/blueprint_handler_private_test.go b/pkg/composer/blueprint/blueprint_handler_private_test.go index aaa6985aa..f6048285e 100644 --- a/pkg/composer/blueprint/blueprint_handler_private_test.go +++ b/pkg/composer/blueprint/blueprint_handler_private_test.go @@ -3070,6 +3070,263 @@ terraform: t.Errorf("Expected inputs evaluation error, got %v", err) } }) + + t.Run("ReplacesTerraformComponentWithReplaceStrategy", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +terraform: + - path: network/vpc + source: core + inputs: + cidr: 10.0.0.0/16 + enable_dns: true + dependsOn: + - backend +`) + + replaceFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: replace-feature +terraform: + - path: network/vpc + source: core + strategy: replace + inputs: + cidr: 172.16.0.0/16 + dependsOn: + - new-dependency +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/replace.yaml": replaceFeature, + } + + config := map[string]any{} + + 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.Path != "network/vpc" { + t.Errorf("Expected path 'network/vpc', got '%s'", component.Path) + } + if len(component.Inputs) != 1 { + t.Errorf("Expected 1 input (replaced), got %d", len(component.Inputs)) + } + if component.Inputs["cidr"] != "172.16.0.0/16" { + t.Errorf("Expected new cidr value, got %v", component.Inputs["cidr"]) + } + if component.Inputs["enable_dns"] != nil { + t.Errorf("Expected old enable_dns to be removed, got %v", component.Inputs["enable_dns"]) + } + if len(component.DependsOn) != 1 { + t.Errorf("Expected 1 dependency (replaced), got %d", len(component.DependsOn)) + } + if component.DependsOn[0] != "new-dependency" { + t.Errorf("Expected new dependency, got %v", component.DependsOn) + } + }) + + t.Run("MergesTerraformComponentWithDefaultStrategy", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +terraform: + - path: network/vpc + source: core + inputs: + cidr: 10.0.0.0/16 + dependsOn: + - backend +`) + + mergeFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: merge-feature +terraform: + - path: network/vpc + source: core + strategy: merge + inputs: + enable_dns: true + dependsOn: + - security +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/merge.yaml": mergeFeature, + } + + config := map[string]any{} + + 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 len(component.Inputs) != 2 { + t.Errorf("Expected 2 inputs (merged), got %d", len(component.Inputs)) + } + if component.Inputs["cidr"] != "10.0.0.0/16" { + t.Errorf("Expected original cidr value preserved, got %v", component.Inputs["cidr"]) + } + if component.Inputs["enable_dns"] != true { + t.Errorf("Expected new enable_dns value added, got %v", component.Inputs["enable_dns"]) + } + if len(component.DependsOn) != 2 { + t.Errorf("Expected 2 dependencies (merged), got %d", len(component.DependsOn)) + } + }) + + t.Run("ReplacesKustomizationWithReplaceStrategy", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +kustomize: + - name: ingress + path: original-path + source: original-source + components: + - nginx + - cert-manager + dependsOn: + - pki + - dns +`) + + replaceFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: replace-feature +kustomize: + - name: ingress + strategy: replace + path: new-path + source: new-source + components: + - traefik + dependsOn: + - new-dependency +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/replace.yaml": replaceFeature, + } + + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(handler.blueprint.Kustomizations)) + } + + kustomization := handler.blueprint.Kustomizations[0] + if kustomization.Name != "ingress" { + t.Errorf("Expected name 'ingress', got '%s'", kustomization.Name) + } + if kustomization.Path != "new-path" { + t.Errorf("Expected path 'new-path', got '%s'", kustomization.Path) + } + if kustomization.Source != "new-source" { + t.Errorf("Expected source 'new-source', got '%s'", kustomization.Source) + } + if len(kustomization.Components) != 1 { + t.Errorf("Expected 1 component (replaced), got %d", len(kustomization.Components)) + } + if kustomization.Components[0] != "traefik" { + t.Errorf("Expected component 'traefik', got %v", kustomization.Components) + } + if len(kustomization.DependsOn) != 1 { + t.Errorf("Expected 1 dependency (replaced), got %d", len(kustomization.DependsOn)) + } + if kustomization.DependsOn[0] != "new-dependency" { + t.Errorf("Expected new dependency, got %v", kustomization.DependsOn) + } + }) + + t.Run("MergesKustomizationWithDefaultStrategy", func(t *testing.T) { + handler := setup(t) + + baseBlueprint := []byte(`kind: Blueprint +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: base +kustomize: + - name: ingress + path: original-path + components: + - nginx + dependsOn: + - pki +`) + + mergeFeature := []byte(`kind: Feature +apiVersion: blueprints.windsorcli.dev/v1alpha1 +metadata: + name: merge-feature +kustomize: + - name: ingress + strategy: merge + components: + - cert-manager + dependsOn: + - dns +`) + + templateData := map[string][]byte{ + "blueprint": baseBlueprint, + "features/merge.yaml": mergeFeature, + } + + config := map[string]any{} + + err := handler.processFeatures(templateData, config) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if len(handler.blueprint.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(handler.blueprint.Kustomizations)) + } + + kustomization := handler.blueprint.Kustomizations[0] + if len(kustomization.Components) != 2 { + t.Errorf("Expected 2 components (merged), got %d", len(kustomization.Components)) + } + if len(kustomization.DependsOn) != 2 { + t.Errorf("Expected 2 dependencies (merged), got %d", len(kustomization.DependsOn)) + } + }) } func TestBaseBlueprintHandler_setRepositoryDefaults(t *testing.T) {