diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index 830fc1a96..3089e847f 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -3,8 +3,10 @@ package v1alpha1 import ( + "fmt" "maps" "slices" + "strings" "github.com/fluxcd/pkg/apis/kustomize" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -391,6 +393,296 @@ func (b *Blueprint) Merge(overlay *Blueprint) { } } +// StrategicMerge performs a strategic merge of the provided overlay Blueprint into the receiver Blueprint. +// This method appends to array fields, deep merges map fields, and updates scalar fields if present in the overlay. +// It is designed for feature composition, enabling the combination of multiple features into a single blueprint. +func (b *Blueprint) StrategicMerge(overlay *Blueprint) error { + if overlay == nil { + return nil + } + + if overlay.Kind != "" { + b.Kind = overlay.Kind + } + if overlay.ApiVersion != "" { + b.ApiVersion = overlay.ApiVersion + } + + if overlay.Metadata.Name != "" { + b.Metadata.Name = overlay.Metadata.Name + } + if overlay.Metadata.Description != "" { + b.Metadata.Description = overlay.Metadata.Description + } + + if overlay.Repository.Url != "" { + b.Repository.Url = overlay.Repository.Url + } + + if overlay.Repository.Ref.Commit != "" { + b.Repository.Ref.Commit = overlay.Repository.Ref.Commit + } else if overlay.Repository.Ref.Name != "" { + b.Repository.Ref.Name = overlay.Repository.Ref.Name + } else if overlay.Repository.Ref.SemVer != "" { + b.Repository.Ref.SemVer = overlay.Repository.Ref.SemVer + } else if overlay.Repository.Ref.Tag != "" { + b.Repository.Ref.Tag = overlay.Repository.Ref.Tag + } else if overlay.Repository.Ref.Branch != "" { + b.Repository.Ref.Branch = overlay.Repository.Ref.Branch + } + + if overlay.Repository.SecretName != "" { + b.Repository.SecretName = overlay.Repository.SecretName + } + + sourceMap := make(map[string]Source) + for _, source := range b.Sources { + sourceMap[source.Name] = source + } + for _, overlaySource := range overlay.Sources { + if overlaySource.Name != "" { + sourceMap[overlaySource.Name] = overlaySource + } + } + b.Sources = make([]Source, 0, len(sourceMap)) + for _, source := range sourceMap { + b.Sources = append(b.Sources, source) + } + + for _, overlayComponent := range overlay.TerraformComponents { + b.strategicMergeTerraformComponent(overlayComponent) + } + + for _, overlayK := range overlay.Kustomizations { + if err := b.strategicMergeKustomization(overlayK); err != nil { + return err + } + } + return nil +} + +// strategicMergeTerraformComponent performs a strategic merge of the provided TerraformComponent into the Blueprint. +// It merges values, appends unique dependencies, updates fields if provided, and inserts the component +// in dependency order if not already present. +func (b *Blueprint) strategicMergeTerraformComponent(component TerraformComponent) { + for i, existing := range b.TerraformComponents { + if existing.Path == component.Path && existing.Source == component.Source { + if len(component.Values) > 0 { + if existing.Values == nil { + existing.Values = make(map[string]any) + } + maps.Copy(existing.Values, component.Values) + } + for _, dep := range component.DependsOn { + if !slices.Contains(existing.DependsOn, dep) { + existing.DependsOn = append(existing.DependsOn, dep) + } + } + if component.Destroy != nil { + existing.Destroy = component.Destroy + } + if component.Parallelism != nil { + existing.Parallelism = component.Parallelism + } + b.TerraformComponents[i] = existing + return + } + } + insertIndex := len(b.TerraformComponents) + if len(component.DependsOn) > 0 { + latestDepIndex := -1 + for _, dep := range component.DependsOn { + for i, existing := range b.TerraformComponents { + if existing.Path == dep { + if i > latestDepIndex { + latestDepIndex = i + } + } + } + } + if latestDepIndex >= 0 { + insertIndex = latestDepIndex + 1 + } + } + if insertIndex >= len(b.TerraformComponents) { + b.TerraformComponents = append(b.TerraformComponents, component) + } else { + b.TerraformComponents = slices.Insert(b.TerraformComponents, insertIndex, component) + } +} + +// strategicMergeKustomization performs a strategic merge of the provided Kustomization into the Blueprint. +// It merges unique components and dependencies, updates fields if provided, and maintains dependency order. +// Returns an error if a dependency cycle is detected during sorting. +func (b *Blueprint) strategicMergeKustomization(kustomization Kustomization) error { + for i, existing := range b.Kustomizations { + if existing.Name == kustomization.Name { + for _, component := range kustomization.Components { + if !slices.Contains(existing.Components, component) { + existing.Components = append(existing.Components, component) + } + } + slices.Sort(existing.Components) + for _, dep := range kustomization.DependsOn { + if !slices.Contains(existing.DependsOn, dep) { + existing.DependsOn = append(existing.DependsOn, dep) + } + } + if kustomization.Path != "" { + existing.Path = kustomization.Path + } + if kustomization.Source != "" { + existing.Source = kustomization.Source + } + if kustomization.Destroy != nil { + existing.Destroy = kustomization.Destroy + } + b.Kustomizations[i] = existing + return b.sortKustomizationsByDependencies() + } + } + b.Kustomizations = append(b.Kustomizations, kustomization) + return b.sortKustomizationsByDependencies() +} + +// sortKustomizationsByDependencies reorders the Blueprint's Kustomizations so that dependencies precede dependents. +// It first applies a topological sort to ensure dependency order, then groups kustomizations with similar name prefixes adjacently. +// Returns an error if a dependency cycle is detected. +func (b *Blueprint) sortKustomizationsByDependencies() error { + if len(b.Kustomizations) <= 1 { + return nil + } + nameToIndex := make(map[string]int) + for i, k := range b.Kustomizations { + nameToIndex[k.Name] = i + } + sorted := b.basicTopologicalSort(nameToIndex) + if sorted == nil { + return fmt.Errorf("dependency cycle detected in kustomizations") + } + sorted = b.groupSimilarPrefixes(sorted, nameToIndex) + newKustomizations := make([]Kustomization, len(b.Kustomizations)) + for i, sortedIndex := range sorted { + newKustomizations[i] = b.Kustomizations[sortedIndex] + } + b.Kustomizations = newKustomizations + return nil +} + +// basicTopologicalSort computes a topological ordering of kustomizations based on dependencies. +// Returns a slice of indices into the Kustomizations slice, ordered so dependencies precede dependents. +// Returns nil if a cycle is detected in the dependency graph. +func (b *Blueprint) basicTopologicalSort(nameToIndex map[string]int) []int { + var sorted []int + visited := make(map[int]bool) + visiting := make(map[int]bool) + + var visit func(int) error + visit = func(componentIndex int) error { + if visiting[componentIndex] { + return fmt.Errorf("cycle detected in dependency graph involving kustomization '%s'", b.Kustomizations[componentIndex].Name) + } + if visited[componentIndex] { + return nil + } + + visiting[componentIndex] = true + for _, depName := range b.Kustomizations[componentIndex].DependsOn { + if depIndex, exists := nameToIndex[depName]; exists { + if err := visit(depIndex); err != nil { + visiting[componentIndex] = false + return err + } + } + } + visiting[componentIndex] = false + visited[componentIndex] = true + sorted = append(sorted, componentIndex) + return nil + } + + for i := range b.Kustomizations { + if !visited[i] { + if err := visit(i); err != nil { + fmt.Printf("Error: %v\n", err) + return nil + } + } + } + return sorted +} + +// groupSimilarPrefixes returns a new ordering of kustomization indices so components with similar +// name prefixes are grouped. It groups kustomizations by the prefix before the first hyphen in +// their name, then processes each group in the order they appear in the input slice. For groups +// with multiple components, it sorts by dependency depth (shallowest first), then by name if +// depths are equal. The resulting slice preserves dependency order and groups related +// kustomizations adjacently. +func (b *Blueprint) groupSimilarPrefixes(sorted []int, nameToIndex map[string]int) []int { + prefixGroups := make(map[string][]int) + for _, idx := range sorted { + prefix := strings.Split(b.Kustomizations[idx].Name, "-")[0] + prefixGroups[prefix] = append(prefixGroups[prefix], idx) + } + + var newSorted []int + processed := make(map[int]bool) + + for _, originalIdx := range sorted { + if processed[originalIdx] { + continue + } + + prefix := strings.Split(b.Kustomizations[originalIdx].Name, "-")[0] + group := prefixGroups[prefix] + + if len(group) == 1 { + newSorted = append(newSorted, group[0]) + processed[group[0]] = true + } else { + slices.SortFunc(group, func(i, j int) int { + depthI := b.calculateDependencyDepth(i, nameToIndex) + depthJ := b.calculateDependencyDepth(j, nameToIndex) + if depthI != depthJ { + return depthI - depthJ + } + return strings.Compare(b.Kustomizations[i].Name, b.Kustomizations[j].Name) + }) + + for _, idx := range group { + if !processed[idx] { + newSorted = append(newSorted, idx) + processed[idx] = true + } + } + } + } + + return newSorted +} + +// calculateDependencyDepth returns the maximum dependency depth for the specified kustomization index. +// It recursively traverses the dependency graph using the provided name-to-index mapping, computing +// the longest path from the given component to any leaf dependency. A component with no dependencies +// has depth 0. Cycles are not detected and may cause stack overflow. +func (b *Blueprint) calculateDependencyDepth(componentIndex int, nameToIndex map[string]int) int { + k := b.Kustomizations[componentIndex] + if len(k.DependsOn) == 0 { + return 0 + } + + maxDepth := 0 + for _, depName := range k.DependsOn { + if depIndex, exists := nameToIndex[depName]; exists { + depth := b.calculateDependencyDepth(depIndex, nameToIndex) + if depth+1 > maxDepth { + maxDepth = depth + 1 + } + } + } + return maxDepth +} + // DeepCopy creates a deep copy of the Kustomization object. func (k *Kustomization) DeepCopy() *Kustomization { if k == nil { diff --git a/api/v1alpha1/blueprint_types_test.go b/api/v1alpha1/blueprint_types_test.go index 6d213798d..2d0d329a5 100644 --- a/api/v1alpha1/blueprint_types_test.go +++ b/api/v1alpha1/blueprint_types_test.go @@ -1155,3 +1155,582 @@ func TestPostBuildOmitEmpty(t *testing.T) { } }) } + +func TestBlueprint_StrategicMerge(t *testing.T) { + t.Run("MergesTerraformComponentsStrategically", func(t *testing.T) { + // Given a base blueprint with terraform components + base := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Path: "network/vpc", + Source: "core", + Values: map[string]any{"cidr": "10.0.0.0/16"}, + DependsOn: []string{"backend"}, + }, + }, + } + + // And an overlay with same component (should merge) and new component (should append) + overlay := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Path: "network/vpc", // Same path+source - should merge + Source: "core", + Values: map[string]any{"enable_dns": true}, + DependsOn: []string{"security"}, + }, + { + Path: "cluster/eks", // New component - should append + Source: "core", + Values: map[string]any{"version": "1.28"}, + }, + }, + } + + // When strategic merging + base.StrategicMerge(overlay) + + // Then should have 2 components + if len(base.TerraformComponents) != 2 { + t.Errorf("Expected 2 terraform components, got %d", len(base.TerraformComponents)) + } + + // And first component should have merged values and dependencies + vpc := base.TerraformComponents[0] + if vpc.Path != "network/vpc" { + t.Errorf("Expected path 'network/vpc', got '%s'", vpc.Path) + } + if len(vpc.Values) != 2 { + t.Errorf("Expected 2 values, got %d", len(vpc.Values)) + } + if vpc.Values["cidr"] != "10.0.0.0/16" { + t.Errorf("Expected original cidr value preserved") + } + if vpc.Values["enable_dns"] != true { + t.Errorf("Expected new enable_dns value added") + } + if len(vpc.DependsOn) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(vpc.DependsOn)) + } + if !contains(vpc.DependsOn, "backend") || !contains(vpc.DependsOn, "security") { + t.Errorf("Expected both backend and security dependencies, got %v", vpc.DependsOn) + } + + // And second component should be the new one + eks := base.TerraformComponents[1] + if eks.Path != "cluster/eks" { + t.Errorf("Expected path 'cluster/eks', got '%s'", eks.Path) + } + }) + + t.Run("MergesKustomizationsStrategically", func(t *testing.T) { + // Given a base blueprint with kustomizations + base := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "ingress", + Components: []string{"nginx"}, + DependsOn: []string{"pki"}, + }, + }, + } + + // And an overlay with same kustomization (should merge) and new kustomization (should append) + overlay := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "ingress", // Same name - should merge + Components: []string{"nginx/tls"}, + DependsOn: []string{"cert-manager"}, + }, + { + Name: "monitoring", // New kustomization - should append + Components: []string{"prometheus"}, + }, + }, + } + + // When strategic merging + base.StrategicMerge(overlay) + + // Then should have 2 kustomizations + if len(base.Kustomizations) != 2 { + t.Errorf("Expected 2 kustomizations, got %d", len(base.Kustomizations)) + } + + // Components should be ordered by their original order since both have unresolved dependencies + ingress := base.Kustomizations[0] + if ingress.Name != "ingress" { + t.Errorf("Expected name 'ingress' at index 0, got '%s'", ingress.Name) + } + + // And second kustomization should be monitoring + monitoring := base.Kustomizations[1] + if monitoring.Name != "monitoring" { + t.Errorf("Expected name 'monitoring' at index 1, got '%s'", monitoring.Name) + } + if len(ingress.Components) != 2 { + t.Errorf("Expected 2 components, got %d", len(ingress.Components)) + } + if !contains(ingress.Components, "nginx") || !contains(ingress.Components, "nginx/tls") { + t.Errorf("Expected both nginx and nginx/tls components, got %v", ingress.Components) + } + if len(ingress.DependsOn) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(ingress.DependsOn)) + } + if !contains(ingress.DependsOn, "pki") || !contains(ingress.DependsOn, "cert-manager") { + t.Errorf("Expected both pki and cert-manager dependencies, got %v", ingress.DependsOn) + } + + // Check monitoring component (should have no dependencies) + if len(monitoring.Components) != 1 { + t.Errorf("Expected 1 component, got %d", len(monitoring.Components)) + } + if !contains(monitoring.Components, "prometheus") { + t.Errorf("Expected prometheus component, got %v", monitoring.Components) + } + if len(monitoring.DependsOn) != 0 { + t.Errorf("Expected no dependencies for monitoring, got %v", monitoring.DependsOn) + } + }) + + t.Run("HandlesDependencyAwareInsertion", func(t *testing.T) { + // Given a base blueprint with ordered components + base := &Blueprint{ + TerraformComponents: []TerraformComponent{ + {Path: "backend", Source: "core"}, + {Path: "network", Source: "core"}, + }, + } + + // When adding a component that depends on existing component + overlay := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Path: "cluster", + Source: "core", + DependsOn: []string{"network"}, // Should be inserted after network + }, + }, + } + + base.StrategicMerge(overlay) + + // Then component should be inserted in correct order + if len(base.TerraformComponents) != 3 { + t.Errorf("Expected 3 components, got %d", len(base.TerraformComponents)) + } + + // Should be: backend, network, cluster (cluster after its dependency) + if base.TerraformComponents[2].Path != "cluster" { + t.Errorf("Expected cluster component at index 2, got '%s'", base.TerraformComponents[2].Path) + } + }) + + t.Run("HandlesNilOverlay", func(t *testing.T) { + // Given a base blueprint + base := &Blueprint{ + Metadata: Metadata{Name: "test"}, + } + + // When strategic merging with nil overlay + base.StrategicMerge(nil) + + // Then base should be unchanged + if base.Metadata.Name != "test" { + t.Errorf("Expected metadata name preserved") + } + }) + + t.Run("MergesMetadataAndRepository", func(t *testing.T) { + // Given a base blueprint + base := &Blueprint{ + Metadata: Metadata{ + Name: "base", + Description: "base description", + }, + Repository: Repository{ + Url: "base-url", + Ref: Reference{Branch: "main"}, + }, + } + + // And an overlay with updated metadata + overlay := &Blueprint{ + Metadata: Metadata{ + Name: "updated", + Description: "updated description", + }, + Repository: Repository{ + Url: "updated-url", + Ref: Reference{Tag: "v1.0.0"}, + }, + } + + // When strategic merging + base.StrategicMerge(overlay) + + // Then metadata should be updated + if base.Metadata.Name != "updated" { + t.Errorf("Expected name 'updated', got '%s'", base.Metadata.Name) + } + if base.Metadata.Description != "updated description" { + t.Errorf("Expected description 'updated description', got '%s'", base.Metadata.Description) + } + + // And repository should be updated + if base.Repository.Url != "updated-url" { + t.Errorf("Expected url 'updated-url', got '%s'", base.Repository.Url) + } + if base.Repository.Ref.Tag != "v1.0.0" { + t.Errorf("Expected tag 'v1.0.0', got '%s'", base.Repository.Ref.Tag) + } + }) + + t.Run("MergesSourcesUniquely", func(t *testing.T) { + // Given a base blueprint with sources + base := &Blueprint{ + Sources: []Source{ + {Name: "source1", Url: "url1"}, + }, + } + + // And an overlay with overlapping and new sources + overlay := &Blueprint{ + Sources: []Source{ + {Name: "source1", Url: "updated-url1"}, // Should update + {Name: "source2", Url: "url2"}, // Should add + }, + } + + // When strategic merging + base.StrategicMerge(overlay) + + // Then should have both sources with updated values + if len(base.Sources) != 2 { + t.Errorf("Expected 2 sources, got %d", len(base.Sources)) + } + + // Check that source1 was updated and source2 was added + sourceMap := make(map[string]string) + for _, source := range base.Sources { + sourceMap[source.Name] = source.Url + } + + if sourceMap["source1"] != "updated-url1" { + t.Errorf("Expected source1 url to be updated") + } + if sourceMap["source2"] != "url2" { + t.Errorf("Expected source2 to be added") + } + }) + + t.Run("EmptyOverlayDoesNothing", func(t *testing.T) { + // Given a base blueprint with content + base := &Blueprint{ + TerraformComponents: []TerraformComponent{ + {Path: "test", Source: "core"}, + }, + Kustomizations: []Kustomization{ + {Name: "test"}, + }, + } + + // When strategic merging with empty overlay + overlay := &Blueprint{} + base.StrategicMerge(overlay) + + // Then base should be unchanged + if len(base.TerraformComponents) != 1 { + t.Errorf("Expected terraform components unchanged") + } + if len(base.Kustomizations) != 1 { + t.Errorf("Expected kustomizations unchanged") + } + }) + + t.Run("KustomizationDependencyAwareInsertion", func(t *testing.T) { + // Given a base blueprint with ordered kustomizations + base := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "policy", Path: "policy"}, + {Name: "pki", Path: "pki"}, + }, + } + + // When adding a kustomization that depends on existing one + overlay := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "ingress", + Path: "ingress", + DependsOn: []string{"pki"}, // Should be inserted after pki + }, + }, + } + + base.StrategicMerge(overlay) + + // Then kustomization should be inserted in correct order + if len(base.Kustomizations) != 3 { + t.Errorf("Expected 3 kustomizations, got %d", len(base.Kustomizations)) + } + + // Should have ingress after pki (its dependency) + 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 { + t.Errorf("Expected pki kustomization to be present") + } + if ingressIndex == -1 { + t.Errorf("Expected ingress kustomization to be present") + } + if pkiIndex >= ingressIndex { + t.Errorf("Expected ingress (index %d) to come after pki (index %d)", ingressIndex, pkiIndex) + } + }) + + t.Run("KustomizationUpdatesFieldsSelectively", func(t *testing.T) { + // Given a base blueprint with a kustomization + base := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "test", + Path: "original-path", + Source: "original-source", + Destroy: ptrBool(false), + }, + }, + } + + // When merging with partial updates + overlay := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "test", // Same name - should merge + Path: "updated-path", + Source: "updated-source", + Destroy: ptrBool(true), + // Note: not setting Components or DependsOn - should preserve existing + }, + }, + } + + base.StrategicMerge(overlay) + + // Then should have updated fields + kustomization := base.Kustomizations[0] + if kustomization.Path != "updated-path" { + t.Errorf("Expected path to be updated to 'updated-path', got '%s'", kustomization.Path) + } + if kustomization.Source != "updated-source" { + t.Errorf("Expected source to be updated to 'updated-source', got '%s'", kustomization.Source) + } + if kustomization.Destroy == nil || *kustomization.Destroy != true { + t.Errorf("Expected destroy to be updated to true, got %v", kustomization.Destroy) + } + }) + + t.Run("KustomizationPreservesExistingComponents", func(t *testing.T) { + // Given a base blueprint with kustomization that has components + base := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "test", + Components: []string{"existing1", "existing2"}, + DependsOn: []string{"dep1"}, + }, + }, + } + + // When merging with additional components and dependencies + overlay := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "test", + Components: []string{"existing2", "new1"}, // existing2 is duplicate, new1 is new + DependsOn: []string{"dep1", "dep2"}, // dep1 is duplicate, dep2 is new + }, + }, + } + + base.StrategicMerge(overlay) + + // Then should have all unique components and dependencies + kustomization := base.Kustomizations[0] + if len(kustomization.Components) != 3 { + t.Errorf("Expected 3 unique components, got %d: %v", len(kustomization.Components), kustomization.Components) + } + + expectedComponents := []string{"existing1", "existing2", "new1"} + for _, expected := range expectedComponents { + if !contains(kustomization.Components, expected) { + t.Errorf("Expected component '%s' to be present, got %v", expected, kustomization.Components) + } + } + + if len(kustomization.DependsOn) != 2 { + t.Errorf("Expected 2 unique dependencies, got %d: %v", len(kustomization.DependsOn), kustomization.DependsOn) + } + + expectedDeps := []string{"dep1", "dep2"} + for _, expected := range expectedDeps { + if !contains(kustomization.DependsOn, expected) { + t.Errorf("Expected dependency '%s' to be present, got %v", expected, kustomization.DependsOn) + } + } + }) + + t.Run("KustomizationMultipleDependencyInsertion", func(t *testing.T) { + // Given a base blueprint with multiple kustomizations + base := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "base", Path: "base"}, + {Name: "pki", Path: "pki"}, + {Name: "storage", Path: "storage"}, + }, + } + + // When adding a kustomization that depends on multiple existing ones + overlay := &Blueprint{ + Kustomizations: []Kustomization{ + { + Name: "app", + Path: "app", + DependsOn: []string{"pki", "storage"}, // Depends on multiple + }, + }, + } + + base.StrategicMerge(overlay) + + // Then should be inserted after the latest dependency + if len(base.Kustomizations) != 4 { + t.Errorf("Expected 4 kustomizations, got %d", len(base.Kustomizations)) + } + + // App should come after its dependencies (pki and storage) + appIndex := -1 + for i, k := range base.Kustomizations { + if k.Name == "app" { + appIndex = i + break + } + } + if appIndex == -1 { + t.Errorf("Expected app kustomization to be present") + } + + // Find indices of dependencies + pkiIndex := -1 + storageIndex := -1 + for i, k := range base.Kustomizations { + if k.Name == "pki" { + pkiIndex = i + } + if k.Name == "storage" { + storageIndex = i + } + } + + // App should come after both dependencies + if appIndex <= pkiIndex || appIndex <= storageIndex { + t.Errorf("Expected app (index %d) to come after pki (index %d) and storage (index %d)", appIndex, pkiIndex, storageIndex) + } + }) + + t.Run("ComplexDependencyOrdering", func(t *testing.T) { + // Test the complex dependency scenario described by the user + // where pki-* components are separated by dns, but dns depends on both pki-base and ingress + + // Start with a base blueprint that has some kustomizations + base := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "policy-base", Path: "policy/base"}, + {Name: "policy-resources", Path: "policy/resources", DependsOn: []string{"policy-base"}}, + }, + } + + // Add kustomizations one by one to trigger strategic merge and sorting + overlay1 := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "pki-base", Path: "pki/base", DependsOn: []string{"policy-resources"}}, + }, + } + base.StrategicMerge(overlay1) + + overlay2 := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "pki-resources", Path: "pki/resources", DependsOn: []string{"pki-base"}}, + }, + } + base.StrategicMerge(overlay2) + + overlay3 := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "ingress", Path: "ingress", DependsOn: []string{"pki-resources"}}, + }, + } + base.StrategicMerge(overlay3) + + overlay4 := &Blueprint{ + Kustomizations: []Kustomization{ + {Name: "dns", Path: "dns", DependsOn: []string{"pki-base", "ingress"}}, + }, + } + base.StrategicMerge(overlay4) + + // Expected order: policy-base, policy-resources, pki-base, pki-resources, ingress, dns + expectedOrder := []string{"policy-base", "policy-resources", "pki-base", "pki-resources", "ingress", "dns"} + + if len(base.Kustomizations) != len(expectedOrder) { + t.Errorf("Expected %d kustomizations, got %d", len(expectedOrder), len(base.Kustomizations)) + } + + for i, expected := range expectedOrder { + if i >= len(base.Kustomizations) || base.Kustomizations[i].Name != expected { + actual := "none" + if i < len(base.Kustomizations) { + actual = base.Kustomizations[i].Name + } + t.Errorf("Expected '%s' at position %d, got '%s'", expected, i, actual) + } + } + + // Verify that dependencies are satisfied + nameToIndex := make(map[string]int) + for i, k := range base.Kustomizations { + nameToIndex[k.Name] = i + } + + for _, k := range base.Kustomizations { + for _, dep := range k.DependsOn { + if depIndex, exists := nameToIndex[dep]; exists { + if depIndex >= nameToIndex[k.Name] { + t.Errorf("Dependency violation: '%s' (index %d) depends on '%s' (index %d), but dependency should come first", + k.Name, nameToIndex[k.Name], dep, depIndex) + } + } + } + } + }) +} + +// Helper function to check if slice contains a value +func contains(slice []string, value string) bool { + for _, item := range slice { + if item == value { + return true + } + } + return false +}