From 9c792ec6fc4e41a170d91c3ab77d2c882d449619 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 31 Jul 2025 09:32:43 -0400 Subject: [PATCH 1/3] feat(blueprint): Add postBuild variable substitution Adds approach for configuring variables that use Flux's `postBuild` variable substitution functionality. This feature can be used by creating simple key/value `values.yaml` in `contexts//kustomize/values.yaml` for global variables or `contexts//kustomize//values.yaml` for component specific variables. Inclusion of these `values.yaml` files has also been added to the templating system. This feature is intended to be used in only limited cases. The majority of configuration needs can be handled through the component pattern and patching. However, certain cases, especially for widespread variables such as `${public_domain}`, this functionality is essential. --- pkg/blueprint/blueprint_handler.go | 173 +- .../blueprint_handler_private_test.go | 1632 ----------------- pkg/blueprint/blueprint_handler_test.go | 1001 ++++++++++ pkg/generators/kustomize_generator.go | 360 ++++ pkg/generators/kustomize_generator_test.go | 1045 +++++++++++ pkg/generators/patch_generator.go | 247 --- pkg/generators/patch_generator_test.go | 1068 ----------- pkg/pipelines/init_test.go | 3 - pkg/pipelines/pipeline.go | 6 +- pkg/template/jsonnet_template.go | 92 +- pkg/template/jsonnet_template_test.go | 696 ++++++- 11 files changed, 3350 insertions(+), 2973 deletions(-) delete mode 100644 pkg/blueprint/blueprint_handler_private_test.go create mode 100644 pkg/generators/kustomize_generator.go create mode 100644 pkg/generators/kustomize_generator_test.go delete mode 100644 pkg/generators/patch_generator.go delete mode 100644 pkg/generators/patch_generator_test.go diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 3487347be..fb204f24a 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -306,6 +306,12 @@ func (b *BaseBlueprintHandler) Install() error { return fmt.Errorf("failed to apply configmap: %w", err) } + if err := b.applyValuesConfigMaps(); err != nil { + spin.Stop() + fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) + return fmt.Errorf("failed to apply values configmaps: %w", err) + } + kustomizations := b.GetKustomizations() kustomizationNames := make([]string, len(kustomizations)) for i, k := range kustomizations { @@ -1087,11 +1093,14 @@ func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { return maxPathTime } -// toFluxKustomization converts a blueprint kustomization to a Flux Kustomization resource. -// Converts blueprint fields to Flux equivalents, resolves dependencies, patches, post-build configuration, -// and determines the correct source kind (GitRepository or OCIRepository) based on the source reference. -// k is the blueprint kustomization to convert. namespace is the target Kubernetes namespace. -// Returns the constructed Flux Kustomization resource. +// toFluxKustomization generates a Flux Kustomization resource from a blueprintv1alpha1.Kustomization definition. +// It performs the following operations: +// - Translates blueprint kustomization fields to their Flux equivalents, including name, namespace, path, interval, prune, wait, force, and timeout. +// - Resolves and maps dependencies (DependsOn) to Flux NamespacedObjectReference objects for correct dependency graph construction. +// - Processes patch definitions: for each patch with a file path, reads the patch file from the kustomize directory, decodes YAML documents, and extracts resource selectors (kind, name, namespace) to build kustomize.Selector objects. Patch content is attached to the Flux patch specification. +// - Handles post-build configuration by mapping substituteFrom entries to Flux SubstituteReference objects, supporting ConfigMap and Secret sources. +// - Determines the source reference type (GitRepository or OCIRepository) based on the blueprint's SourceRef.Kind, and sets the appropriate reference in the Flux Kustomization spec. +// - Assembles and returns a kustomizev1.Kustomization object fully populated for Flux consumption, ready for application to a Kubernetes cluster. func (b *BaseBlueprintHandler) toFluxKustomization(k blueprintv1alpha1.Kustomization, namespace string) kustomizev1.Kustomization { dependsOn := make([]meta.NamespacedObjectReference, len(k.DependsOn)) for i, dep := range k.DependsOn { @@ -1167,19 +1176,63 @@ func (b *BaseBlueprintHandler) toFluxKustomization(k blueprintv1alpha1.Kustomiza } var postBuild *kustomizev1.PostBuild + substituteFrom := make([]kustomizev1.SubstituteReference, 0) + + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: "blueprint", + Optional: false, + }) + + configRoot, err := b.configHandler.GetConfigRoot() + if err == nil { + globalValuesPath := filepath.Join(configRoot, "kustomize", "values.yaml") + if _, err := b.shims.Stat(globalValuesPath); err == nil { + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: "values-global", + Optional: false, + }) + } + } + + configMapName := fmt.Sprintf("values-%s", k.Name) + if err == nil { + valuesPath := filepath.Join(configRoot, "kustomize", k.Name, "values.yaml") + if _, err := b.shims.Stat(valuesPath); err == nil { + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: configMapName, + Optional: false, + }) + } + } + if k.PostBuild != nil { - substituteFrom := make([]kustomizev1.SubstituteReference, len(k.PostBuild.SubstituteFrom)) - for i, ref := range k.PostBuild.SubstituteFrom { - substituteFrom[i] = kustomizev1.SubstituteReference{ - Kind: ref.Kind, - Name: ref.Name, - Optional: ref.Optional, + for _, ref := range k.PostBuild.SubstituteFrom { + duplicate := false + for _, existing := range substituteFrom { + if existing.Kind == ref.Kind && existing.Name == ref.Name { + duplicate = true + break + } + } + if !duplicate { + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: ref.Kind, + Name: ref.Name, + Optional: ref.Optional, + }) } } postBuild = &kustomizev1.PostBuild{ Substitute: k.PostBuild.Substitute, SubstituteFrom: substituteFrom, } + } else { + postBuild = &kustomizev1.PostBuild{ + SubstituteFrom: substituteFrom, + } } interval := metav1.Duration{Duration: k.Interval.Duration} @@ -1248,3 +1301,101 @@ func (b *BaseBlueprintHandler) isOCISource(sourceNameOrURL string) bool { } return false } + +// applyValuesConfigMaps creates ConfigMaps from values.yaml files in the kustomize directory for post-build variable substitution. +// It generates a ConfigMap for the global values.yaml if present, and for each component subdirectory containing a values.yaml file. +// The resulting ConfigMaps can be referenced in PostBuild.SubstituteFrom for variable substitution. +func (b *BaseBlueprintHandler) applyValuesConfigMaps() error { + configRoot, err := b.configHandler.GetConfigRoot() + if err != nil { + return fmt.Errorf("failed to get config root: %w", err) + } + + kustomizeDir := filepath.Join(configRoot, "kustomize") + if _, err := b.shims.Stat(kustomizeDir); os.IsNotExist(err) { + return nil + } + + globalValuesPath := filepath.Join(kustomizeDir, "values.yaml") + if _, err := b.shims.Stat(globalValuesPath); err == nil { + if err := b.createConfigMapFromValues(globalValuesPath, "values-global"); err != nil { + return fmt.Errorf("failed to create global values ConfigMap: %w", err) + } + } + + entries, err := b.shims.ReadDir(kustomizeDir) + if err != nil { + return fmt.Errorf("failed to read kustomize directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + componentValuesPath := filepath.Join(kustomizeDir, entry.Name(), "values.yaml") + if _, err := b.shims.Stat(componentValuesPath); err == nil { + configMapName := fmt.Sprintf("values-%s", entry.Name()) + if err := b.createConfigMapFromValues(componentValuesPath, configMapName); err != nil { + return fmt.Errorf("failed to create ConfigMap for component %s: %w", entry.Name(), err) + } + } + } + + return nil +} + +// validateValuesForSubstitution checks that all values are valid for Flux post-build variable substitution. +// Permitted types are string, numeric, and boolean. Complex types (maps, slices) are rejected. +// Returns an error if any value is not a supported type. +func (b *BaseBlueprintHandler) validateValuesForSubstitution(values map[string]any) error { + for key, value := range values { + switch v := value.(type) { + case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: + continue + case map[string]any, []any: + return fmt.Errorf("values for post-build substitution cannot contain complex types (maps or slices), key '%s' has type %T", key, v) + default: + return fmt.Errorf("values for post-build substitution can only contain strings, numbers, and booleans, key '%s' has unsupported type %T", key, v) + } + } + return nil +} + +// createConfigMapFromValues reads a values.yaml file from valuesPath, unmarshals its contents, and creates a ConfigMap named configMapName in the "flux-system" namespace for post-build variable substitution. +// Only scalar values (string, int, float, bool) are supported. Complex types are rejected. The resulting ConfigMap data is a map of string keys to string values. +func (b *BaseBlueprintHandler) createConfigMapFromValues(valuesPath, configMapName string) error { + data, err := b.shims.ReadFile(valuesPath) + if err != nil { + return fmt.Errorf("failed to read values file %s: %w", valuesPath, err) + } + + 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) + } + + if err := b.validateValuesForSubstitution(values); err != nil { + return fmt.Errorf("invalid values in %s: %w", valuesPath, err) + } + + stringValues := make(map[string]string) + for key, value := range values { + switch v := value.(type) { + case string: + stringValues[key] = v + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + stringValues[key] = fmt.Sprintf("%v", v) + case bool: + stringValues[key] = fmt.Sprintf("%t", v) + default: + return fmt.Errorf("unsupported value type for key %s: %T", key, v) + } + } + + if err := b.kubernetesManager.ApplyConfigMap(configMapName, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE, stringValues); err != nil { + return fmt.Errorf("failed to apply ConfigMap %s: %w", configMapName, err) + } + + return nil +} diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go deleted file mode 100644 index ff7877152..000000000 --- a/pkg/blueprint/blueprint_handler_private_test.go +++ /dev/null @@ -1,1632 +0,0 @@ -package blueprint - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" - - sourcev1 "github.com/fluxcd/source-controller/api/v1" - blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/kubernetes" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// mockFileInfo implements os.FileInfo for testing -type mockFileInfo struct { - name string -} - -func (m mockFileInfo) Name() string { return m.name } -func (m mockFileInfo) Size() int64 { return 0 } -func (m mockFileInfo) Mode() os.FileMode { return 0644 } -func (m mockFileInfo) ModTime() time.Time { return time.Time{} } -func (m mockFileInfo) IsDir() bool { return false } -func (m mockFileInfo) Sys() any { return nil } - -// ============================================================================= -// Test Private Methods -// ============================================================================= - -func TestBaseBlueprintHandler_isValidTerraformRemoteSource(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - return handler, mocks - } - - t.Run("ValidGitHTTPS", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid git HTTPS source - source := "git::https://github.com/example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) - } - }) - - t.Run("ValidGitSSH", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid git SSH source - source := "git@github.com:example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) - } - }) - - t.Run("ValidHTTPS", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid HTTPS source - source := "https://github.com/example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) - } - }) - - t.Run("ValidZip", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid ZIP source - source := "https://github.com/example/repo/archive/main.zip" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) - } - }) - - t.Run("ValidRegistry", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid registry source - source := "registry.terraform.io/example/module" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) - } - }) - - t.Run("ValidCustomDomain", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking a valid custom domain source - source := "example.com/module" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be valid - if !valid { - t.Errorf("Expected %s to be valid, got invalid", source) - } - }) - - t.Run("InvalidSource", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // When checking an invalid source - source := "invalid-source" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be invalid - if valid { - t.Errorf("Expected %s to be invalid, got valid", source) - } - }) - - t.Run("InvalidRegex", func(t *testing.T) { - // Given a blueprint handler with a mock that returns error - handler, mocks := setup(t) - mocks.Shims.RegexpMatchString = func(pattern, s string) (bool, error) { - return false, fmt.Errorf("mock regex error") - } - - // When checking a source with regex error - source := "git::https://github.com/example/repo.git" - valid := handler.isValidTerraformRemoteSource(source) - - // Then it should be invalid - if valid { - t.Errorf("Expected %s to be invalid with regex error, got valid", source) - } - }) -} - -func TestBlueprintHandler_resolveComponentSources(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler with repository and sources - handler, _ := setup(t) - - // Mock Kubernetes manager - mockK8sManager := &kubernetes.MockKubernetesManager{} - mockK8sManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { - return nil - } - handler.(*BaseBlueprintHandler).kubernetesManager = mockK8sManager - - // Set repository and sources directly on the blueprint - baseHandler := handler.(*BaseBlueprintHandler) - baseHandler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - baseHandler.blueprint.Sources = expectedSources - - // When resolving component sources - handler.(*BaseBlueprintHandler).resolveComponentSources(&handler.(*BaseBlueprintHandler).blueprint) - }) - - t.Run("SourceURLWithoutDotGit", func(t *testing.T) { - // Given a blueprint handler with repository and source without .git suffix - handler, _ := setup(t) - - // Mock Kubernetes manager - mockK8sManager := &kubernetes.MockKubernetesManager{} - mockK8sManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { - return nil - } - handler.(*BaseBlueprintHandler).kubernetesManager = mockK8sManager - - // Set repository and sources directly on the blueprint - baseHandler := handler.(*BaseBlueprintHandler) - baseHandler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source2", - Url: "https://example.com/source2", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - baseHandler.blueprint.Sources = expectedSources - - // When resolving component sources - handler.(*BaseBlueprintHandler).resolveComponentSources(&handler.(*BaseBlueprintHandler).blueprint) - }) - - t.Run("SourceWithSecretName", func(t *testing.T) { - // Given a blueprint handler with repository and source with secret name - handler, _ := setup(t) - - // Mock Kubernetes manager - mockK8sManager := &kubernetes.MockKubernetesManager{} - mockK8sManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { - return nil - } - handler.(*BaseBlueprintHandler).kubernetesManager = mockK8sManager - - // Set repository and sources directly on the blueprint - baseHandler := handler.(*BaseBlueprintHandler) - baseHandler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source3", - Url: "https://example.com/source3.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - SecretName: "git-credentials", - }, - } - baseHandler.blueprint.Sources = expectedSources - - // When resolving component sources - handler.(*BaseBlueprintHandler).resolveComponentSources(&handler.(*BaseBlueprintHandler).blueprint) - }) -} - -func TestBlueprintHandler_resolveComponentPaths(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And terraform components have been set - expectedComponents := []blueprintv1alpha1.TerraformComponent{ - { - Source: "source1", - Path: "path/to/code", - }, - } - baseHandler.blueprint.TerraformComponents = expectedComponents - - // When resolving component paths - blueprint := baseHandler.blueprint.DeepCopy() - baseHandler.resolveComponentPaths(blueprint) - - // Then each component should have the correct full path - for _, component := range blueprint.TerraformComponents { - expectedPath := filepath.Join(baseHandler.projectRoot, "terraform", component.Path) - if component.FullPath != expectedPath { - t.Errorf("Expected component path to be %v, but got %v", expectedPath, component.FullPath) - } - } - }) - - t.Run("isValidTerraformRemoteSource", func(t *testing.T) { - handler, _ := setup(t) - - // Given a set of test cases for terraform source validation - tests := []struct { - name string - source string - want bool - }{ - {"ValidLocalPath", "/absolute/path/to/module", false}, - {"ValidRelativePath", "./relative/path/to/module", false}, - {"InvalidLocalPath", "/invalid/path/to/module", false}, - {"ValidGitURL", "git::https://github.com/user/repo.git", true}, - {"ValidSSHGitURL", "git@github.com:user/repo.git", true}, - {"ValidHTTPURL", "https://github.com/user/repo.git", true}, - {"ValidHTTPZipURL", "https://example.com/archive.zip", true}, - {"InvalidHTTPURL", "https://example.com/not-a-zip", false}, - {"ValidTerraformRegistry", "registry.terraform.io/hashicorp/consul/aws", true}, - {"ValidGitHubReference", "github.com/hashicorp/terraform-aws-consul", true}, - {"InvalidSource", "invalid-source", false}, - {"VersionFileGitAtURL", "git@github.com:user/version.git", true}, - {"VersionFileGitAtURLWithPath", "git@github.com:user/version.git@v1.0.0", true}, - {"ValidGitLabURL", "git::https://gitlab.com/user/repo.git", true}, - {"ValidSSHGitLabURL", "git@gitlab.com:user/repo.git", true}, - {"ErrorCausingPattern", "[invalid-regex", false}, - } - - // When validating each source - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Then the validation result should match the expected outcome - if got := handler.(*BaseBlueprintHandler).isValidTerraformRemoteSource(tt.source); got != tt.want { - t.Errorf("isValidTerraformRemoteSource(%s) = %v, want %v", tt.source, got, tt.want) - } - }) - } - }) - - t.Run("ValidRemoteSourceWithFullPath", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And a source with URL and path prefix - baseHandler.blueprint.Sources = []blueprintv1alpha1.Source{{ - Name: "test-source", - Url: "https://github.com/user/repo.git", - PathPrefix: "terraform", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }} - - // And a terraform component referencing that source - baseHandler.blueprint.TerraformComponents = []blueprintv1alpha1.TerraformComponent{{ - Source: "test-source", - Path: "module/path", - }} - - // When resolving component sources and paths - blueprint := baseHandler.blueprint.DeepCopy() - baseHandler.resolveComponentSources(blueprint) - baseHandler.resolveComponentPaths(blueprint) - - // Then the source should be properly resolved - if blueprint.TerraformComponents[0].Source != "https://github.com/user/repo.git//terraform/module/path?ref=main" { - t.Errorf("Unexpected resolved source: %v", blueprint.TerraformComponents[0].Source) - } - - // And the full path should be correctly constructed - expectedPath := filepath.Join(baseHandler.projectRoot, ".windsor", ".tf_modules", "module/path") - if blueprint.TerraformComponents[0].FullPath != expectedPath { - t.Errorf("Unexpected full path: %v", blueprint.TerraformComponents[0].FullPath) - } - }) - - t.Run("RegexpMatchStringError", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And a mock regexp matcher that returns an error - originalRegexpMatchString := baseHandler.shims.RegexpMatchString - defer func() { baseHandler.shims.RegexpMatchString = originalRegexpMatchString }() - baseHandler.shims.RegexpMatchString = func(pattern, s string) (bool, error) { - return false, fmt.Errorf("mocked error in regexpMatchString") - } - - // When validating an invalid regex pattern - if got := baseHandler.isValidTerraformRemoteSource("[invalid-regex"); got != false { - t.Errorf("isValidTerraformRemoteSource([invalid-regex) = %v, want %v", got, false) - } - }) -} - -func TestBlueprintHandler_processBlueprintData(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } - - t.Run("ValidBlueprintData", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{ - Sources: []blueprintv1alpha1.Source{}, - TerraformComponents: []blueprintv1alpha1.TerraformComponent{}, - Kustomizations: []blueprintv1alpha1.Kustomization{}, - } - - // And valid blueprint data - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: A test blueprint - authors: - - John Doe -sources: - - name: test-source - url: git::https://example.com/test-repo.git -terraform: - - source: test-source - path: path/to/code -kustomize: - - name: test-kustomization - path: ./kustomize -repository: - url: git::https://example.com/test-repo.git - ref: - branch: main -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned - if err != nil { - t.Errorf("processBlueprintData failed: %v", err) - } - - // And the metadata should be correctly set - if blueprint.Metadata.Name != "test-blueprint" { - t.Errorf("Expected name 'test-blueprint', got %s", blueprint.Metadata.Name) - } - if blueprint.Metadata.Description != "A test blueprint" { - t.Errorf("Expected description 'A test blueprint', got %s", blueprint.Metadata.Description) - } - if len(blueprint.Metadata.Authors) != 1 || blueprint.Metadata.Authors[0] != "John Doe" { - t.Errorf("Expected authors ['John Doe'], got %v", blueprint.Metadata.Authors) - } - - // And the sources should be correctly set - if len(blueprint.Sources) != 1 || blueprint.Sources[0].Name != "test-source" { - t.Errorf("Expected one source named 'test-source', got %v", blueprint.Sources) - } - - // And the terraform components should be correctly set - if len(blueprint.TerraformComponents) != 1 || blueprint.TerraformComponents[0].Source != "test-source" { - t.Errorf("Expected one component with source 'test-source', got %v", blueprint.TerraformComponents) - } - - // And the kustomizations should be correctly set - if len(blueprint.Kustomizations) != 1 || blueprint.Kustomizations[0].Name != "test-kustomization" { - t.Errorf("Expected one kustomization named 'test-kustomization', got %v", blueprint.Kustomizations) - } - - // And the repository should be correctly set - if blueprint.Repository.Url != "git::https://example.com/test-repo.git" { - t.Errorf("Expected repository URL 'git::https://example.com/test-repo.git', got %s", blueprint.Repository.Url) - } - if blueprint.Repository.Ref.Branch != "main" { - t.Errorf("Expected repository branch 'main', got %s", blueprint.Repository.Ref.Branch) - } - }) - - t.Run("MissingRequiredFields", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with missing required fields - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: "" - description: "" -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned since validation is removed - if err != nil { - t.Errorf("Expected no error for missing required fields, got: %v", err) - } - }) - - t.Run("InvalidYAML", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And invalid YAML data - data := []byte(`invalid yaml content`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for invalid YAML, got nil") - } - if !strings.Contains(err.Error(), "error unmarshalling blueprint data") { - t.Errorf("Expected error about unmarshalling, got: %v", err) - } - }) - - t.Run("InvalidKustomization", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with an invalid kustomization interval - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: A test blueprint - authors: - - John Doe -kustomize: - - name: test-kustomization - interval: invalid-interval - path: ./kustomize -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then an error should be returned - if err == nil { - t.Fatal("Expected error for invalid kustomization, got nil") - } - if !strings.Contains(err.Error(), "error unmarshalling kustomization YAML") { - t.Errorf("Expected error about unmarshalling kustomization YAML, got: %v", err) - } - }) - - t.Run("ErrorMarshallingKustomizationMap", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And a mock YAML marshaller that returns an error - baseHandler.shims.YamlMarshalNonNull = func(v any) ([]byte, error) { - if _, ok := v.(map[string]any); ok { - return nil, fmt.Errorf("mock kustomization map marshal error") - } - return []byte{}, nil - } - - // And valid blueprint data - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: Test description - authors: - - Test Author -kustomize: - - name: test-kustomization - path: ./test -`) - - // When processing the blueprint data - err := baseHandler.processBlueprintData(data, blueprint) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for kustomization map marshalling, got nil") - } - if !strings.Contains(err.Error(), "error marshalling kustomization map") { - t.Errorf("Expected error about marshalling kustomization map, got: %v", err) - } - }) - - t.Run("InvalidKustomizationIntervalZero", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with a zero kustomization interval - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: Test description - authors: - - Test Author -kustomize: - - apiVersion: kustomize.toolkit.fluxcd.io/v1 - kind: Kustomization - metadata: - name: test-kustomization - spec: - interval: 0s - path: ./test -`) - - // When processing the blueprint data - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for kustomization with zero interval, got: %v", err) - } - }) - - t.Run("InvalidKustomizationIntervalValue", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with an invalid kustomization interval - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: Test description - authors: - - Test Author -kustomize: - - apiVersion: kustomize.toolkit.fluxcd.io/v1 - kind: Kustomization - metadata: - name: test-kustomization - spec: - interval: "invalid" - path: ./test -`) - // When processing the blueprint data - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for invalid kustomization interval value, got: %v", err) - } - }) - - t.Run("MissingDescription", func(t *testing.T) { - // Given a blueprint handler and data with missing description - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - authors: - - John Doe -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned since validation is removed - if err != nil { - t.Errorf("Expected no error for missing description, got: %v", err) - } - }) - - t.Run("MissingAuthors", func(t *testing.T) { - // Given a blueprint handler and data with empty authors list - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: A test blueprint - authors: [] -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned since validation is removed - if err != nil { - t.Errorf("Expected no error for empty authors list, got: %v", err) - } - }) -} - -func TestBlueprintHandler_toFluxKustomization(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - handler.configHandler = mocks.ConfigHandler - - // Set up mock shims to return expected patch content for the test - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - // Extract the relative path from the full path using filepath operations - pathParts := strings.Split(path, string(filepath.Separator)) - var relativePath string - for i, part := range pathParts { - if part == "kustomize" { - relativePath = strings.Join(pathParts[i:], "/") // Always use forward slashes for consistency - break - } - } - - switch relativePath { - case "kustomize/patch1.yaml": - return []byte(`apiVersion: apps/v1 -kind: Deployment -metadata: - name: app-deployment - namespace: test-namespace -spec: - replicas: 3`), nil - case "kustomize/patch2.yaml": - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: app-service - namespace: test-namespace -spec: - type: ClusterIP`), nil - default: - return nil, fmt.Errorf("file not found: %s", path) - } - } - - return handler - } - - t.Run("BasicConversion", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And a basic blueprint kustomization - blueprintKustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Source: "test-source", - Path: "test/path", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{true}[0], - Wait: &[]bool{false}[0], - } - - // When converting to kubernetes kustomization - result := handler.toFluxKustomization(blueprintKustomization, "test-namespace") - - // Then the basic fields should be correctly mapped - if result.Name != "test-kustomization" { - t.Errorf("Expected name to be test-kustomization, got %s", result.Name) - } - if result.Namespace != "test-namespace" { - t.Errorf("Expected namespace to be test-namespace, got %s", result.Namespace) - } - if result.Spec.SourceRef.Name != "test-source" { - t.Errorf("Expected source name to be test-source, got %s", result.Spec.SourceRef.Name) - } - if result.Spec.SourceRef.Kind != "GitRepository" { - t.Errorf("Expected source kind to be GitRepository, got %s", result.Spec.SourceRef.Kind) - } - if result.Spec.Path != "test/path" { - t.Errorf("Expected path to be test/path, got %s", result.Spec.Path) - } - if result.Spec.Interval.Duration != 5*time.Minute { - t.Errorf("Expected interval to be 5m, got %v", result.Spec.Interval.Duration) - } - if result.Spec.RetryInterval.Duration != 1*time.Minute { - t.Errorf("Expected retry interval to be 1m, got %v", result.Spec.RetryInterval.Duration) - } - if result.Spec.Timeout.Duration != 10*time.Minute { - t.Errorf("Expected timeout to be 10m, got %v", result.Spec.Timeout.Duration) - } - if result.Spec.Force != true { - t.Errorf("Expected force to be true, got %v", result.Spec.Force) - } - if result.Spec.Wait != false { - t.Errorf("Expected wait to be false, got %v", result.Spec.Wait) - } - if result.Spec.Prune != true { - t.Errorf("Expected prune to be true (default), got %v", result.Spec.Prune) - } - }) - - t.Run("WithPatches", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And a kustomization with patches - blueprintKustomization := blueprintv1alpha1.Kustomization{ - Name: "patched-kustomization", - Source: "test-source", - Path: "test/path", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{true}[0], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "patch1.yaml", - }, - { - Path: "patch2.yaml", - }, - }, - } - - // When converting to kubernetes kustomization - result := handler.toFluxKustomization(blueprintKustomization, "test-namespace") - - // Then the patches should be correctly mapped - if len(result.Spec.Patches) != 2 { - t.Errorf("Expected 2 patches, got %d", len(result.Spec.Patches)) - } - expectedPatch1 := `apiVersion: apps/v1 -kind: Deployment -metadata: - name: app-deployment - namespace: test-namespace -spec: - replicas: 3` - if result.Spec.Patches[0].Patch != expectedPatch1 { - t.Errorf("Expected first patch content to be:\n%s\n\ngot:\n%s", expectedPatch1, result.Spec.Patches[0].Patch) - } - if result.Spec.Patches[0].Target.Kind != "Deployment" { - t.Errorf("Expected first patch target kind to be Deployment, got %s", result.Spec.Patches[0].Target.Kind) - } - if result.Spec.Patches[0].Target.Name != "app-deployment" { - t.Errorf("Expected first patch target name to be app-deployment, got %s", result.Spec.Patches[0].Target.Name) - } - expectedPatch2 := `apiVersion: v1 -kind: Service -metadata: - name: app-service - namespace: test-namespace -spec: - type: ClusterIP` - if result.Spec.Patches[1].Patch != expectedPatch2 { - t.Errorf("Expected second patch content to be:\n%s\n\ngot:\n%s", expectedPatch2, result.Spec.Patches[1].Patch) - } - if result.Spec.Patches[1].Target.Kind != "Service" { - t.Errorf("Expected second patch target kind to be Service, got %s", result.Spec.Patches[1].Target.Kind) - } - if result.Spec.Patches[1].Target.Name != "app-service" { - t.Errorf("Expected second patch target name to be app-service, got %s", result.Spec.Patches[1].Target.Name) - } - }) - - t.Run("WithCustomPrune", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And a kustomization with custom prune setting - customPrune := false - blueprintKustomization := blueprintv1alpha1.Kustomization{ - Name: "custom-prune-kustomization", - Source: "test-source", - Path: "test/path", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{true}[0], - Prune: &customPrune, - } - - // When converting to kubernetes kustomization - result := handler.toFluxKustomization(blueprintKustomization, "test-namespace") - - // Then the custom prune setting should be used - if result.Spec.Prune != false { - t.Errorf("Expected prune to be false (custom), got %v", result.Spec.Prune) - } - }) - - t.Run("WithDependsOn", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And a kustomization with dependencies - blueprintKustomization := blueprintv1alpha1.Kustomization{ - Name: "dependent-kustomization", - Source: "test-source", - Path: "test/path", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{true}[0], - DependsOn: []string{"dependency-1", "dependency-2"}, - } - - // When converting to kubernetes kustomization - result := handler.toFluxKustomization(blueprintKustomization, "test-namespace") - - // Then the dependencies should be correctly mapped - if len(result.Spec.DependsOn) != 2 { - t.Errorf("Expected 2 dependencies, got %d", len(result.Spec.DependsOn)) - } - if result.Spec.DependsOn[0].Name != "dependency-1" { - t.Errorf("Expected first dependency name to be dependency-1, got %s", result.Spec.DependsOn[0].Name) - } - if result.Spec.DependsOn[0].Namespace != "test-namespace" { - t.Errorf("Expected first dependency namespace to be test-namespace, got %s", result.Spec.DependsOn[0].Namespace) - } - if result.Spec.DependsOn[1].Name != "dependency-2" { - t.Errorf("Expected second dependency name to be dependency-2, got %s", result.Spec.DependsOn[1].Name) - } - if result.Spec.DependsOn[1].Namespace != "test-namespace" { - t.Errorf("Expected second dependency namespace to be test-namespace, got %s", result.Spec.DependsOn[1].Namespace) - } - }) - - t.Run("WithPostBuild", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And a kustomization with postBuild configuration - blueprintKustomization := blueprintv1alpha1.Kustomization{ - Name: "postbuild-kustomization", - Source: "test-source", - Path: "test/path", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{true}[0], - PostBuild: &blueprintv1alpha1.PostBuild{ - Substitute: map[string]string{ - "var1": "value1", - "var2": "value2", - }, - SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ - { - Kind: "ConfigMap", - Name: "config-map-1", - Optional: false, - }, - { - Kind: "Secret", - Name: "secret-1", - Optional: true, - }, - }, - }, - } - - // When converting to kubernetes kustomization - result := handler.toFluxKustomization(blueprintKustomization, "test-namespace") - - // Then the postBuild should be correctly mapped - if result.Spec.PostBuild == nil { - t.Error("Expected postBuild to be set, got nil") - } - if len(result.Spec.PostBuild.Substitute) != 2 { - t.Errorf("Expected 2 substitute variables, got %d", len(result.Spec.PostBuild.Substitute)) - } - if result.Spec.PostBuild.Substitute["var1"] != "value1" { - t.Errorf("Expected var1 to be value1, got %s", result.Spec.PostBuild.Substitute["var1"]) - } - if result.Spec.PostBuild.Substitute["var2"] != "value2" { - t.Errorf("Expected var2 to be value2, got %s", result.Spec.PostBuild.Substitute["var2"]) - } - if len(result.Spec.PostBuild.SubstituteFrom) != 2 { - t.Errorf("Expected 2 substitute references, got %d", len(result.Spec.PostBuild.SubstituteFrom)) - } - if result.Spec.PostBuild.SubstituteFrom[0].Kind != "ConfigMap" { - t.Errorf("Expected first substitute reference kind to be ConfigMap, got %s", result.Spec.PostBuild.SubstituteFrom[0].Kind) - } - if result.Spec.PostBuild.SubstituteFrom[0].Name != "config-map-1" { - t.Errorf("Expected first substitute reference name to be config-map-1, got %s", result.Spec.PostBuild.SubstituteFrom[0].Name) - } - if result.Spec.PostBuild.SubstituteFrom[0].Optional != false { - t.Errorf("Expected first substitute reference optional to be false, got %v", result.Spec.PostBuild.SubstituteFrom[0].Optional) - } - if result.Spec.PostBuild.SubstituteFrom[1].Kind != "Secret" { - t.Errorf("Expected second substitute reference kind to be Secret, got %s", result.Spec.PostBuild.SubstituteFrom[1].Kind) - } - if result.Spec.PostBuild.SubstituteFrom[1].Name != "secret-1" { - t.Errorf("Expected second substitute reference name to be secret-1, got %s", result.Spec.PostBuild.SubstituteFrom[1].Name) - } - if result.Spec.PostBuild.SubstituteFrom[1].Optional != true { - t.Errorf("Expected second substitute reference optional to be true, got %v", result.Spec.PostBuild.SubstituteFrom[1].Optional) - } - }) - - t.Run("WithComponents", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And a kustomization with components - blueprintKustomization := blueprintv1alpha1.Kustomization{ - Name: "components-kustomization", - Source: "test-source", - Path: "test/path", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{true}[0], - Components: []string{"component-1", "component-2"}, - } - - // When converting to kubernetes kustomization - result := handler.toFluxKustomization(blueprintKustomization, "test-namespace") - - // Then the components should be correctly mapped - if len(result.Spec.Components) != 2 { - t.Errorf("Expected 2 components, got %d", len(result.Spec.Components)) - } - if result.Spec.Components[0] != "component-1" { - t.Errorf("Expected first component to be component-1, got %s", result.Spec.Components[0]) - } - if result.Spec.Components[1] != "component-2" { - t.Errorf("Expected second component to be component-2, got %s", result.Spec.Components[1]) - } - }) - - t.Run("CompleteConversion", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And a kustomization with all features - customPrune := false - blueprintKustomization := blueprintv1alpha1.Kustomization{ - Name: "complete-kustomization", - Source: "complete-source", - Path: "complete/path", - Interval: &metav1.Duration{Duration: 15 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 3 * time.Minute}, - Timeout: &metav1.Duration{Duration: 30 * time.Minute}, - Force: &[]bool{true}[0], - Wait: &[]bool{false}[0], - Prune: &customPrune, - DependsOn: []string{"dep-1", "dep-2", "dep-3"}, - Components: []string{"comp-1", "comp-2"}, - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "patch1.yaml", - }, - { - Path: "patch2.yaml", - }, - }, - PostBuild: &blueprintv1alpha1.PostBuild{ - Substitute: map[string]string{ - "env": "production", - "region": "us-west-2", - }, - SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ - { - Kind: "ConfigMap", - Name: "env-config", - Optional: false, - }, - }, - }, - } - - // When converting to kubernetes kustomization - result := handler.toFluxKustomization(blueprintKustomization, "production-namespace") - - // Then all fields should be correctly converted - if result.Name != "complete-kustomization" { - t.Errorf("Expected name to be complete-kustomization, got %s", result.Name) - } - if result.Namespace != "production-namespace" { - t.Errorf("Expected namespace to be production-namespace, got %s", result.Namespace) - } - if result.Kind != "Kustomization" { - t.Errorf("Expected kind to be Kustomization, got %s", result.Kind) - } - if result.APIVersion != "kustomize.toolkit.fluxcd.io/v1" { - t.Errorf("Expected apiVersion to be kustomize.toolkit.fluxcd.io/v1, got %s", result.APIVersion) - } - if result.Spec.SourceRef.Name != "complete-source" { - t.Errorf("Expected source name to be complete-source, got %s", result.Spec.SourceRef.Name) - } - if result.Spec.Path != "complete/path" { - t.Errorf("Expected path to be complete/path, got %s", result.Spec.Path) - } - if result.Spec.Interval.Duration != 15*time.Minute { - t.Errorf("Expected interval to be 15m, got %v", result.Spec.Interval.Duration) - } - if result.Spec.Prune != false { - t.Errorf("Expected prune to be false, got %v", result.Spec.Prune) - } - if len(result.Spec.DependsOn) != 3 { - t.Errorf("Expected 3 dependencies, got %d", len(result.Spec.DependsOn)) - } - if len(result.Spec.Components) != 2 { - t.Errorf("Expected 2 components, got %d", len(result.Spec.Components)) - } - if len(result.Spec.Patches) != 2 { - t.Errorf("Expected 2 patches, got %d", len(result.Spec.Patches)) - } - if result.Spec.PostBuild == nil { - t.Error("Expected postBuild to be set, got nil") - } - }) -} - -func TestBaseBlueprintHandler_applySourceRepository(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } - - t.Run("GitSource", func(t *testing.T) { - // Given a blueprint handler with a git source - handler, mocks := setup(t) - - gitSource := blueprintv1alpha1.Source{ - Name: "git-source", - Url: "https://github.com/example/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - gitRepoApplied := false - mocks.KubernetesManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { - gitRepoApplied = true - if repo.Name != "git-source" { - t.Errorf("Expected repo name 'git-source', got %s", repo.Name) - } - if repo.Spec.URL != "https://github.com/example/repo.git" { - t.Errorf("Expected URL 'https://github.com/example/repo.git', got %s", repo.Spec.URL) - } - return nil - } - - // When applying the source repository - err := handler.applySourceRepository(gitSource, "default") - - // Then it should call ApplyGitRepository - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if !gitRepoApplied { - t.Error("Expected ApplyGitRepository to be called") - } - }) - - t.Run("OCISource", func(t *testing.T) { - // Given a blueprint handler with an OCI source - handler, mocks := setup(t) - - ociSource := blueprintv1alpha1.Source{ - Name: "oci-source", - Url: "oci://ghcr.io/example/repo:v1.0.0", - } - - ociRepoApplied := false - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - ociRepoApplied = true - if repo.Name != "oci-source" { - t.Errorf("Expected repo name 'oci-source', got %s", repo.Name) - } - if repo.Spec.URL != "oci://ghcr.io/example/repo" { - t.Errorf("Expected URL 'oci://ghcr.io/example/repo', got %s", repo.Spec.URL) - } - if repo.Spec.Reference.Tag != "v1.0.0" { - t.Errorf("Expected tag 'v1.0.0', got %s", repo.Spec.Reference.Tag) - } - return nil - } - - // When applying the source repository - err := handler.applySourceRepository(ociSource, "default") - - // Then it should call ApplyOCIRepository - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if !ociRepoApplied { - t.Error("Expected ApplyOCIRepository to be called") - } - }) - - t.Run("GitSourceError", func(t *testing.T) { - // Given a blueprint handler with git source that fails - handler, mocks := setup(t) - - gitSource := blueprintv1alpha1.Source{ - Name: "git-source", - Url: "https://github.com/example/repo.git", - } - - mocks.KubernetesManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { - return fmt.Errorf("git repository error") - } - - // When applying the source repository - err := handler.applySourceRepository(gitSource, "default") - - // Then it should return the error - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "git repository error") { - t.Errorf("Expected git repository error, got: %v", err) - } - }) - - t.Run("OCISourceError", func(t *testing.T) { - // Given a blueprint handler with OCI source that fails - handler, mocks := setup(t) - - ociSource := blueprintv1alpha1.Source{ - Name: "oci-source", - Url: "oci://ghcr.io/example/repo:v1.0.0", - } - - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - return fmt.Errorf("oci repository error") - } - - // When applying the source repository - err := handler.applySourceRepository(ociSource, "default") - - // Then it should return the error - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "oci repository error") { - t.Errorf("Expected oci repository error, got: %v", err) - } - }) -} - -func TestBaseBlueprintHandler_applyOCIRepository(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } - - t.Run("BasicOCIRepository", func(t *testing.T) { - // Given a blueprint handler with basic OCI source - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "basic-oci", - Url: "oci://registry.example.com/repo:v1.0.0", - } - - var appliedRepo *sourcev1.OCIRepository - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should create the correct OCIRepository - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if appliedRepo == nil { - t.Fatal("Expected OCIRepository to be applied") - } - if appliedRepo.Name != "basic-oci" { - t.Errorf("Expected name 'basic-oci', got %s", appliedRepo.Name) - } - if appliedRepo.Namespace != "test-namespace" { - t.Errorf("Expected namespace 'test-namespace', got %s", appliedRepo.Namespace) - } - if appliedRepo.Spec.URL != "oci://registry.example.com/repo" { - t.Errorf("Expected URL 'oci://registry.example.com/repo', got %s", appliedRepo.Spec.URL) - } - if appliedRepo.Spec.Reference.Tag != "v1.0.0" { - t.Errorf("Expected tag 'v1.0.0', got %s", appliedRepo.Spec.Reference.Tag) - } - }) - - t.Run("OCIRepositoryWithoutTag", func(t *testing.T) { - // Given an OCI source without embedded tag - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "no-tag-oci", - Url: "oci://registry.example.com/repo", - } - - var appliedRepo *sourcev1.OCIRepository - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should default to latest tag - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if appliedRepo.Spec.Reference.Tag != "latest" { - t.Errorf("Expected default tag 'latest', got %s", appliedRepo.Spec.Reference.Tag) - } - }) - - t.Run("OCIRepositoryWithRefField", func(t *testing.T) { - // Given an OCI source with ref field instead of embedded tag - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "ref-field-oci", - Url: "oci://registry.example.com/repo", - Ref: blueprintv1alpha1.Reference{ - Tag: "v2.0.0", - }, - } - - var appliedRepo *sourcev1.OCIRepository - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should use the ref field tag - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if appliedRepo.Spec.Reference.Tag != "v2.0.0" { - t.Errorf("Expected tag 'v2.0.0', got %s", appliedRepo.Spec.Reference.Tag) - } - }) - - t.Run("OCIRepositoryWithSemVer", func(t *testing.T) { - // Given an OCI source with semver reference - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "semver-oci", - Url: "oci://registry.example.com/repo", - Ref: blueprintv1alpha1.Reference{ - SemVer: ">=1.0.0 <2.0.0", - }, - } - - var appliedRepo *sourcev1.OCIRepository - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should use the semver reference - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if appliedRepo.Spec.Reference.SemVer != ">=1.0.0 <2.0.0" { - t.Errorf("Expected semver '>=1.0.0 <2.0.0', got %s", appliedRepo.Spec.Reference.SemVer) - } - }) - - t.Run("OCIRepositoryWithDigest", func(t *testing.T) { - // Given an OCI source with commit/digest reference - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "digest-oci", - Url: "oci://registry.example.com/repo", - Ref: blueprintv1alpha1.Reference{ - Commit: "sha256:abc123", - }, - } - - var appliedRepo *sourcev1.OCIRepository - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should use the digest reference - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if appliedRepo.Spec.Reference.Digest != "sha256:abc123" { - t.Errorf("Expected digest 'sha256:abc123', got %s", appliedRepo.Spec.Reference.Digest) - } - }) - - t.Run("OCIRepositoryWithSecret", func(t *testing.T) { - // Given an OCI source with secret name - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "secret-oci", - Url: "oci://private-registry.example.com/repo:v1.0.0", - SecretName: "registry-credentials", - } - - var appliedRepo *sourcev1.OCIRepository - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should include the secret reference - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if appliedRepo.Spec.SecretRef == nil { - t.Error("Expected SecretRef to be set") - } else if appliedRepo.Spec.SecretRef.Name != "registry-credentials" { - t.Errorf("Expected secret name 'registry-credentials', got %s", appliedRepo.Spec.SecretRef.Name) - } - }) - - t.Run("OCIRepositoryWithPortInURL", func(t *testing.T) { - // Given an OCI source with port in URL (should not be treated as tag) - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "port-oci", - Url: "oci://registry.example.com:5000/repo", - Ref: blueprintv1alpha1.Reference{ - Tag: "v1.0.0", - }, - } - - var appliedRepo *sourcev1.OCIRepository - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should preserve the port and use ref field - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - if appliedRepo.Spec.URL != "oci://registry.example.com:5000/repo" { - t.Errorf("Expected URL with port 'oci://registry.example.com:5000/repo', got %s", appliedRepo.Spec.URL) - } - if appliedRepo.Spec.Reference.Tag != "v1.0.0" { - t.Errorf("Expected tag 'v1.0.0', got %s", appliedRepo.Spec.Reference.Tag) - } - }) - - t.Run("OCIRepositoryError", func(t *testing.T) { - // Given an OCI source that fails to apply - handler, mocks := setup(t) - - source := blueprintv1alpha1.Source{ - Name: "error-oci", - Url: "oci://registry.example.com/repo:v1.0.0", - } - - mocks.KubernetesManager.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - return fmt.Errorf("failed to apply oci repository") - } - - // When applying the OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then it should return the error - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply oci repository") { - t.Errorf("Expected oci repository error, got: %v", err) - } - }) -} - -func TestBaseBlueprintHandler_isOCISource(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler - } - - t.Run("MainRepositoryOCI", func(t *testing.T) { - // Given a blueprint with OCI main repository - handler := setup(t) - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{Name: "test-blueprint"}, - Repository: blueprintv1alpha1.Repository{ - Url: "oci://ghcr.io/example/blueprint:v1.0.0", - }, - } - - // When checking if main repository is OCI - result := handler.isOCISource("test-blueprint") - - // Then it should return true - if !result { - t.Error("Expected main repository to be identified as OCI source") - } - }) - - t.Run("MainRepositoryGit", func(t *testing.T) { - // Given a blueprint with Git main repository - handler := setup(t) - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{Name: "test-blueprint"}, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/example/blueprint.git", - }, - } - - // When checking if main repository is OCI - result := handler.isOCISource("test-blueprint") - - // Then it should return false - if result { - t.Error("Expected main repository to not be identified as OCI source") - } - }) - - t.Run("AdditionalSourceOCI", func(t *testing.T) { - // Given a blueprint with OCI additional source - handler := setup(t) - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{Name: "test-blueprint"}, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/example/blueprint.git", - }, - Sources: []blueprintv1alpha1.Source{ - { - Name: "oci-source", - Url: "oci://ghcr.io/example/source:latest", - }, - { - Name: "git-source", - Url: "https://github.com/example/source.git", - }, - }, - } - - // When checking if additional source is OCI - result := handler.isOCISource("oci-source") - - // Then it should return true - if !result { - t.Error("Expected additional source to be identified as OCI source") - } - }) - - t.Run("AdditionalSourceGit", func(t *testing.T) { - // Given a blueprint with Git additional source - handler := setup(t) - handler.blueprint = blueprintv1alpha1.Blueprint{ - Sources: []blueprintv1alpha1.Source{ - { - Name: "git-source", - Url: "https://github.com/example/source.git", - }, - }, - } - - // When checking if additional source is OCI - result := handler.isOCISource("git-source") - - // Then it should return false - if result { - t.Error("Expected additional source to not be identified as OCI source") - } - }) -} diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 4825aa79a..4adbb6f3f 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -28,6 +28,19 @@ import ( // Test Setup // ============================================================================= +// mockFileInfo implements os.FileInfo for testing +type mockFileInfo struct { + name string + isDir bool +} + +func (m mockFileInfo) Name() string { return m.name } +func (m mockFileInfo) Size() int64 { return 0 } +func (m mockFileInfo) Mode() os.FileMode { return 0644 } +func (m mockFileInfo) ModTime() time.Time { return time.Time{} } +func (m mockFileInfo) IsDir() bool { return m.isDir } +func (m mockFileInfo) Sys() any { return nil } + type mockJsonnetVM struct { EvaluateFunc func(filename, snippet string) (string, error) TLACalls []struct{ Key, Val string } @@ -3510,3 +3523,991 @@ type: Opaque`), nil t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) } } + +// ============================================================================= +// Values ConfigMap Tests +// ============================================================================= + +func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { + // Given a handler with mocks + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + handler.configHandler = mocks.ConfigHandler + handler.kubernetesManager = mocks.KubernetesManager + return handler + } + + t.Run("SuccessWithGlobalValues", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock kustomize directory with global values.yaml + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/test/config/kustomize" { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == "/test/config/kustomize/values.yaml" { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for global values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == "/test/config/kustomize/values.yaml" { + return []byte(`domain: example.com +port: 80 +enabled: true`), nil + } + return nil, os.ErrNotExist + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "domain": "example.com", + "port": 80, + "enabled": true, + } + return nil + } + + // And mock Kubernetes manager + var appliedConfigMaps []string + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + appliedConfigMaps = append(appliedConfigMaps, name) + return nil + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should succeed + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) + } + + // And it should apply the global values ConfigMap + if len(appliedConfigMaps) != 1 { + t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) + } + if appliedConfigMaps[0] != "values-global" { + t.Errorf("expected ConfigMap name to be 'values-global', got '%s'", appliedConfigMaps[0]) + } + }) + + t.Run("SuccessWithComponentValues", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock kustomize directory with component values + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/test/config/kustomize" { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == "/test/config/kustomize/ingress/values.yaml" { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock directory read + handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + if name == "/test/config/kustomize" { + return []os.DirEntry{ + &mockDirEntry{name: "ingress", isDir: true}, + }, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for component values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == "/test/config/kustomize/ingress/values.yaml" { + return []byte(`host: ingress.example.com +ssl: true`), nil + } + return nil, os.ErrNotExist + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "host": "ingress.example.com", + "ssl": true, + } + return nil + } + + // And mock Kubernetes manager + var appliedConfigMaps []string + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + appliedConfigMaps = append(appliedConfigMaps, name) + return nil + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should succeed + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) + } + + // And it should apply the component values ConfigMap + if len(appliedConfigMaps) != 1 { + t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) + } + if appliedConfigMaps[0] != "values-ingress" { + t.Errorf("expected ConfigMap name to be 'values-ingress', got '%s'", appliedConfigMaps[0]) + } + }) + + t.Run("NoKustomizeDirectory", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that kustomize directory doesn't exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should succeed (no-op) + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed when no kustomize directory, got: %v", err) + } + }) + + t.Run("ConfigRootError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root that fails + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "", os.ErrNotExist + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should fail + if err == nil { + t.Fatal("expected applyValuesConfigMaps to fail with config root error") + } + if !strings.Contains(err.Error(), "failed to get config root") { + t.Errorf("expected error about config root, got: %v", err) + } + }) + + t.Run("ReadDirError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock kustomize directory exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/test/config/kustomize" { + return &mockFileInfo{name: "kustomize"}, nil + } + return nil, os.ErrNotExist + } + + // And mock ReadDir that fails + handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + return nil, os.ErrPermission + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should fail + if err == nil { + t.Fatal("expected applyValuesConfigMaps to fail with ReadDir error") + } + if !strings.Contains(err.Error(), "failed to read kustomize directory") { + t.Errorf("expected error about reading kustomize directory, got: %v", err) + } + }) + + t.Run("ComponentConfigMapError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock kustomize directory with component values + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/test/config/kustomize" { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == "/test/config/kustomize/ingress/values.yaml" { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock directory read + handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + if name == "/test/config/kustomize" { + return []os.DirEntry{ + &mockDirEntry{name: "ingress", isDir: true}, + }, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for component values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == "/test/config/kustomize/ingress/values.yaml" { + return []byte(`host: ingress.example.com`), nil + } + return nil, os.ErrNotExist + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "host": "ingress.example.com", + } + return nil + } + + // And mock Kubernetes manager that fails + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + return os.ErrPermission + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should fail + if err == nil { + t.Fatal("expected applyValuesConfigMaps to fail with ConfigMap error") + } + if !strings.Contains(err.Error(), "failed to create ConfigMap for component ingress") { + t.Errorf("expected error about component ConfigMap creation, got: %v", err) + } + }) +} + +func TestBaseBlueprintHandler_createConfigMapFromValues(t *testing.T) { + // Given a handler with mocks + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + handler.configHandler = mocks.ConfigHandler + handler.kubernetesManager = mocks.KubernetesManager + return handler + } + + t.Run("SuccessWithStringValues", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock file read + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`domain: example.com +environment: production`), nil + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "domain": "example.com", + "environment": "production", + } + return nil + } + + // And mock Kubernetes manager + var appliedName, appliedNamespace string + var appliedData map[string]string + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + appliedName = name + appliedNamespace = namespace + appliedData = data + return nil + } + + // When creating ConfigMap from values + err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + + // Then it should succeed + if err != nil { + t.Fatalf("expected createConfigMapFromValues to succeed, got: %v", err) + } + + // And it should apply the ConfigMap with correct data + if appliedName != "test-config" { + t.Errorf("expected ConfigMap name to be 'test-config', got '%s'", appliedName) + } + if appliedNamespace != "system-gitops" { + t.Errorf("expected namespace to be 'system-gitops', got '%s'", appliedNamespace) + } + if len(appliedData) != 2 { + t.Errorf("expected 2 values in ConfigMap, got %d", len(appliedData)) + } + if appliedData["domain"] != "example.com" { + t.Errorf("expected domain to be 'example.com', got '%s'", appliedData["domain"]) + } + if appliedData["environment"] != "production" { + t.Errorf("expected environment to be 'production', got '%s'", appliedData["environment"]) + } + }) + + t.Run("SuccessWithMixedTypes", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock file read + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`port: 80 +enabled: true +host: localhost`), nil + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "port": 80, + "enabled": true, + "host": "localhost", + } + return nil + } + + // And mock Kubernetes manager + var appliedData map[string]string + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + appliedData = data + return nil + } + + // When creating ConfigMap from values + err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + + // Then it should succeed + if err != nil { + t.Fatalf("expected createConfigMapFromValues to succeed, got: %v", err) + } + + // And it should convert all types to strings + if appliedData["port"] != "80" { + t.Errorf("expected port to be '80', got '%s'", appliedData["port"]) + } + if appliedData["enabled"] != "true" { + t.Errorf("expected enabled to be 'true', got '%s'", appliedData["enabled"]) + } + if appliedData["host"] != "localhost" { + t.Errorf("expected host to be 'localhost', got '%s'", appliedData["host"]) + } + }) + + t.Run("ReadFileError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock file read that fails + handler.shims.ReadFile = func(name string) ([]byte, error) { + return nil, os.ErrNotExist + } + + // When creating ConfigMap from values + err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + + // Then it should fail + if err == nil { + t.Fatal("expected createConfigMapFromValues 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) + } + }) + + t.Run("YamlUnmarshalError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock file read + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`invalid: yaml: content`), nil + } + + // And mock YAML unmarshal that fails + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return os.ErrInvalid + } + + // When creating ConfigMap from values + err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + + // Then it should fail + if err == nil { + t.Fatal("expected createConfigMapFromValues to fail with YamlUnmarshal error") + } + if !strings.Contains(err.Error(), "failed to unmarshal values file") { + t.Errorf("expected error about unmarshaling values file, got: %v", err) + } + }) + + t.Run("UnsupportedValueType", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock file read + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`complex: [1, 2, 3]`), nil + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "complex": []any{1, 2, 3}, + } + return nil + } + + // When creating ConfigMap from values + err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + + // Then it should fail with validation error + if err == nil { + t.Fatal("expected createConfigMapFromValues to fail with validation error") + } + if !strings.Contains(err.Error(), "complex types") { + t.Errorf("expected error to mention complex types, got: %v", err) + } + }) + + t.Run("ValidationWithComplexTypes", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And test cases for validation + testCases := []struct { + name string + values map[string]any + expectError bool + errorMsg string + }{ + { + name: "ValidScalarTypes", + values: map[string]any{ + "string": "value", + "int": 42, + "float": 3.14, + "bool": true, + }, + expectError: false, + }, + { + name: "InvalidMapType", + values: map[string]any{ + "nested": map[string]any{"key": "value"}, + }, + expectError: true, + errorMsg: "complex types", + }, + { + name: "InvalidSliceType", + values: map[string]any{ + "array": []any{1, 2, 3}, + }, + expectError: true, + errorMsg: "complex types", + }, + { + name: "MixedValidAndInvalid", + values: map[string]any{ + "valid": "string", + "invalid": map[string]any{"nested": "value"}, + }, + expectError: true, + errorMsg: "complex types", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // When validating values + err := handler.validateValuesForSubstitution(tc.values) + + // 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) + } + } + }) + } + }) + + t.Run("ApplyConfigMapError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock file read + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`domain: example.com`), nil + } + + // And mock YAML unmarshal + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "domain": "example.com", + } + return nil + } + + // And mock Kubernetes manager that fails + mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) + mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + return os.ErrPermission + } + + // When creating ConfigMap from values + err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + + // Then it should fail + if err == nil { + t.Fatal("expected createConfigMapFromValues to fail with ApplyConfigMap error") + } + if !strings.Contains(err.Error(), "failed to apply ConfigMap test-config") { + t.Errorf("expected error about applying ConfigMap, got: %v", err) + } + }) +} + +// ============================================================================= +// toFluxKustomization ConfigMap Tests +// ============================================================================= + +func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testing.T) { + // Given a handler with mocks + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + handler.configHandler = mocks.ConfigHandler + handler.kubernetesManager = mocks.KubernetesManager + return handler + } + + t.Run("WithGlobalValuesConfigMap", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that global values.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/test/config/kustomize/values.yaml" { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And a kustomization + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with ConfigMap references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should have the blueprint ConfigMap reference + if len(result.Spec.PostBuild.SubstituteFrom) < 1 { + t.Fatal("expected at least 1 SubstituteFrom reference") + } + + blueprintFound := false + globalValuesFound := false + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "blueprint" { + blueprintFound = true + if ref.Optional != false { + t.Errorf("expected blueprint ConfigMap to be Optional=false, got %v", ref.Optional) + } + } + if ref.Kind == "ConfigMap" && ref.Name == "values-global" { + globalValuesFound = true + if ref.Optional != false { + t.Errorf("expected values-global ConfigMap to be Optional=false, got %v", ref.Optional) + } + } + } + + if !blueprintFound { + t.Error("expected blueprint ConfigMap reference to be present") + } + if !globalValuesFound { + t.Error("expected values-global ConfigMap reference to be present") + } + }) + + t.Run("WithComponentValuesConfigMap", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that component values.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/test/config/kustomize/ingress/values.yaml" { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And a kustomization with component name + kustomization := blueprintv1alpha1.Kustomization{ + Name: "ingress", + Path: "ingress/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with ConfigMap references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should have the component-specific ConfigMap reference + componentValuesFound := false + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "values-ingress" { + componentValuesFound = true + if ref.Optional != false { + t.Errorf("expected values-ingress ConfigMap to be Optional=false, got %v", ref.Optional) + } + break + } + } + + if !componentValuesFound { + t.Error("expected values-ingress ConfigMap reference to be present") + } + }) + + t.Run("WithExistingPostBuild", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that global values.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "/test/config/kustomize/values.yaml" { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And a kustomization with existing PostBuild + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + PostBuild: &blueprintv1alpha1.PostBuild{ + Substitute: map[string]string{ + "VAR1": "value1", + "VAR2": "value2", + }, + SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: "existing-config", + Optional: true, + }, + }, + }, + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with both existing and new references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should preserve existing Substitute values + if len(result.Spec.PostBuild.Substitute) != 2 { + t.Errorf("expected 2 Substitute values, got %d", len(result.Spec.PostBuild.Substitute)) + } + if result.Spec.PostBuild.Substitute["VAR1"] != "value1" { + t.Errorf("expected VAR1 to be 'value1', got '%s'", result.Spec.PostBuild.Substitute["VAR1"]) + } + if result.Spec.PostBuild.Substitute["VAR2"] != "value2" { + t.Errorf("expected VAR2 to be 'value2', got '%s'", result.Spec.PostBuild.Substitute["VAR2"]) + } + + // And it should have the correct SubstituteFrom references + blueprintFound := false + globalValuesFound := false + existingConfigFound := false + + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "blueprint" { + blueprintFound = true + } + if ref.Kind == "ConfigMap" && ref.Name == "values-global" { + globalValuesFound = true + } + if ref.Kind == "ConfigMap" && ref.Name == "existing-config" { + existingConfigFound = true + if ref.Optional != true { + t.Errorf("expected existing-config to be Optional=true, got %v", ref.Optional) + } + } + } + + if !blueprintFound { + t.Error("expected blueprint ConfigMap reference to be present") + } + if !globalValuesFound { + t.Error("expected values-global ConfigMap reference to be present") + } + if !existingConfigFound { + t.Error("expected existing-config ConfigMap reference to be preserved") + } + }) + + t.Run("WithoutValuesConfigMaps", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + + // And mock that no values.yaml files exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And a kustomization + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with only blueprint ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should only have the blueprint ConfigMap reference + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + + ref := result.Spec.PostBuild.SubstituteFrom[0] + if ref.Kind != "ConfigMap" { + t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + } + if ref.Name != "blueprint" { + t.Errorf("expected Name to be 'blueprint', got '%s'", ref.Name) + } + if ref.Optional != false { + t.Errorf("expected Optional to be false, got %v", ref.Optional) + } + }) + + t.Run("ConfigRootError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } + + // And mock config root that fails + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "", os.ErrNotExist + } + + // And a kustomization + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should still have PostBuild with only blueprint ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should only have the blueprint ConfigMap reference (no values ConfigMaps due to error) + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + + ref := result.Spec.PostBuild.SubstituteFrom[0] + if ref.Kind != "ConfigMap" { + t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + } + if ref.Name != "blueprint" { + t.Errorf("expected Name to be 'blueprint', got '%s'", ref.Name) + } + }) +} diff --git a/pkg/generators/kustomize_generator.go b/pkg/generators/kustomize_generator.go new file mode 100644 index 000000000..f4f700e50 --- /dev/null +++ b/pkg/generators/kustomize_generator.go @@ -0,0 +1,360 @@ +package generators + +import ( + "fmt" + "path/filepath" + "strings" + + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "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 +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewKustomizeGenerator creates a new KustomizeGenerator with the provided dependency injector. +// It initializes the base generator and prepares it for kustomize file generation. +func NewKustomizeGenerator(injector di.Injector) *KustomizeGenerator { + return &KustomizeGenerator{ + BaseGenerator: *NewGenerator(injector), + } +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Initialize sets up the KustomizeGenerator dependencies including the blueprint handler. +// Calls the base generator's Initialize method and then resolves the blueprint handler +// for kustomize-specific operations. +func (g *KustomizeGenerator) Initialize() error { + if err := g.BaseGenerator.Initialize(); err != nil { + return fmt.Errorf("failed to initialize base generator: %w", err) + } + + blueprintHandler := g.injector.Resolve("blueprintHandler") + if blueprintHandler == nil { + return fmt.Errorf("blueprint handler not found in dependency injector") + } + + handler, ok := blueprintHandler.(KustomizeBlueprintHandler) + if !ok { + return fmt.Errorf("resolved blueprint handler is not of expected type") + } + + g.blueprintHandler = handler + 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. +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) + } + + for key, values := range data { + if strings.HasPrefix(key, "kustomize/") { + if err := g.generatePatchFile(key, values, configRoot, shouldOverwrite); err != nil { + return err + } + } else if strings.HasPrefix(key, "values/") { + if err := g.generateValuesFile(key, values, configRoot, shouldOverwrite); err != nil { + return err + } + } + } + + return nil +} + +// ============================================================================= +// Private Methods +// ============================================================================= + +// generatePatchFile generates patch files for kustomizations based on the provided key and values. +// It validates the kustomization name and patch directory, ensures values are a map, constructs the full patch file path, +// validates the path, and delegates file generation to generatePatchFiles. 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/") + + if err := g.validateKustomizationName(patchPath); err != nil { + return fmt.Errorf("invalid kustomization name %s: %w", patchPath, err) + } + + patchesDir := filepath.Join(configRoot, "kustomize") + 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 kustomization %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.generatePatchFiles(fullPatchPath, valuesMap, overwrite); err != nil { + return fmt.Errorf("failed to generate patch files for %s: %w", patchPath, err) + } + + return nil +} + +// generateValuesFile creates values.yaml files for post-build variable substitution in kustomize workflows. +// Accepts a key, values map, configuration root, and overwrite flag. Validates the values file name and directory, +// ensures the values are a map with only scalar types, determines the correct file path for global or component-specific values, +// validates the final path, and writes the values file. Returns an error if any validation or file operation fails. +func (g *KustomizeGenerator) generateValuesFile(key string, values any, configRoot string, overwrite bool) error { + valuesPath := strings.TrimPrefix(key, "values/") + + if err := g.validateKustomizationName(valuesPath); err != nil { + return fmt.Errorf("invalid values name %s: %w", valuesPath, err) + } + + 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 for kustomization %s must be a map, got %T", valuesPath, values) + } + + if err := g.validateValuesForSubstitution(valuesMap); err != nil { + return fmt.Errorf("invalid values for post-build substitution %s: %w", valuesPath, err) + } + + var fullValuesPath string + if valuesPath == "global" { + fullValuesPath = filepath.Join(valuesDir, "values.yaml") + } else { + fullValuesPath = filepath.Join(valuesDir, valuesPath, "values.yaml") + } + + if err := g.validatePath(fullValuesPath, configRoot); err != nil { + return fmt.Errorf("invalid values file path %s: %w", fullValuesPath, err) + } + + if err := g.generateValuesFiles(fullValuesPath, valuesMap, overwrite); err != nil { + return fmt.Errorf("failed to generate values files for %s: %w", valuesPath, err) + } + + return nil +} + +// 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") + } + + components := strings.Split(name, "/") + for _, component := range components { + 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) + } + } + } + + return nil +} + +// validateValuesForSubstitution checks that all values are valid for Flux post-build variable substitution. +// Permitted types are string, numeric, and boolean. Complex types (maps, slices) are rejected. +// Returns an error if any value is not a supported type. +func (g *KustomizeGenerator) validateValuesForSubstitution(values map[string]any) error { + for key, value := range values { + switch v := value.(type) { + case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: + continue + case map[string]any, []any: + return fmt.Errorf("values for post-build substitution cannot contain complex types (maps or slices), key '%s' has type %T", key, v) + default: + return fmt.Errorf("values for post-build substitution can only contain strings, numbers, and booleans, key '%s' has unsupported type %T", key, v) + } + } + 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. +func (g *KustomizeGenerator) validateKubernetesManifest(content any) error { + contentMap, ok := content.(map[string]any) + if !ok { + return fmt.Errorf("content must be a map, got %T", content) + } + + apiVersion, ok := contentMap["apiVersion"].(string) + if !ok || apiVersion == "" { + return fmt.Errorf("manifest missing or invalid 'apiVersion' field") + } + + kind, ok := contentMap["kind"].(string) + if !ok || kind == "" { + return fmt.Errorf("manifest missing or invalid 'kind' field") + } + + metadata, ok := contentMap["metadata"].(map[string]any) + if !ok { + return fmt.Errorf("manifest missing 'metadata' field") + } + + name, ok := metadata["name"].(string) + if !ok || name == "" { + return fmt.Errorf("manifest missing or invalid 'metadata.name' field") + } + + return nil +} + +// generatePatchFiles writes a YAML patch file to patchPath using the provided values map. +// patchPath must be a file path. If it doesn't have a .yaml or .yml extension, .yaml will be automatically appended. +// For Jsonnet format, values is a direct object. If overwrite is false, existing files are not replaced. +// The content must be a valid Kubernetes manifest map with non-empty "apiVersion", "kind", and "metadata.name" fields. +// Returns an error on validation, marshalling, or file operation failure. +func (g *KustomizeGenerator) generatePatchFiles(patchPath string, values map[string]any, overwrite bool) error { + if !strings.HasSuffix(patchPath, ".yaml") && !strings.HasSuffix(patchPath, ".yml") { + patchPath = patchPath + ".yaml" + } + + dir := filepath.Dir(patchPath) + 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(patchPath); err == nil { + return nil + } + } + + if err := g.validateKubernetesManifest(values); err != nil { + return fmt.Errorf("invalid Kubernetes manifest for %s: %w", patchPath, err) + } + + yamlData, err := g.shims.MarshalYAML(values) + if err != nil { + return fmt.Errorf("failed to marshal content to YAML for %s: %w", patchPath, err) + } + + if err := g.shims.WriteFile(patchPath, yamlData, 0644); err != nil { + return fmt.Errorf("failed to write patch file %s: %w", patchPath, err) + } + + return nil +} + +// generateValuesFiles writes a YAML values file to valuesPath using the provided values map. +// valuesPath must be a file path. If it doesn't have a .yaml or .yml extension, .yaml will be automatically appended. +// For Jsonnet format, values is a direct object. If overwrite is false, existing files are not replaced. +// The content must be a valid YAML map structure suitable for post-build variable substitution. +// Returns an error on validation, marshalling, or file operation failure. +func (g *KustomizeGenerator) generateValuesFiles(valuesPath string, values map[string]any, overwrite bool) error { + if !strings.HasSuffix(valuesPath, ".yaml") && !strings.HasSuffix(valuesPath, ".yml") { + valuesPath = valuesPath + ".yaml" + } + + dir := filepath.Dir(valuesPath) + 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(valuesPath); 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", valuesPath, err) + } + + if err := g.shims.WriteFile(valuesPath, yamlData, 0644); err != nil { + return fmt.Errorf("failed to write values file %s: %w", valuesPath, err) + } + + return nil +} + +// ============================================================================= +// Interface Compliance +// ============================================================================= + +// Ensure KustomizeGenerator implements Generator +var _ Generator = (*KustomizeGenerator)(nil) diff --git a/pkg/generators/kustomize_generator_test.go b/pkg/generators/kustomize_generator_test.go new file mode 100644 index 000000000..30ea67fd6 --- /dev/null +++ b/pkg/generators/kustomize_generator_test.go @@ -0,0 +1,1045 @@ +package generators + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/windsorcli/cli/pkg/config" + "github.com/windsorcli/cli/pkg/di" +) + +// ============================================================================= +// 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 + 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 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 + } + + data := map[string]any{ + "kustomize/test-patch": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + }, + } + + err := generator.Generate(data) + if err != nil { + t.Fatalf("expected Generate to succeed with kustomize data, 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 + } + + // 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 + } + + data := map[string]any{ + "values/global": map[string]any{ + "domain": "example.com", + "port": 80, + "enabled": true, + }, + } + + err := generator.Generate(data) + if err != nil { + t.Fatalf("expected Generate to succeed with values data, got: %v", err) + } + }) + + t.Run("ConfigRootError", func(t *testing.T) { + generator, mocks := setupKustomizeGeneratorMocks(t) + _ = generator.Initialize() + + // Mock config handler to fail + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + + data := map[string]any{"kustomize/test": "value"} + err := generator.Generate(data) + if err == nil { + t.Fatal("expected Generate to fail with config root error") + } + if !strings.Contains(err.Error(), "failed to get config root") { + t.Errorf("expected error about config root, 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 + } + + // 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.MarshalYAML = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + + key := "kustomize/test-patch" + values := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + } + + err := generator.generatePatchFile(key, values, "/test/config", false) + if err != nil { + t.Fatalf("expected generatePatchFile to succeed, got: %v", err) + } + }) + + t.Run("InvalidKustomizationName", func(t *testing.T) { + generator, _ := setupKustomizeGeneratorMocks(t) + + key := "kustomize/invalid@name" + values := map[string]any{"test": "value"} + + err := generator.generatePatchFile(key, values, "/test/config", false) + if err == nil { + t.Fatal("expected generatePatchFile to fail with invalid name") + } + if !strings.Contains(err.Error(), "invalid kustomization name") { + t.Errorf("expected error about invalid name, got: %v", err) + } + }) + + 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 + } + + // Mock shims to fail path validation + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, fmt.Errorf("path error") + } + + key := "kustomize/test-patch" + values := map[string]any{"test": "value"} + + err := generator.generatePatchFile(key, values, "/test/config", false) + if err == nil { + t.Fatal("expected generatePatchFile to fail with path error") + } + }) +} + +func TestKustomizeGenerator_generateValuesFile(t *testing.T) { + t.Run("SuccessGlobal", 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.MarshalYAML = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + + key := "values/global" + values := map[string]any{ + "domain": "example.com", + "port": 80, + "enabled": true, + } + + err := generator.generateValuesFile(key, values, "/test/config", false) + if err != nil { + t.Fatalf("expected generateValuesFile to succeed, got: %v", err) + } + }) + + t.Run("SuccessComponent", 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.MarshalYAML = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + + key := "values/ingress" + values := map[string]any{ + "host": "example.com", + "tls": true, + } + + err := generator.generateValuesFile(key, values, "/test/config", false) + if err != nil { + t.Fatalf("expected generateValuesFile to succeed, got: %v", err) + } + }) + + t.Run("InvalidValuesName", func(t *testing.T) { + generator, _ := setupKustomizeGeneratorMocks(t) + + key := "values/invalid@name" + values := map[string]any{"test": "value"} + + err := generator.generateValuesFile(key, values, "/test/config", false) + if err == nil { + t.Fatal("expected generateValuesFile to fail with invalid name") + } + if !strings.Contains(err.Error(), "invalid values name") { + t.Errorf("expected error about invalid name, got: %v", 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 + } + + key := "values/global" + values := "not a map" + + err := generator.generateValuesFile(key, values, "/test/config", false) + if err == nil { + t.Fatal("expected generateValuesFile to fail with invalid type") + } + if !strings.Contains(err.Error(), "must be a map") { + t.Errorf("expected error about invalid type, 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 + } + + key := "values/global" + values := map[string]any{ + "valid": "string", + "invalid": map[string]any{"nested": "value"}, + } + + err := generator.generateValuesFile(key, values, "/test/config", false) + if err == nil { + t.Fatal("expected generateValuesFile to fail with invalid values") + } + if !strings.Contains(err.Error(), "complex types") { + t.Errorf("expected error about complex types, got: %v", err) + } + }) +} + +func TestKustomizeGenerator_generatePatchFiles(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 + } + 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 + } + + patchPath := "/test/patch.yaml" + values := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + } + + err := generator.generatePatchFiles(patchPath, values, false) + if err != nil { + t.Fatalf("expected generatePatchFiles to succeed, got: %v", err) + } + }) + + t.Run("InvalidManifest", func(t *testing.T) { + generator, mocks := setupKustomizeGeneratorMocks(t) + + // Mock shims to avoid file system issues + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + patchPath := "/test/patch.yaml" + values := map[string]any{ + "invalid": "manifest", + } + + err := generator.generatePatchFiles(patchPath, values, false) + if err == nil { + t.Fatal("expected generatePatchFiles to fail with invalid manifest") + } + if !strings.Contains(err.Error(), "invalid Kubernetes manifest") { + t.Errorf("expected error about invalid manifest, 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") + } + + patchPath := "/test/patch.yaml" + values := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + } + + err := generator.generatePatchFiles(patchPath, values, false) + if err == nil { + t.Fatal("expected generatePatchFiles to fail with mkdir error") + } + if !strings.Contains(err.Error(), "failed to create directory") { + t.Errorf("expected error about directory creation, got: %v", err) + } + }) + + t.Run("MarshalYAMLError", func(t *testing.T) { + generator, mocks := setupKustomizeGeneratorMocks(t) + + // Mock shims + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { + return nil, fmt.Errorf("marshal error") + } + + patchPath := "/test/patch.yaml" + values := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + } + + err := generator.generatePatchFiles(patchPath, values, false) + if err == nil { + t.Fatal("expected generatePatchFiles to fail with marshal error") + } + if !strings.Contains(err.Error(), "failed to marshal content to YAML") { + t.Errorf("expected error about YAML marshaling, 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 + } + mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write error") + } + + patchPath := "/test/patch.yaml" + values := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + } + + err := generator.generatePatchFiles(patchPath, values, false) + if err == nil { + t.Fatal("expected generatePatchFiles to fail with write error") + } + if !strings.Contains(err.Error(), "failed to write patch file") { + t.Errorf("expected error about file writing, got: %v", err) + } + }) +} + +func TestKustomizeGenerator_generateValuesFiles(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 + } + 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 + } + + valuesPath := "/test/values.yaml" + values := map[string]any{ + "domain": "example.com", + "port": 80, + "enabled": true, + } + + err := generator.generateValuesFiles(valuesPath, values, false) + if err != nil { + t.Fatalf("expected generateValuesFiles to succeed, 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") + } + + valuesPath := "/test/values.yaml" + values := map[string]any{"test": "value"} + + err := generator.generateValuesFiles(valuesPath, values, false) + if err == nil { + t.Fatal("expected generateValuesFiles to fail with mkdir error") + } + if !strings.Contains(err.Error(), "failed to create directory") { + t.Errorf("expected error about directory creation, got: %v", err) + } + }) + + t.Run("MarshalYAMLError", func(t *testing.T) { + generator, mocks := setupKustomizeGeneratorMocks(t) + + // Mock shims + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { + return nil, fmt.Errorf("marshal error") + } + + valuesPath := "/test/values.yaml" + values := map[string]any{"test": "value"} + + err := generator.generateValuesFiles(valuesPath, values, false) + if err == nil { + t.Fatal("expected generateValuesFiles to fail with marshal error") + } + if !strings.Contains(err.Error(), "failed to marshal content to YAML") { + t.Errorf("expected error about YAML marshaling, 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 + } + mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { + return []byte("test yaml"), nil + } + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write error") + } + + valuesPath := "/test/values.yaml" + values := map[string]any{"test": "value"} + + err := generator.generateValuesFiles(valuesPath, values, false) + if err == nil { + t.Fatal("expected generateValuesFiles to fail with write error") + } + if !strings.Contains(err.Error(), "failed to write values file") { + t.Errorf("expected error about file writing, 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_validateValuesForSubstitution(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: "InvalidMapType", + values: map[string]any{ + "nested": map[string]any{"key": "value"}, + }, + expectError: true, + errorMsg: "complex types", + }, + { + name: "InvalidSliceType", + values: map[string]any{ + "array": []any{1, 2, 3}, + }, + expectError: true, + errorMsg: "complex types", + }, + { + name: "MixedValidAndInvalid", + values: map[string]any{ + "valid": "string", + "invalid": map[string]any{"nested": "value"}, + }, + expectError: true, + errorMsg: "complex types", + }, + { + 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.validateValuesForSubstitution(tc.values) + + // 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/generators/patch_generator.go b/pkg/generators/patch_generator.go deleted file mode 100644 index 501a437e6..000000000 --- a/pkg/generators/patch_generator.go +++ /dev/null @@ -1,247 +0,0 @@ -package generators - -import ( - "fmt" - "path/filepath" - "strings" - - blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/di" -) - -// The PatchGenerator is a specialized component that manages Kustomize patch files. -// It provides functionality to process patch templates and generate patch files for -// kustomizations defined in the blueprint. The PatchGenerator ensures proper -// patch file generation with templating support. - -// ============================================================================= -// Types -// ============================================================================= - -// BlueprintHandler defines the interface for accessing blueprint data -type BlueprintHandler interface { - GetKustomizations() []blueprintv1alpha1.Kustomization -} - -// PatchGenerator is a generator that processes and generates patch files -type PatchGenerator struct { - BaseGenerator - blueprintHandler BlueprintHandler -} - -// ============================================================================= -// Constructor -// ============================================================================= - -// NewPatchGenerator creates a new PatchGenerator with the provided dependency injector. -// It initializes the base generator and prepares it for patch file generation. -func NewPatchGenerator(injector di.Injector) *PatchGenerator { - return &PatchGenerator{ - BaseGenerator: *NewGenerator(injector), - } -} - -// ============================================================================= -// Public Methods -// ============================================================================= - -// Initialize sets up the PatchGenerator dependencies including the blueprint handler. -// Calls the base generator's Initialize method and then resolves the blueprint handler -// for patch-specific operations. -func (g *PatchGenerator) Initialize() error { - if err := g.BaseGenerator.Initialize(); err != nil { - return fmt.Errorf("failed to initialize base generator: %w", err) - } - - blueprintHandler := g.injector.Resolve("blueprintHandler") - if blueprintHandler == nil { - return fmt.Errorf("blueprint handler not found in dependency injector") - } - - handler, ok := blueprintHandler.(BlueprintHandler) - if !ok { - return fmt.Errorf("resolved blueprint handler is not of expected type") - } - - g.blueprintHandler = handler - return nil -} - -// Generate creates patch files for kustomizations using the provided template data. -// Processes data keyed by "kustomize/" to generate patch files. -// The template engine has already filtered to only include referenced patches, so this -// processes all provided patch data without additional filtering. -// Returns an error if data is nil, if patch generation fails, or if validation fails. -func (g *PatchGenerator) 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) - } - - for key, values := range data { - if !strings.HasPrefix(key, "kustomize/") { - continue - } - - patchPath := strings.TrimPrefix(key, "kustomize/") - - if err := g.validateKustomizationName(patchPath); err != nil { - return fmt.Errorf("invalid kustomization name %s: %w", patchPath, err) - } - - patchesDir := filepath.Join(configRoot, "kustomize") - 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 kustomization %s must be a map, got %T", patchPath, values) - } - - // Create the full patch path including subdirectories - 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.generatePatchFiles(fullPatchPath, valuesMap, shouldOverwrite); err != nil { - return fmt.Errorf("failed to generate patch files for %s: %w", patchPath, err) - } - } - - return nil -} - -// ============================================================================= -// Private Methods -// ============================================================================= - -// 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 *PatchGenerator) 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") - } - - components := strings.Split(name, "/") - for _, component := range components { - 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) - } - } - } - - 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 *PatchGenerator) 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. -func (g *PatchGenerator) validateKubernetesManifest(content any) error { - contentMap, ok := content.(map[string]any) - if !ok { - return fmt.Errorf("content must be a map, got %T", content) - } - - apiVersion, ok := contentMap["apiVersion"].(string) - if !ok || apiVersion == "" { - return fmt.Errorf("manifest missing or invalid 'apiVersion' field") - } - - kind, ok := contentMap["kind"].(string) - if !ok || kind == "" { - return fmt.Errorf("manifest missing or invalid 'kind' field") - } - - metadata, ok := contentMap["metadata"].(map[string]any) - if !ok { - return fmt.Errorf("manifest missing 'metadata' field") - } - - name, ok := metadata["name"].(string) - if !ok || name == "" { - return fmt.Errorf("manifest missing or invalid 'metadata.name' field") - } - - return nil -} - -// generatePatchFiles writes a YAML patch file to patchPath using the provided values map. -// patchPath must be a file path. If it doesn't have a .yaml or .yml extension, .yaml will be automatically appended. -// For Jsonnet format, values is a direct object. If overwrite is false, existing files are not replaced. -// The content must be a valid Kubernetes manifest map with non-empty "apiVersion", "kind", and "metadata.name" fields. -// Returns an error on validation, marshalling, or file operation failure. -func (g *PatchGenerator) generatePatchFiles(patchPath string, values map[string]any, overwrite bool) error { - if !strings.HasSuffix(patchPath, ".yaml") && !strings.HasSuffix(patchPath, ".yml") { - patchPath = patchPath + ".yaml" - } - - dir := filepath.Dir(patchPath) - 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(patchPath); err == nil { - return nil - } - } - - if err := g.validateKubernetesManifest(values); err != nil { - return fmt.Errorf("invalid Kubernetes manifest for %s: %w", patchPath, err) - } - - yamlData, err := g.shims.MarshalYAML(values) - if err != nil { - return fmt.Errorf("failed to marshal content to YAML for %s: %w", patchPath, err) - } - - if err := g.shims.WriteFile(patchPath, yamlData, 0644); err != nil { - return fmt.Errorf("failed to write patch file %s: %w", patchPath, err) - } - - return nil -} diff --git a/pkg/generators/patch_generator_test.go b/pkg/generators/patch_generator_test.go deleted file mode 100644 index 234c0b40b..000000000 --- a/pkg/generators/patch_generator_test.go +++ /dev/null @@ -1,1068 +0,0 @@ -package generators - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "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" -) - -// patchMockFileInfo implements os.FileInfo for testing -type patchMockFileInfo struct { - isDir bool -} - -func (m *patchMockFileInfo) Name() string { return "mock" } -func (m *patchMockFileInfo) Size() int64 { return 0 } -func (m *patchMockFileInfo) Mode() os.FileMode { return 0 } -func (m *patchMockFileInfo) ModTime() time.Time { return time.Time{} } -func (m *patchMockFileInfo) IsDir() bool { return m.isDir } -func (m *patchMockFileInfo) Sys() interface{} { return nil } - -// ============================================================================= -// Test Setup -// ============================================================================= - -// PatchMocks provides mock dependencies for patch generator tests -type PatchMocks struct { - Injector di.Injector - ConfigHandler *config.MockConfigHandler - Shell *shell.MockShell - Shims *Shims -} - -// setupPatchGeneratorMocks creates mock dependencies for patch generator tests -func setupPatchGeneratorMocks(t *testing.T) *PatchMocks { - mocks := &PatchMocks{ - Injector: di.NewInjector(), - ConfigHandler: &config.MockConfigHandler{}, - Shell: &shell.MockShell{}, - Shims: NewShims(), - } - - // Set up mock file operations - 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 } - - // Create mock blueprint handler with kustomizations that reference patches - mockBlueprintHandler := &blueprint.MockBlueprintHandler{ - GetKustomizationsFunc: func() []blueprintv1alpha1.Kustomization { - return []blueprintv1alpha1.Kustomization{ - { - Name: "dns", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/dns/coredns.yaml", - }, - }, - }, - { - Name: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/nginx.yaml", - }, - }, - }, - } - }, - } - - // Register mocks with injector - mocks.Injector.Register("configHandler", mocks.ConfigHandler) - mocks.Injector.Register("shell", mocks.Shell) - mocks.Injector.Register("shims", mocks.Shims) - mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) - mocks.Injector.Register("artifactBuilder", &artifact.MockArtifact{}) - - return mocks -} - -// createPatchGeneratorWithMocks creates a patch generator with mocked shims -func createPatchGeneratorWithMocks(t *testing.T) (*PatchGenerator, *PatchMocks) { - mocks := setupPatchGeneratorMocks(t) - generator := NewPatchGenerator(mocks.Injector) - - // Replace the generator's shims with our mocked ones - generator.shims = mocks.Shims - - return generator, mocks -} - -// ============================================================================= -// Constructor Tests -// ============================================================================= - -func TestNewPatchGenerator(t *testing.T) { - // Given creating a new patch generator - mocks := setupPatchGeneratorMocks(t) - generator := NewPatchGenerator(mocks.Injector) - - // Then the generator should be created successfully - if generator == nil { - t.Fatal("expected generator to be created") - } -} - -// ============================================================================= -// Initialization Tests -// ============================================================================= - -func TestPatchGenerator_Initialize(t *testing.T) { - // Given a patch generator with mocks - generator, _ := createPatchGeneratorWithMocks(t) - - t.Run("Success", func(t *testing.T) { - // When initializing the generator - err := generator.Initialize() - - // Then it should succeed - if err != nil { - t.Fatalf("expected Initialize to succeed, got: %v", err) - } - }) - - t.Run("BlueprintHandlerNotFound", func(t *testing.T) { - // Given an injector without blueprint handler but with all base dependencies - emptyInjector := di.NewInjector() - // Add required base dependencies - emptyInjector.Register("configHandler", &config.MockConfigHandler{}) - emptyInjector.Register("shell", &shell.MockShell{}) - emptyInjector.Register("shims", NewShims()) - emptyInjector.Register("artifactBuilder", &artifact.MockArtifact{}) - // Don't register blueprintHandler - generator := NewPatchGenerator(emptyInjector) - - // When initializing the generator - err := generator.Initialize() - - // Then it should fail - if err == nil { - t.Fatal("expected Initialize to fail with missing blueprint handler") - } - if !strings.Contains(err.Error(), "failed to resolve blueprint handler") { - t.Errorf("expected error about missing blueprint handler, got: %v", err) - } - }) - - t.Run("BlueprintHandlerWrongType", func(t *testing.T) { - // Given an injector with wrong type for blueprint handler - wrongTypeInjector := di.NewInjector() - // Add required base dependencies - wrongTypeInjector.Register("configHandler", &config.MockConfigHandler{}) - wrongTypeInjector.Register("shell", &shell.MockShell{}) - wrongTypeInjector.Register("shims", NewShims()) - wrongTypeInjector.Register("artifactBuilder", &artifact.MockArtifact{}) - wrongTypeInjector.Register("blueprintHandler", "not-a-blueprint-handler") - generator := NewPatchGenerator(wrongTypeInjector) - - // When initializing the generator - err := generator.Initialize() - - // Then it should fail - if err == nil { - t.Fatal("expected Initialize to fail with wrong blueprint handler type") - } - if !strings.Contains(err.Error(), "failed to resolve blueprint handler") { - t.Errorf("expected error about wrong type, got: %v", err) - } - }) - - t.Run("BaseGeneratorInitializeError", func(t *testing.T) { - // Given an injector missing required dependencies - incompleteInjector := di.NewInjector() - generator := NewPatchGenerator(incompleteInjector) - - // When initializing the generator - err := generator.Initialize() - - // Then it should fail - if err == nil { - t.Fatal("expected Initialize to fail with missing base dependencies") - } - if !strings.Contains(err.Error(), "failed to initialize base generator") { - t.Errorf("expected error about base generator initialization, got: %v", err) - } - }) -} - -// ============================================================================= -// Generate Method Tests -// ============================================================================= - -func TestPatchGenerator_Generate(t *testing.T) { - // Given a patch generator with mocks - generator, mocks := createPatchGeneratorWithMocks(t) - - // Set up mock expectations - mocks.ConfigHandler.GetContextFunc = func() string { return "test-context" } - mocks.ConfigHandler.GetConfigRootFunc = func() (string, error) { return "/test/config", nil } - - if err := generator.Initialize(); err != nil { - t.Fatalf("failed to initialize generator: %v", err) - } - - t.Run("InitPipelineData", func(t *testing.T) { - // Given init pipeline data (no "kustomize/" prefix) - data := map[string]any{ - "terraform/vpc": map[string]any{"region": "us-west-2"}, - "blueprint": map[string]any{"name": "test"}, - } - - // When calling Generate with init pipeline data - err := generator.Generate(data) - - // Then it should succeed (no-op for init pipeline) - if err != nil { - t.Fatalf("expected Generate to succeed with init pipeline data, got: %v", err) - } - }) - - t.Run("InstallPipelineData", func(t *testing.T) { - // Given install pipeline data (with "kustomize/" prefix) - data := map[string]any{ - "kustomize/dns": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "coredns-custom-test", - "namespace": "kube-system", - }, - "data": map[string]any{ - "custom.server": "test {\n forward . 8.8.8.8\n}", - }, - }, - } - - // And mock file operations - 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 } - - // When calling Generate with install pipeline data - err := generator.Generate(data) - - // Then it should succeed - if err != nil { - t.Fatalf("expected Generate to succeed with install pipeline data, got: %v", err) - } - }) - - t.Run("SubdirectoryStructure", func(t *testing.T) { - // Given install pipeline data with subdirectory structure - data := map[string]any{ - "kustomize/ingress/nginx": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "nginx-config", - "namespace": "default", - }, - "data": map[string]any{ - "nginx.conf": "server { listen 80; }", - }, - }, - } - - // And mock file operations that track created directories and files - var createdDirs []string - var createdFiles []string - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - createdDirs = append(createdDirs, path) - return nil - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - createdFiles = append(createdFiles, name) - return nil - } - - // When calling Generate with subdirectory structure - err := generator.Generate(data) - - // Then it should succeed - if err != nil { - t.Fatalf("expected Generate to succeed with subdirectory structure, got: %v", err) - } - - // And it should create the correct file - expectedFile := "/test/config/kustomize/ingress/nginx.yaml" - found := false - for _, file := range createdFiles { - // Normalize paths for cross-platform comparison - normalizedFile := filepath.ToSlash(file) - normalizedExpected := filepath.ToSlash(expectedFile) - if strings.HasSuffix(normalizedFile, normalizedExpected) { - found = true - break - } - } - if !found { - t.Errorf("expected file %s to be created, but was not found in: %v", expectedFile, createdFiles) - } - }) - - t.Run("MissingConfigRoot", func(t *testing.T) { - // Given config root fails - mocks.ConfigHandler.GetConfigRootFunc = func() (string, error) { - return "", os.ErrNotExist - } - - // And install pipeline data - data := map[string]any{ - "kustomize/dns": map[string]any{}, - } - - // When calling Generate - err := generator.Generate(data) - - // Then it should fail - if err == nil { - t.Fatal("expected Generate to fail with missing config root") - } - }) - - t.Run("NilData", func(t *testing.T) { - // When calling Generate with nil data - err := generator.Generate(nil) - - // Then it should fail - 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("NoKustomizations", func(t *testing.T) { - // Given a blueprint handler with no kustomizations - mockBlueprintHandler := &blueprint.MockBlueprintHandler{ - GetKustomizationsFunc: func() []blueprintv1alpha1.Kustomization { - return []blueprintv1alpha1.Kustomization{} - }, - } - mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) - - // And proper config root mock - mocks.ConfigHandler.GetConfigRootFunc = func() (string, error) { return "/test/config", nil } - - // And patch data with valid content - data := map[string]any{ - "kustomize/dns": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - } - - // When calling Generate - err := generator.Generate(data) - - // Then it should succeed (no-op) - if err != nil { - t.Fatalf("expected Generate to succeed with no kustomizations, got: %v", err) - } - }) - - t.Run("NoPatchReferences", func(t *testing.T) { - // Given a blueprint handler with kustomizations but no patch references - mockBlueprintHandler := &blueprint.MockBlueprintHandler{ - GetKustomizationsFunc: func() []blueprintv1alpha1.Kustomization { - return []blueprintv1alpha1.Kustomization{ - { - Name: "dns", - Patches: []blueprintv1alpha1.BlueprintPatch{}, - }, - } - }, - } - mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) - - // And proper config root mock - mocks.ConfigHandler.GetConfigRootFunc = func() (string, error) { return "/test/config", nil } - - // And patch data with valid content - data := map[string]any{ - "kustomize/dns": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, - } - - // When calling Generate - err := generator.Generate(data) - - // Then it should succeed (no-op) - if err != nil { - t.Fatalf("expected Generate to succeed with no patch references, got: %v", err) - } - }) - - t.Run("InvalidValuesType", func(t *testing.T) { - // Given proper config root mock - mocks.ConfigHandler.GetConfigRootFunc = func() (string, error) { return "/test/config", nil } - - // And patch data with invalid values type - data := map[string]any{ - "kustomize/dns": "not-a-map", - } - - // When calling Generate - err := generator.Generate(data) - - // Then it should fail - if err == nil { - t.Fatal("expected Generate to fail with invalid values type") - } - if !strings.Contains(err.Error(), "must be a map") { - t.Errorf("expected error about map requirement, got: %v", err) - } - }) - - t.Run("EmptyData", func(t *testing.T) { - // Given proper config root mock - mocks.ConfigHandler.GetConfigRootFunc = func() (string, error) { return "/test/config", nil } - - // And empty patch data (no patches to process) - data := map[string]any{} - - // When calling Generate - err := generator.Generate(data) - - // Then it should succeed with no patches to process - if err != nil { - t.Fatalf("expected Generate to succeed with empty data, got: %v", err) - } - }) - - t.Run("OverwriteFlag", func(t *testing.T) { - // Given proper config root mock - mocks.ConfigHandler.GetConfigRootFunc = func() (string, error) { return "/test/config", nil } - - // And patch data - data := map[string]any{ - "kustomize/dns": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test", - }, - }, - } - - // And mock file operations - 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 } - - // When calling Generate with overwrite flag - err := generator.Generate(data, true) - - // Then it should succeed - if err != nil { - t.Fatalf("expected Generate to succeed with overwrite flag, got: %v", err) - } - }) -} - -// ============================================================================= -// Helper Tests -// ============================================================================= - - - -func TestPatchGenerator_generatePatchFiles(t *testing.T) { - // Given a patch generator with mocks - generator, mocks := createPatchGeneratorWithMocks(t) - - t.Run("Success", func(t *testing.T) { - // Given valid patch files - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - "data": map[string]any{ - "config": "value", - }, - } - - // And mock file operations - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { return nil } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { return nil } - - // When generating patch files - err := generator.generatePatchFiles("/test/kustomize/test.yaml", values, false) - - // Then it should succeed - if err != nil { - t.Fatalf("expected generatePatchFiles to succeed, got: %v", err) - } - }) - - t.Run("MkdirAllError", func(t *testing.T) { - // Given mock that fails on MkdirAll - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return os.ErrPermission - } - - // And valid patch files - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test", - }, - } - - // When generating patch files - err := generator.generatePatchFiles("/test/kustomize/test.yaml", values, false) - - // Then it should fail - if err == nil { - t.Fatal("expected generatePatchFiles to fail with MkdirAll error") - } - if !strings.Contains(err.Error(), "failed to create directory") { - t.Errorf("expected error about directory creation, got: %v", err) - } - }) - - t.Run("WriteFileError", func(t *testing.T) { - // Given mock that fails on WriteFile - 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 os.ErrPermission - } - - // And valid patch files - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test", - }, - } - - // When generating patch files - err := generator.generatePatchFiles("/test/kustomize/test.yaml", values, false) - - // Then it should fail - if err == nil { - t.Fatal("expected generatePatchFiles to fail with WriteFile error") - } - if !strings.Contains(err.Error(), "failed to write patch file") { - t.Errorf("expected error about file writing, got: %v", err) - } - }) - - t.Run("MarshalYAMLError", func(t *testing.T) { - // Given mock that fails on MarshalYAML - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { return nil } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return nil, os.ErrInvalid - } - - // And valid patch files - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test", - }, - } - - // When generating patch files - err := generator.generatePatchFiles("/test/kustomize/test.yaml", values, false) - - // Then it should fail - if err == nil { - t.Fatal("expected generatePatchFiles to fail with MarshalYAML error") - } - if !strings.Contains(err.Error(), "failed to marshal content to YAML") { - t.Errorf("expected error about YAML marshalling, got: %v", err) - } - }) - - t.Run("InvalidManifest", func(t *testing.T) { - // Given invalid manifest (missing required fields) - values := map[string]any{ - "apiVersion": "v1", - // Missing kind and metadata - } - - // And mock file operations - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { return nil } - - // When generating patch files - err := generator.generatePatchFiles("/test/kustomize/test.yaml", values, false) - - // Then it should fail - if err == nil { - t.Fatal("expected generatePatchFiles to fail with invalid manifest") - } - if !strings.Contains(err.Error(), "invalid Kubernetes manifest") { - t.Errorf("expected error about invalid manifest, got: %v", err) - } - }) - - t.Run("AutoAppendYamlExtension", func(t *testing.T) { - // Given patch files without .yaml extension - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - // And mock file operations that track written files - var writtenFiles []string - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { return nil } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { return []byte("test yaml"), nil } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenFiles = append(writtenFiles, name) - return nil - } - - // When generating patch files - err := generator.generatePatchFiles("/test/kustomize/test", values, false) - - // Then it should succeed - if err != nil { - t.Fatalf("expected generatePatchFiles to succeed, got: %v", err) - } - - // And it should append .yaml extension - expectedFile := "/test/kustomize/test.yaml" - found := false - for _, file := range writtenFiles { - if strings.HasSuffix(file, expectedFile) { - found = true - break - } - } - if !found { - t.Errorf("expected file %s to be written, but was not found in: %v", expectedFile, writtenFiles) - } - }) - - t.Run("SkipExistingFiles", func(t *testing.T) { - // Given patch files - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test", - }, - } - - // And mock that indicates file exists - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { return nil } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - // Return directory for /test/patches - if name == "/test/patches" { - return &patchMockFileInfo{isDir: true}, nil - } - // Return file exists for individual files - return nil, nil // File exists - } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - t.Error("WriteFile should not be called when file exists and overwrite is false") - return nil - } - - // When generating patch files with overwrite=false - err := generator.generatePatchFiles("/test/patches", values, false) - - // Then it should succeed (skips existing files) - if err != nil { - t.Fatalf("expected generatePatchFiles to succeed, got: %v", err) - } - }) - - t.Run("OverwriteExistingFiles", func(t *testing.T) { - // Given patch files - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test", - }, - } - - // And mock that indicates file exists - var writeFileCalled bool - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { return nil } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - // Return directory for /test/patches - if name == "/test/patches" { - return &patchMockFileInfo{isDir: true}, nil - } - // Return file exists for individual files - return nil, nil // File exists - } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { return []byte("test yaml"), nil } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writeFileCalled = true - return nil - } - - // When generating patch files with overwrite=true - err := generator.generatePatchFiles("/test/patches", values, true) - - // Then it should succeed - if err != nil { - t.Fatalf("expected generatePatchFiles to succeed, got: %v", err) - } - - // And WriteFile should be called (overwrites existing files) - if !writeFileCalled { - t.Error("expected WriteFile to be called when overwrite is true") - } - }) -} - -// ============================================================================= -// Validation Method Tests -// ============================================================================= - -func TestPatchGenerator_validateKustomizationName(t *testing.T) { - // Given a patch generator - generator, _ := createPatchGeneratorWithMocks(t) - - t.Run("ValidName", func(t *testing.T) { - // When validating a valid kustomization name - err := generator.validateKustomizationName("valid-name") - - // Then it should succeed - if err != nil { - t.Errorf("expected validation to succeed, got: %v", err) - } - }) - - t.Run("ValidSubdirectoryPath", func(t *testing.T) { - // When validating a valid subdirectory path - err := generator.validateKustomizationName("ingress/nginx") - - // Then it should succeed - if err != nil { - t.Errorf("expected validation to succeed for subdirectory path, got: %v", err) - } - }) - - t.Run("ValidNestedSubdirectoryPath", func(t *testing.T) { - // When validating a valid nested subdirectory path - err := generator.validateKustomizationName("ingress/nginx/config") - - // Then it should succeed - if err != nil { - t.Errorf("expected validation to succeed for nested subdirectory path, got: %v", err) - } - }) - - t.Run("EmptyPathComponent", func(t *testing.T) { - // When validating a path with empty components - testCases := []string{"//test", "test//", "test//name", "/test"} - - for _, name := range testCases { - t.Run(name, func(t *testing.T) { - err := generator.validateKustomizationName(name) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for path with empty components") - } - if !strings.Contains(err.Error(), "empty path components") { - t.Errorf("expected error about empty path components, got: %v", err) - } - }) - } - }) - - t.Run("EmptyName", func(t *testing.T) { - // When validating an empty name - err := generator.validateKustomizationName("") - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for empty name") - } - if !strings.Contains(err.Error(), "cannot be empty") { - t.Errorf("expected error about empty name, got: %v", err) - } - }) - - t.Run("PathTraversalCharacters", func(t *testing.T) { - // When validating names with path traversal characters - testCases := []string{"../test", "test/../", "test\\..", "..\\test"} - - for _, name := range testCases { - t.Run(name, func(t *testing.T) { - err := generator.validateKustomizationName(name) - - // Then it should fail - if err == nil { - t.Errorf("expected validation to fail for %s", name) - } - if !strings.Contains(err.Error(), "path traversal characters") { - t.Errorf("expected error about path traversal characters, got: %v", err) - } - }) - } - }) - - t.Run("InvalidCharacters", func(t *testing.T) { - // When validating names with invalid character - testCases := []string{"testname", "test:name", "test\"name", "test|name", "test?name", "test*name"} - - for _, name := range testCases { - t.Run(name, func(t *testing.T) { - err := generator.validateKustomizationName(name) - - // Then it should fail - if err == nil { - t.Errorf("expected validation to fail for %s", name) - } - if !strings.Contains(err.Error(), "invalid character") { - t.Errorf("expected error about invalid character, got: %v", err) - } - }) - } - }) -} - -func TestPatchGenerator_validatePath(t *testing.T) { - // Given a patch generator - generator, _ := createPatchGeneratorWithMocks(t) - - t.Run("ValidPath", func(t *testing.T) { - // When validating a valid path within base directory - err := generator.validatePath("/base/path/subdir", "/base/path") - - // Then it should succeed - if err != nil { - t.Errorf("expected validation to succeed, got: %v", err) - } - }) - - t.Run("PathOutsideBase", func(t *testing.T) { - // When validating a path outside the base directory - err := generator.validatePath("/different/path", "/base/path") - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for path outside base") - } - if !strings.Contains(err.Error(), "outside base path") { - t.Errorf("expected error about path outside base, got: %v", err) - } - }) - - t.Run("SamePath", func(t *testing.T) { - // When validating the same path as base - err := generator.validatePath("/base/path", "/base/path") - - // Then it should succeed - if err != nil { - t.Errorf("expected validation to succeed, got: %v", err) - } - }) -} - -func TestPatchGenerator_validateKubernetesManifest(t *testing.T) { - // Given a patch generator - generator, _ := createPatchGeneratorWithMocks(t) - - t.Run("ValidManifest", func(t *testing.T) { - // When validating a valid Kubernetes manifest - manifest := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should succeed - if err != nil { - t.Errorf("expected validation to succeed, got: %v", err) - } - }) - - t.Run("NotMap", func(t *testing.T) { - // When validating non-map content - err := generator.validateKubernetesManifest("not-a-map") - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for non-map content") - } - if !strings.Contains(err.Error(), "must be a map") { - t.Errorf("expected error about map requirement, got: %v", err) - } - }) - - t.Run("MissingAPIVersion", func(t *testing.T) { - // When validating manifest missing apiVersion - manifest := map[string]any{ - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for missing apiVersion") - } - if !strings.Contains(err.Error(), "missing or invalid 'apiVersion' field") { - t.Errorf("expected error about missing apiVersion, got: %v", err) - } - }) - - t.Run("EmptyAPIVersion", func(t *testing.T) { - // When validating manifest with empty apiVersion - manifest := map[string]any{ - "apiVersion": "", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for empty apiVersion") - } - if !strings.Contains(err.Error(), "missing or invalid 'apiVersion' field") { - t.Errorf("expected error about missing apiVersion, got: %v", err) - } - }) - - t.Run("MissingKind", func(t *testing.T) { - // When validating manifest missing kind - manifest := map[string]any{ - "apiVersion": "v1", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for missing kind") - } - if !strings.Contains(err.Error(), "missing or invalid 'kind' field") { - t.Errorf("expected error about missing kind, got: %v", err) - } - }) - - t.Run("EmptyKind", func(t *testing.T) { - // When validating manifest with empty kind - manifest := map[string]any{ - "apiVersion": "v1", - "kind": "", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for empty kind") - } - if !strings.Contains(err.Error(), "missing or invalid 'kind' field") { - t.Errorf("expected error about missing kind, got: %v", err) - } - }) - - t.Run("MissingMetadata", func(t *testing.T) { - // When validating manifest missing metadata - manifest := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for missing metadata") - } - if !strings.Contains(err.Error(), "missing 'metadata' field") { - t.Errorf("expected error about missing metadata, got: %v", err) - } - }) - - t.Run("MissingName", func(t *testing.T) { - // When validating manifest missing name - manifest := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "labels": map[string]any{ - "app": "test", - }, - }, - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for missing name") - } - if !strings.Contains(err.Error(), "missing or invalid 'metadata.name' field") { - t.Errorf("expected error about missing name, got: %v", err) - } - }) - - t.Run("EmptyName", func(t *testing.T) { - // When validating manifest with empty name - manifest := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "", - }, - } - - err := generator.validateKubernetesManifest(manifest) - - // Then it should fail - if err == nil { - t.Error("expected validation to fail for empty name") - } - if !strings.Contains(err.Error(), "missing or invalid 'metadata.name' field") { - t.Errorf("expected error about missing name, got: %v", err) - } - }) -} diff --git a/pkg/pipelines/init_test.go b/pkg/pipelines/init_test.go index ef646e639..e50418da1 100644 --- a/pkg/pipelines/init_test.go +++ b/pkg/pipelines/init_test.go @@ -1153,8 +1153,6 @@ func TestInitPipeline_setDefaultConfiguration_HostPortsValidation(t *testing.T) // Additional Coverage Tests // ============================================================================= - - func TestInitPipeline_loadBlueprintFromTemplate(t *testing.T) { // Given a pipeline with mocks mocks := setupInitMocks(t) @@ -1500,5 +1498,4 @@ func TestInitPipeline_processTemplateData(t *testing.T) { } }) - } diff --git a/pkg/pipelines/pipeline.go b/pkg/pipelines/pipeline.go index 03b9e3bbb..904c43b9b 100644 --- a/pkg/pipelines/pipeline.go +++ b/pkg/pipelines/pipeline.go @@ -286,9 +286,9 @@ func (p *BasePipeline) withGenerators() ([]generators.Generator, error) { // Create patch generator for Kustomize patch templating only if cluster.enabled if p.configHandler.GetBool("cluster.enabled", false) { - patchGenerator := generators.NewPatchGenerator(p.injector) - p.injector.Register("patchGenerator", patchGenerator) - generatorList = append(generatorList, patchGenerator) + kustomizeGenerator := generators.NewKustomizeGenerator(p.injector) + p.injector.Register("kustomizeGenerator", kustomizeGenerator) + generatorList = append(generatorList, kustomizeGenerator) } return generatorList, nil diff --git a/pkg/template/jsonnet_template.go b/pkg/template/jsonnet_template.go index 6dee84118..563bb1ebc 100644 --- a/pkg/template/jsonnet_template.go +++ b/pkg/template/jsonnet_template.go @@ -2,6 +2,7 @@ package template import ( "fmt" + "path/filepath" "strings" "github.com/windsorcli/cli/pkg/config" @@ -63,25 +64,39 @@ func (t *JsonnetTemplate) Initialize() error { return nil } -// Process executes a two-phase Jsonnet template processing workflow. -// Phase 1: Processes "blueprint.jsonnet" to extract patch references. -// Phase 2: Processes only referenced patches and other templates, then removes the patches field from the blueprint in renderedData. +// Process performs two-phase Jsonnet template processing for blueprint and related files. +// Phase 1: Processes "blueprint.jsonnet" to extract patch and values references. +// Phase 2: Processes only referenced patch and values templates, omitting unreferenced files, and removes the patches field from the blueprint in renderedData. // Returns an error if any processing step fails. func (t *JsonnetTemplate) Process(templateData map[string][]byte, renderedData map[string]any) error { if err := t.processTemplate("blueprint.jsonnet", templateData, renderedData); err != nil { return err } patchRefs := t.extractPatchReferences(renderedData) + valuesRefs := t.extractValuesReferences(renderedData) patchSet := make(map[string]bool) + valuesSet := make(map[string]bool) for _, ref := range patchRefs { patchSet[ref] = true } + for _, ref := range valuesRefs { + valuesSet[ref] = true + } for templatePath := range templateData { if templatePath == "blueprint.jsonnet" { continue } if strings.HasPrefix(templatePath, "kustomize/") { - if !patchSet[templatePath] { + if strings.HasSuffix(templatePath, "/values.jsonnet") { + if !valuesSet[templatePath] { + continue + } + } else if !patchSet[templatePath] { + continue + } + } + if strings.HasPrefix(templatePath, "values/") { + if !valuesSet[templatePath] { continue } } @@ -136,9 +151,13 @@ func (t *JsonnetTemplate) processJsonnetTemplate(templateContent string) (map[st // processTemplate processes a single template file and stores the result in renderedData under a key determined by the template path. // Recognized mappings: -// - "blueprint.jsonnet" → "blueprint" -// - "terraform/*.jsonnet" → "terraform/*" (without .jsonnet extension) -// - "kustomize/*.jsonnet" → "kustomize/*" (without .jsonnet extension) +// - "blueprint.jsonnet" maps to "blueprint" +// - "terraform/*.jsonnet" maps to "terraform/*" (without .jsonnet extension) +// - "kustomize/*.jsonnet" maps to "kustomize/*" (without .jsonnet extension) +// - "kustomize//values.jsonnet" maps to "values/" +// - "kustomize/values.jsonnet" maps to "values/global" +// - "values/*.jsonnet" maps to "values/*" (without .jsonnet extension) +// // If the template does not exist in templateData, no action is performed. Returns an error if processing fails. Unrecognized template types are ignored. func (t *JsonnetTemplate) processTemplate(templatePath string, templateData map[string][]byte, renderedData map[string]any) error { content, exists := templateData[templatePath] @@ -152,6 +171,24 @@ func (t *JsonnetTemplate) processTemplate(templatePath string, templateData map[ } else if strings.HasPrefix(templatePath, "terraform/") && strings.HasSuffix(templatePath, ".jsonnet") { outputKey = strings.TrimSuffix(templatePath, ".jsonnet") } else if strings.HasPrefix(templatePath, "kustomize/") && strings.HasSuffix(templatePath, ".jsonnet") { + if strings.HasSuffix(templatePath, "/values.jsonnet") { + pathParts := strings.Split(templatePath, "/") + if len(pathParts) == 3 && pathParts[0] == "kustomize" && pathParts[2] == "values.jsonnet" { + component := pathParts[1] + if component == "values" { + outputKey = "values/global" + } else { + outputKey = "values/" + component + } + } else if len(pathParts) == 2 && pathParts[0] == "kustomize" && pathParts[1] == "values.jsonnet" { + outputKey = "values/global" + } else { + outputKey = strings.TrimSuffix(templatePath, ".jsonnet") + } + } else { + outputKey = strings.TrimSuffix(templatePath, ".jsonnet") + } + } else if strings.HasPrefix(templatePath, "values/") && strings.HasSuffix(templatePath, ".jsonnet") { outputKey = strings.TrimSuffix(templatePath, ".jsonnet") } else { return nil @@ -211,6 +248,47 @@ func (t *JsonnetTemplate) extractPatchReferences(renderedData map[string]any) [] return templatePaths } +// extractValuesReferences returns a slice of values template file paths found in the kustomize directory structure. +// Always includes the global values template ("kustomize/values.jsonnet") and automatically discovers component-specific values templates. +// Returns an empty slice if the blueprint or kustomize section is missing or malformed. +func (t *JsonnetTemplate) extractValuesReferences(renderedData map[string]any) []string { + var templatePaths []string + blueprintData, ok := renderedData["blueprint"] + if !ok { + return templatePaths + } + blueprintMap, ok := blueprintData.(map[string]any) + if !ok { + return templatePaths + } + _, ok = blueprintMap["kustomize"].([]any) + if !ok { + return templatePaths + } + + // Always include global values template + templatePaths = append(templatePaths, "kustomize/values.jsonnet") + + // Automatically discover component-specific values templates from kustomize directory + projectRoot, err := t.shell.GetProjectRoot() + if err == nil { + templateDir := filepath.Join(projectRoot, "contexts", "_template", "kustomize") + if entries, err := t.shims.ReadDir(templateDir); err == nil { + for _, entry := range entries { + if entry.IsDir() { + componentValuesPath := filepath.Join(templateDir, entry.Name(), "values.jsonnet") + if _, err := t.shims.Stat(componentValuesPath); err == nil { + templatePath := fmt.Sprintf("kustomize/%s/values.jsonnet", entry.Name()) + templatePaths = append(templatePaths, templatePath) + } + } + } + } + } + + return templatePaths +} + // cleanupBlueprint removes the patches field from each kustomization in the blueprint within renderedData. // This is used to clean up the output after all referenced patches have been processed. func (t *JsonnetTemplate) cleanupBlueprint(renderedData map[string]any) { diff --git a/pkg/template/jsonnet_template_test.go b/pkg/template/jsonnet_template_test.go index f2c4ce620..3bf0b44d1 100644 --- a/pkg/template/jsonnet_template_test.go +++ b/pkg/template/jsonnet_template_test.go @@ -2,8 +2,10 @@ package template import ( "fmt" + "os" "strings" "testing" + "time" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" @@ -31,7 +33,7 @@ type Mocks struct { // ============================================================================= // setupMocks creates and configures mock dependencies for testing -func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { +func setupMocks(t *testing.T, _ ...*SetupOptions) *Mocks { t.Helper() configHandler := &config.MockConfigHandler{} @@ -238,7 +240,7 @@ func TestJsonnetTemplate_Process(t *testing.T) { // And template data containing blueprint and kustomize/ .jsonnet files with subdirectory structure templateData := map[string][]byte{ - "blueprint.jsonnet": []byte(`{ kustomize: [{ name: "ingress", patches: [{ path: "ingress/patches/nginx" }] }, { name: "dns", patches: [{ path: "dns/patches/coredns" }] }] }`), + "blueprint.jsonnet": []byte(`{ kustomize: [{ name: "ingress", patches: [{ path: "ingress/patches/nginx" }] }, { name: "dns", patches: [{ path: "dns/patches/coredns" }] }] }`), "kustomize/ingress/patches/nginx.jsonnet": []byte(`local context = std.extVar("context"); { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "nginx-config" } }`), "kustomize/dns/patches/coredns.jsonnet": []byte(`local context = std.extVar("context"); { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "coredns-config" } }`), } @@ -1535,3 +1537,693 @@ local context = std.extVar("context"); } }) } + +func TestJsonnetTemplate_extractValuesReferences(t *testing.T) { + setup := func(t *testing.T) (*JsonnetTemplate, *Mocks) { + t.Helper() + mocks, template := setupJsonnetTemplateMocks(t) + return template, mocks + } + + t.Run("EmptyRenderedData", func(t *testing.T) { + // Given a template and empty rendered data + template, _ := setup(t) + + // When extracting values references + result := template.extractValuesReferences(map[string]any{}) + + // Then should return empty slice + if len(result) != 0 { + t.Errorf("expected empty slice, got %d items", len(result)) + } + }) + + t.Run("MissingBlueprint", func(t *testing.T) { + // Given rendered data without blueprint + template, _ := setup(t) + renderedData := map[string]any{ + "other": "data", + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should return empty slice + if len(result) != 0 { + t.Errorf("expected empty slice, got %d items", len(result)) + } + }) + + t.Run("BlueprintNotMap", func(t *testing.T) { + // Given rendered data with blueprint as string + template, _ := setup(t) + renderedData := map[string]any{ + "blueprint": "not a map", + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should return empty slice + if len(result) != 0 { + t.Errorf("expected empty slice, got %d items", len(result)) + } + }) + + t.Run("MissingKustomize", func(t *testing.T) { + // Given rendered data with blueprint but no kustomize + template, _ := setup(t) + renderedData := map[string]any{ + "blueprint": map[string]any{ + "other": "data", + }, + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should return empty slice + if len(result) != 0 { + t.Errorf("expected empty slice, got %d items", len(result)) + } + }) + + t.Run("KustomizeNotArray", func(t *testing.T) { + // Given rendered data with kustomize as string + template, _ := setup(t) + renderedData := map[string]any{ + "blueprint": map[string]any{ + "kustomize": "not an array", + }, + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should return empty slice + if len(result) != 0 { + t.Errorf("expected empty slice, got %d items", len(result)) + } + }) + + t.Run("ValidBlueprintWithKustomize", func(t *testing.T) { + // Given valid rendered data with kustomize array + template, mocks := setup(t) + + // Initialize the template first + _ = template.Initialize() + + renderedData := map[string]any{ + "blueprint": map[string]any{ + "kustomize": []any{ + map[string]any{"name": "test"}, + }, + }, + } + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/test/project", nil + } + + // Mock template shims for directory operations + template.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + return []os.DirEntry{ + &mockDirEntry{name: "ingress", isDir: true}, + &mockDirEntry{name: "database", isDir: true}, + &mockDirEntry{name: "file.txt", isDir: false}, + }, nil + } + template.shims.Stat = func(name string) (os.FileInfo, error) { + // Return success for values.jsonnet files + if strings.HasSuffix(name, "values.jsonnet") { + return &mockFileInfo{name: "values.jsonnet", isDir: false}, nil + } + return nil, fmt.Errorf("file not found") + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should include global values and discovered component values + expected := []string{ + "kustomize/values.jsonnet", + "kustomize/ingress/values.jsonnet", + "kustomize/database/values.jsonnet", + } + if len(result) != len(expected) { + t.Errorf("expected %d items, got %d", len(expected), len(result)) + } + for i, expectedPath := range expected { + if i >= len(result) { + t.Errorf("missing expected path: %s", expectedPath) + continue + } + if result[i] != expectedPath { + t.Errorf("expected path %s at index %d, got %s", expectedPath, i, result[i]) + } + } + }) + + t.Run("GetProjectRootError", func(t *testing.T) { + // Given valid rendered data but shell error + template, mocks := setup(t) + + // Initialize the template first + _ = template.Initialize() + + renderedData := map[string]any{ + "blueprint": map[string]any{ + "kustomize": []any{ + map[string]any{"name": "test"}, + }, + }, + } + + // Mock shell to return error + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should only include global values (no discovery) + expected := []string{"kustomize/values.jsonnet"} + if len(result) != len(expected) { + t.Errorf("expected %d items, got %d", len(expected), len(result)) + } + if len(result) > 0 && result[0] != expected[0] { + t.Errorf("expected path %s, got %s", expected[0], result[0]) + } + }) + + t.Run("ReadDirError", func(t *testing.T) { + // Given valid rendered data but directory read error + template, mocks := setup(t) + + // Initialize the template first + _ = template.Initialize() + + renderedData := map[string]any{ + "blueprint": map[string]any{ + "kustomize": []any{ + map[string]any{"name": "test"}, + }, + }, + } + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/test/project", nil + } + + // Mock template shims to return error for ReadDir + template.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + return nil, fmt.Errorf("read dir error") + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should only include global values (no discovery) + expected := []string{"kustomize/values.jsonnet"} + if len(result) != len(expected) { + t.Errorf("expected %d items, got %d", len(expected), len(result)) + } + if len(result) > 0 && result[0] != expected[0] { + t.Errorf("expected path %s, got %s", expected[0], result[0]) + } + }) + + t.Run("ComponentValuesFileNotExists", func(t *testing.T) { + // Given valid rendered data but component values file doesn't exist + template, mocks := setup(t) + + // Initialize the template first + _ = template.Initialize() + + renderedData := map[string]any{ + "blueprint": map[string]any{ + "kustomize": []any{ + map[string]any{"name": "test"}, + }, + }, + } + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/test/project", nil + } + + // Mock template shims for directory operations + template.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + return []os.DirEntry{ + &mockDirEntry{name: "ingress", isDir: true}, + }, nil + } + template.shims.Stat = func(name string) (os.FileInfo, error) { + // Return error for all values.jsonnet files (they don't exist) + return nil, fmt.Errorf("file not found") + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should only include global values (no component values found) + expected := []string{"kustomize/values.jsonnet"} + if len(result) != len(expected) { + t.Errorf("expected %d items, got %d", len(expected), len(result)) + } + if len(result) > 0 && result[0] != expected[0] { + t.Errorf("expected path %s, got %s", expected[0], result[0]) + } + }) + + t.Run("MixedComponents", func(t *testing.T) { + // Given valid rendered data with mixed components (some with values, some without) + template, mocks := setup(t) + + // Initialize the template first + _ = template.Initialize() + + renderedData := map[string]any{ + "blueprint": map[string]any{ + "kustomize": []any{ + map[string]any{"name": "test"}, + }, + }, + } + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/test/project", nil + } + + // Mock template shims for directory operations + template.shims.ReadDir = func(name string) ([]os.DirEntry, error) { + return []os.DirEntry{ + &mockDirEntry{name: "ingress", isDir: true}, + &mockDirEntry{name: "database", isDir: true}, + &mockDirEntry{name: "api", isDir: true}, + }, nil + } + template.shims.Stat = func(name string) (os.FileInfo, error) { + // Only ingress and database have values.jsonnet files + if strings.Contains(name, "ingress/values.jsonnet") || strings.Contains(name, "database/values.jsonnet") { + return &mockFileInfo{name: "values.jsonnet", isDir: false}, nil + } + return nil, fmt.Errorf("file not found") + } + + // When extracting values references + result := template.extractValuesReferences(renderedData) + + // Then should include global values and only existing component values + expected := []string{ + "kustomize/values.jsonnet", + "kustomize/ingress/values.jsonnet", + "kustomize/database/values.jsonnet", + } + if len(result) != len(expected) { + t.Errorf("expected %d items, got %d", len(expected), len(result)) + } + for i, expectedPath := range expected { + if i >= len(result) { + t.Errorf("missing expected path: %s", expectedPath) + continue + } + if result[i] != expectedPath { + t.Errorf("expected path %s at index %d, got %s", expectedPath, i, result[i]) + } + } + }) +} + +// ============================================================================= +// Test Helpers +// ============================================================================= + +type mockDirEntry struct { + name string + isDir bool +} + +func (m *mockDirEntry) Name() string { + return m.name +} + +func (m *mockDirEntry) IsDir() bool { + return m.isDir +} + +func (m *mockDirEntry) Type() os.FileMode { + if m.isDir { + return os.ModeDir + } + return 0 +} + +func (m *mockDirEntry) Info() (os.FileInfo, error) { + return &mockFileInfo{name: m.name, isDir: m.isDir}, nil +} + +type mockFileInfo struct { + name string + isDir bool +} + +func (m *mockFileInfo) Name() string { + return m.name +} + +func (m *mockFileInfo) Size() int64 { + return 0 +} + +func (m *mockFileInfo) Mode() os.FileMode { + if m.isDir { + return os.ModeDir + } + return 0 +} + +func (m *mockFileInfo) ModTime() time.Time { + return time.Now() +} + +func (m *mockFileInfo) IsDir() bool { + return m.isDir +} + +func (m *mockFileInfo) Sys() interface{} { + return nil +} + +func TestJsonnetTemplate_processTemplate(t *testing.T) { + setup := func(t *testing.T) (*JsonnetTemplate, *Mocks) { + t.Helper() + mocks, template := setupJsonnetTemplateMocks(t) + _ = template.Initialize() + return template, mocks + } + + t.Run("BlueprintJsonnet", func(t *testing.T) { + // Given a template and blueprint.jsonnet content + template, _ := setup(t) + templateData := map[string][]byte{ + "blueprint.jsonnet": []byte(`{ kustomize: [{ name: "test" }] }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"kustomize":[{"name":"test"}]}`, nil + }, + } + } + + // When processing blueprint.jsonnet + err := template.processTemplate("blueprint.jsonnet", templateData, renderedData) + + // Then should succeed and add blueprint data + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["blueprint"] == nil { + t.Error("expected blueprint data to be added") + } + }) + + t.Run("TerraformJsonnet", func(t *testing.T) { + // Given a template and terraform jsonnet content + template, _ := setup(t) + templateData := map[string][]byte{ + "terraform/main.jsonnet": []byte(`{ region: "us-west-2" }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"region":"us-west-2"}`, nil + }, + } + } + + // When processing terraform/main.jsonnet + err := template.processTemplate("terraform/main.jsonnet", templateData, renderedData) + + // Then should succeed and add terraform data + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["terraform/main"] == nil { + t.Error("expected terraform data to be added") + } + }) + + t.Run("KustomizePatchJsonnet", func(t *testing.T) { + // Given a template and kustomize patch content + template, _ := setup(t) + templateData := map[string][]byte{ + "kustomize/ingress/patch.jsonnet": []byte(`{ apiVersion: "v1", kind: "ConfigMap" }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"apiVersion":"v1","kind":"ConfigMap"}`, nil + }, + } + } + + // When processing kustomize patch + err := template.processTemplate("kustomize/ingress/patch.jsonnet", templateData, renderedData) + + // Then should succeed and add kustomize data + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["kustomize/ingress/patch"] == nil { + t.Error("expected kustomize patch data to be added") + } + }) + + t.Run("KustomizeValuesGlobal", func(t *testing.T) { + // Given a template and global values content + template, _ := setup(t) + templateData := map[string][]byte{ + "kustomize/values.jsonnet": []byte(`{ domain: "example.com" }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"domain":"example.com"}`, nil + }, + } + } + + // When processing global values + err := template.processTemplate("kustomize/values.jsonnet", templateData, renderedData) + + // Then should succeed and add values/global data + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["values/global"] == nil { + t.Error("expected values/global data to be added") + } + }) + + t.Run("KustomizeValuesComponent", func(t *testing.T) { + // Given a template and component values content + template, _ := setup(t) + templateData := map[string][]byte{ + "kustomize/ingress/values.jsonnet": []byte(`{ host: "example.com" }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"host":"example.com"}`, nil + }, + } + } + + // When processing component values + err := template.processTemplate("kustomize/ingress/values.jsonnet", templateData, renderedData) + + // Then should succeed and add values/ingress data + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["values/ingress"] == nil { + t.Error("expected values/ingress data to be added") + } + }) + + t.Run("KustomizeValuesComponentWithValuesSubdirectory", func(t *testing.T) { + // Given a template and component values in a "values" subdirectory + template, _ := setup(t) + templateData := map[string][]byte{ + "kustomize/values/values.jsonnet": []byte(`{ global: "config" }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"global":"config"}`, nil + }, + } + } + + // When processing values subdirectory + err := template.processTemplate("kustomize/values/values.jsonnet", templateData, renderedData) + + // Then should succeed and add values/global data (special case) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["values/global"] == nil { + t.Error("expected values/global data to be added") + } + }) + + t.Run("ValuesJsonnet", func(t *testing.T) { + // Given a template and values.jsonnet content + template, _ := setup(t) + templateData := map[string][]byte{ + "values/global.jsonnet": []byte(`{ config: "value" }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"config":"value"}`, nil + }, + } + } + + // When processing values.jsonnet + err := template.processTemplate("values/global.jsonnet", templateData, renderedData) + + // Then should succeed and add values data + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["values/global"] == nil { + t.Error("expected values data to be added") + } + }) + + t.Run("UnsupportedPath", func(t *testing.T) { + // Given a template and unsupported path + template, _ := setup(t) + templateData := map[string][]byte{ + "unsupported/path.jsonnet": []byte(`{ data: "value" }`), + } + renderedData := map[string]any{} + + // When processing unsupported path + err := template.processTemplate("unsupported/path.jsonnet", templateData, renderedData) + + // Then should return nil (no error, just ignored) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(renderedData) != 0 { + t.Error("expected no data to be added for unsupported path") + } + }) + + t.Run("MissingTemplateData", func(t *testing.T) { + // Given a template and missing template data + template, _ := setup(t) + templateData := map[string][]byte{} + renderedData := map[string]any{} + + // When processing missing template + err := template.processTemplate("blueprint.jsonnet", templateData, renderedData) + + // Then should return nil (no error, just ignored) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(renderedData) != 0 { + t.Error("expected no data to be added for missing template") + } + }) + + t.Run("JsonnetProcessingError", func(t *testing.T) { + // Given a template and blueprint content + template, _ := setup(t) + templateData := map[string][]byte{ + "blueprint.jsonnet": []byte(`{ invalid: jsonnet }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing to fail + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return "", fmt.Errorf("jsonnet processing error") + }, + } + } + + // When processing blueprint with jsonnet error + err := template.processTemplate("blueprint.jsonnet", templateData, renderedData) + + // Then should return error + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to process template") { + t.Errorf("expected error about template processing, got: %v", err) + } + }) + + t.Run("KustomizeValuesComplexPath", func(t *testing.T) { + // Given a template and complex kustomize values path + template, _ := setup(t) + templateData := map[string][]byte{ + "kustomize/ingress/nginx/values.jsonnet": []byte(`{ port: 80 }`), + } + renderedData := map[string]any{} + + // Mock jsonnet processing + template.shims.NewJsonnetVM = func() JsonnetVM { + return &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"port":80}`, nil + }, + } + } + + // When processing complex kustomize values path + err := template.processTemplate("kustomize/ingress/nginx/values.jsonnet", templateData, renderedData) + + // Then should succeed and add the full path as key + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["kustomize/ingress/nginx/values"] == nil { + t.Error("expected complex path data to be added") + } + }) +} From 3a8f3e4989f0f9874ef9ae62ff33cba69b6fde86 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 31 Jul 2025 09:50:41 -0400 Subject: [PATCH 2/3] Fix Windows tests --- pkg/blueprint/blueprint_handler_test.go | 14 +++++++------- pkg/template/jsonnet_template_test.go | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 4adbb6f3f..83685d191 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -3552,10 +3552,10 @@ func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { // And mock kustomize directory with global values.yaml handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == "/test/config/kustomize" { + if name == filepath.Join("/test/config", "kustomize") { return &mockFileInfo{name: "kustomize"}, nil } - if name == "/test/config/kustomize/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist @@ -3563,7 +3563,7 @@ func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { // And mock file read for global values handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == "/test/config/kustomize/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { return []byte(`domain: example.com port: 80 enabled: true`), nil @@ -3619,10 +3619,10 @@ enabled: true`), nil // And mock kustomize directory with component values handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == "/test/config/kustomize" { + if name == filepath.Join("/test/config", "kustomize") { return &mockFileInfo{name: "kustomize"}, nil } - if name == "/test/config/kustomize/ingress/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist @@ -3630,7 +3630,7 @@ enabled: true`), nil // And mock directory read handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { - if name == "/test/config/kustomize" { + if name == filepath.Join("/test/config", "kustomize") { return []os.DirEntry{ &mockDirEntry{name: "ingress", isDir: true}, }, nil @@ -3640,7 +3640,7 @@ enabled: true`), nil // And mock file read for component values handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == "/test/config/kustomize/ingress/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { return []byte(`host: ingress.example.com ssl: true`), nil } diff --git a/pkg/template/jsonnet_template_test.go b/pkg/template/jsonnet_template_test.go index 3bf0b44d1..5ef5ea5f4 100644 --- a/pkg/template/jsonnet_template_test.go +++ b/pkg/template/jsonnet_template_test.go @@ -3,6 +3,7 @@ package template import ( "fmt" "os" + "path/filepath" "strings" "testing" "time" @@ -1830,7 +1831,8 @@ func TestJsonnetTemplate_extractValuesReferences(t *testing.T) { } template.shims.Stat = func(name string) (os.FileInfo, error) { // Only ingress and database have values.jsonnet files - if strings.Contains(name, "ingress/values.jsonnet") || strings.Contains(name, "database/values.jsonnet") { + // Use filepath operations to handle cross-platform path separators + if strings.Contains(name, filepath.Join("ingress", "values.jsonnet")) || strings.Contains(name, filepath.Join("database", "values.jsonnet")) { return &mockFileInfo{name: "values.jsonnet", isDir: false}, nil } return nil, fmt.Errorf("file not found") From bc17bb7ecd6d331b7a67e530b38ee8931abe9bf2 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 31 Jul 2025 14:53:33 -0400 Subject: [PATCH 3/3] Windows fix --- pkg/blueprint/blueprint_handler_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 83685d191..c709ee817 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -3740,7 +3740,7 @@ ssl: true`), nil // And mock kustomize directory exists handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == "/test/config/kustomize" { + if name == filepath.Join("/test/config", "kustomize") { return &mockFileInfo{name: "kustomize"}, nil } return nil, os.ErrNotExist @@ -3775,10 +3775,10 @@ ssl: true`), nil // And mock kustomize directory with component values handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == "/test/config/kustomize" { + if name == filepath.Join("/test/config", "kustomize") { return &mockFileInfo{name: "kustomize"}, nil } - if name == "/test/config/kustomize/ingress/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist @@ -3786,7 +3786,7 @@ ssl: true`), nil // And mock directory read handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { - if name == "/test/config/kustomize" { + if name == filepath.Join("/test/config", "kustomize") { return []os.DirEntry{ &mockDirEntry{name: "ingress", isDir: true}, }, nil @@ -3796,7 +3796,7 @@ ssl: true`), nil // And mock file read for component values handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == "/test/config/kustomize/ingress/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { return []byte(`host: ingress.example.com`), nil } return nil, os.ErrNotExist @@ -4171,7 +4171,7 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin // And mock that global values.yaml exists handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == "/test/config/kustomize/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist @@ -4249,7 +4249,7 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin // And mock that component values.yaml exists handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == "/test/config/kustomize/ingress/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist @@ -4314,7 +4314,7 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin // And mock that global values.yaml exists handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == "/test/config/kustomize/values.yaml" { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist