From 09cf98905b8805024b537ff4c15949aa208e0f11 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 14 Aug 2025 10:17:46 -0400 Subject: [PATCH] refactor(blueprint): Template and apply kustomize values and patches in memory The templating architecture is shifting. Values and patches placed in `contexts//kustomize` are now treated as overrides. As such, `windsor init` will no longer output templated values to these folders, and instead, handle applying them in-memory. The implementation then applies user overrides on top of the base in-memory values before applying to the cluster. --- pkg/blueprint/blueprint_handler.go | 288 +++-- pkg/blueprint/blueprint_handler_test.go | 872 ++++++++++++- pkg/blueprint/mock_blueprint_handler.go | 10 +- pkg/blueprint/mock_blueprint_handler_test.go | 91 ++ pkg/generators/kustomize_generator.go | 215 +--- pkg/generators/kustomize_generator_test.go | 1160 ++++++------------ pkg/pipelines/pipeline.go | 7 +- 7 files changed, 1550 insertions(+), 1093 deletions(-) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 4eb2e8740..4a2dd14dd 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -46,6 +46,7 @@ type BlueprintHandler interface { LoadData(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error Write(overwrite ...bool) error Install() error + SetRenderedKustomizeData(data map[string]any) GetMetadata() blueprintv1alpha1.Metadata GetSources() []blueprintv1alpha1.Source GetRepository() blueprintv1alpha1.Repository @@ -69,14 +70,16 @@ type BaseBlueprintHandler struct { blueprint blueprintv1alpha1.Blueprint projectRoot string shims *Shims + kustomizeData map[string]any } // NewBlueprintHandler creates a new instance of BaseBlueprintHandler. // It initializes the handler with the provided dependency injector. func NewBlueprintHandler(injector di.Injector) *BaseBlueprintHandler { return &BaseBlueprintHandler{ - injector: injector, - shims: NewShims(), + injector: injector, + shims: NewShims(), + kustomizeData: make(map[string]any), } } @@ -419,6 +422,12 @@ func (b *BaseBlueprintHandler) GetKustomizations() []blueprintv1alpha1.Kustomiza return kustomizations } +// SetRenderedKustomizeData stores rendered kustomize data for use during install. +// This includes values and patches from template processing that should be composed with user-defined files. +func (b *BaseBlueprintHandler) SetRenderedKustomizeData(data map[string]any) { + b.kustomizeData = data +} + // GetDefaultTemplateData generates default template data based on the provider configuration. // It uses the embedded default template to create a map of template files that can be // used by the init pipeline for generating context-specific configurations. @@ -1075,44 +1084,7 @@ func (b *BaseBlueprintHandler) toFluxKustomization(k blueprintv1alpha1.Kustomiza var patchContent string if p.Path != "" { - configRoot, err := b.configHandler.GetConfigRoot() - if err != nil { - continue - } - patchFilePath := filepath.Join(configRoot, "kustomize", p.Path) - data, err := b.shims.ReadFile(patchFilePath) - if err != nil { - continue - } - patchContent = string(data) - - decoder := yaml.NewDecoder(strings.NewReader(patchContent)) - for { - var patchData map[string]any - if err := decoder.Decode(&patchData); err != nil { - if err == io.EOF { - break - } - continue - } - - if kind, ok := patchData["kind"].(string); ok { - if metadata, ok := patchData["metadata"].(map[string]any); ok { - if name, ok := metadata["name"].(string); ok { - patchNamespace := namespace - if ns, ok := metadata["namespace"].(string); ok { - patchNamespace = ns - } - target = &kustomize.Selector{ - Kind: kind, - Name: name, - Namespace: patchNamespace, - } - break - } - } - } - } + patchContent, target = b.resolvePatchFromPath(p.Path, namespace) } if p.Patch != "" { @@ -1143,25 +1115,15 @@ func (b *BaseBlueprintHandler) toFluxKustomization(k blueprintv1alpha1.Kustomiza Optional: false, }) - configRoot, err := b.configHandler.GetConfigRoot() - if err == nil { - valuesPath := filepath.Join(configRoot, "kustomize", "values.yaml") - if _, err := b.shims.Stat(valuesPath); err == nil { - data, err := b.shims.ReadFile(valuesPath) - if err == nil { - var values map[string]any - if err := b.shims.YamlUnmarshal(data, &values); err == nil { - configMapName := fmt.Sprintf("values-%s", k.Name) - if _, hasComponent := values[k.Name]; hasComponent { - substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ - Kind: "ConfigMap", - Name: configMapName, - Optional: false, - }) - } - } - } - } + hasComponentValues := b.hasComponentValues(k.Name) + + if hasComponentValues { + configMapName := fmt.Sprintf("values-%s", k.Name) + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: configMapName, + Optional: false, + }) } if k.PostBuild != nil { @@ -1240,6 +1202,143 @@ func (b *BaseBlueprintHandler) toFluxKustomization(k blueprintv1alpha1.Kustomiza } } +// resolvePatchFromPath yields patch content as YAML string and the target selector for a given patch path. +// Combines template data with user-defined files; user files take precedence. If a user file exists and cannot be merged as YAML, it overrides template data entirely. +// patchPath: relative path to the patch file within the kustomize directory +// defaultNamespace: namespace to use if not specified in patch metadata +// Output: patch content (YAML), extracted target selector or nil if not found +func (b *BaseBlueprintHandler) resolvePatchFromPath(patchPath, defaultNamespace string) (string, *kustomize.Selector) { + patchKey := "kustomize/patches/" + strings.TrimPrefix(patchPath, "kustomize/patches/") + if strings.HasSuffix(patchKey, ".yaml") || strings.HasSuffix(patchKey, ".yml") { + patchKey = strings.TrimSuffix(patchKey, filepath.Ext(patchKey)) + } + + var basePatchData map[string]any + var target *kustomize.Selector + + if renderedPatch, exists := b.kustomizeData[patchKey]; exists { + if patchMap, ok := renderedPatch.(map[string]any); ok { + basePatchData = make(map[string]any) + for k, v := range patchMap { + basePatchData[k] = v + } + } + } + + configRoot, err := b.configHandler.GetConfigRoot() + if err == nil { + patchFilePath := filepath.Join(configRoot, "kustomize", patchPath) + if data, err := b.shims.ReadFile(patchFilePath); err == nil { + if basePatchData == nil { + target = b.extractTargetFromPatchContent(string(data), defaultNamespace) + return string(data), target + } + + var userPatchData map[string]any + if err := b.shims.YamlUnmarshal(data, &userPatchData); err == nil { + maps.Copy(basePatchData, userPatchData) + } else { + target = b.extractTargetFromPatchContent(string(data), defaultNamespace) + return string(data), target + } + } + } + + if basePatchData == nil { + return "", nil + } + + patchYAML, err := b.shims.YamlMarshal(basePatchData) + if err != nil { + return "", nil + } + + target = b.extractTargetFromPatchData(basePatchData, defaultNamespace) + return string(patchYAML), target +} + +// extractTargetFromPatchData extracts target selector information from patch data map. +// Returns nil if the required metadata fields are not found or invalid. +func (b *BaseBlueprintHandler) extractTargetFromPatchData(patchData map[string]any, defaultNamespace string) *kustomize.Selector { + kind, ok := patchData["kind"].(string) + if !ok { + return nil + } + + metadata, ok := patchData["metadata"].(map[string]any) + if !ok { + return nil + } + + name, ok := metadata["name"].(string) + if !ok { + return nil + } + + namespace := defaultNamespace + if ns, ok := metadata["namespace"].(string); ok { + namespace = ns + } + + return &kustomize.Selector{ + Kind: kind, + Name: name, + Namespace: namespace, + } +} + +// extractTargetFromPatchContent extracts target selector information from patch YAML content. +// Parses the YAML and returns the first valid target found, or nil if none found. +func (b *BaseBlueprintHandler) extractTargetFromPatchContent(patchContent, defaultNamespace string) *kustomize.Selector { + decoder := yaml.NewDecoder(strings.NewReader(patchContent)) + for { + var patchData map[string]any + if err := decoder.Decode(&patchData); err != nil { + if err == io.EOF { + break + } + continue + } + + if target := b.extractTargetFromPatchData(patchData, defaultNamespace); target != nil { + return target + } + } + return nil +} + +// hasComponentValues determines if component-specific values exist for the specified component name. +// It inspects both in-memory rendered template data and user-defined disk files, returning true if either contains the component section. +// User-defined values take precedence in the presence of both sources. +func (b *BaseBlueprintHandler) hasComponentValues(componentName string) bool { + hasTemplateComponent := false + if kustomizeValues, exists := b.kustomizeData["kustomize/values"]; exists { + if valuesMap, ok := kustomizeValues.(map[string]any); ok { + if _, hasComponent := valuesMap[componentName]; hasComponent { + hasTemplateComponent = true + } + } + } + + hasUserComponent := false + configRoot, err := b.configHandler.GetConfigRoot() + if err == nil { + valuesPath := filepath.Join(configRoot, "kustomize", "values.yaml") + if _, err := b.shims.Stat(valuesPath); err == nil { + if data, err := b.shims.ReadFile(valuesPath); err == nil { + var values map[string]any + if err := b.shims.YamlUnmarshal(data, &values); err == nil { + if _, hasComponent := values[componentName]; hasComponent { + hasUserComponent = true + } + } + } + } + } + + return hasTemplateComponent || hasUserComponent +} + // isOCISource returns true if the provided sourceNameOrURL is an OCI repository reference. // It checks if the input is a resolved OCI URL, matches the blueprint's main repository with an OCI URL, // or matches any additional source with an OCI URL. @@ -1258,10 +1357,10 @@ func (b *BaseBlueprintHandler) isOCISource(sourceNameOrURL string) bool { return false } -// applyValuesConfigMaps creates ConfigMaps for post-build variable substitution using the centralized values.yaml in the kustomize directory. -// It generates a ConfigMap for the "common" section and for each component section in values.yaml. +// applyValuesConfigMaps creates ConfigMaps for post-build variable substitution using rendered values data and any existing values.yaml files. +// It generates a ConfigMap for the "common" section and for each component section, merging rendered template values with user-defined values. +// User-defined values take precedence over template values in case of conflicts. // The resulting ConfigMaps are referenced in PostBuild.SubstituteFrom for variable substitution. -// Only function header documentation is permitted; no comments are present inside the function body. func (b *BaseBlueprintHandler) applyValuesConfigMaps() error { configRoot, err := b.configHandler.GetConfigRoot() if err != nil { @@ -1303,37 +1402,39 @@ func (b *BaseBlueprintHandler) applyValuesConfigMaps() error { mergedCommonValues["BUILD_ID"] = buildID } - kustomizeDir := filepath.Join(configRoot, "kustomize") - if _, err := b.shims.Stat(kustomizeDir); os.IsNotExist(err) { - if len(mergedCommonValues) > 0 { - if err := b.createConfigMap(mergedCommonValues, "values-common"); err != nil { - return fmt.Errorf("failed to create merged common values ConfigMap: %w", err) + var userValues map[string]any + valuesPath := filepath.Join(configRoot, "kustomize", "values.yaml") + if _, err := b.shims.Stat(valuesPath); err == nil { + data, err := b.shims.ReadFile(valuesPath) + if err == nil { + if err := b.shims.YamlUnmarshal(data, &userValues); err != nil { + return fmt.Errorf("failed to unmarshal values file %s: %w", valuesPath, err) } } - return nil } - valuesPath := filepath.Join(kustomizeDir, "values.yaml") - if _, err := b.shims.Stat(valuesPath); os.IsNotExist(err) { - if len(mergedCommonValues) > 0 { - if err := b.createConfigMap(mergedCommonValues, "values-common"); err != nil { - return fmt.Errorf("failed to create merged common values ConfigMap: %w", err) - } + if userValues == nil { + userValues = make(map[string]any) + } + + var renderedValues map[string]any + if kustomizeValues, exists := b.kustomizeData["kustomize/values"]; exists { + if valuesMap, ok := kustomizeValues.(map[string]any); ok { + renderedValues = valuesMap } - return nil } - data, err := b.shims.ReadFile(valuesPath) - if err != nil { - return fmt.Errorf("failed to read values file %s: %w", valuesPath, err) + if renderedValues == nil { + renderedValues = make(map[string]any) } - var values map[string]any - if err := b.shims.YamlUnmarshal(data, &values); err != nil { - return fmt.Errorf("failed to unmarshal values file %s: %w", valuesPath, err) + allValues := make(map[string]any) + for k, v := range renderedValues { // Template values are base + allValues[k] = v } + allValues = b.deepMergeMaps(allValues, userValues) // Deep merge user values over template values - if commonValues, exists := values["common"]; exists { + if commonValues, exists := allValues["common"]; exists { if commonMap, ok := commonValues.(map[string]any); ok { maps.Copy(mergedCommonValues, commonMap) } @@ -1345,7 +1446,7 @@ func (b *BaseBlueprintHandler) applyValuesConfigMaps() error { } } - for componentName, componentValues := range values { + for componentName, componentValues := range allValues { if componentName == "common" { continue } @@ -1429,3 +1530,24 @@ func (b *BaseBlueprintHandler) getBuildIDFromFile() (string, error) { return strings.TrimSpace(string(data)), nil } + +// deepMergeMaps returns a new map from a deep merge of base and overlay maps. +// Overlay values take precedence; nested maps merge recursively. Non-map overlay values replace base values. +func (b *BaseBlueprintHandler) deepMergeMaps(base, overlay map[string]any) map[string]any { + result := make(map[string]any) + for k, v := range base { + result[k] = v + } + for k, overlayValue := range overlay { + if baseValue, exists := result[k]; exists { + if baseMap, baseIsMap := baseValue.(map[string]any); baseIsMap { + if overlayMap, overlayIsMap := overlayValue.(map[string]any); overlayIsMap { + result[k] = b.deepMergeMaps(baseMap, overlayMap) + continue + } + } + } + result[k] = overlayValue + } + return result +} diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 1de8b0305..77848d05b 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -3850,15 +3850,17 @@ ingress: return nil, os.ErrNotExist } + // Mock YAML marshal + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test"), nil + } + // When applying values ConfigMaps err := handler.applyValuesConfigMaps() - // Then it should fail - if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with ReadFile error") - } - if !strings.Contains(err.Error(), "failed to read values file") { - t.Errorf("expected error about reading values file, got: %v", err) + // Then it should still succeed since ReadFile errors are now ignored and rendered values take precedence + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed despite ReadFile error, got: %v", err) } }) @@ -5056,3 +5058,861 @@ contexts: } } } + +// ============================================================================= +// New Functionality Tests +// ============================================================================= + +func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + handler.shims = NewShims() + handler.configHandler = config.NewMockConfigHandler() + return handler + } + + t.Run("WithRenderedDataOnly", func(t *testing.T) { + // Given a handler with rendered patch data only + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "test-namespace", + }, + "data": map[string]any{ + "key": "value", + }, + }, + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Kind != "ConfigMap" { + t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) + } + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + if target.Namespace != "test-namespace" { + t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) + } + }) + + t.Run("WithNoData", func(t *testing.T) { + // Given a handler with no data + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then empty content and nil target should be returned + if content != "" { + t.Errorf("Expected empty content, got = '%s'", content) + } + if target != nil { + t.Error("Expected target to be nil") + } + }) + + t.Run("WithYamlExtension", func(t *testing.T) { + // Given a handler with patch path containing .yaml extension + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + }, + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + // When resolving patch from path with .yaml extension + content, target := handler.resolvePatchFromPath("test.yaml", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + }) + + t.Run("WithYmlExtension", func(t *testing.T) { + // Given a handler with patch path containing .yml extension + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + }, + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + // When resolving patch from path with .yml extension + content, target := handler.resolvePatchFromPath("test.yml", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + }) + + t.Run("WithBothRenderedAndUserDataMerge", func(t *testing.T) { + // Given a handler with both rendered and user data that can be merged + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "rendered-config", + "namespace": "rendered-namespace", + }, + "data": map[string]any{ + "rendered-key": "rendered-value", + }, + }, + } + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: user-config + namespace: user-namespace +data: + user-key: user-value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "user-config", + "namespace": "user-namespace", + }, + "data": map[string]any{ + "user-key": "user-value", + }, + } + return nil + } + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("merged yaml"), nil + } + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then merged content should be returned and target should be extracted from merged data + if content != "merged yaml" { + t.Errorf("Expected content = 'merged yaml', got = '%s'", content) + } + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "user-config" { + t.Errorf("Expected target name = 'user-config', got = '%s'", target.Name) + } + if target.Namespace != "user-namespace" { + t.Errorf("Expected target namespace = 'user-namespace', got = '%s'", target.Namespace) + } + }) +} + +func TestBaseBlueprintHandler_extractTargetFromPatchData(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } + + t.Run("ValidPatchData", func(t *testing.T) { + // Given valid patch data with all required fields + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "test-namespace", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be extracted correctly + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Kind != "ConfigMap" { + t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) + } + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + if target.Namespace != "test-namespace" { + t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) + } + }) + + t.Run("WithCustomNamespace", func(t *testing.T) { + // Given patch data with custom namespace + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "custom-namespace", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then custom namespace should be used + if target.Namespace != "custom-namespace" { + t.Errorf("Expected target namespace = 'custom-namespace', got = '%s'", target.Namespace) + } + }) + + t.Run("MissingKind", func(t *testing.T) { + // Given patch data missing kind field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "metadata": map[string]any{ + "name": "test-config", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when kind is missing") + } + }) + + t.Run("MissingMetadata", func(t *testing.T) { + // Given patch data missing metadata field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when metadata is missing") + } + }) + + t.Run("MissingName", func(t *testing.T) { + // Given patch data missing name field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{}, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when name is missing") + } + }) + + t.Run("InvalidKindType", func(t *testing.T) { + // Given patch data with invalid kind type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": 42, + "metadata": map[string]any{ + "name": "test-config", + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when kind type is invalid") + } + }) + + t.Run("InvalidMetadataType", func(t *testing.T) { + // Given patch data with invalid metadata type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": "not a map", + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when metadata type is invalid") + } + }) + + t.Run("InvalidNameType", func(t *testing.T) { + // Given patch data with invalid name type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": 42, + }, + } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when name type is invalid") + } + }) +} + +func TestBaseBlueprintHandler_extractTargetFromPatchContent(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } + + t.Run("ValidYamlContent", func(t *testing.T) { + // Given valid YAML content + handler := setup(t) + content := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: test-namespace` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be extracted correctly + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + }) + + t.Run("MultipleDocuments", func(t *testing.T) { + // Given YAML with multiple documents + handler := setup(t) + content := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: first-config +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: second-config` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then first valid target should be extracted + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "first-config" { + t.Errorf("Expected target name = 'first-config', got = '%s'", target.Name) + } + }) + + t.Run("InvalidYamlContent", func(t *testing.T) { + // Given invalid YAML content + handler := setup(t) + content := `invalid: yaml: content: with: colons: everywhere` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil for invalid YAML") + } + }) + + t.Run("EmptyContent", func(t *testing.T) { + // Given empty content + handler := setup(t) + content := "" + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil for empty content") + } + }) + + t.Run("NoValidTargets", func(t *testing.T) { + // Given YAML with no valid targets + handler := setup(t) + content := `apiVersion: v1 +kind: ConfigMap +# Missing metadata.name` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when no valid targets") + } + }) +} + +func TestBaseBlueprintHandler_hasComponentValues(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + handler.shims = NewShims() + handler.configHandler = config.NewMockConfigHandler() + return handler + } + + t.Run("TemplateComponentExists", func(t *testing.T) { + // Given handler with component in template data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/values": map[string]any{ + "test-component": map[string]any{ + "key": "value", + }, + }, + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in template data") + } + }) + + t.Run("UserComponentExists", func(t *testing.T) { + // Given handler with component in user file + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "values.yaml", isDir: false}, nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`test-component: + key: value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "test-component": map[string]any{ + "key": "value", + }, + } + return nil + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in user file") + } + }) + + t.Run("BothTemplateAndUserExist", func(t *testing.T) { + // Given handler with component in both template and user data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/values": map[string]any{ + "test-component": map[string]any{ + "template-key": "template-value", + }, + }, + } + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "values.yaml", isDir: false}, nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`test-component: + user-key: user-value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "test-component": map[string]any{ + "user-key": "user-value", + }, + } + return nil + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in both sources") + } + }) + + t.Run("NoComponentExists", func(t *testing.T) { + // Given handler with no component data + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist") + } + }) + + t.Run("ConfigRootError", func(t *testing.T) { + // Given handler with config root error + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when config root fails") + } + }) + + t.Run("FileNotExists", func(t *testing.T) { + // Given handler with file not existing + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when file doesn't exist") + } + }) + + t.Run("InvalidValuesFile", func(t *testing.T) { + // Given handler with invalid values file + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "values.yaml", isDir: false}, nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte("invalid yaml"), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("invalid yaml") + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when values file is invalid") + } + }) +} + +func TestBaseBlueprintHandler_deepMergeMaps(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } + + t.Run("SimpleMerge", func(t *testing.T) { + // Given base and overlay maps with simple values + handler := setup(t) + base := map[string]any{ + "key1": "base-value1", + "key2": "base-value2", + } + overlay := map[string]any{ + "key2": "overlay-value2", + "key3": "overlay-value3", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then result should contain merged values + if result["key1"] != "base-value1" { + t.Errorf("Expected key1 = 'base-value1', got = '%v'", result["key1"]) + } + if result["key2"] != "overlay-value2" { + t.Errorf("Expected key2 = 'overlay-value2', got = '%v'", result["key2"]) + } + if result["key3"] != "overlay-value3" { + t.Errorf("Expected key3 = 'overlay-value3', got = '%v'", result["key3"]) + } + }) + + t.Run("NestedMapMerge", func(t *testing.T) { + // Given base and overlay maps with nested maps + handler := setup(t) + base := map[string]any{ + "nested": map[string]any{ + "base-key": "base-value", + }, + } + overlay := map[string]any{ + "nested": map[string]any{ + "overlay-key": "overlay-value", + }, + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then nested maps should be merged + nested := result["nested"].(map[string]any) + if nested["base-key"] != "base-value" { + t.Errorf("Expected nested.base-key = 'base-value', got = '%v'", nested["base-key"]) + } + if nested["overlay-key"] != "overlay-value" { + t.Errorf("Expected nested.overlay-key = 'overlay-value', got = '%v'", nested["overlay-key"]) + } + }) + + t.Run("OverlayPrecedence", func(t *testing.T) { + // Given base and overlay maps with conflicting keys + handler := setup(t) + base := map[string]any{ + "key": "base-value", + } + overlay := map[string]any{ + "key": "overlay-value", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then overlay value should take precedence + if result["key"] != "overlay-value" { + t.Errorf("Expected key = 'overlay-value', got = '%v'", result["key"]) + } + }) + + t.Run("DeepNestedMerge", func(t *testing.T) { + // Given base and overlay maps with deeply nested maps + handler := setup(t) + base := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "base-key": "base-value", + }, + }, + } + overlay := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "overlay-key": "overlay-value", + }, + }, + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then deeply nested maps should be merged + level1 := result["level1"].(map[string]any) + level2 := level1["level2"].(map[string]any) + if level2["base-key"] != "base-value" { + t.Errorf("Expected level2.base-key = 'base-value', got = '%v'", level2["base-key"]) + } + if level2["overlay-key"] != "overlay-value" { + t.Errorf("Expected level2.overlay-key = 'overlay-value', got = '%v'", level2["overlay-key"]) + } + }) + + t.Run("EmptyMaps", func(t *testing.T) { + // Given empty base and overlay maps + handler := setup(t) + base := map[string]any{} + overlay := map[string]any{} + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then result should be empty + if len(result) != 0 { + t.Errorf("Expected empty result, got %d items", len(result)) + } + }) + + t.Run("NonMapOverlay", func(t *testing.T) { + // Given base map and non-map overlay value + handler := setup(t) + base := map[string]any{ + "key": map[string]any{ + "nested": "value", + }, + } + overlay := map[string]any{ + "key": "string-value", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then overlay value should replace base value + if result["key"] != "string-value" { + t.Errorf("Expected key = 'string-value', got = '%v'", result["key"]) + } + }) + + t.Run("MixedTypes", func(t *testing.T) { + // Given base and overlay maps with mixed types + handler := setup(t) + base := map[string]any{ + "string": "base-string", + "number": 42, + "nested": map[string]any{ + "key": "base-nested", + }, + } + overlay := map[string]any{ + "string": "overlay-string", + "bool": true, + "nested": map[string]any{ + "overlay-key": "overlay-nested", + }, + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then all values should be merged correctly + if result["string"] != "overlay-string" { + t.Errorf("Expected string = 'overlay-string', got = '%v'", result["string"]) + } + if result["number"] != 42 { + t.Errorf("Expected number = 42, got = '%v'", result["number"]) + } + if result["bool"] != true { + t.Errorf("Expected bool = true, got = '%v'", result["bool"]) + } + nested := result["nested"].(map[string]any) + if nested["key"] != "base-nested" { + t.Errorf("Expected nested.key = 'base-nested', got = '%v'", nested["key"]) + } + if nested["overlay-key"] != "overlay-nested" { + t.Errorf("Expected nested.overlay-key = 'overlay-nested', got = '%v'", nested["overlay-key"]) + } + }) +} + +func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } + + t.Run("SetData", func(t *testing.T) { + // Given a handler with no existing data + handler := setup(t) + data := map[string]any{ + "key1": "value1", + "key2": 42, + } + // When setting rendered kustomize data + handler.SetRenderedKustomizeData(data) + // Then data should be stored + if !reflect.DeepEqual(handler.kustomizeData, data) { + t.Errorf("Expected kustomizeData = %v, got = %v", data, handler.kustomizeData) + } + }) + + t.Run("OverwriteData", func(t *testing.T) { + // Given a handler with existing data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "existing": "data", + } + newData := map[string]any{ + "new": "data", + } + // When setting new rendered kustomize data + handler.SetRenderedKustomizeData(newData) + // Then new data should overwrite existing data + if !reflect.DeepEqual(handler.kustomizeData, newData) { + t.Errorf("Expected kustomizeData = %v, got = %v", newData, handler.kustomizeData) + } + }) + + t.Run("EmptyData", func(t *testing.T) { + // Given a handler with existing data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "existing": "data", + } + emptyData := map[string]any{} + // When setting empty rendered kustomize data + handler.SetRenderedKustomizeData(emptyData) + // Then empty data should be stored + if !reflect.DeepEqual(handler.kustomizeData, emptyData) { + t.Errorf("Expected kustomizeData = %v, got = %v", emptyData, handler.kustomizeData) + } + }) + + t.Run("ComplexData", func(t *testing.T) { + // Given a handler with no existing data + handler := setup(t) + complexData := map[string]any{ + "nested": map[string]any{ + "level1": map[string]any{ + "level2": []any{ + "string1", + 123, + map[string]any{"key": "value"}, + }, + }, + }, + "array": []any{ + "item1", + 456, + map[string]any{"nested": "data"}, + }, + } + // When setting complex rendered kustomize data + handler.SetRenderedKustomizeData(complexData) + // Then complex data should be stored + if !reflect.DeepEqual(handler.kustomizeData, complexData) { + t.Errorf("Expected kustomizeData = %v, got = %v", complexData, handler.kustomizeData) + } + }) +} diff --git a/pkg/blueprint/mock_blueprint_handler.go b/pkg/blueprint/mock_blueprint_handler.go index 7fa63e3f9..3ec7d0e5e 100644 --- a/pkg/blueprint/mock_blueprint_handler.go +++ b/pkg/blueprint/mock_blueprint_handler.go @@ -23,7 +23,8 @@ type MockBlueprintHandler struct { InstallFunc func() error GetRepositoryFunc func() blueprintv1alpha1.Repository - DownFunc func() error + DownFunc func() error + SetRenderedKustomizeDataFunc func(data map[string]any) } // ============================================================================= @@ -131,6 +132,13 @@ func (m *MockBlueprintHandler) Down() error { return nil } +// SetRenderedKustomizeData implements BlueprintHandler interface +func (m *MockBlueprintHandler) SetRenderedKustomizeData(data map[string]any) { + if m.SetRenderedKustomizeDataFunc != nil { + m.SetRenderedKustomizeDataFunc(data) + } +} + // WaitForKustomizations calls the mock WaitForKustomizationsFunc if set, otherwise returns nil func (m *MockBlueprintHandler) WaitForKustomizations(message string, names ...string) error { if m.WaitForKustomizationsFunc != nil { diff --git a/pkg/blueprint/mock_blueprint_handler_test.go b/pkg/blueprint/mock_blueprint_handler_test.go index 8bf1daf51..181a7c7fd 100644 --- a/pkg/blueprint/mock_blueprint_handler_test.go +++ b/pkg/blueprint/mock_blueprint_handler_test.go @@ -582,3 +582,94 @@ func TestMockBlueprintHandler_Write(t *testing.T) { } }) } + +func TestMockBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { + setup := func(t *testing.T) *MockBlueprintHandler { + t.Helper() + return &MockBlueprintHandler{} + } + + t.Run("WithFuncSet", func(t *testing.T) { + // Given a mock handler with SetRenderedKustomizeData function + handler := setup(t) + expectedData := map[string]any{ + "key1": "value1", + "key2": 42, + "key3": []string{"item1", "item2"}, + } + var receivedData map[string]any + handler.SetRenderedKustomizeDataFunc = func(data map[string]any) { + receivedData = data + } + // When setting rendered kustomize data + handler.SetRenderedKustomizeData(expectedData) + // Then the function should be called with correct data + if !reflect.DeepEqual(receivedData, expectedData) { + t.Errorf("Expected data = %v, got = %v", expectedData, receivedData) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a mock handler without SetRenderedKustomizeData function + handler := setup(t) + testData := map[string]any{ + "key1": "value1", + "key2": 42, + } + // When setting rendered kustomize data + // Then no panic should occur + handler.SetRenderedKustomizeData(testData) + // Test passes if no panic occurs + }) + + t.Run("WithEmptyData", func(t *testing.T) { + // Given a mock handler with SetRenderedKustomizeData function + handler := setup(t) + expectedData := map[string]any{} + var receivedData map[string]any + handler.SetRenderedKustomizeDataFunc = func(data map[string]any) { + receivedData = data + } + // When setting empty rendered kustomize data + handler.SetRenderedKustomizeData(expectedData) + // Then the function should be called with empty data + if !reflect.DeepEqual(receivedData, expectedData) { + t.Errorf("Expected data = %v, got = %v", expectedData, receivedData) + } + }) + + t.Run("WithComplexData", func(t *testing.T) { + // Given a mock handler with SetRenderedKustomizeData function + handler := setup(t) + expectedData := map[string]any{ + "nested": map[string]any{ + "level1": map[string]any{ + "level2": []any{ + "string1", + 123, + map[string]any{"key": "value"}, + }, + }, + }, + "array": []any{ + "item1", + 456, + map[string]any{"nested": "data"}, + }, + "mixed": []map[string]any{ + {"key1": "value1"}, + {"key2": 789}, + }, + } + var receivedData map[string]any + handler.SetRenderedKustomizeDataFunc = func(data map[string]any) { + receivedData = data + } + // When setting complex rendered kustomize data + handler.SetRenderedKustomizeData(expectedData) + // Then the function should be called with correct complex data + if !reflect.DeepEqual(receivedData, expectedData) { + t.Errorf("Expected data = %v, got = %v", expectedData, receivedData) + } + }) +} diff --git a/pkg/generators/kustomize_generator.go b/pkg/generators/kustomize_generator.go index c137a0e00..b617259e9 100644 --- a/pkg/generators/kustomize_generator.go +++ b/pkg/generators/kustomize_generator.go @@ -2,31 +2,20 @@ package generators import ( "fmt" - "path/filepath" "strings" - blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/di" ) -// The KustomizeGenerator is a specialized component that manages Kustomize files. -// It provides functionality to process patch templates and values templates, generating -// patch files and values.yaml files for kustomizations defined in the blueprint. -// The KustomizeGenerator ensures proper file generation with templating support. - // ============================================================================= // Types // ============================================================================= -// KustomizeBlueprintHandler defines the interface for accessing blueprint data -type KustomizeBlueprintHandler interface { - GetKustomizations() []blueprintv1alpha1.Kustomization -} - // KustomizeGenerator is a generator that processes and generates kustomize files type KustomizeGenerator struct { BaseGenerator - blueprintHandler KustomizeBlueprintHandler + blueprintHandler blueprint.BlueprintHandler } // ============================================================================= @@ -58,7 +47,7 @@ func (g *KustomizeGenerator) Initialize() error { return fmt.Errorf("blueprint handler not found in dependency injector") } - handler, ok := blueprintHandler.(KustomizeBlueprintHandler) + handler, ok := blueprintHandler.(blueprint.BlueprintHandler) if !ok { return fmt.Errorf("resolved blueprint handler is not of expected type") } @@ -67,39 +56,30 @@ func (g *KustomizeGenerator) Initialize() error { return nil } -// Generate creates kustomize files using the provided template data. -// Processes data keyed by "kustomize/" for patches and -// "values/" for values.yaml files. -// The template engine has already filtered to only include referenced files, so this -// processes all provided data without additional filtering. -// Returns an error if data is nil, if generation fails, or if validation fails. +// Generate processes kustomize template data and stores it in-memory for use during install. +// Filters data for kustomize-related keys and stores them in the blueprint handler +// instead of writing files to disk. This allows values and patches to be composed +// with user-defined files at install time. +// Returns an error if data is nil or if storing the data fails. func (g *KustomizeGenerator) Generate(data map[string]any, overwrite ...bool) error { if data == nil { return fmt.Errorf("data cannot be nil") } - shouldOverwrite := false - if len(overwrite) > 0 { - shouldOverwrite = overwrite[0] - } - - configRoot, err := g.configHandler.GetConfigRoot() - if err != nil { - return fmt.Errorf("failed to get config root: %w", err) - } - + kustomizeData := make(map[string]any) for key, values := range data { - if strings.HasPrefix(key, "kustomize/patches/") { - if err := g.generatePatchFile(key, values, configRoot, shouldOverwrite); err != nil { - return err - } - } else if key == "kustomize/values" { - if err := g.generateValuesFile(key, values, configRoot, shouldOverwrite); err != nil { - return err + if strings.HasPrefix(key, "kustomize/") { + if err := g.validateKustomizeData(key, values); err != nil { + return fmt.Errorf("invalid kustomize data for key %s: %w", key, err) } + kustomizeData[key] = values } } + if len(kustomizeData) > 0 { + g.blueprintHandler.SetRenderedKustomizeData(kustomizeData) + } + return nil } @@ -107,106 +87,23 @@ func (g *KustomizeGenerator) Generate(data map[string]any, overwrite ...bool) er // Private Methods // ============================================================================= -// generatePatchFile generates patch files for kustomizations based on the provided key and values. -// It validates the patch path, ensures values are a map, constructs the full patch file path, -// validates the path, validates the Kubernetes manifest, and writes the patch file. Returns an error if any step fails. -func (g *KustomizeGenerator) generatePatchFile(key string, values any, configRoot string, overwrite bool) error { - patchPath := strings.TrimPrefix(key, "kustomize/patches/") - - if err := g.validateKustomizationName(patchPath); err != nil { - return fmt.Errorf("invalid patch path %s: %w", patchPath, err) - } - - patchesDir := filepath.Join(configRoot, "kustomize", "patches") - if err := g.validatePath(patchesDir, configRoot); err != nil { - return fmt.Errorf("invalid patches directory path %s: %w", patchesDir, err) - } - - valuesMap, ok := values.(map[string]any) - if !ok { - return fmt.Errorf("values for patch %s must be a map, got %T", patchPath, values) - } - - fullPatchPath := filepath.Join(patchesDir, patchPath) - if !strings.HasSuffix(fullPatchPath, ".yaml") && !strings.HasSuffix(fullPatchPath, ".yml") { - fullPatchPath = fullPatchPath + ".yaml" - } - if err := g.validatePath(fullPatchPath, configRoot); err != nil { - return fmt.Errorf("invalid patch file path %s: %w", fullPatchPath, err) - } - - if err := g.validateKubernetesManifest(valuesMap); err != nil { - return fmt.Errorf("invalid Kubernetes manifest for %s: %w", patchPath, err) - } - - return g.writeYamlFile(fullPatchPath, valuesMap, overwrite) -} - -// generateValuesFile writes a centralized values.yaml for kustomize post-build substitution. -// Accepts only "kustomize/values" as key. Validates that values is a map with only scalar types or one-level nested maps. -// Merges with any existing values.yaml, overwriting keys with new values. Writes the result to values.yaml in the kustomize directory. -// Returns error on invalid key, type, path, or file operation. -func (g *KustomizeGenerator) generateValuesFile(key string, values any, configRoot string, overwrite bool) error { - if key != "kustomize/values" { - return fmt.Errorf("invalid values key %s, expected 'kustomize/values'", key) - } - - valuesDir := filepath.Join(configRoot, "kustomize") - if err := g.validatePath(valuesDir, configRoot); err != nil { - return fmt.Errorf("invalid values directory path %s: %w", valuesDir, err) - } - - valuesMap, ok := values.(map[string]any) - if !ok { - return fmt.Errorf("values must be a map, got %T", values) - } - - if err := g.validatePostBuildValues(valuesMap, "", 0); err != nil { - return fmt.Errorf("invalid values for post-build substitution: %w", err) - } - - fullValuesPath := filepath.Join(valuesDir, "values.yaml") - if err := g.validatePath(fullValuesPath, configRoot); err != nil { - return fmt.Errorf("invalid values file path %s: %w", fullValuesPath, err) - } - - existingValues := make(map[string]any) - if _, err := g.shims.Stat(fullValuesPath); err == nil { - if data, err := g.shims.ReadFile(fullValuesPath); err == nil { - if err := g.shims.YamlUnmarshal(data, &existingValues); err != nil { - return fmt.Errorf("failed to unmarshal existing values file %s: %w", fullValuesPath, err) - } +// validateKustomizeData validates kustomize template data based on the key type. +// Validates patches as Kubernetes manifests and values for post-build substitution compatibility. +func (g *KustomizeGenerator) validateKustomizeData(key string, values any) error { + if strings.HasPrefix(key, "kustomize/patches/") { + valuesMap, ok := values.(map[string]any) + if !ok { + return fmt.Errorf("patch values must be a map, got %T", values) } + return g.validateKubernetesManifest(valuesMap) } - for k, v := range valuesMap { - existingValues[k] = v - } - - return g.writeYamlFile(fullValuesPath, existingValues, overwrite) -} - -// validateKustomizationName validates that a kustomization name is safe and valid. -// Prevents path traversal attacks and ensures names contain only valid characters. -// Now handles full paths including subdirectories (e.g., "ingress/nginx"). -func (g *KustomizeGenerator) validateKustomizationName(name string) error { - if name == "" { - return fmt.Errorf("kustomization name cannot be empty") - } - - if strings.Contains(name, "..") || strings.Contains(name, "\\") { - return fmt.Errorf("kustomization name cannot contain path traversal characters") - } - - for _, component := range strings.Split(name, "/") { - if component == "" { - return fmt.Errorf("kustomization name cannot contain empty path components") - } - for _, char := range component { - if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_') { - return fmt.Errorf("kustomization name component '%s' contains invalid character '%c'", component, char) - } + if key == "kustomize/values" { + valuesMap, ok := values.(map[string]any) + if !ok { + return fmt.Errorf("values must be a map, got %T", values) } + return g.validatePostBuildValues(valuesMap, "", 0) } return nil @@ -242,26 +139,6 @@ func (g *KustomizeGenerator) validatePostBuildValues(values map[string]any, pare return nil } -// validatePath ensures the target path is within the base path to prevent path traversal attacks. -// Returns an error if the target path is outside the base path or contains invalid characters. -func (g *KustomizeGenerator) validatePath(targetPath, basePath string) error { - absTarget, err := filepath.Abs(targetPath) - if err != nil { - return fmt.Errorf("failed to get absolute path for %s: %w", targetPath, err) - } - - absBase, err := filepath.Abs(basePath) - if err != nil { - return fmt.Errorf("failed to get absolute path for %s: %w", basePath, err) - } - - if !strings.HasPrefix(absTarget, absBase) { - return fmt.Errorf("target path %s is outside base path %s", absTarget, absBase) - } - - return nil -} - // validateKubernetesManifest validates that the content represents a valid Kubernetes manifest. // Checks for required fields like apiVersion, kind, and metadata.name. // Returns an error if the manifest is invalid. @@ -294,38 +171,6 @@ func (g *KustomizeGenerator) validateKubernetesManifest(content any) error { return nil } -// writeYamlFile writes a YAML file to filePath using the provided values map. -// filePath must be a file path. If it doesn't have a .yaml or .yml extension, .yaml will be automatically appended. -// If overwrite is false, existing files are not replaced. -// Returns an error on marshalling or file operation failure. -func (g *KustomizeGenerator) writeYamlFile(filePath string, values map[string]any, overwrite bool) error { - if !strings.HasSuffix(filePath, ".yaml") && !strings.HasSuffix(filePath, ".yml") { - filePath = filePath + ".yaml" - } - - dir := filepath.Dir(filePath) - if err := g.shims.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - - if !overwrite { - if _, err := g.shims.Stat(filePath); err == nil { - return nil - } - } - - yamlData, err := g.shims.MarshalYAML(values) - if err != nil { - return fmt.Errorf("failed to marshal content to YAML for %s: %w", filePath, err) - } - - if err := g.shims.WriteFile(filePath, yamlData, 0644); err != nil { - return fmt.Errorf("failed to write file %s: %w", filePath, err) - } - - return nil -} - // ============================================================================= // Interface Compliance // ============================================================================= diff --git a/pkg/generators/kustomize_generator_test.go b/pkg/generators/kustomize_generator_test.go index 082ce8d33..fc5cd1096 100644 --- a/pkg/generators/kustomize_generator_test.go +++ b/pkg/generators/kustomize_generator_test.go @@ -1,260 +1,133 @@ package generators import ( - "fmt" - "os" - "path/filepath" "strings" "testing" + bundler "github.com/windsorcli/cli/pkg/artifact" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" ) -// ============================================================================= -// Mock Types -// ============================================================================= - -// setupKustomizeGeneratorMocks sets up a KustomizeGenerator and Mocks for testing -func setupKustomizeGeneratorMocks(t *testing.T) (*KustomizeGenerator, *Mocks) { - mocks := setupMocks(t) - generator := NewKustomizeGenerator(mocks.Injector) - generator.shims = mocks.Shims - return generator, mocks -} - -// ============================================================================= -// Constructor Tests -// ============================================================================= - -func TestNewKustomizeGenerator(t *testing.T) { - // Given an injector - injector := di.NewInjector() - - // When creating a new KustomizeGenerator - generator := NewKustomizeGenerator(injector) - - // Then it should be properly initialized - if generator == nil { - t.Fatal("expected generator to be created") - } - if generator.injector != injector { - t.Error("expected injector to be set") - } -} - -// ============================================================================= -// Initialize Tests -// ============================================================================= - -func TestKustomizeGenerator_Initialize(t *testing.T) { - t.Run("Success", func(t *testing.T) { - generator, _ := setupKustomizeGeneratorMocks(t) - // Should succeed with default blueprint handler - err := generator.Initialize() - if err != nil { - t.Fatalf("expected Initialize to succeed, got: %v", err) - } - if generator.blueprintHandler == nil { - t.Error("expected blueprint handler to be set") - } - }) - - t.Run("MissingBlueprintHandler", func(t *testing.T) { - // Create injector without blueprint handler but with config handler +func TestKustomizeGenerator_Generate_InMemory(t *testing.T) { + t.Run("FiltersAndStoresKustomizeData", func(t *testing.T) { injector := di.NewInjector() - configHandler := config.NewMockConfigHandler() - configHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - injector.Register("configHandler", configHandler) generator := NewKustomizeGenerator(injector) - err := generator.Initialize() - if err == nil { - t.Fatal("expected Initialize to fail") - } - if !strings.Contains(err.Error(), "failed to resolve blueprint handler") { - t.Errorf("expected error about blueprint handler, got: %v", err) - } - }) - - t.Run("InvalidBlueprintHandlerType", func(t *testing.T) { - // Create injector with wrong type but with config handler - injector := di.NewInjector() - configHandler := config.NewMockConfigHandler() - configHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - injector.Register("configHandler", configHandler) - injector.Register("blueprintHandler", "not a handler") - generator := NewKustomizeGenerator(injector) - err := generator.Initialize() - if err == nil { - t.Fatal("expected Initialize to fail") - } - if !strings.Contains(err.Error(), "failed to resolve blueprint handler") { - t.Errorf("expected error about blueprint handler, got: %v", err) - } - }) -} - -// ============================================================================= -// Generate Tests -// ============================================================================= - -func TestKustomizeGenerator_Generate(t *testing.T) { - t.Run("NilData", func(t *testing.T) { - generator, _ := setupKustomizeGeneratorMocks(t) - _ = generator.Initialize() - err := generator.Generate(nil) - if err == nil { - t.Fatal("expected Generate to fail with nil data") - } - if !strings.Contains(err.Error(), "data cannot be nil") { - t.Errorf("expected error about nil data, got: %v", err) - } - }) - - t.Run("EmptyData", func(t *testing.T) { - generator, _ := setupKustomizeGeneratorMocks(t) - _ = generator.Initialize() - data := map[string]any{} - err := generator.Generate(data) - if err != nil { - t.Fatalf("expected Generate to succeed with empty data, got: %v", err) - } - }) - - t.Run("KustomizeData", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - _ = generator.Initialize() - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + // Mock blueprint handler + mockBlueprintHandler := &blueprint.MockBlueprintHandler{} + var setData map[string]any + mockBlueprintHandler.SetRenderedKustomizeDataFunc = func(data map[string]any) { + setData = data } - // Mock shims for file operations - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: filepath.Base(name), isDir: false}, nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil - } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil - } + // Initialize with mock + generator.blueprintHandler = mockBlueprintHandler data := map[string]any{ - "kustomize/patches/test-patch": map[string]any{ + "kustomize/patches/test": map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ "name": "test-config", }, }, + "kustomize/values": map[string]any{ + "environment": "test", + }, + "other/file": "should be ignored", + "terraform/main.tf": "terraform content", } - err := generator.Generate(data) + err := generator.Generate(data, false) if err != nil { - t.Fatalf("expected Generate to succeed with kustomize data, got: %v", err) + t.Fatalf("expected Generate to succeed, got: %v", err) } - }) - t.Run("ValuesData", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - _ = generator.Initialize() - - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + // Verify only kustomize data was stored + if len(setData) != 2 { + t.Errorf("expected 2 kustomize items, got %d", len(setData)) } - - // Mock shims for file operations - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: filepath.Base(name), isDir: false}, nil + if _, exists := setData["kustomize/patches/test"]; !exists { + t.Error("expected kustomize/patches/test to be stored") } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + if _, exists := setData["kustomize/values"]; !exists { + t.Error("expected kustomize/values to be stored") } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil + if _, exists := setData["other/file"]; exists { + t.Error("expected non-kustomize data to be filtered out") } - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("test yaml"), nil - } - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - // Mock unmarshaling to return an empty map - values := v.(*map[string]any) - *values = make(map[string]any) - return nil - } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + }) + + t.Run("NoKustomizeData", func(t *testing.T) { + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) + + mockBlueprintHandler := &blueprint.MockBlueprintHandler{} + called := false + mockBlueprintHandler.SetRenderedKustomizeDataFunc = func(data map[string]any) { + called = true } + generator.blueprintHandler = mockBlueprintHandler + data := map[string]any{ - "kustomize/values": map[string]any{ - "domain": "example.com", - "port": 80, - "enabled": true, - }, + "other/file": "should be ignored", + "terraform/main.tf": "terraform content", } - err := generator.Generate(data) + err := generator.Generate(data, false) if err != nil { - t.Fatalf("expected Generate to succeed with values data, got: %v", err) + t.Fatalf("expected Generate to succeed with no kustomize data, got: %v", err) + } + + if called { + t.Error("expected SetRenderedKustomizeData not to be called when no kustomize data present") } }) - t.Run("ConfigRootError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - _ = generator.Initialize() + t.Run("ValidationError", func(t *testing.T) { + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) + + mockBlueprintHandler := &blueprint.MockBlueprintHandler{} + generator.blueprintHandler = mockBlueprintHandler - // Mock config handler to fail - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("config root error") + data := map[string]any{ + "kustomize/patches/test": "invalid data - should be map", } - data := map[string]any{"kustomize/patches/test": "value"} - err := generator.Generate(data) + err := generator.Generate(data, false) if err == nil { - t.Fatal("expected Generate to fail with config root error") + t.Fatal("expected Generate to fail with validation error") } - if !strings.Contains(err.Error(), "failed to get config root") { - t.Errorf("expected error about config root, got: %v", err) + if !strings.Contains(err.Error(), "invalid kustomize data") { + t.Errorf("expected validation error, got: %v", err) } }) -} -func TestKustomizeGenerator_generatePatchFile(t *testing.T) { - t.Run("Success", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } + t.Run("NilData", func(t *testing.T) { + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) - // Mock shims - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: filepath.Base(name), isDir: false}, nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil + err := generator.Generate(nil, false) + if err == nil { + t.Fatal("expected Generate to fail with nil data") } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + if !strings.Contains(err.Error(), "data cannot be nil") { + t.Errorf("expected nil data error, got: %v", err) } + }) +} - key := "kustomize/patches/test-patch" - values := map[string]any{ +func TestKustomizeGenerator_validateKustomizeData(t *testing.T) { + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) + + t.Run("ValidPatch", func(t *testing.T) { + data := map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ @@ -262,699 +135,358 @@ func TestKustomizeGenerator_generatePatchFile(t *testing.T) { }, } - err := generator.generatePatchFile(key, values, "/test/config", false) + err := generator.validateKustomizeData("kustomize/patches/test", data) if err != nil { - t.Fatalf("expected generatePatchFile to succeed, got: %v", err) + t.Errorf("expected valid patch to pass validation, got: %v", err) } }) - t.Run("InvalidKustomizationName", func(t *testing.T) { - generator, _ := setupKustomizeGeneratorMocks(t) - - key := "kustomize/patches/invalid@name" - values := map[string]any{"test": "value"} + t.Run("InvalidPatch", func(t *testing.T) { + data := map[string]any{ + "kind": "ConfigMap", + // Missing apiVersion and metadata.name + } - err := generator.generatePatchFile(key, values, "/test/config", false) + err := generator.validateKustomizeData("kustomize/patches/test", data) if err == nil { - t.Fatal("expected generatePatchFile to fail with invalid name") - } - if !strings.Contains(err.Error(), "invalid patch path") { - t.Errorf("expected error about invalid patch path, got: %v", err) + t.Error("expected invalid patch to fail validation") } }) - t.Run("PathValidationError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + t.Run("ValidValues", func(t *testing.T) { + data := map[string]any{ + "environment": "test", + "port": 80, + "enabled": true, } - // Mock shims to fail path validation - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, fmt.Errorf("path error") + err := generator.validateKustomizeData("kustomize/values", data) + if err != nil { + t.Errorf("expected valid values to pass validation, got: %v", err) } + }) - key := "kustomize/patches/test-patch" - values := map[string]any{"test": "value"} + t.Run("InvalidValues", func(t *testing.T) { + data := map[string]any{ + "invalid": []string{"slice", "not", "allowed"}, + } - err := generator.generatePatchFile(key, values, "/test/config", false) + err := generator.validateKustomizeData("kustomize/values", data) if err == nil { - t.Fatal("expected generatePatchFile to fail with path error") + t.Error("expected invalid values to fail validation") } }) -} - -func TestKustomizeGenerator_generateValuesFile(t *testing.T) { - t.Run("SuccessCommon", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // Mock shims - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: filepath.Base(name), isDir: false}, nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil - } - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("test yaml"), nil - } - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - // Mock unmarshaling to return an empty map - values := v.(*map[string]any) - *values = make(map[string]any) - return nil - } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + t.Run("NonMapData", func(t *testing.T) { + err := generator.validateKustomizeData("kustomize/patches/test", "not a map") + if err == nil { + t.Error("expected non-map data to fail validation") } - - key := "kustomize/values" - values := map[string]any{ - "domain": "example.com", - "port": 80, - "enabled": true, + if !strings.Contains(err.Error(), "patch values must be a map") { + t.Errorf("expected map type error, got: %v", err) } + }) - err := generator.generateValuesFile(key, values, "/test/config", false) + t.Run("UnknownKey", func(t *testing.T) { + data := map[string]any{"test": "value"} + err := generator.validateKustomizeData("kustomize/unknown", data) if err != nil { - t.Fatalf("expected generateValuesFile to succeed, got: %v", err) + t.Errorf("expected unknown key to pass (no validation), got: %v", err) } }) +} - t.Run("SuccessComponent", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) +func TestKustomizeGenerator_Initialize(t *testing.T) { + setup := func(t *testing.T) *KustomizeGenerator { + t.Helper() + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) + return generator + } - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } + setupWithBaseDependencies := func(t *testing.T) *KustomizeGenerator { + t.Helper() + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) - // Mock shims - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: filepath.Base(name), isDir: false}, nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil - } - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("test yaml"), nil - } - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - // Mock unmarshaling to return an empty map - values := v.(*map[string]any) - *values = make(map[string]any) - return nil - } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil - } + // Register required base dependencies + mockConfigHandler := &config.MockConfigHandler{} + mockShell := &shell.MockShell{} + mockArtifactBuilder := &bundler.MockArtifact{} - key := "kustomize/values" - values := map[string]any{ - "host": "example.com", - "tls": true, - } + generator.injector.Register("configHandler", mockConfigHandler) + generator.injector.Register("shell", mockShell) + generator.injector.Register("artifactBuilder", mockArtifactBuilder) + + return generator + } - err := generator.generateValuesFile(key, values, "/test/config", false) + t.Run("Success", func(t *testing.T) { + // Given a generator with all required dependencies + generator := setupWithBaseDependencies(t) + mockBlueprintHandler := &blueprint.MockBlueprintHandler{} + generator.injector.Register("blueprintHandler", mockBlueprintHandler) + // When initializing + err := generator.Initialize() + // Then no error should be returned if err != nil { - t.Fatalf("expected generateValuesFile to succeed, got: %v", err) + t.Errorf("Expected error = %v, got = %v", nil, err) } - }) - - t.Run("InvalidValuesType", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + if generator.blueprintHandler != mockBlueprintHandler { + t.Error("Expected blueprint handler to be set") } + }) - key := "kustomize/values" - values := "not a map" - - err := generator.generateValuesFile(key, values, "/test/config", false) + t.Run("BaseGeneratorInitializationFailure", func(t *testing.T) { + // Given a generator with missing config handler + generator := setup(t) + // When initializing + err := generator.Initialize() + // Then error should be returned if err == nil { - t.Fatal("expected generateValuesFile to fail with invalid type") + t.Error("Expected error for base generator initialization failure") } - if !strings.Contains(err.Error(), "must be a map") { - t.Errorf("expected error about invalid type, got: %v", err) + if !strings.Contains(err.Error(), "failed to initialize base generator") { + t.Errorf("Expected base generator error, got: %v", err) } }) - t.Run("InvalidValuesContent", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + t.Run("BlueprintHandlerNotFound", func(t *testing.T) { + // Given a generator with base dependencies but no blueprint handler + generator := setupWithBaseDependencies(t) + // When initializing + err := generator.Initialize() + // Then error should be returned from base generator + if err == nil { + t.Error("Expected error for missing blueprint handler") } - - // Mock config handler - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + if !strings.Contains(err.Error(), "failed to initialize base generator") { + t.Errorf("Expected base generator error, got: %v", err) } + }) - // Mock shims - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: filepath.Base(name), isDir: false}, nil - } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil + t.Run("BlueprintHandlerWrongType", func(t *testing.T) { + // Given a generator with wrong type in injector + generator := setupWithBaseDependencies(t) + generator.injector.Register("blueprintHandler", "not a blueprint handler") + // When initializing + err := generator.Initialize() + // Then error should be returned from base generator + if err == nil { + t.Error("Expected error for wrong blueprint handler type") } - mocks.Shims.ReadFile = func(name string) ([]byte, error) { - return []byte("test yaml"), nil + if !strings.Contains(err.Error(), "failed to initialize base generator") { + t.Errorf("Expected base generator error, got: %v", err) } - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - // Mock unmarshaling to return an empty map - values := v.(*map[string]any) - *values = make(map[string]any) - return nil + }) + + t.Run("BlueprintHandlerNil", func(t *testing.T) { + // Given a generator with nil blueprint handler + generator := setupWithBaseDependencies(t) + generator.injector.Register("blueprintHandler", nil) + // When initializing + err := generator.Initialize() + // Then error should be returned from base generator + if err == nil { + t.Error("Expected error for nil blueprint handler") } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + if !strings.Contains(err.Error(), "failed to initialize base generator") { + t.Errorf("Expected base generator error, got: %v", err) } + }) +} - key := "kustomize/values" +func TestKustomizeGenerator_validatePostBuildValues(t *testing.T) { + setup := func(t *testing.T) *KustomizeGenerator { + t.Helper() + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) + return generator + } + + t.Run("ValidScalarTypes", func(t *testing.T) { + // Given a generator and valid scalar values + generator := setup(t) values := map[string]any{ - "valid": "string", - "valid_nested": map[string]any{"nested": "value"}, + "string": "test", + "int": 42, + "int8": int8(8), + "int16": int16(16), + "int32": int32(32), + "int64": int64(64), + "uint": uint(100), + "uint8": uint8(8), + "uint16": uint16(16), + "uint32": uint32(32), + "uint64": uint64(64), + "float32": float32(3.14), + "float64": float64(3.14159), + "bool": true, + } + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then no error should be returned + if err != nil { + t.Errorf("Expected error = %v, got = %v", nil, err) } + }) - err := generator.generateValuesFile(key, values, "/test/config", false) + t.Run("ValidNestedMap", func(t *testing.T) { + // Given a generator and valid nested map + generator := setup(t) + values := map[string]any{ + "nested": map[string]any{ + "string": "test", + "int": 42, + "bool": true, + }, + } + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then no error should be returned if err != nil { - t.Fatalf("expected generateValuesFile to succeed with valid nested values, got: %v", err) + t.Errorf("Expected error = %v, got = %v", nil, err) } }) -} - -func TestKustomizeGenerator_writeYamlFile(t *testing.T) { - t.Run("Success", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - // Mock shims - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + t.Run("InvalidSlice", func(t *testing.T) { + // Given a generator and values containing a slice + generator := setup(t) + values := map[string]any{ + "invalid": []any{"slice", "not", "allowed"}, } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return nil + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then error should be returned + if err == nil { + t.Error("Expected error for slice values") } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected slice error, got: %v", err) } + }) - valuesPath := "/test/values.yaml" + t.Run("InvalidNestedComplexType", func(t *testing.T) { + // Given a generator and values with nested complex types + generator := setup(t) values := map[string]any{ - "domain": "example.com", - "port": 80, - "enabled": true, + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "too deep", + }, + }, } - - err := generator.writeYamlFile(valuesPath, values, false) - if err != nil { - t.Fatalf("expected writeYamlFile to succeed, got: %v", err) + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then error should be returned + if err == nil { + t.Error("Expected error for nested complex types") + } + if !strings.Contains(err.Error(), "cannot contain nested complex types") { + t.Errorf("Expected nested complex type error, got: %v", err) } }) - t.Run("MkdirAllError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock shims to fail - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("mkdir error") + t.Run("InvalidUnsupportedType", func(t *testing.T) { + // Given a generator and values with unsupported type + generator := setup(t) + values := map[string]any{ + "unsupported": struct{}{}, } - - valuesPath := "/test/values.yaml" - values := map[string]any{"test": "value"} - - err := generator.writeYamlFile(valuesPath, values, false) + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then error should be returned if err == nil { - t.Fatal("expected writeYamlFile to fail with mkdir error") + t.Error("Expected error for unsupported type") } - if !strings.Contains(err.Error(), "failed to create directory") { - t.Errorf("expected error about directory creation, got: %v", err) + if !strings.Contains(err.Error(), "unsupported type") { + t.Errorf("Expected unsupported type error, got: %v", err) } }) - t.Run("MarshalYAMLError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) + t.Run("EmptyMap", func(t *testing.T) { + // Given a generator and empty values map + generator := setup(t) + values := map[string]any{} + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then no error should be returned + if err != nil { + t.Errorf("Expected error = %v, got = %v", nil, err) + } + }) - // Mock shims - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + t.Run("ParentKeyReporting", func(t *testing.T) { + // Given a generator and values with parent key context + generator := setup(t) + values := map[string]any{ + "invalid": []any{"slice", "not", "allowed"}, } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return nil, fmt.Errorf("marshal error") + // When validating post-build values with parent key + err := generator.validatePostBuildValues(values, "parent", 0) + // Then error should include parent key in message + if err == nil { + t.Error("Expected error for slice values") } + if !strings.Contains(err.Error(), "parent.invalid") { + t.Errorf("Expected parent key in error message, got: %v", err) + } + }) - valuesPath := "/test/values.yaml" - values := map[string]any{"test": "value"} - - err := generator.writeYamlFile(valuesPath, values, false) + t.Run("NestedSliceInMap", func(t *testing.T) { + // Given a generator and nested map containing slice + generator := setup(t) + values := map[string]any{ + "nested": map[string]any{ + "invalid": []any{"slice", "in", "nested"}, + }, + } + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then error should be returned if err == nil { - t.Fatal("expected writeYamlFile to fail with marshal error") + t.Error("Expected error for slice in nested map") + } + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected slice error, got: %v", err) } - if !strings.Contains(err.Error(), "failed to marshal content to YAML") { - t.Errorf("expected error about YAML marshaling, got: %v", err) + if !strings.Contains(err.Error(), "nested.invalid") { + t.Errorf("Expected nested key in error message, got: %v", err) } }) - t.Run("WriteFileError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock shims - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + t.Run("MixedValidAndInvalid", func(t *testing.T) { + // Given a generator and values with mix of valid and invalid types + generator := setup(t) + values := map[string]any{ + "valid": "string", + "invalid": []any{"slice", "not", "allowed"}, } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then error should be returned for the invalid type + if err == nil { + t.Error("Expected error for invalid type") } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return fmt.Errorf("write error") + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected slice error, got: %v", err) } + }) - valuesPath := "/test/values.yaml" - values := map[string]any{"test": "value"} - - err := generator.writeYamlFile(valuesPath, values, false) + t.Run("NilValue", func(t *testing.T) { + // Given a generator and values with nil value + generator := setup(t) + values := map[string]any{ + "nil": nil, + } + // When validating post-build values + err := generator.validatePostBuildValues(values, "", 0) + // Then error should be returned for unsupported type if err == nil { - t.Fatal("expected writeYamlFile to fail with write error") + t.Error("Expected error for nil value") } - if !strings.Contains(err.Error(), "failed to write file") { - t.Errorf("expected error about file writing, got: %v", err) + if !strings.Contains(err.Error(), "unsupported type") { + t.Errorf("Expected unsupported type error, got: %v", err) } }) } - -// ============================================================================= -// Validation Tests -// ============================================================================= - -func TestKustomizeGenerator_validateKustomizationName(t *testing.T) { - // Given a generator - generator, _ := setupKustomizeGeneratorMocks(t) - - testCases := []struct { - name string - input string - expectError bool - errorMsg string - }{ - { - name: "ValidSimpleName", - input: "test-kustomization", - expectError: false, - }, - { - name: "ValidWithHyphens", - input: "test-kustomization-name", - expectError: false, - }, - { - name: "ValidWithUnderscores", - input: "test_kustomization_name", - expectError: false, - }, - { - name: "ValidWithNumbers", - input: "test-kustomization-123", - expectError: false, - }, - { - name: "ValidSubdirectory", - input: "ingress/nginx", - expectError: false, - }, - { - name: "ValidNestedSubdirectory", - input: "ingress/nginx/controller", - expectError: false, - }, - { - name: "EmptyName", - input: "", - expectError: true, - errorMsg: "cannot be empty", - }, - { - name: "PathTraversal", - input: "test/../malicious", - expectError: true, - errorMsg: "path traversal", - }, - { - name: "BackslashPathTraversal", - input: "test\\..\\malicious", - expectError: true, - errorMsg: "path traversal", - }, - { - name: "EmptyPathComponent", - input: "test//component", - expectError: true, - errorMsg: "empty path components", - }, - { - name: "InvalidCharacter", - input: "test@kustomization", - expectError: true, - errorMsg: "invalid character", - }, - { - name: "InvalidCharacterInSubdirectory", - input: "ingress/nginx@controller", - expectError: true, - errorMsg: "invalid character", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // When validating name - err := generator.validateKustomizationName(tc.input) - - // Then check expected result - if tc.expectError { - if err == nil { - t.Fatal("expected validation to fail") - } - if !strings.Contains(err.Error(), tc.errorMsg) { - t.Errorf("expected error to contain '%s', got: %v", tc.errorMsg, err) - } - } else { - if err != nil { - t.Errorf("expected validation to pass, got: %v", err) - } - } - }) - } -} - -func TestKustomizeGenerator_validatePostBuildValues(t *testing.T) { - // Given a generator - generator, _ := setupKustomizeGeneratorMocks(t) - - testCases := []struct { - name string - values map[string]any - expectError bool - errorMsg string - }{ - { - name: "ValidScalarTypes", - values: map[string]any{ - "string": "value", - "int": 42, - "int8": int8(8), - "int16": int16(16), - "int32": int32(32), - "int64": int64(64), - "uint": uint(42), - "uint8": uint8(8), - "uint16": uint16(16), - "uint32": uint32(32), - "uint64": uint64(64), - "float32": float32(3.14), - "float64": 3.14, - "bool": true, - }, - expectError: false, - }, - { - name: "ValidNestedMapType", - values: map[string]any{ - "nested": map[string]any{"key": "value"}, - }, - expectError: false, - }, - { - name: "InvalidSliceType", - values: map[string]any{ - "array": []any{1, 2, 3}, - }, - expectError: true, - errorMsg: "slices", - }, - { - name: "MixedValidAndValidNested", - values: map[string]any{ - "valid": "string", - "valid_nested": map[string]any{"nested": "value"}, - }, - expectError: false, - }, - { - name: "InvalidDeeplyNestedMap", - values: map[string]any{ - "nested": map[string]any{ - "deeply_nested": map[string]any{"key": "value"}, - }, - }, - expectError: true, - errorMsg: "nested complex types", - }, - { - name: "InvalidNestedSlice", - values: map[string]any{ - "nested": map[string]any{ - "array": []any{1, 2, 3}, - }, - }, - expectError: true, - errorMsg: "slices", - }, - { - name: "UnsupportedType", - values: map[string]any{ - "unsupported": make(chan int), - }, - expectError: true, - errorMsg: "unsupported type", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // When validating values - err := generator.validatePostBuildValues(tc.values, "", 0) - - // Then check expected result - if tc.expectError { - if err == nil { - t.Fatal("expected validation to fail") - } - if !strings.Contains(err.Error(), tc.errorMsg) { - t.Errorf("expected error to contain '%s', got: %v", tc.errorMsg, err) - } - } else { - if err != nil { - t.Errorf("expected validation to pass, got: %v", err) - } - } - }) - } -} - -func TestKustomizeGenerator_validatePath(t *testing.T) { - // Given a generator - generator, _ := setupKustomizeGeneratorMocks(t) - - testCases := []struct { - name string - targetPath string - basePath string - expectError bool - errorMsg string - }{ - { - name: "ValidPath", - targetPath: "/base/valid/path", - basePath: "/base", - expectError: false, - }, - { - name: "ValidSubPath", - targetPath: "/base/sub/path/file.yaml", - basePath: "/base", - expectError: false, - }, - { - name: "PathTraversal", - targetPath: "/base/../malicious/path", - basePath: "/base", - expectError: true, - errorMsg: "outside base path", - }, - { - name: "DifferentBase", - targetPath: "/other/path", - basePath: "/base", - expectError: true, - errorMsg: "outside base path", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // When validating path - err := generator.validatePath(tc.targetPath, tc.basePath) - - // Then check expected result - if tc.expectError { - if err == nil { - t.Fatal("expected validation to fail") - } - if !strings.Contains(err.Error(), tc.errorMsg) { - t.Errorf("expected error to contain '%s', got: %v", tc.errorMsg, err) - } - } else { - if err != nil { - t.Errorf("expected validation to pass, got: %v", err) - } - } - }) - } -} - -func TestKustomizeGenerator_validateKubernetesManifest(t *testing.T) { - // Given a generator - generator, _ := setupKustomizeGeneratorMocks(t) - - testCases := []struct { - name string - content any - expectError bool - errorMsg string - }{ - { - name: "ValidManifest", - content: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - expectError: false, - }, - { - name: "NotMap", - content: "not a map", - expectError: true, - errorMsg: "must be a map", - }, - { - name: "MissingApiVersion", - content: map[string]any{ - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - expectError: true, - errorMsg: "missing or invalid 'apiVersion'", - }, - { - name: "EmptyApiVersion", - content: map[string]any{ - "apiVersion": "", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - expectError: true, - errorMsg: "missing or invalid 'apiVersion'", - }, - { - name: "MissingKind", - content: map[string]any{ - "apiVersion": "v1", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - expectError: true, - errorMsg: "missing or invalid 'kind'", - }, - { - name: "EmptyKind", - content: map[string]any{ - "apiVersion": "v1", - "kind": "", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - expectError: true, - errorMsg: "missing or invalid 'kind'", - }, - { - name: "MissingMetadata", - content: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - }, - expectError: true, - errorMsg: "missing 'metadata'", - }, - { - name: "MissingName", - content: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{}, - }, - expectError: true, - errorMsg: "missing or invalid 'metadata.name'", - }, - { - name: "EmptyName", - content: map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "", - }, - }, - expectError: true, - errorMsg: "missing or invalid 'metadata.name'", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // When validating manifest - err := generator.validateKubernetesManifest(tc.content) - - // Then check expected result - if tc.expectError { - if err == nil { - t.Fatal("expected validation to fail") - } - if !strings.Contains(err.Error(), tc.errorMsg) { - t.Errorf("expected error to contain '%s', got: %v", tc.errorMsg, err) - } - } else { - if err != nil { - t.Errorf("expected validation to pass, got: %v", err) - } - } - }) - } -} diff --git a/pkg/pipelines/pipeline.go b/pkg/pipelines/pipeline.go index 6abec4cd2..41a1ffc54 100644 --- a/pkg/pipelines/pipeline.go +++ b/pkg/pipelines/pipeline.go @@ -267,8 +267,9 @@ func (p *BasePipeline) withStack() stack.Stack { return stack } -// withGenerators creates and registers generators including git, terraform, and patch generators. -// Returns a slice of initialized generators or an error if creation fails. +// withGenerators creates and registers generator instances for git, terraform, and kustomize based on configuration. +// It always registers the git generator. The terraform generator is registered if "terraform.enabled" is true. +// The kustomize generator is registered if "cluster.enabled" is true. Returns a slice of initialized generators or an error. func (p *BasePipeline) withGenerators() ([]generators.Generator, error) { var generatorList []generators.Generator @@ -276,14 +277,12 @@ func (p *BasePipeline) withGenerators() ([]generators.Generator, error) { p.injector.Register("gitGenerator", gitGenerator) generatorList = append(generatorList, gitGenerator) - // Only create Terraform generator if Terraform is enabled if p.configHandler.GetBool("terraform.enabled", false) { terraformGenerator := generators.NewTerraformGenerator(p.injector) p.injector.Register("terraformGenerator", terraformGenerator) generatorList = append(generatorList, terraformGenerator) } - // Create patch generator for Kustomize patch templating only if cluster.enabled if p.configHandler.GetBool("cluster.enabled", false) { kustomizeGenerator := generators.NewKustomizeGenerator(p.injector) p.injector.Register("kustomizeGenerator", kustomizeGenerator)