diff --git a/go.mod b/go.mod index bd47232b2..6130d3ae2 100644 --- a/go.mod +++ b/go.mod @@ -160,6 +160,7 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sasha-s/go-deadlock v0.3.5 // indirect diff --git a/pkg/artifact/artifact.go b/pkg/artifact/artifact.go index f7d811ba1..da59e61ad 100644 --- a/pkg/artifact/artifact.go +++ b/pkg/artifact/artifact.go @@ -342,8 +342,8 @@ func (a *ArtifactBuilder) Pull(ociRefs []string) (map[string][]byte, error) { // GetTemplateData extracts and returns template data from an OCI artifact reference. // Downloads and caches the OCI artifact, decompresses the tar.gz payload, and returns a map // with forward-slash file paths as keys and file contents as values. The returned map always includes -// "ociUrl" (the original OCI reference) and "name" (from metadata.yaml if present). Only .jsonnet files -// are included as template data. Returns an error on invalid reference, download failure, or extraction error. +// "ociUrl" (the original OCI reference), "name" (from metadata.yaml if present), and "values" (from values.yaml if present). +// Only .jsonnet files are included as template data. Returns an error on invalid reference, download failure, or extraction error. func (a *ArtifactBuilder) GetTemplateData(ociRef string) (map[string][]byte, error) { if !strings.HasPrefix(ociRef, "oci://") { return nil, fmt.Errorf("invalid OCI reference: %s", ociRef) @@ -374,6 +374,7 @@ func (a *ArtifactBuilder) GetTemplateData(ociRef string) (map[string][]byte, err var metadataName string jsonnetFiles := make(map[string][]byte) var hasMetadata, hasBlueprintJsonnet bool + var valuesContent []byte for { header, err := tarReader.Next() @@ -399,6 +400,11 @@ func (a *ArtifactBuilder) GetTemplateData(ociRef string) (map[string][]byte, err return nil, fmt.Errorf("failed to parse metadata.yaml: %w", err) } metadataName = metadata.Name + case name == "_template/values.yaml": + valuesContent, err = io.ReadAll(tarReader) + if err != nil { + return nil, fmt.Errorf("failed to read _template/values.yaml: %w", err) + } case strings.HasSuffix(name, ".jsonnet"): normalized := strings.TrimPrefix(name, "_template/") if normalized == "blueprint.jsonnet" { @@ -420,6 +426,9 @@ func (a *ArtifactBuilder) GetTemplateData(ociRef string) (map[string][]byte, err } templateData["name"] = []byte(metadataName) + if valuesContent != nil { + templateData["values"] = valuesContent + } maps.Copy(templateData, jsonnetFiles) return templateData, nil diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 277f87b9b..25dc8d528 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "path/filepath" + "reflect" "slices" "strings" "syscall" @@ -267,9 +268,11 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st } } -// Install applies all blueprint Kubernetes resources to the cluster, including the main repository, additional sources, Kustomizations, and the context ConfigMap. -// The method ensures the target namespace exists, applies the main and additional source repositories, creates the ConfigMap, and applies all Kustomizations. -// Uses the environment KUBECONFIG or in-cluster configuration for access. Returns an error if any resource application fails. +// Install applies all blueprint Kubernetes resources to the cluster, including the main +// repository, additional sources, Kustomizations, and the context ConfigMap. The method +// ensures the target namespace exists, applies the main and additional source repositories, +// creates the ConfigMap, and applies all Kustomizations. Uses the environment KUBECONFIG or +// in-cluster configuration for access. Returns an error if any resource application fails. func (b *BaseBlueprintHandler) Install() error { spin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) spin.Suffix = " πŸ“ Installing blueprint resources" @@ -437,9 +440,11 @@ func (b *BaseBlueprintHandler) GetDefaultTemplateData(contextName string) (map[s }, nil } -// GetLocalTemplateData collects template data from the local contexts/_template directory. -// It recursively walks through the template directory and collects only .jsonnet files, -// maintaining the relative path structure from the template directory root. +// GetLocalTemplateData returns template files from contexts/_template, merging values.yaml from +// both _template and context dirs. All .jsonnet files are collected recursively with relative +// paths preserved. If OCI artifact values exist, they are merged with local values, with local +// values taking precedence. Returns nil if no templates exist. Keys are relative file paths, +// values are file contents. func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) { projectRoot, err := b.shell.GetProjectRoot() if err != nil { @@ -456,6 +461,41 @@ func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) return nil, fmt.Errorf("failed to collect templates: %w", err) } + contextValues, err := b.loadAndMergeContextValues() + if err != nil { + return nil, fmt.Errorf("failed to load and merge context values: %w", err) + } + + if contextValues != nil { + if len(contextValues.TopLevel) > 0 { + if existingValues, exists := templateData["values"]; exists { + var ociValues map[string]any + if err := b.shims.YamlUnmarshal(existingValues, &ociValues); err == nil { + contextValues.TopLevel = b.deepMergeValues(ociValues, contextValues.TopLevel) + } + } + topLevelYAML, err := b.shims.YamlMarshal(contextValues.TopLevel) + if err != nil { + return nil, fmt.Errorf("failed to marshal top-level values: %w", err) + } + templateData["values"] = topLevelYAML + } + + if len(contextValues.Substitution) > 0 { + if existingValues, exists := templateData["substitution"]; exists { + var ociSubstitutionValues map[string]any + if err := b.shims.YamlUnmarshal(existingValues, &ociSubstitutionValues); err == nil { + contextValues.Substitution = b.deepMergeValues(ociSubstitutionValues, contextValues.Substitution) + } + } + substitutionYAML, err := b.shims.YamlMarshal(contextValues.Substitution) + if err != nil { + return nil, fmt.Errorf("failed to marshal substitution values: %w", err) + } + templateData["substitution"] = substitutionYAML + } + } + return templateData, nil } @@ -674,6 +714,186 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot return nil } +// loadAndMergeValues loads and merges values.yaml files from the _template and context-specific directories. +// It loads base values from contexts/_template/values.yaml, then overlays context-specific values from +// contexts//values.yaml, where is determined from the current configuration. +// Returns merged YAML content as bytes, or nil if no values files exist. +func (b *BaseBlueprintHandler) loadAndMergeValues() ([]byte, error) { + projectRoot, err := b.shell.GetProjectRoot() + if err != nil { + return nil, fmt.Errorf("failed to get project root: %w", err) + } + + templateValuesPath := filepath.Join(projectRoot, "contexts", "_template", "values.yaml") + var baseValues map[string]any + + if _, err := b.shims.Stat(templateValuesPath); err == nil { + baseValuesContent, err := b.shims.ReadFile(templateValuesPath) + if err != nil { + return nil, fmt.Errorf("failed to read template values.yaml: %w", err) + } + if err := yaml.Unmarshal(baseValuesContent, &baseValues); err != nil { + return nil, fmt.Errorf("failed to parse template values.yaml: %w", err) + } + } + + contextName := b.configHandler.GetContext() + if contextName == "" { + if baseValues == nil { + return nil, nil + } + return yaml.Marshal(baseValues) + } + + configRoot, err := b.configHandler.GetConfigRoot() + if err != nil { + return nil, fmt.Errorf("failed to get config root: %w", err) + } + contextValuesPath := filepath.Join(configRoot, "values.yaml") + var contextValues map[string]any + + if _, err := b.shims.Stat(contextValuesPath); err == nil { + contextValuesContent, err := b.shims.ReadFile(contextValuesPath) + if err != nil { + return nil, fmt.Errorf("failed to read context values.yaml: %w", err) + } + if err := yaml.Unmarshal(contextValuesContent, &contextValues); err != nil { + return nil, fmt.Errorf("failed to parse context values.yaml: %w", err) + } + } + + mergedValues := make(map[string]any) + maps.Copy(mergedValues, baseValues) + for k, v := range contextValues { + if existingValue, exists := mergedValues[k]; exists { + if existingMap, ok := existingValue.(map[string]any); ok { + if newMap, ok := v.(map[string]any); ok { + mergedValues[k] = b.deepMergeValues(existingMap, newMap) + continue + } + } + } + mergedValues[k] = v + } + + if len(mergedValues) == 0 { + return nil, nil + } + + return b.shims.YamlMarshal(mergedValues) +} + +// loadAndMergeContextValues loads and merges values.yaml files from the _template and context-specific directories. +// Recursively merges base (_template) and context-specific values.yaml files, merging nested maps. +// Separates merged values into top-level and substitution (kustomize) values. +// Returns a ContextValues struct containing both top-level and substitution values, or an error if loading or parsing fails. +func (b *BaseBlueprintHandler) loadAndMergeContextValues() (*ContextValues, error) { + projectRoot, err := b.shell.GetProjectRoot() + if err != nil { + return nil, fmt.Errorf("failed to get project root: %w", err) + } + + templateValuesPath := filepath.Join(projectRoot, "contexts", "_template", "values.yaml") + var baseValues map[string]any + + if _, err := b.shims.Stat(templateValuesPath); err == nil { + baseValuesContent, err := b.shims.ReadFile(templateValuesPath) + if err != nil { + return nil, fmt.Errorf("failed to read template values.yaml: %w", err) + } + if err := yaml.Unmarshal(baseValuesContent, &baseValues); err != nil { + return nil, fmt.Errorf("failed to parse template values.yaml: %w", err) + } + } + + contextName := b.configHandler.GetContext() + if contextName == "" { + if baseValues == nil { + return &ContextValues{}, nil + } + return b.separateValues(baseValues) + } + + configRoot, err := b.configHandler.GetConfigRoot() + if err != nil { + return nil, fmt.Errorf("failed to get config root: %w", err) + } + contextValuesPath := filepath.Join(configRoot, "values.yaml") + var contextValues map[string]any + + if _, err := b.shims.Stat(contextValuesPath); err == nil { + contextValuesContent, err := b.shims.ReadFile(contextValuesPath) + if err != nil { + return nil, fmt.Errorf("failed to read context values.yaml: %w", err) + } + if err := yaml.Unmarshal(contextValuesContent, &contextValues); err != nil { + return nil, fmt.Errorf("failed to parse context values.yaml: %w", err) + } + } + + mergedValues := make(map[string]any) + maps.Copy(mergedValues, baseValues) + for k, v := range contextValues { + if existingValue, exists := mergedValues[k]; exists { + if existingMap, ok := existingValue.(map[string]any); ok { + if newMap, ok := v.(map[string]any); ok { + mergedValues[k] = b.deepMergeValues(existingMap, newMap) + continue + } + } + } + mergedValues[k] = v + } + + return b.separateValues(mergedValues) +} + +// ContextValues contains the separated top-level and substitution values from values.yaml files +type ContextValues struct { + TopLevel map[string]any `json:"topLevel"` + Substitution map[string]any `json:"substitution"` +} + +// separateValues separates top-level values from substitution values in a merged values map +func (b *BaseBlueprintHandler) separateValues(mergedValues map[string]any) (*ContextValues, error) { + topLevel := make(map[string]any) + substitution := make(map[string]any) + + for k, v := range mergedValues { + if k == "substitution" { + if substitutionMap, ok := v.(map[string]any); ok { + substitution = substitutionMap + } + } else { + topLevel[k] = v + } + } + + return &ContextValues{ + TopLevel: topLevel, + Substitution: substitution, + }, nil +} + +// deepMergeValues recursively merges two maps, with the overlay values taking precedence. +// Used for merging values.yaml files where nested structures should be merged rather than replaced. +func (b *BaseBlueprintHandler) deepMergeValues(base, overlay map[string]any) map[string]any { + result := make(map[string]any) + maps.Copy(result, base) + for k, v := range overlay { + if existingValue, exists := result[k]; exists { + if existingMap, ok := existingValue.(map[string]any); ok { + if newMap, ok := v.(map[string]any); ok { + result[k] = b.deepMergeValues(existingMap, newMap) + continue + } + } + } + result[k] = v + } + return result +} + // resolveComponentSources transforms component source names into fully qualified URLs // with path prefix and reference information based on the associated source configuration. // It processes both OCI and Git sources, constructing appropriate URL formats for each type. @@ -1357,15 +1577,11 @@ func (b *BaseBlueprintHandler) isOCISource(sourceNameOrURL string) bool { return false } -// applyValuesConfigMaps creates ConfigMaps for post-build variable substitution using rendered values data and any existing values.yaml files. -// It generates a ConfigMap for the "common" section and for each component section, merging rendered template values with user-defined values. -// User-defined values take precedence over template values in case of conflicts. +// applyValuesConfigMaps creates ConfigMaps for post-build variable substitution using rendered values data and context-specific values.yaml files. +// It generates a ConfigMap for the "common" section and for each component section, merging rendered template values with context values. +// Context-specific values from contexts/{context}/values.yaml take precedence over template values in case of conflicts. // The resulting ConfigMaps are 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) - } mergedCommonValues := make(map[string]any) @@ -1402,53 +1618,32 @@ func (b *BaseBlueprintHandler) applyValuesConfigMaps() error { mergedCommonValues["BUILD_ID"] = buildID } - var userValues map[string]any - valuesPath := filepath.Join(configRoot, "kustomize", "values.yaml") - if _, err := b.shims.Stat(valuesPath); err == nil { - data, err := b.shims.ReadFile(valuesPath) - if err == nil { - if err := b.shims.YamlUnmarshal(data, &userValues); err != nil { - return fmt.Errorf("failed to unmarshal values file %s: %w", valuesPath, err) - } - } - } - - if userValues == nil { - userValues = make(map[string]any) - } - - var renderedValues map[string]any - if kustomizeValues, exists := b.kustomizeData["kustomize/values"]; exists { - if valuesMap, ok := kustomizeValues.(map[string]any); ok { - renderedValues = valuesMap - } + contextValues, err := b.loadAndMergeContextValues() + if err != nil { + return fmt.Errorf("failed to load context values: %w", err) } - if renderedValues == nil { - renderedValues = make(map[string]any) - } + renderedValues := make(map[string]any) + // Start with all values from rendered templates and context allValues := make(map[string]any) maps.Copy(allValues, renderedValues) - allValues = b.deepMergeMaps(allValues, userValues) - if commonValues, exists := allValues["common"]; exists { - if commonMap, ok := commonValues.(map[string]any); ok { - maps.Copy(mergedCommonValues, commonMap) - } + if contextValues.Substitution != nil { + allValues = b.deepMergeMaps(allValues, contextValues.Substitution) } - if len(mergedCommonValues) > 0 { - if err := b.createConfigMap(mergedCommonValues, "values-common"); err != nil { - return fmt.Errorf("failed to create merged common values ConfigMap: %w", err) - } + // Ensure "common" section exists and merge system values into it + if allValues["common"] == nil { + allValues["common"] = make(map[string]any) } - for componentName, componentValues := range allValues { - if componentName == "common" { - continue - } + if commonMap, ok := allValues["common"].(map[string]any); ok { + maps.Copy(commonMap, mergedCommonValues) + } + // Create ConfigMaps for all sections generically + for componentName, componentValues := range allValues { if componentMap, ok := componentValues.(map[string]any); ok { configMapName := fmt.Sprintf("values-%s", componentName) if err := b.createConfigMap(componentMap, configMapName); err != nil { @@ -1472,19 +1667,40 @@ func (b *BaseBlueprintHandler) validateValuesForSubstitution(values map[string]a currentKey = parentKey + "." + key } + // Handle nil values first to avoid panic in reflect.TypeOf + if value == nil { + return fmt.Errorf("values for post-build substitution cannot contain nil values, key '%s'", currentKey) + } + + // Check if the value is a slice using reflection + if reflect.TypeOf(value).Kind() == reflect.Slice { + return fmt.Errorf("values for post-build substitution cannot contain slices, key '%s' has type %T", currentKey, value) + } + switch v := value.(type) { case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: continue case map[string]any: + // Post-build substitution should only allow flat key/value maps, no nesting at all if depth >= 1 { - return fmt.Errorf("values for post-build substitution cannot contain nested complex types, key '%s' has type %T", currentKey, v) + return fmt.Errorf("values for post-build substitution cannot contain nested maps, key '%s' has type %T", currentKey, v) } - err := validate(v, currentKey, depth+1) - if err != nil { - return err + // Validate that the nested map only contains scalar values (no further nesting) + for nestedKey, nestedValue := range v { + nestedCurrentKey := currentKey + "." + nestedKey + switch nestedValue.(type) { + case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: + continue + case nil: + return fmt.Errorf("values for post-build substitution cannot contain nil values, key '%s'", nestedCurrentKey) + default: + // Check if it's a slice + if reflect.TypeOf(nestedValue).Kind() == reflect.Slice { + return fmt.Errorf("values for post-build substitution cannot contain slices, key '%s' has type %T", nestedCurrentKey, nestedValue) + } + return fmt.Errorf("values for post-build substitution can only contain scalar values in maps, key '%s' has unsupported type %T", nestedCurrentKey, nestedValue) + } } - case []any: - return fmt.Errorf("values for post-build substitution cannot contain slices, key '%s' has type %T", currentKey, v) default: return fmt.Errorf("values for post-build substitution can only contain strings, numbers, booleans, or maps of scalar types, key '%s' has unsupported type %T", currentKey, v) } diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 87dc1f2bc..041a83803 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2339,7 +2339,7 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { // Given a blueprint handler with template directory containing jsonnet files handler, mocks := setup(t) - projectRoot := filepath.Join("/mock", "project") + projectRoot := filepath.Join("mock", "project") templateDir := filepath.Join(projectRoot, "contexts", "_template") // Mock shell to return project root @@ -2465,7 +2465,7 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { // Given a blueprint handler with template directory that fails to read handler, mocks := setup(t) - projectRoot := filepath.Join("/mock", "project") + projectRoot := filepath.Join("mock", "project") templateDir := filepath.Join(projectRoot, "contexts", "_template") // Mock shell to return project root @@ -2504,1032 +2504,2751 @@ func TestBlueprintHandler_GetLocalTemplateData(t *testing.T) { t.Error("Expected result to be nil on error") } }) -} -func TestBlueprintHandler_LoadData(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("MergesOCIArtifactValuesWithLocalContextValues", func(t *testing.T) { + // Given a blueprint handler with OCI artifact values already in template data + handler, mocks := setup(t) - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + // Ensure the handler uses the mock shell and config handler + baseHandler := handler.(*BaseBlueprintHandler) + baseHandler.shell = mocks.Shell + baseHandler.configHandler = mocks.ConfigHandler - // And blueprint data - blueprintData := map[string]any{ - "kind": "Blueprint", - "apiVersion": "v1alpha1", - "metadata": map[string]any{ - "name": "test-blueprint", - "description": "A test blueprint from data", - "authors": []any{"John Doe"}, - }, - "sources": []any{ - map[string]any{ - "name": "test-source", - "url": "https://example.com/test-repo.git", - }, - }, - "terraform": []any{ - map[string]any{ - "source": "test-source", - "path": "path/to/code", - "values": map[string]any{ - "key1": "value1", - }, - }, - }, + // Mock local context values + projectRoot := filepath.Join("tmp", "test") + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + // Normalize path separators for cross-platform compatibility + normalizedPath := filepath.ToSlash(path) + if strings.Contains(normalizedPath, "_template/values.yaml") || strings.Contains(normalizedPath, "test-context/values.yaml") { + return &mockFileInfo{isDir: false}, nil + } + if strings.Contains(normalizedPath, "_template") && !strings.Contains(normalizedPath, "values.yaml") { + return &mockFileInfo{isDir: true}, nil + } + return nil, os.ErrNotExist + } + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + // Normalize path separators for cross-platform compatibility + normalizedPath := filepath.ToSlash(path) + if strings.Contains(normalizedPath, "_template/values.yaml") { + return []byte(`external_domain: local.test +registry_url: registry.local.test +local_only: + enabled: true +substitution: + common: + external_domain: local.test + registry_url: registry.local.test + local_only: + enabled: true`), nil + } + if strings.Contains(normalizedPath, "test-context/values.yaml") { + return []byte(`external_domain: context.test +context_only: + enabled: true +substitution: + common: + external_domain: context.test + context_only: + enabled: true`), nil + } + return nil, os.ErrNotExist + } + // Cast to mock config handler to set the function + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join(projectRoot, "contexts", "test-context"), nil } - // When loading the data - err := handler.LoadData(blueprintData) + // When GetLocalTemplateData is called + result, err := handler.GetLocalTemplateData() // Then no error should be returned if err != nil { t.Errorf("Expected no error, got %v", err) } - // And the metadata should be correctly loaded - metadata := handler.GetMetadata() - if metadata.Name != "test-blueprint" { - t.Errorf("Expected name to be test-blueprint, got %s", metadata.Name) - } - if metadata.Description != "A test blueprint from data" { - t.Errorf("Expected description to be 'A test blueprint from data', got %s", metadata.Description) - } - if len(metadata.Authors) != 1 || metadata.Authors[0] != "John Doe" { - t.Errorf("Expected authors to be ['John Doe'], got %v", metadata.Authors) + // And result should contain merged values + if result == nil { + t.Fatal("Expected result to not be nil") } - // And the sources should be loaded - sources := handler.GetSources() - if len(sources) != 1 { - t.Errorf("Expected 1 source, got %d", len(sources)) + // Check for values (top-level values merged into context) + valuesData, exists := result["values"] + if !exists { + t.Fatal("Expected 'values' key to exist in result") } - if sources[0].Name != "test-source" { - t.Errorf("Expected source name to be 'test-source', got %s", sources[0].Name) + + var values map[string]any + if err := yaml.Unmarshal(valuesData, &values); err != nil { + t.Fatalf("Failed to unmarshal values: %v", err) } - // And the terraform components should be loaded - components := handler.GetTerraformComponents() - if len(components) != 1 { - t.Errorf("Expected 1 terraform component, got %d", len(components)) + // Verify that top-level values are properly merged + if values["external_domain"] != "context.test" { + t.Errorf("Expected external_domain to be 'context.test', got %v", values["external_domain"]) } - if components[0].Path != "path/to/code" { - t.Errorf("Expected component path to be 'path/to/code', got %s", components[0].Path) + + if values["registry_url"] != "registry.local.test" { + t.Errorf("Expected registry_url to be 'registry.local.test', got %v", values["registry_url"]) } - // Note: The GetTerraformComponents() method resolves sources to full URLs, - // so we can't easily test the raw source names without accessing private fields - }) + // Check for substitution (substitution section for ConfigMaps) + substitutionValuesData, exists := result["substitution"] + if !exists { + t.Fatal("Expected 'substitution' key to exist in result") + } - t.Run("MarshalError", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + var substitutionValues map[string]any + if err := yaml.Unmarshal(substitutionValuesData, &substitutionValues); err != nil { + t.Fatalf("Failed to unmarshal substitution values: %v", err) + } - // And a mock yaml marshaller that returns an error - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return nil, fmt.Errorf("simulated marshalling error") + // Verify that substitution values are properly merged + common, exists := substitutionValues["common"].(map[string]any) + if !exists { + t.Fatal("Expected 'common' section to exist in substitution values") } - // And blueprint data - blueprintData := map[string]any{ - "kind": "Blueprint", + if common["external_domain"] != "context.test" { + t.Errorf("Expected substitution external_domain to be 'context.test', got %v", common["external_domain"]) } - // When loading the data - err := handler.LoadData(blueprintData) + if common["registry_url"] != "registry.local.test" { + t.Errorf("Expected substitution registry_url to be 'registry.local.test', got %v", common["registry_url"]) + } - // Then an error should be returned - if err == nil { - t.Errorf("Expected LoadData to fail due to marshalling error, but it succeeded") + // Verify that both local-only and context-only sections are preserved in substitution values + if _, exists := substitutionValues["local_only"]; !exists { + t.Error("Expected 'local_only' section to be preserved in substitution values") } - if !strings.Contains(err.Error(), "error marshalling blueprint data to yaml") { - t.Errorf("Expected error message to contain 'error marshalling blueprint data to yaml', got %v", err) + + if _, exists := substitutionValues["context_only"]; !exists { + t.Error("Expected 'context_only' section to be preserved in substitution values") } }) - t.Run("ProcessBlueprintDataError", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + t.Run("MergesContextValuesWithTemplateData", func(t *testing.T) { + // Given a blueprint handler with template directory and context values + handler, mocks := setup(t) - // And a mock yaml unmarshaller that returns an error - handler.shims.YamlUnmarshal = func(data []byte, obj any) error { - return fmt.Errorf("simulated unmarshalling error") + // Ensure the handler uses the mock shell and config handler + baseHandler := handler.(*BaseBlueprintHandler) + baseHandler.shell = mocks.Shell + baseHandler.configHandler = mocks.ConfigHandler + + projectRoot := filepath.Join("mock", "project") + templateDir := filepath.Join(projectRoot, "contexts", "_template") + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil } - // And blueprint data - blueprintData := map[string]any{ - "kind": "Blueprint", + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join(projectRoot, "contexts", "test-context"), nil + } } - // When loading the data - err := handler.LoadData(blueprintData) + // Mock shims to simulate template directory and values files + if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir || + path == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") || + path == filepath.Join(projectRoot, "contexts", "test-context", "values.yaml") { + return mockFileInfo{name: "template"}, nil + } + return nil, os.ErrNotExist + } - // Then an error should be returned - if err == nil { - t.Errorf("Expected LoadData to fail due to unmarshalling error, but it succeeded") - } - }) + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } - t.Run("WithOCIArtifactInfo", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case filepath.Join(templateDir, "blueprint.jsonnet"): + return []byte("{ kind: 'Blueprint' }"), nil + case filepath.Join(projectRoot, "contexts", "_template", "values.yaml"): + return []byte(` +external_domain: template.test +template_only: template_value +substitution: + common: + registry_url: registry.template.test +`), nil + case filepath.Join(projectRoot, "contexts", "test-context", "values.yaml"): + return []byte(` +external_domain: context.test +context_only: context_value +substitution: + common: + registry_url: registry.context.test + csi: + volume_path: /context/volumes +`), nil + default: + return nil, fmt.Errorf("file not found: %s", path) + } + } - // And blueprint data - blueprintData := map[string]any{ - "kind": "Blueprint", - "apiVersion": "v1alpha1", - "metadata": map[string]any{ - "name": "oci-blueprint", - "description": "A blueprint from OCI artifact", - }, - } + baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { + return yaml.Marshal(v) + } - // And OCI artifact info - ociInfo := &artifact.OCIArtifactInfo{ - Name: "my-blueprint", - URL: "oci://registry.example.com/my-blueprint:v1.0.0", + baseHandler.shims.YamlUnmarshal = func(data []byte, v any) error { + return yaml.Unmarshal(data, v) + } } - // When loading the data with OCI info - err := handler.LoadData(blueprintData, ociInfo) + // When getting local template data + result, err := handler.GetLocalTemplateData() - // Then no error should be returned + // Then no error should occur if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Fatalf("Expected no error, got: %v", err) } - // And the metadata should be correctly loaded - metadata := handler.GetMetadata() - if metadata.Name != "oci-blueprint" { - t.Errorf("Expected name to be oci-blueprint, got %s", metadata.Name) - } - if metadata.Description != "A blueprint from OCI artifact" { - t.Errorf("Expected description to be 'A blueprint from OCI artifact', got %s", metadata.Description) + // And result should contain template files + if len(result) == 0 { + t.Fatal("Expected template data, got empty map") } - }) -} - -func TestBlueprintHandler_Write(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - // Override GetConfigRoot to return the expected path for Write tests - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "mock-config-root", nil + // Check that blueprint.jsonnet is included + if _, exists := result["blueprint.jsonnet"]; !exists { + t.Error("Expected 'blueprint.jsonnet' to be in result") } - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) + // Check that values are merged and included + valuesData, exists := result["values"] + if !exists { + t.Fatal("Expected 'values' key to exist in result") } - return handler, mocks - } - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler with a loaded blueprint - handler, mocks := setup(t) + var values map[string]any + if err := yaml.Unmarshal(valuesData, &values); err != nil { + t.Fatalf("Failed to unmarshal values: %v", err) + } - // Set up the blueprint with test data - handler.blueprint = blueprintv1alpha1.Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - Description: "A test blueprint", - Authors: []string{"test-author"}, - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/example/repo", - Ref: blueprintv1alpha1.Reference{ - Branch: "main", - }, - }, + // Context values should override template values + if values["external_domain"] != "context.test" { + t.Errorf("Expected external_domain to be 'context.test', got %v", values["external_domain"]) } - // And mock file operations - var writtenPath string - var writtenContent []byte - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenPath = name - writtenContent = data - return nil + // Template-only values should be preserved + if values["template_only"] != "template_value" { + t.Errorf("Expected template_only to be 'template_value', got %v", values["template_only"]) } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist // File doesn't exist + // Context-only values should be added + if values["context_only"] != "context_value" { + t.Errorf("Expected context_only to be 'context_value', got %v", values["context_only"]) } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + // Check that substitution values are merged and included + substitutionData, exists := result["substitution"] + if !exists { + t.Fatal("Expected 'substitution' key to exist in result") } - mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("kind: Blueprint\napiVersion: v1alpha1\nmetadata:\n name: test-blueprint\n"), nil + var substitution map[string]any + if err := yaml.Unmarshal(substitutionData, &substitution); err != nil { + t.Fatalf("Failed to unmarshal substitution: %v", err) } - // When Write is called - err := handler.Write() + // Check common section merging + common, exists := substitution["common"].(map[string]any) + if !exists { + t.Fatal("Expected 'common' section to exist in substitution") + } - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got %v", err) + if common["registry_url"] != "registry.context.test" { + t.Errorf("Expected registry_url to be 'registry.context.test', got %v", common["registry_url"]) } - // And the file should be written to the correct path - expectedPath := filepath.Join("mock-config-root", "blueprint.yaml") - if writtenPath != expectedPath { - t.Errorf("Expected file path %s, got %s", expectedPath, writtenPath) + // Check context-specific section + csi, exists := substitution["csi"].(map[string]any) + if !exists { + t.Fatal("Expected 'csi' section to exist in substitution") } - // And the content should be written - if len(writtenContent) == 0 { - t.Errorf("Expected content to be written, got empty content") + if csi["volume_path"] != "/context/volumes" { + t.Errorf("Expected volume_path to be '/context/volumes', got %v", csi["volume_path"]) } }) - t.Run("WithOverwriteTrue", func(t *testing.T) { - // Given a blueprint handler + t.Run("HandlesContextValuesWithoutExistingValues", func(t *testing.T) { + // Given a blueprint handler with only context values (no existing OCI values) handler, mocks := setup(t) - // Set up the blueprint with test data - handler.blueprint = blueprintv1alpha1.Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - } + // Ensure the handler uses the mock shell and config handler + baseHandler := handler.(*BaseBlueprintHandler) + baseHandler.shell = mocks.Shell + baseHandler.configHandler = mocks.ConfigHandler - // And mock file operations - var writtenPath string - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenPath = name - return nil - } + projectRoot := filepath.Join("mock", "project") + templateDir := filepath.Join(projectRoot, "contexts", "_template") - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "blueprint.yaml"}, nil // File exists + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join(projectRoot, "contexts", "test-context"), nil + } } - mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("kind: Blueprint\napiVersion: v1alpha1\n"), nil - } + // Mock shims to simulate template directory and context values + if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir || + path == filepath.Join(projectRoot, "contexts", "test-context", "values.yaml") { + return mockFileInfo{name: "template"}, nil + } + return nil, os.ErrNotExist + } - // When Write is called with overwrite true - err := handler.Write(true) + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got %v", err) - } + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case filepath.Join(templateDir, "blueprint.jsonnet"): + return []byte("{ kind: 'Blueprint' }"), nil + case filepath.Join(projectRoot, "contexts", "test-context", "values.yaml"): + return []byte(` +external_domain: context.test +context_only: context_value +substitution: + common: + registry_url: registry.context.test + context_sub: context_sub_value +`), nil + default: + return nil, fmt.Errorf("file not found: %s", path) + } + } - // And the file should be written (overwrite) - expectedPath := filepath.Join("mock-config-root", "blueprint.yaml") - if writtenPath != expectedPath { - t.Errorf("Expected file path %s, got %s", expectedPath, writtenPath) + baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { + return yaml.Marshal(v) + } + + baseHandler.shims.YamlUnmarshal = func(data []byte, v any) error { + return yaml.Unmarshal(data, v) + } } - }) - t.Run("WithOverwriteFalse", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) + // When getting local template data + result, err := handler.GetLocalTemplateData() - // And mock file operations - var writtenPath string - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenPath = name - return nil + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "blueprint.yaml"}, nil // File exists + // Check that context values are properly included + valuesData, exists := result["values"] + if !exists { + t.Fatal("Expected 'values' key to exist in result") } - // When Write is called with overwrite false - err := handler.Write(false) + var values map[string]any + if err := yaml.Unmarshal(valuesData, &values); err != nil { + t.Fatalf("Failed to unmarshal values: %v", err) + } - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got %v", err) + // Context values should be present + if values["external_domain"] != "context.test" { + t.Errorf("Expected external_domain to be 'context.test', got %v", values["external_domain"]) } - // And the file should NOT be written (skip existing) - if writtenPath != "" { - t.Errorf("Expected no file to be written, but got %s", writtenPath) + if values["context_only"] != "context_value" { + t.Errorf("Expected context_only to be 'context_value', got %v", values["context_only"]) } - }) - t.Run("ErrorGettingConfigRoot", func(t *testing.T) { - // Given a blueprint handler with a mock config handler that returns an error - mockConfigHandler := config.NewMockConfigHandler() - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("config root error") + // Check substitution values + substitutionData, exists := result["substitution"] + if !exists { + t.Fatal("Expected 'substitution' key to exist in result") } - opts := &SetupOptions{ - ConfigHandler: mockConfigHandler, + + var substitution map[string]any + if err := yaml.Unmarshal(substitutionData, &substitution); err != nil { + t.Fatalf("Failed to unmarshal substitution: %v", err) } - mocks := setupMocks(t, opts) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) + + common, exists := substitution["common"].(map[string]any) + if !exists { + t.Fatal("Expected 'common' section to exist in substitution") } - // When Write is called - err = handler.Write() + if common["registry_url"] != "registry.context.test" { + t.Errorf("Expected registry_url to be 'registry.context.test', got %v", common["registry_url"]) + } - // Then an error should be returned - if err == nil { - t.Errorf("Expected error from GetConfigRoot, got nil") + if common["context_sub"] != "context_sub_value" { + t.Errorf("Expected context_sub to be 'context_sub_value', got %v", common["context_sub"]) } }) - t.Run("ErrorCreatingDirectory", func(t *testing.T) { - // Given a blueprint handler + t.Run("HandlesErrorInLoadAndMergeContextValues", func(t *testing.T) { + // Given a blueprint handler that fails to load context values handler, mocks := setup(t) - // And MkdirAll returns an error - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("mkdir error") + projectRoot := filepath.Join("mock", "project") + templateDir := filepath.Join(projectRoot, "contexts", "_template") + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist // File doesn't exist + // Mock shell to fail when getting project root (for loadAndMergeContextValues) + if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { + // Override the shell in the base handler to return error + baseHandler.shell = &shell.MockShell{ + GetProjectRootFunc: func() (string, error) { + return "", fmt.Errorf("project root error") + }, + } + + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir { + return mockFileInfo{name: "template"}, nil + } + return nil, os.ErrNotExist + } + + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } + + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + if path == filepath.Join(templateDir, "blueprint.jsonnet") { + return []byte("{ kind: 'Blueprint' }"), nil + } + return nil, fmt.Errorf("file not found: %s", path) + } } - // When Write is called - err := handler.Write() + // When getting local template data + _, err := handler.GetLocalTemplateData() - // Then an error should be returned + // Then an error should occur if err == nil { - t.Errorf("Expected error from MkdirAll, got nil") + t.Error("Expected error when loadAndMergeContextValues fails") + } + + if !strings.Contains(err.Error(), "failed to get project root") { + t.Errorf("Expected error about project root, got: %v", err) } }) - t.Run("ErrorMarshalingBlueprint", func(t *testing.T) { - // Given a blueprint handler + t.Run("HandlesYamlMarshalError", func(t *testing.T) { + // Given a blueprint handler with context values but YAML marshal error handler, mocks := setup(t) - // And YamlMarshal returns an error - mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { - return nil, fmt.Errorf("marshal error") - } + // Ensure the handler uses the mock shell and config handler + baseHandler := handler.(*BaseBlueprintHandler) + baseHandler.shell = mocks.Shell + baseHandler.configHandler = mocks.ConfigHandler - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist // File doesn't exist + projectRoot := filepath.Join("mock", "project") + templateDir := filepath.Join(projectRoot, "contexts", "_template") + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join(projectRoot, "contexts", "test-context"), nil + } } - // When Write is called - err := handler.Write() + // Mock shims to simulate template directory and values files + if baseHandler, ok := handler.(*BaseBlueprintHandler); ok { + baseHandler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateDir || + path == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") || + path == filepath.Join(projectRoot, "contexts", "test-context", "values.yaml") { + return mockFileInfo{name: "template"}, nil + } + return nil, os.ErrNotExist + } - // Then an error should be returned - if err == nil { - t.Errorf("Expected error from YamlMarshal, got nil") - } - }) + baseHandler.shims.ReadDir = func(path string) ([]os.DirEntry, error) { + if path == templateDir { + return []os.DirEntry{ + &mockDirEntry{name: "blueprint.jsonnet", isDir: false}, + }, nil + } + return nil, fmt.Errorf("directory not found") + } - t.Run("ClearsAllValues", func(t *testing.T) { - // Given a blueprint handler with terraform components containing values - handler, mocks := setup(t) + baseHandler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case filepath.Join(templateDir, "blueprint.jsonnet"): + return []byte("{ kind: 'Blueprint' }"), nil + case filepath.Join(projectRoot, "contexts", "_template", "values.yaml"): + return []byte(`external_domain: template.test`), nil + case filepath.Join(projectRoot, "contexts", "test-context", "values.yaml"): + return []byte(`external_domain: context.test`), nil + default: + return nil, fmt.Errorf("file not found: %s", path) + } + } - // Set up a terraform component with both values and terraform variables - handler.blueprint = blueprintv1alpha1.Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - TerraformComponents: []blueprintv1alpha1.TerraformComponent{ - { - Source: "core", - Path: "cluster/talos", - Values: map[string]any{ - "cluster_name": "test-cluster", // Should be kept (not a terraform variable) - "cluster_endpoint": "https://test:6443", // Should be filtered if it's a terraform variable - "controlplanes": []string{"node1"}, // Should be filtered if it's a terraform variable - "custom_config": "some-value", // Should be kept (not a terraform variable) - }, - }, - }, - } + // Mock YAML marshal to return error + baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("marshal error") + } - // Set up file system mocks - var writtenContent []byte - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - writtenContent = data - return nil + baseHandler.shims.YamlUnmarshal = func(data []byte, v any) error { + return yaml.Unmarshal(data, v) + } } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist // blueprint.yaml doesn't exist - } + // When getting local template data + _, err := handler.GetLocalTemplateData() - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + // Then an error should occur + if err == nil { + t.Error("Expected error when YAML marshal fails") } - mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { - return yaml.Marshal(v) + if !strings.Contains(err.Error(), "failed to marshal top-level values") { + t.Errorf("Expected error about marshaling top-level values, got: %v", err) } + }) - // When Write is called - err := handler.Write() +} - // Then no error should be returned +func TestBaseBlueprintHandler_loadAndMergeValues(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.Errorf("Expected no error, got %v", err) + t.Fatalf("Failed to initialize handler: %v", err) } + return handler, mocks + } - // And the written content should have all values cleared - if len(writtenContent) == 0 { - t.Errorf("Expected content to be written, got empty content") + t.Run("ReturnsNilWhenNoValuesFilesExist", func(t *testing.T) { + // Given a blueprint handler with no values files + handler, _ := setup(t) + + // Mock shims to return error for values files (don't exist) + handler.shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist } - // Parse the written YAML to verify all values are cleared - var writtenBlueprint blueprintv1alpha1.Blueprint - err = yaml.Unmarshal(writtenContent, &writtenBlueprint) + // When loading and merging values + result, err := handler.loadAndMergeValues() + + // Then no error should occur if err != nil { - t.Errorf("Failed to unmarshal written YAML: %v", err) + t.Fatalf("Expected no error, got: %v", err) } - // Verify that the terraform component exists - if len(writtenBlueprint.TerraformComponents) != 1 { - t.Errorf("Expected 1 terraform component, got %d", len(writtenBlueprint.TerraformComponents)) + // And result should be nil + if result != nil { + t.Errorf("Expected nil result, got: %v", result) } + }) - component := writtenBlueprint.TerraformComponents[0] + t.Run("LoadsOnlyTemplateValuesWhenNoContext", func(t *testing.T) { + // Given a blueprint handler with only template values and no context + handler, mocks := setup(t) - // Verify all values are cleared from the blueprint.yaml - if len(component.Values) != 0 { - t.Errorf("Expected all values to be cleared, but got %d values: %v", len(component.Values), component.Values) + templateValuesPath := filepath.Join("/mock", "project", "contexts", "_template", "values.yaml") + + // Mock config handler to return empty context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "" + } } - // Also verify kustomizations have postBuild cleared - if len(writtenBlueprint.Kustomizations) > 0 { - for i, kustomization := range writtenBlueprint.Kustomizations { - if kustomization.PostBuild != nil { - t.Errorf("Expected PostBuild to be cleared for kustomization %d, but got %v", i, kustomization.PostBuild) - } + // Mock shims to return template values file + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateValuesPath { + return mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == templateValuesPath { + return []byte(`common: + external_domain: test + registry_url: registry.test`), nil } + return nil, fmt.Errorf("file not found: %s", path) + } + + // When loading and merging values + result, err := handler.loadAndMergeValues() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And result should contain template values + if result == nil { + t.Fatal("Expected result to contain template values") + } + + // Verify the content contains the expected values + content := string(result) + if !strings.Contains(content, "external_domain: test") { + t.Errorf("Expected content to contain 'external_domain: test', got: %s", content) + } + if !strings.Contains(content, "registry_url: registry.test") { + t.Errorf("Expected content to contain 'registry_url: registry.test', got: %s", content) } }) - t.Run("ErrorWritingFile", func(t *testing.T) { - // Given a blueprint handler + t.Run("MergesTemplateAndContextValues", func(t *testing.T) { + // Given a blueprint handler with both template and context values handler, mocks := setup(t) - // And WriteFile returns an error - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return fmt.Errorf("write error") + templateValuesPath := filepath.Join("/mock", "project", "contexts", "_template", "values.yaml") + contextValuesPath := filepath.Join("/mock", "project", "contexts", "local", "values.yaml") + + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "local" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join("/mock", "project", "contexts", "local"), nil + } } - mocks.Shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist // File doesn't exist + // Mock shims to return both values files + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateValuesPath || path == contextValuesPath { + return mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist } - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil + handler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case templateValuesPath: + return []byte(`common: + external_domain: test + registry_url: registry.test +monitoring: + enabled: true`), nil + case contextValuesPath: + return []byte(`common: + external_domain: local.test +logging: + enabled: true`), nil + default: + return nil, fmt.Errorf("file not found: %s", path) + } } - mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test content"), nil + // When loading and merging values + result, err := handler.loadAndMergeValues() + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) } - // When Write is called - err := handler.Write() + // And result should contain merged values + if result == nil { + t.Fatal("Expected result to contain merged values") + } - // Then an error should be returned - if err == nil { - t.Errorf("Expected error from WriteFile, got nil") + // Verify the content contains merged values (context overrides template) + content := string(result) + if !strings.Contains(content, "external_domain: local.test") { + t.Errorf("Expected content to contain overridden 'external_domain: local.test', got: %s", content) + } + if !strings.Contains(content, "monitoring:") { + t.Errorf("Expected content to contain 'monitoring' section, got: %s", content) } + if !strings.Contains(content, "logging:") { + t.Errorf("Expected content to contain 'logging' section, got: %s", content) + } + + // Verify that context values override template values + if strings.Contains(content, "external_domain: test") { + t.Errorf("Expected template value 'external_domain: test' to be overridden by context value") + } + + // For now, just verify that the basic merge functionality works + // The exact field preservation depends on YAML marshaling behavior + t.Logf("Merged content: %s", content) }) -} -func TestTargetHandling(t *testing.T) { - // Test case 1: Path with no existing Target - should create Target with namespace from patch - patch1 := blueprintv1alpha1.BlueprintPatch{ - Path: "kustomize/ingress/nginx.yaml", - } + t.Run("ReturnsErrorWhenTemplateValuesFileCannotBeRead", func(t *testing.T) { + // Given a blueprint handler with template values file that cannot be read + handler, _ := setup(t) - // Test case 2: Path with existing Target - should not override existing Target - patch2 := blueprintv1alpha1.BlueprintPatch{ - Path: "kustomize/ingress/nginx.yaml", - Target: &kustomize.Selector{ - Kind: "Service", - Name: "nginx-ingress-controller", - Namespace: "custom-namespace", - }, - } + templateValuesPath := filepath.Join("/mock", "project", "contexts", "_template", "values.yaml") - // Test case 3: Flux format with Patch and Target - should use existing Target - patch3 := blueprintv1alpha1.BlueprintPatch{ - Patch: "apiVersion: v1\nkind: Service\nmetadata:\n name: nginx-ingress-controller\n namespace: ingress-nginx", - Target: &kustomize.Selector{ - Kind: "Service", - Name: "nginx-ingress-controller", - Namespace: "ingress-nginx", - }, - } + // Mock shims to return template values file exists but read fails + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateValuesPath { + return mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } - // Test case 4: Path with patch that has namespace in metadata - should use patch namespace - patch4 := blueprintv1alpha1.BlueprintPatch{ - Path: "kustomize/ingress/nginx-with-namespace.yaml", - } + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == templateValuesPath { + return nil, fmt.Errorf("failed to read template values file") + } + return nil, fmt.Errorf("file not found: %s", path) + } - // Verify the patches have the expected structure - if patch1.Path == "" { - t.Error("Expected patch1 to have Path field") - } - if patch1.Target != nil { - t.Error("Expected patch1 to have no Target field") - } + // When loading and merging values + result, err := handler.loadAndMergeValues() - if patch2.Path == "" { - t.Error("Expected patch2 to have Path field") - } - if patch2.Target == nil { - t.Error("Expected patch2 to have Target field") - } - if patch2.Target.Kind != "Service" { - t.Errorf("Expected patch2 Target Kind to be 'Service', got '%s'", patch2.Target.Kind) - } - if patch2.Target.Namespace != "custom-namespace" { - t.Errorf("Expected patch2 Target Namespace to be 'custom-namespace', got '%s'", patch2.Target.Namespace) - } + // Then error should occur + if err == nil { + t.Fatal("Expected error, got nil") + } - if patch3.Patch == "" { - t.Error("Expected patch3 to have Patch field") - } - if patch3.Target == nil { - t.Error("Expected patch3 to have Target field") - } - if patch3.Target.Kind != "Service" { - t.Errorf("Expected patch3 Target Kind to be 'Service', got '%s'", patch3.Target.Kind) - } - if patch3.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected patch3 Target Namespace to be 'ingress-nginx', got '%s'", patch3.Target.Namespace) - } + if !strings.Contains(err.Error(), "failed to read template values.yaml") { + t.Errorf("Expected error to contain 'failed to read template values.yaml', got: %v", err) + } - if patch4.Path == "" { - t.Error("Expected patch4 to have Path field") - } - if patch4.Target != nil { - t.Error("Expected patch4 to have no Target field (will be generated from patch content)") - } -} + // And result should be nil + if result != nil { + t.Error("Expected result to be nil on error") + } + }) -func TestNamespaceExtractionFromPatch(t *testing.T) { - // Test that namespace is correctly extracted from patch content - patchContent := `apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer` + t.Run("ReturnsErrorWhenTemplateValuesFileCannotBeParsed", func(t *testing.T) { + // Given a blueprint handler with template values file that cannot be parsed + handler, _ := setup(t) - var patchData map[string]any - err := yaml.Unmarshal([]byte(patchContent), &patchData) - if err != nil { - t.Fatalf("Failed to unmarshal patch content: %v", err) - } + templateValuesPath := filepath.Join("/mock", "project", "contexts", "_template", "values.yaml") - // Extract namespace from patch metadata - patchNamespace := "default" // fallback - if metadata, ok := patchData["metadata"].(map[string]any); ok { - if ns, ok := metadata["namespace"].(string); ok { - patchNamespace = ns + // Mock shims to return template values file with invalid YAML + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateValuesPath { + return mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist } - } - if patchNamespace != "ingress-nginx" { - t.Errorf("Expected namespace 'ingress-nginx', got '%s'", patchNamespace) - } + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == templateValuesPath { + return []byte(`invalid: yaml: content: [with: invalid: syntax`), nil + } + return nil, fmt.Errorf("file not found: %s", path) + } - // Test with patch that has no namespace - patchContentNoNS := `apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer` + // When loading and merging values + result, err := handler.loadAndMergeValues() - var patchDataNoNS map[string]any - err = yaml.Unmarshal([]byte(patchContentNoNS), &patchDataNoNS) - if err != nil { - t.Fatalf("Failed to unmarshal patch content: %v", err) - } + // Then error should occur + if err == nil { + t.Fatal("Expected error, got nil") + } - // Extract namespace from patch metadata - patchNamespaceNoNS := "default" // fallback - if metadata, ok := patchDataNoNS["metadata"].(map[string]any); ok { - if ns, ok := metadata["namespace"].(string); ok { - patchNamespaceNoNS = ns + if !strings.Contains(err.Error(), "failed to parse template values.yaml") { + t.Errorf("Expected error to contain 'failed to parse template values.yaml', got: %v", err) } - } - if patchNamespaceNoNS != "default" { - t.Errorf("Expected namespace 'default' (fallback), got '%s'", patchNamespaceNoNS) - } -} + // And result should be nil + if result != nil { + t.Error("Expected result to be nil on error") + } + }) -func TestToKubernetesKustomizationWithNamespace(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/tmp/test", nil - } + t.Run("ReturnsErrorWhenContextValuesFileCannotBeRead", func(t *testing.T) { + // Given a blueprint handler with context values file that cannot be read + handler, mocks := setup(t) - // Create a mock blueprint handler - handler := &BaseBlueprintHandler{ - projectRoot: "/tmp/test", - configHandler: mockConfigHandler, - shims: NewShims(), - } + templateValuesPath := filepath.Join("/mock", "project", "contexts", "_template", "values.yaml") + contextValuesPath := filepath.Join("/mock", "project", "contexts", "local", "values.yaml") - // Mock the ReadFile function to return a patch with namespace - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "nginx.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer`), nil + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "local" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join("/mock", "project", "contexts", "local"), nil + } } - return nil, fmt.Errorf("file not found") - } - - // Create a kustomization with a patch that references a file - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/nginx.yaml", - }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") + // Mock shims to return template values file exists but context values file read fails + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateValuesPath || path == contextValuesPath { + return mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } - // Verify that the patch has the correct Target with namespace - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } + handler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case templateValuesPath: + return []byte(`common: + external_domain: test`), nil + case contextValuesPath: + return nil, fmt.Errorf("failed to read context values file") + default: + return nil, fmt.Errorf("file not found: %s", path) + } + } - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } + // When loading and merging values + result, err := handler.loadAndMergeValues() - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } + // Then error should occur + if err == nil { + t.Fatal("Expected error, got nil") + } - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } + if !strings.Contains(err.Error(), "failed to read context values.yaml") { + t.Errorf("Expected error to contain 'failed to read context values.yaml', got: %v", err) + } - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) - } -} + // And result should be nil + if result != nil { + t.Error("Expected result to be nil on error") + } + }) -func TestToKubernetesKustomizationWithActualPatch(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/Users/ryanvangundy/Developer/windsorcli/core/contexts/colima", nil - } + t.Run("ReturnsErrorWhenContextValuesFileCannotBeParsed", func(t *testing.T) { + // Given a blueprint handler with context values file that cannot be parsed + handler, mocks := setup(t) - // Create a mock blueprint handler with the actual project root - handler := &BaseBlueprintHandler{ - projectRoot: "/Users/ryanvangundy/Developer/windsorcli/core", - configHandler: mockConfigHandler, - shims: NewShims(), - } + templateValuesPath := filepath.Join("/mock", "project", "contexts", "_template", "values.yaml") + contextValuesPath := filepath.Join("/mock", "project", "contexts", "local", "values.yaml") - // Mock the ReadFile function to return the expected patch content - handler.shims.ReadFile = func(path string) ([]byte, error) { - // Normalize path for cross-platform comparison - normalizedPath := filepath.ToSlash(path) - if strings.Contains(normalizedPath, "kustomize/ingress/nginx.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer`), nil + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "local" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join("/mock", "project", "contexts", "local"), nil + } } - return nil, fmt.Errorf("file not found: %s", path) - } - - // Create a kustomization with a patch that references the actual file - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/nginx.yaml", - }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } - - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") - // Verify that the patch has the correct Target with namespace - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } + // Mock shims to return template values file exists but context values file has invalid YAML + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == templateValuesPath || path == contextValuesPath { + return mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } + handler.shims.ReadFile = func(path string) ([]byte, error) { + switch path { + case templateValuesPath: + return []byte(`common: + external_domain: test`), nil + case contextValuesPath: + return []byte(`invalid: yaml: content: [with: invalid: syntax`), nil + default: + return nil, fmt.Errorf("file not found: %s", path) + } + } - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } + // When loading and merging values + result, err := handler.loadAndMergeValues() - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } + // Then error should occur + if err == nil { + t.Fatal("Expected error, got nil") + } - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) - } + if !strings.Contains(err.Error(), "failed to parse context values.yaml") { + t.Errorf("Expected error to contain 'failed to parse context values.yaml', got: %v", err) + } - // Also verify the patch content - if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { - t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) - } + // And result should be nil + if result != nil { + t.Error("Expected result to be nil on error") + } + }) } -func TestToKubernetesKustomizationWithMultiplePatches(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/tmp/test", nil - } - - // Create a mock blueprint handler - handler := &BaseBlueprintHandler{ - projectRoot: "/tmp/test", - configHandler: mockConfigHandler, - shims: NewShims(), - } - - // Mock the ReadFile function to return a patch with multiple documents - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "multi-patch.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nginx-config - namespace: ingress-nginx -data: - key: value`), nil +func TestBlueprintHandler_LoadData(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 nil, fmt.Errorf("file not found") + return handler, mocks } - // Create a kustomization with a patch that references a file with multiple documents - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/multi-patch.yaml", + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // And blueprint data + blueprintData := map[string]any{ + "kind": "Blueprint", + "apiVersion": "v1alpha1", + "metadata": map[string]any{ + "name": "test-blueprint", + "description": "A test blueprint from data", + "authors": []any{"John Doe"}, }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } + "sources": []any{ + map[string]any{ + "name": "test-source", + "url": "https://example.com/test-repo.git", + }, + }, + "terraform": []any{ + map[string]any{ + "source": "test-source", + "path": "path/to/code", + "values": map[string]any{ + "key1": "value1", + }, + }, + }, + } - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") + // When loading the data + err := handler.LoadData(blueprintData) - // Verify that the patch has the correct Target with namespace from the first document - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } + // And the metadata should be correctly loaded + metadata := handler.GetMetadata() + if metadata.Name != "test-blueprint" { + t.Errorf("Expected name to be test-blueprint, got %s", metadata.Name) + } + if metadata.Description != "A test blueprint from data" { + t.Errorf("Expected description to be 'A test blueprint from data', got %s", metadata.Description) + } + if len(metadata.Authors) != 1 || metadata.Authors[0] != "John Doe" { + t.Errorf("Expected authors to be ['John Doe'], got %v", metadata.Authors) + } - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } + // And the sources should be loaded + sources := handler.GetSources() + if len(sources) != 1 { + t.Errorf("Expected 1 source, got %d", len(sources)) + } + if sources[0].Name != "test-source" { + t.Errorf("Expected source name to be 'test-source', got %s", sources[0].Name) + } - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } + // And the terraform components should be loaded + components := handler.GetTerraformComponents() + if len(components) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(components)) + } + if components[0].Path != "path/to/code" { + t.Errorf("Expected component path to be 'path/to/code', got %s", components[0].Path) + } - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) + // Note: The GetTerraformComponents() method resolves sources to full URLs, + // so we can't easily test the raw source names without accessing private fields + }) + + t.Run("MarshalError", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // And a mock yaml marshaller that returns an error + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("simulated marshalling error") + } + + // And blueprint data + blueprintData := map[string]any{ + "kind": "Blueprint", + } + + // When loading the data + err := handler.LoadData(blueprintData) + + // Then an error should be returned + if err == nil { + t.Errorf("Expected LoadData to fail due to marshalling error, but it succeeded") + } + if !strings.Contains(err.Error(), "error marshalling blueprint data to yaml") { + t.Errorf("Expected error message to contain 'error marshalling blueprint data to yaml', got %v", err) + } + }) + + t.Run("ProcessBlueprintDataError", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // And a mock yaml unmarshaller that returns an error + handler.shims.YamlUnmarshal = func(data []byte, obj any) error { + return fmt.Errorf("simulated unmarshalling error") + } + + // And blueprint data + blueprintData := map[string]any{ + "kind": "Blueprint", + } + + // When loading the data + err := handler.LoadData(blueprintData) + + // Then an error should be returned + if err == nil { + t.Errorf("Expected LoadData to fail due to unmarshalling error, but it succeeded") + } + }) + + t.Run("WithOCIArtifactInfo", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // And blueprint data + blueprintData := map[string]any{ + "kind": "Blueprint", + "apiVersion": "v1alpha1", + "metadata": map[string]any{ + "name": "oci-blueprint", + "description": "A blueprint from OCI artifact", + }, + } + + // And OCI artifact info + ociInfo := &artifact.OCIArtifactInfo{ + Name: "my-blueprint", + URL: "oci://registry.example.com/my-blueprint:v1.0.0", + } + + // When loading the data with OCI info + err := handler.LoadData(blueprintData, ociInfo) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the metadata should be correctly loaded + metadata := handler.GetMetadata() + if metadata.Name != "oci-blueprint" { + t.Errorf("Expected name to be oci-blueprint, got %s", metadata.Name) + } + if metadata.Description != "A blueprint from OCI artifact" { + t.Errorf("Expected description to be 'A blueprint from OCI artifact', got %s", metadata.Description) + } + }) +} + +func TestBlueprintHandler_Write(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + + // Override GetConfigRoot to return the expected path for Write tests + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "mock-config-root", nil + } + + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks } - // Verify the patch content contains both documents - if !strings.Contains(patch.Patch, "kind: Service") { - t.Errorf("Expected patch to contain 'kind: Service', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "kind: ConfigMap") { - t.Errorf("Expected patch to contain 'kind: ConfigMap', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { - t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) - } -} + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler with a loaded blueprint + handler, mocks := setup(t) + + // Set up the blueprint with test data + handler.blueprint = blueprintv1alpha1.Blueprint{ + Kind: "Blueprint", + ApiVersion: "v1alpha1", + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + Description: "A test blueprint", + Authors: []string{"test-author"}, + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/example/repo", + Ref: blueprintv1alpha1.Reference{ + Branch: "main", + }, + }, + } + + // And mock file operations + var writtenPath string + var writtenContent []byte + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenPath = name + writtenContent = data + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist // File doesn't exist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("kind: Blueprint\napiVersion: v1alpha1\nmetadata:\n name: test-blueprint\n"), nil + } + + // When Write is called + err := handler.Write() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the file should be written to the correct path + expectedPath := filepath.Join("mock-config-root", "blueprint.yaml") + if writtenPath != expectedPath { + t.Errorf("Expected file path %s, got %s", expectedPath, writtenPath) + } + + // And the content should be written + if len(writtenContent) == 0 { + t.Errorf("Expected content to be written, got empty content") + } + }) + + t.Run("WithOverwriteTrue", func(t *testing.T) { + // Given a blueprint handler + handler, mocks := setup(t) + + // Set up the blueprint with test data + handler.blueprint = blueprintv1alpha1.Blueprint{ + Kind: "Blueprint", + ApiVersion: "v1alpha1", + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + } + + // And mock file operations + var writtenPath string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenPath = name + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "blueprint.yaml"}, nil // File exists + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("kind: Blueprint\napiVersion: v1alpha1\n"), nil + } + + // When Write is called with overwrite true + err := handler.Write(true) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the file should be written (overwrite) + expectedPath := filepath.Join("mock-config-root", "blueprint.yaml") + if writtenPath != expectedPath { + t.Errorf("Expected file path %s, got %s", expectedPath, writtenPath) + } + }) + + t.Run("WithOverwriteFalse", func(t *testing.T) { + // Given a blueprint handler + handler, mocks := setup(t) + + // And mock file operations + var writtenPath string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenPath = name + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "blueprint.yaml"}, nil // File exists + } + + // When Write is called with overwrite false + err := handler.Write(false) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the file should NOT be written (skip existing) + if writtenPath != "" { + t.Errorf("Expected no file to be written, but got %s", writtenPath) + } + }) + + t.Run("ErrorGettingConfigRoot", func(t *testing.T) { + // Given a blueprint handler with a mock config handler that returns an error + mockConfigHandler := config.NewMockConfigHandler() + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + opts := &SetupOptions{ + ConfigHandler: mockConfigHandler, + } + mocks := setupMocks(t, opts) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + + // When Write is called + err = handler.Write() + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error from GetConfigRoot, got nil") + } + }) + + t.Run("ErrorCreatingDirectory", func(t *testing.T) { + // Given a blueprint handler + handler, mocks := setup(t) + + // And MkdirAll returns an error + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return fmt.Errorf("mkdir error") + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist // File doesn't exist + } + + // When Write is called + err := handler.Write() + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error from MkdirAll, got nil") + } + }) + + t.Run("ErrorMarshalingBlueprint", func(t *testing.T) { + // Given a blueprint handler + handler, mocks := setup(t) + + // And YamlMarshal returns an error + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("marshal error") + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist // File doesn't exist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + // When Write is called + err := handler.Write() + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error from YamlMarshal, got nil") + } + }) + + t.Run("ClearsAllValues", func(t *testing.T) { + // Given a blueprint handler with terraform components containing values + handler, mocks := setup(t) + + // Set up a terraform component with both values and terraform variables + handler.blueprint = blueprintv1alpha1.Blueprint{ + Kind: "Blueprint", + ApiVersion: "v1alpha1", + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Source: "core", + Path: "cluster/talos", + Values: map[string]any{ + "cluster_name": "test-cluster", // Should be kept (not a terraform variable) + "cluster_endpoint": "https://test:6443", // Should be filtered if it's a terraform variable + "controlplanes": []string{"node1"}, // Should be filtered if it's a terraform variable + "custom_config": "some-value", // Should be kept (not a terraform variable) + }, + }, + }, + } + + // Set up file system mocks + var writtenContent []byte + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + writtenContent = data + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist // blueprint.yaml doesn't exist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return yaml.Marshal(v) + } + + // When Write is called + err := handler.Write() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the written content should have all values cleared + if len(writtenContent) == 0 { + t.Errorf("Expected content to be written, got empty content") + } + + // Parse the written YAML to verify all values are cleared + var writtenBlueprint blueprintv1alpha1.Blueprint + err = yaml.Unmarshal(writtenContent, &writtenBlueprint) + if err != nil { + t.Errorf("Failed to unmarshal written YAML: %v", err) + } + + // Verify that the terraform component exists + if len(writtenBlueprint.TerraformComponents) != 1 { + t.Errorf("Expected 1 terraform component, got %d", len(writtenBlueprint.TerraformComponents)) + } + + component := writtenBlueprint.TerraformComponents[0] + + // Verify all values are cleared from the blueprint.yaml + if len(component.Values) != 0 { + t.Errorf("Expected all values to be cleared, but got %d values: %v", len(component.Values), component.Values) + } + + // Also verify kustomizations have postBuild cleared + if len(writtenBlueprint.Kustomizations) > 0 { + for i, kustomization := range writtenBlueprint.Kustomizations { + if kustomization.PostBuild != nil { + t.Errorf("Expected PostBuild to be cleared for kustomization %d, but got %v", i, kustomization.PostBuild) + } + } + } + }) + + t.Run("ErrorWritingFile", func(t *testing.T) { + // Given a blueprint handler + handler, mocks := setup(t) + + // And WriteFile returns an error + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + return fmt.Errorf("write error") + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist // File doesn't exist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test content"), nil + } + + // When Write is called + err := handler.Write() + + // Then an error should be returned + if err == nil { + t.Errorf("Expected error from WriteFile, got nil") + } + }) +} + +func TestTargetHandling(t *testing.T) { + // Test case 1: Path with no existing Target - should create Target with namespace from patch + patch1 := blueprintv1alpha1.BlueprintPatch{ + Path: "kustomize/ingress/nginx.yaml", + } + + // Test case 2: Path with existing Target - should not override existing Target + patch2 := blueprintv1alpha1.BlueprintPatch{ + Path: "kustomize/ingress/nginx.yaml", + Target: &kustomize.Selector{ + Kind: "Service", + Name: "nginx-ingress-controller", + Namespace: "custom-namespace", + }, + } + + // Test case 3: Flux format with Patch and Target - should use existing Target + patch3 := blueprintv1alpha1.BlueprintPatch{ + Patch: "apiVersion: v1\nkind: Service\nmetadata:\n name: nginx-ingress-controller\n namespace: ingress-nginx", + Target: &kustomize.Selector{ + Kind: "Service", + Name: "nginx-ingress-controller", + Namespace: "ingress-nginx", + }, + } + + // Test case 4: Path with patch that has namespace in metadata - should use patch namespace + patch4 := blueprintv1alpha1.BlueprintPatch{ + Path: "kustomize/ingress/nginx-with-namespace.yaml", + } + + // Verify the patches have the expected structure + if patch1.Path == "" { + t.Error("Expected patch1 to have Path field") + } + if patch1.Target != nil { + t.Error("Expected patch1 to have no Target field") + } + + if patch2.Path == "" { + t.Error("Expected patch2 to have Path field") + } + if patch2.Target == nil { + t.Error("Expected patch2 to have Target field") + } + if patch2.Target.Kind != "Service" { + t.Errorf("Expected patch2 Target Kind to be 'Service', got '%s'", patch2.Target.Kind) + } + if patch2.Target.Namespace != "custom-namespace" { + t.Errorf("Expected patch2 Target Namespace to be 'custom-namespace', got '%s'", patch2.Target.Namespace) + } + + if patch3.Patch == "" { + t.Error("Expected patch3 to have Patch field") + } + if patch3.Target == nil { + t.Error("Expected patch3 to have Target field") + } + if patch3.Target.Kind != "Service" { + t.Errorf("Expected patch3 Target Kind to be 'Service', got '%s'", patch3.Target.Kind) + } + if patch3.Target.Namespace != "ingress-nginx" { + t.Errorf("Expected patch3 Target Namespace to be 'ingress-nginx', got '%s'", patch3.Target.Namespace) + } + + if patch4.Path == "" { + t.Error("Expected patch4 to have Path field") + } + if patch4.Target != nil { + t.Error("Expected patch4 to have no Target field (will be generated from patch content)") + } +} + +func TestNamespaceExtractionFromPatch(t *testing.T) { + // Test that namespace is correctly extracted from patch content + patchContent := `apiVersion: v1 +kind: Service +metadata: + name: nginx-ingress-controller + namespace: ingress-nginx +spec: + externalIPs: + - 10.5.1.1 + type: LoadBalancer` + + var patchData map[string]any + err := yaml.Unmarshal([]byte(patchContent), &patchData) + if err != nil { + t.Fatalf("Failed to unmarshal patch content: %v", err) + } + + // Extract namespace from patch metadata + patchNamespace := "default" // fallback + if metadata, ok := patchData["metadata"].(map[string]any); ok { + if ns, ok := metadata["namespace"].(string); ok { + patchNamespace = ns + } + } + + if patchNamespace != "ingress-nginx" { + t.Errorf("Expected namespace 'ingress-nginx', got '%s'", patchNamespace) + } + + // Test with patch that has no namespace + patchContentNoNS := `apiVersion: v1 +kind: Service +metadata: + name: nginx-ingress-controller +spec: + externalIPs: + - 10.5.1.1 + type: LoadBalancer` + + var patchDataNoNS map[string]any + err = yaml.Unmarshal([]byte(patchContentNoNS), &patchDataNoNS) + if err != nil { + t.Fatalf("Failed to unmarshal patch content: %v", err) + } + + // Extract namespace from patch metadata + patchNamespaceNoNS := "default" // fallback + if metadata, ok := patchDataNoNS["metadata"].(map[string]any); ok { + if ns, ok := metadata["namespace"].(string); ok { + patchNamespaceNoNS = ns + } + } + + if patchNamespaceNoNS != "default" { + t.Errorf("Expected namespace 'default' (fallback), got '%s'", patchNamespaceNoNS) + } +} + +func TestToKubernetesKustomizationWithNamespace(t *testing.T) { + // Create a mock config handler + mockConfigHandler := &config.MockConfigHandler{} + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/tmp/test", nil + } + + // Create a mock blueprint handler + handler := &BaseBlueprintHandler{ + projectRoot: "/tmp/test", + configHandler: mockConfigHandler, + shims: NewShims(), + } + + // Mock the ReadFile function to return a patch with namespace + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.Contains(name, "nginx.yaml") { + return []byte(`apiVersion: v1 +kind: Service +metadata: + name: nginx-ingress-controller + namespace: ingress-nginx +spec: + externalIPs: + - 10.5.1.1 + type: LoadBalancer`), nil + } + return nil, fmt.Errorf("file not found") + } + + // Create a kustomization with a patch that references a file + kustomization := blueprintv1alpha1.Kustomization{ + Name: "ingress", + Path: "ingress", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/ingress/nginx.yaml", + }, + }, + Interval: &metav1.Duration{Duration: time.Minute}, + RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, + Timeout: &metav1.Duration{Duration: 5 * time.Minute}, + Wait: &[]bool{true}[0], + Force: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{true}[0], + Components: []string{"nginx"}, + DependsOn: []string{"pki-resources"}, + } + + // Convert to Kubernetes kustomization + result := handler.toFluxKustomization(kustomization, "system-gitops") + + // Verify that the patch has the correct Target with namespace + if len(result.Spec.Patches) != 1 { + t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) + } + + patch := result.Spec.Patches[0] + if patch.Target == nil { + t.Fatal("Expected Target to be set") + } + + if patch.Target.Kind != "Service" { + t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) + } + + if patch.Target.Name != "nginx-ingress-controller" { + t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) + } + + if patch.Target.Namespace != "ingress-nginx" { + t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) + } +} + +func TestToKubernetesKustomizationWithActualPatch(t *testing.T) { + // Create a mock config handler + mockConfigHandler := &config.MockConfigHandler{} + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/Users/ryanvangundy/Developer/windsorcli/core/contexts/colima", nil + } + + // Create a mock blueprint handler with the actual project root + handler := &BaseBlueprintHandler{ + projectRoot: "/Users/ryanvangundy/Developer/windsorcli/core", + configHandler: mockConfigHandler, + shims: NewShims(), + } + + // Mock the ReadFile function to return the expected patch content + handler.shims.ReadFile = func(path string) ([]byte, error) { + // Normalize path for cross-platform comparison + normalizedPath := filepath.ToSlash(path) + if strings.Contains(normalizedPath, "kustomize/ingress/nginx.yaml") { + return []byte(`apiVersion: v1 +kind: Service +metadata: + name: nginx-ingress-controller + namespace: ingress-nginx +spec: + externalIPs: + - 10.5.1.1 + type: LoadBalancer`), nil + } + return nil, fmt.Errorf("file not found: %s", path) + } + + // Create a kustomization with a patch that references the actual file + kustomization := blueprintv1alpha1.Kustomization{ + Name: "ingress", + Path: "ingress", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/ingress/nginx.yaml", + }, + }, + Interval: &metav1.Duration{Duration: time.Minute}, + RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, + Timeout: &metav1.Duration{Duration: 5 * time.Minute}, + Wait: &[]bool{true}[0], + Force: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{true}[0], + Components: []string{"nginx"}, + DependsOn: []string{"pki-resources"}, + } + + // Convert to Kubernetes kustomization + result := handler.toFluxKustomization(kustomization, "system-gitops") + + // Verify that the patch has the correct Target with namespace + if len(result.Spec.Patches) != 1 { + t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) + } + + patch := result.Spec.Patches[0] + if patch.Target == nil { + t.Fatal("Expected Target to be set") + } + + if patch.Target.Kind != "Service" { + t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) + } + + if patch.Target.Name != "nginx-ingress-controller" { + t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) + } + + if patch.Target.Namespace != "ingress-nginx" { + t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) + } + + // Also verify the patch content + if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { + t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) + } +} + +func TestToKubernetesKustomizationWithMultiplePatches(t *testing.T) { + // Create a mock config handler + mockConfigHandler := &config.MockConfigHandler{} + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/tmp/test", nil + } + + // Create a mock blueprint handler + handler := &BaseBlueprintHandler{ + projectRoot: "/tmp/test", + configHandler: mockConfigHandler, + shims: NewShims(), + } + + // Mock the ReadFile function to return a patch with multiple documents + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.Contains(name, "multi-patch.yaml") { + return []byte(`apiVersion: v1 +kind: Service +metadata: + name: nginx-ingress-controller + namespace: ingress-nginx +spec: + externalIPs: + - 10.5.1.1 + type: LoadBalancer +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-config + namespace: ingress-nginx +data: + key: value`), nil + } + return nil, fmt.Errorf("file not found") + } + + // Create a kustomization with a patch that references a file with multiple documents + kustomization := blueprintv1alpha1.Kustomization{ + Name: "ingress", + Path: "ingress", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/ingress/multi-patch.yaml", + }, + }, + Interval: &metav1.Duration{Duration: time.Minute}, + RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, + Timeout: &metav1.Duration{Duration: 5 * time.Minute}, + Wait: &[]bool{true}[0], + Force: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{true}[0], + Components: []string{"nginx"}, + DependsOn: []string{"pki-resources"}, + } + + // Convert to Kubernetes kustomization + result := handler.toFluxKustomization(kustomization, "system-gitops") + + // Verify that the patch has the correct Target with namespace from the first document + if len(result.Spec.Patches) != 1 { + t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) + } + + patch := result.Spec.Patches[0] + if patch.Target == nil { + t.Fatal("Expected Target to be set") + } + + if patch.Target.Kind != "Service" { + t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) + } + + if patch.Target.Name != "nginx-ingress-controller" { + t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) + } + + if patch.Target.Namespace != "ingress-nginx" { + t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) + } + + // Verify the patch content contains both documents + if !strings.Contains(patch.Patch, "kind: Service") { + t.Errorf("Expected patch to contain 'kind: Service', got: %s", patch.Patch) + } + if !strings.Contains(patch.Patch, "kind: ConfigMap") { + t.Errorf("Expected patch to contain 'kind: ConfigMap', got: %s", patch.Patch) + } + if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { + t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) + } +} + +func TestToKubernetesKustomizationWithEdgeCases(t *testing.T) { + // Create a mock config handler + mockConfigHandler := &config.MockConfigHandler{} + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/tmp/test", nil + } + + // Create a mock blueprint handler + handler := &BaseBlueprintHandler{ + projectRoot: "/tmp/test", + configHandler: mockConfigHandler, + shims: NewShims(), + } + + // Mock the ReadFile function to return a patch with edge cases + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.Contains(name, "edge-case.yaml") { + return []byte(`# Comment at the top +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx-ingress-controller + namespace: ingress-nginx +spec: + externalIPs: + - 10.5.1.1 + type: LoadBalancer +--- +# Comment between documents +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-config + namespace: ingress-nginx +data: + key: value +--- +# Empty document +--- +# Another comment +apiVersion: v1 +kind: Secret +metadata: + name: nginx-secret + namespace: ingress-nginx +type: Opaque`), nil + } + return nil, fmt.Errorf("file not found") + } + + // Create a kustomization with a patch that references a file with edge cases + kustomization := blueprintv1alpha1.Kustomization{ + Name: "ingress", + Path: "ingress", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/ingress/edge-case.yaml", + }, + }, + Interval: &metav1.Duration{Duration: time.Minute}, + RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, + Timeout: &metav1.Duration{Duration: 5 * time.Minute}, + Wait: &[]bool{true}[0], + Force: &[]bool{false}[0], + Prune: &[]bool{true}[0], + Destroy: &[]bool{true}[0], + Components: []string{"nginx"}, + DependsOn: []string{"pki-resources"}, + } + + // Convert to Kubernetes kustomization + result := handler.toFluxKustomization(kustomization, "system-gitops") + + // Verify that the patch has the correct Target with namespace from the first valid document + if len(result.Spec.Patches) != 1 { + t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) + } + + patch := result.Spec.Patches[0] + if patch.Target == nil { + t.Fatal("Expected Target to be set") + } + + if patch.Target.Kind != "Service" { + t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) + } + + if patch.Target.Name != "nginx-ingress-controller" { + t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) + } + + if patch.Target.Namespace != "ingress-nginx" { + t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) + } + + // Verify the patch content contains all documents + if !strings.Contains(patch.Patch, "kind: Service") { + t.Errorf("Expected patch to contain 'kind: Service', got: %s", patch.Patch) + } + if !strings.Contains(patch.Patch, "kind: ConfigMap") { + t.Errorf("Expected patch to contain 'kind: ConfigMap', got: %s", patch.Patch) + } + if !strings.Contains(patch.Patch, "kind: Secret") { + t.Errorf("Expected patch to contain 'kind: Secret', got: %s", patch.Patch) + } + if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { + 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 + handler.shell = mocks.Shell + return handler + } + + t.Run("SuccessWithGlobalValues", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + + // And mock kustomize directory with global config.yaml + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize") { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for centralized values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return []byte(`common: + 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{ + "common": 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 common values ConfigMap + if len(appliedConfigMaps) != 1 { + t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) + } + if appliedConfigMaps[0] != "values-common" { + t.Errorf("expected ConfigMap name to be 'values-common', got '%s'", appliedConfigMaps[0]) + } + }) + + t.Run("SuccessWithComponentValues", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + projectRoot := filepath.Join("test", "project") + configRoot := filepath.Join("test", "config") + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return configRoot, nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + + // Mock shell for project root + mockShell := handler.shell.(*shell.MockShell) + mockShell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil + } + + // And mock context values with component values + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + if name == filepath.Join(configRoot, "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for context values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { + return []byte(`substitution: + common: + domain: template.com + ingress: + host: template.example.com`), nil + } + if name == filepath.Join(configRoot, "values.yaml") { + return []byte(`substitution: + common: + domain: example.com + ingress: + host: ingress.example.com + ssl: true`), nil + } + return nil, os.ErrNotExist + } + + // 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 both common and component values ConfigMaps + if len(appliedConfigMaps) != 2 { + t.Errorf("expected 2 ConfigMaps to be applied, got %d: %v", len(appliedConfigMaps), appliedConfigMaps) + } + + // Check that both ConfigMaps were applied (order may vary) + commonFound := false + ingressFound := false + for _, name := range appliedConfigMaps { + if name == "values-common" { + commonFound = true + } + if name == "values-ingress" { + ingressFound = true + } + } + if !commonFound { + t.Error("expected values-common ConfigMap to be applied") + } + if !ingressFound { + t.Error("expected values-ingress ConfigMap to be applied") + } + }) + + 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("ReadFileError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + + // And mock kustomize directory and config.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize") { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock ReadFile that fails + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return nil, os.ErrPermission + } + return nil, os.ErrNotExist + } + + // Mock YAML marshal + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test"), nil + } + + // When applying values ConfigMaps + err := handler.applyValuesConfigMaps() + + // Then it should still succeed since ReadFile errors are now ignored and rendered values take precedence + if err != nil { + t.Fatalf("expected applyValuesConfigMaps to succeed despite ReadFile error, got: %v", err) + } + }) + + t.Run("ComponentConfigMapError", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And mock config root and other config methods + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "example.com" + case "network.loadbalancer_ips.start": + return "192.168.1.100" + case "network.loadbalancer_ips.end": + return "192.168.1.200" + case "docker.registry_url": + return "registry.example.com" + case "id": + return "test-id" + default: + return "" + } + } + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host/path:/container/path"} + } + return []string{} + } + + // And mock centralized config.yaml with component values + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize") { + return &mockFileInfo{name: "kustomize"}, nil + } + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock file read for centralized values + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return []byte(`common: + domain: example.com +ingress: + 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{ + "common": map[string]any{ + "domain": "example.com", + }, + "ingress": 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 common") { + t.Errorf("expected error about common ConfigMap creation, 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 config.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize", "config.yaml") { + return &mockFileInfo{name: "config.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") + } + + commonValuesFound := false + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "values-common" { + commonValuesFound = true + if ref.Optional != false { + t.Errorf("expected values-common ConfigMap to be Optional=false, got %v", ref.Optional) + } + } + } + + if !commonValuesFound { + t.Error("expected values-common 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 global values.yaml exists + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist + } + + // And mock the values.yaml content with ingress component + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return []byte(`ingress: + key: value`), nil + } + return nil, os.ErrNotExist + } + + handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { + values := map[string]any{ + "ingress": map[string]any{ + "key": "value", + }, + } + reflect.ValueOf(v).Elem().Set(reflect.ValueOf(values)) + return nil + } + + // 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 == filepath.Join("/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 + commonValuesFound := false + existingConfigFound := false + + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Kind == "ConfigMap" && ref.Name == "values-common" { + commonValuesFound = 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 !commonValuesFound { + t.Error("expected values-common 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 config.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") -func TestToKubernetesKustomizationWithEdgeCases(t *testing.T) { - // Create a mock config handler - mockConfigHandler := &config.MockConfigHandler{} - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/tmp/test", nil - } + // Then it should have PostBuild with only common ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } - // Create a mock blueprint handler - handler := &BaseBlueprintHandler{ - projectRoot: "/tmp/test", - configHandler: mockConfigHandler, - shims: NewShims(), - } + // And it should only have the common ConfigMap reference + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } - // Mock the ReadFile function to return a patch with edge cases - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "edge-case.yaml") { - return []byte(`# Comment at the top ---- -apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - externalIPs: - - 10.5.1.1 - type: LoadBalancer ---- -# Comment between documents ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nginx-config - namespace: ingress-nginx -data: - key: value ---- -# Empty document ---- -# Another comment -apiVersion: v1 -kind: Secret -metadata: - name: nginx-secret - namespace: ingress-nginx -type: Opaque`), nil + ref := result.Spec.PostBuild.SubstituteFrom[0] + if ref.Kind != "ConfigMap" { + t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) } - return nil, fmt.Errorf("file not found") - } + if ref.Name != "values-common" { + t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) + } + if ref.Optional != false { + t.Errorf("expected Optional to be false, got %v", ref.Optional) + } + }) - // Create a kustomization with a patch that references a file with edge cases - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/edge-case.yaml", - }, - }, - Interval: &metav1.Duration{Duration: time.Minute}, - RetryInterval: &metav1.Duration{Duration: 2 * time.Minute}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - Wait: &[]bool{true}[0], - Force: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{true}[0], - Components: []string{"nginx"}, - DependsOn: []string{"pki-resources"}, - } + t.Run("ConfigRootError", func(t *testing.T) { + // Given a handler + handler := setup(t) - // Convert to Kubernetes kustomization - result := handler.toFluxKustomization(kustomization, "system-gitops") + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, + } - // Verify that the patch has the correct Target with namespace from the first valid document - if len(result.Spec.Patches) != 1 { - t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) - } + // And mock config root that fails + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "", os.ErrNotExist + } - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("Expected Target to be set") - } + // 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], + } - if patch.Target.Kind != "Service" { - t.Errorf("Expected Target Kind to be 'Service', got '%s'", patch.Target.Kind) - } + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("Expected Target Name to be 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } + // Then it should still have PostBuild with only blueprint ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("Expected Target Namespace to be 'ingress-nginx', got '%s'", patch.Target.Namespace) - } + // 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)) + } - // Verify the patch content contains all documents - if !strings.Contains(patch.Patch, "kind: Service") { - t.Errorf("Expected patch to contain 'kind: Service', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "kind: ConfigMap") { - t.Errorf("Expected patch to contain 'kind: ConfigMap', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "kind: Secret") { - t.Errorf("Expected patch to contain 'kind: Secret', got: %s", patch.Patch) - } - if !strings.Contains(patch.Patch, "namespace: ingress-nginx") { - t.Errorf("Expected patch to contain 'namespace: ingress-nginx', got: %s", patch.Patch) - } + ref := result.Spec.PostBuild.SubstituteFrom[0] + if ref.Kind != "ConfigMap" { + t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + } + if ref.Name != "values-common" { + t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) + } + }) } -// ============================================================================= -// Values ConfigMap Tests -// ============================================================================= - -func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { +func TestBaseBlueprintHandler_toFluxKustomization_Comprehensive(t *testing.T) { // Given a handler with mocks setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() @@ -3538,439 +5257,525 @@ func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { handler.shims = mocks.Shims handler.configHandler = mocks.ConfigHandler handler.kubernetesManager = mocks.KubernetesManager - handler.shell = mocks.Shell return handler } - t.Run("SuccessWithGlobalValues", func(t *testing.T) { + t.Run("BasicKustomizationConversion", 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 a basic 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 correct basic fields + 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.Path != "test/path" { + t.Errorf("expected Path to be 'test/path', got '%s'", result.Spec.Path) + } + if result.Spec.SourceRef.Name != "test-source" { + t.Errorf("expected SourceRef.Name to be 'test-source', got '%s'", result.Spec.SourceRef.Name) + } + if result.Spec.SourceRef.Kind != "GitRepository" { + t.Errorf("expected SourceRef.Kind to be 'GitRepository', got '%s'", result.Spec.SourceRef.Kind) + } + if result.Spec.Interval.Duration != 5*time.Minute { + t.Errorf("expected Interval to be 5 minutes, got %v", result.Spec.Interval.Duration) + } + if result.Spec.RetryInterval.Duration != 1*time.Minute { + t.Errorf("expected RetryInterval to be 1 minute, got %v", result.Spec.RetryInterval.Duration) + } + if result.Spec.Timeout.Duration != 10*time.Minute { + t.Errorf("expected Timeout to be 10 minutes, got %v", result.Spec.Timeout.Duration) + } + if result.Spec.Force != false { + t.Errorf("expected Force to be false, 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, got %v", result.Spec.Prune) + } + if result.Spec.DeletionPolicy != "WaitForTermination" { + t.Errorf("expected DeletionPolicy to be 'WaitForTermination', got '%s'", result.Spec.DeletionPolicy) + } + }) + + t.Run("WithDependsOn", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + // 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 a kustomization with dependencies + 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], + DependsOn: []string{"dependency1", "dependency2"}, } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have correct dependencies + if len(result.Spec.DependsOn) != 2 { + t.Errorf("expected 2 dependencies, got %d", len(result.Spec.DependsOn)) } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" + + expectedDeps := map[string]bool{ + "dependency1": false, + "dependency2": false, } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} + + for _, dep := range result.Spec.DependsOn { + if dep.Namespace != "test-namespace" { + t.Errorf("expected dependency namespace to be 'test-namespace', got '%s'", dep.Namespace) } - return []string{} + expectedDeps[dep.Name] = true } - // And mock kustomize directory with global config.yaml - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil + for depName, found := range expectedDeps { + if !found { + t.Errorf("expected dependency '%s' not found", depName) } - return nil, os.ErrNotExist } + }) - // And mock file read for centralized values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return []byte(`common: - domain: example.com - port: 80 - enabled: true`), nil - } - return nil, os.ErrNotExist + t.Run("WithOCISource", func(t *testing.T) { + // Given a handler + handler := setup(t) + + // And initialize the blueprint with OCI repository + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "oci://registry.example.com/test/repo", + }, } - // And mock YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "common": map[string]any{ - "domain": "example.com", - "port": 80, - "enabled": true, - }, - } - return nil + // And a kustomization with OCI source + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "oci://registry.example.com/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], } - // 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 converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have OCI source type + if result.Spec.SourceRef.Kind != "OCIRepository" { + t.Errorf("expected SourceRef.Kind to be 'OCIRepository', got '%s'", result.Spec.SourceRef.Kind) + } + if result.Spec.SourceRef.Name != "oci://registry.example.com/test/source" { + t.Errorf("expected SourceRef.Name to be 'oci://registry.example.com/test/source', got '%s'", result.Spec.SourceRef.Name) } + }) - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() + t.Run("WithDestroyPolicy", func(t *testing.T) { + // Given a handler + handler := setup(t) - // Then it should succeed - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) + // 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 it should apply the common values ConfigMap - if len(appliedConfigMaps) != 1 { - t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) + // And a kustomization with destroy policy + destroy := true + 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], + Destroy: &destroy, } - if appliedConfigMaps[0] != "values-common" { - t.Errorf("expected ConfigMap name to be 'values-common', got '%s'", appliedConfigMaps[0]) + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have WaitForTermination deletion policy + if result.Spec.DeletionPolicy != "WaitForTermination" { + t.Errorf("expected DeletionPolicy to be 'WaitForTermination', got '%s'", result.Spec.DeletionPolicy) } }) - t.Run("SuccessWithComponentValues", func(t *testing.T) { + t.Run("WithPatchFromFile", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock config root and other config methods + // 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 } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - // And mock centralized values.yaml with component values - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } + // And mock patch file content + patchContent := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: test-namespace +data: + key: value` - // And mock file read for centralized values handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return []byte(`common: - domain: example.com -ingress: - host: ingress.example.com - ssl: true`), nil + if name == filepath.Join("/test/config", "kustomize", "patch.yaml") { + return []byte(patchContent), 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{ - "common": map[string]any{ - "domain": "example.com", - }, - "ingress": map[string]any{ - "host": "ingress.example.com", - "ssl": true, + // And a kustomization with patch from file + 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], + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "patch.yaml", }, - } - 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() + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") - // Then it should succeed - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) + // Then it should have the patch + if len(result.Spec.Patches) != 1 { + t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) } - // And it should apply both common and component values ConfigMaps - if len(appliedConfigMaps) != 2 { - t.Errorf("expected 2 ConfigMaps to be applied, got %d: %v", len(appliedConfigMaps), appliedConfigMaps) + patch := result.Spec.Patches[0] + if patch.Patch != patchContent { + t.Errorf("expected patch content to match, got '%s'", patch.Patch) } - // Check that both ConfigMaps were applied (order may vary) - commonFound := false - ingressFound := false - for _, name := range appliedConfigMaps { - if name == "values-common" { - commonFound = true + if patch.Target == nil { + t.Error("expected patch target to be set") + } else { + if patch.Target.Kind != "ConfigMap" { + t.Errorf("expected target kind to be 'ConfigMap', got '%s'", patch.Target.Kind) } - if name == "values-ingress" { - ingressFound = true + if patch.Target.Name != "test-config" { + t.Errorf("expected target name to be 'test-config', got '%s'", patch.Target.Name) + } + if patch.Target.Namespace != "test-namespace" { + t.Errorf("expected target namespace to be 'test-namespace', got '%s'", patch.Target.Namespace) } - } - if !commonFound { - t.Error("expected values-common ConfigMap to be applied") - } - if !ingressFound { - t.Error("expected values-ingress ConfigMap to be applied") } }) - t.Run("NoKustomizeDirectory", func(t *testing.T) { + t.Run("WithInlinePatch", 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 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 that kustomize directory doesn't exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + // And a kustomization with inline patch + inlinePatch := `apiVersion: v1 +kind: ConfigMap +metadata: + name: inline-config +data: + inline: value` + + 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], + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: inlinePatch, + }, + }, } - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") - // Then it should succeed (no-op) - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed when no kustomize directory, got: %v", err) + // Then it should have the inline patch + if len(result.Spec.Patches) != 1 { + t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) + } + + patch := result.Spec.Patches[0] + if patch.Patch != inlinePatch { + t.Errorf("expected patch content to match, got '%s'", patch.Patch) + } + + if patch.Target != nil { + t.Error("expected patch target to be nil for inline patch") } }) - t.Run("ConfigRootError", func(t *testing.T) { + t.Run("WithPatchTarget", 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 + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", + }, } - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() + // And a kustomization with patch target + 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], + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: "patch content", + Target: &kustomize.Selector{ + Kind: "Deployment", + Name: "test-deployment", + Namespace: "custom-namespace", + }, + }, + }, + } - // Then it should fail - if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with config root error") + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have the patch with target + if len(result.Spec.Patches) != 1 { + t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) } - if !strings.Contains(err.Error(), "failed to get config root") { - t.Errorf("expected error about config root, got: %v", err) + + patch := result.Spec.Patches[0] + if patch.Patch != "patch content" { + t.Errorf("expected patch content to match, got '%s'", patch.Patch) + } + + if patch.Target == nil { + t.Error("expected patch target to be set") + } else { + if patch.Target.Kind != "Deployment" { + t.Errorf("expected target kind to be 'Deployment', got '%s'", patch.Target.Kind) + } + if patch.Target.Name != "test-deployment" { + t.Errorf("expected target name to be 'test-deployment', got '%s'", patch.Target.Name) + } + if patch.Target.Namespace != "custom-namespace" { + t.Errorf("expected target namespace to be 'custom-namespace', got '%s'", patch.Target.Namespace) + } } }) - t.Run("ReadFileError", func(t *testing.T) { + t.Run("WithMultiplePatches", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} + // 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 kustomize directory and config.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist + // And a kustomization with multiple patches + 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], + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: "patch1", + }, + { + Patch: "patch2", + Target: &kustomize.Selector{ + Kind: "Service", + Name: "test-service", + }, + }, + }, } - // And mock ReadFile that fails - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return nil, os.ErrPermission - } - return nil, os.ErrNotExist + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have both patches + if len(result.Spec.Patches) != 2 { + t.Errorf("expected 2 patches, got %d", len(result.Spec.Patches)) } - // Mock YAML marshal - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test"), nil + if result.Spec.Patches[0].Patch != "patch1" { + t.Errorf("expected first patch content to be 'patch1', got '%s'", result.Spec.Patches[0].Patch) } - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() + if result.Spec.Patches[1].Patch != "patch2" { + t.Errorf("expected second patch content to be 'patch2', got '%s'", result.Spec.Patches[1].Patch) + } - // Then it should still succeed since ReadFile errors are now ignored and rendered values take precedence - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed despite ReadFile error, got: %v", err) + if result.Spec.Patches[1].Target == nil { + t.Error("expected second patch target to be set") + } else { + 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 != "test-service" { + t.Errorf("expected second patch target name to be 'test-service', got '%s'", result.Spec.Patches[1].Target.Name) + } } }) - t.Run("ComponentConfigMapError", func(t *testing.T) { + t.Run("WithComponents", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} + // 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 centralized config.yaml with component values - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist + // And a kustomization with components + 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], + Components: []string{"component1", "component2"}, } - // And mock file read for centralized values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return []byte(`common: - domain: example.com -ingress: - host: ingress.example.com`), nil - } - return nil, os.ErrNotExist - } + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") - // And mock YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "common": map[string]any{ - "domain": "example.com", - }, - "ingress": map[string]any{ - "host": "ingress.example.com", - }, - } - return nil + // Then it should have the components + if len(result.Spec.Components) != 2 { + t.Errorf("expected 2 components, got %d", len(result.Spec.Components)) } - // 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 + expectedComponents := map[string]bool{ + "component1": false, + "component2": false, } - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should fail - if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with ConfigMap error") + for _, component := range result.Spec.Components { + expectedComponents[component] = true } - if !strings.Contains(err.Error(), "failed to create merged common values ConfigMap") { - t.Errorf("expected error about common ConfigMap creation, got: %v", err) + + for componentName, found := range expectedComponents { + if !found { + t.Errorf("expected component '%s' not found", componentName) + } } }) -} - -// ============================================================================= -// 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) { + t.Run("WithCustomPrune", func(t *testing.T) { // Given a handler handler := setup(t) @@ -3984,21 +5789,8 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin }, } - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that global config.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And a kustomization + // And a kustomization with custom prune setting + prune := false kustomization := blueprintv1alpha1.Kustomization{ Name: "test-kustomization", Path: "test/path", @@ -4008,1911 +5800,2106 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin Timeout: &metav1.Duration{Duration: 10 * time.Minute}, Force: &[]bool{false}[0], Wait: &[]bool{false}[0], + Prune: &prune, } // 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") + // Then it should have the custom prune setting + if result.Spec.Prune != false { + t.Errorf("expected Prune to be false, got %v", result.Spec.Prune) } + }) +} - // And it should have the blueprint ConfigMap reference - if len(result.Spec.PostBuild.SubstituteFrom) < 1 { - t.Fatal("expected at least 1 SubstituteFrom reference") - } +func TestBaseBlueprintHandler_applyConfigMap_WithBuildID(t *testing.T) { + mocks := setupMocks(t, &SetupOptions{ + ConfigStr: ` +contexts: + test: + id: "test-id" + dns: + domain: "test.com" + network: + loadbalancer_ips: + start: "10.0.0.1" + end: "10.0.0.10" + docker: + registry_url: "registry.test" + cluster: + workers: + volumes: ["/tmp:/data"] +`, + }) - commonValuesFound := false - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-common" { - commonValuesFound = true - if ref.Optional != false { - t.Errorf("expected values-common ConfigMap to be Optional=false, got %v", ref.Optional) - } - } + handler := NewBlueprintHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("failed to initialize handler: %v", err) + } + + // Set up build ID by mocking the file system + testBuildID := "build-1234567890" + projectRoot, err := mocks.Shell.GetProjectRoot() + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + buildIDPath := filepath.Join(projectRoot, ".windsor", ".build-id") + + // Mock the file system to return our test build ID + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == buildIDPath { + return mockFileInfo{name: ".build-id", isDir: false}, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == buildIDPath { + return []byte(testBuildID), nil } + return []byte{}, nil + } + + // Mock the kubernetes manager to capture the ConfigMap data + var capturedData map[string]string + mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + capturedData = data + return nil + } + + // Call applyValuesConfigMaps + if err := handler.applyValuesConfigMaps(); err != nil { + t.Fatalf("failed to apply ConfigMap: %v", err) + } + + // Verify BUILD_ID is included in the ConfigMap data + if capturedData == nil { + t.Fatal("ConfigMap data was not captured") + } + + buildID, exists := capturedData["BUILD_ID"] + if !exists { + t.Fatal("BUILD_ID not found in ConfigMap data") + } + + if buildID != testBuildID { + t.Errorf("expected BUILD_ID to be %s, got %s", testBuildID, buildID) + } - if !commonValuesFound { - t.Error("expected values-common ConfigMap reference to be present") + // Verify other expected fields are present + expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} + for _, field := range expectedFields { + if _, exists := capturedData[field]; !exists { + t.Errorf("expected field %s not found in ConfigMap data", field) } - }) + } +} - t.Run("WithComponentValuesConfigMap", func(t *testing.T) { - // Given a handler - handler := setup(t) +func TestBaseBlueprintHandler_applyConfigMap_WithoutBuildID(t *testing.T) { + mocks := setupMocks(t, &SetupOptions{ + ConfigStr: ` +contexts: + test: + id: "test-id" + dns: + domain: "test.com" + network: + loadbalancer_ips: + start: "10.0.0.1" + end: "10.0.0.10" + docker: + registry_url: "registry.test" + cluster: + workers: + volumes: ["/tmp:/data"] +`, + }) - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } + handler := NewBlueprintHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("failed to initialize handler: %v", err) + } - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } + // Mock the file system to simulate missing .build-id file + projectRoot, err := mocks.Shell.GetProjectRoot() + if err != nil { + t.Fatalf("failed to get project root: %v", err) + } + buildIDPath := filepath.Join(projectRoot, ".windsor", ".build-id") - // And mock that global values.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } + // Mock the file system to return file not found for .build-id + handler.shims.Stat = func(path string) (os.FileInfo, error) { + if path == buildIDPath { return nil, os.ErrNotExist } - - // And mock the values.yaml content with ingress component - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return []byte(`ingress: - key: value`), nil - } + return nil, os.ErrNotExist + } + handler.shims.ReadFile = func(path string) ([]byte, error) { + if path == buildIDPath { return nil, os.ErrNotExist } + return []byte{}, nil + } - handler.shims.YamlUnmarshal = func(data []byte, v interface{}) error { - values := map[string]any{ - "ingress": map[string]any{ - "key": "value", - }, - } - reflect.ValueOf(v).Elem().Set(reflect.ValueOf(values)) - return nil - } - - // 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") - } + // Mock the kubernetes manager to capture the ConfigMap data + var capturedData map[string]string + mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { + capturedData = data + return nil + } - // 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 - } - } + // Call applyValuesConfigMaps - this should not cause an error + if err := handler.applyValuesConfigMaps(); err != nil { + t.Fatalf("failed to apply ConfigMap: %v", err) + } - if !componentValuesFound { - t.Error("expected values-ingress ConfigMap reference to be present") - } - }) + // Verify BUILD_ID is not included in the ConfigMap data when file doesn't exist + if capturedData == nil { + t.Fatal("ConfigMap data was not captured") + } - t.Run("WithExistingPostBuild", func(t *testing.T) { - // Given a handler - handler := setup(t) + buildID, exists := capturedData["BUILD_ID"] + if exists { + t.Errorf("expected BUILD_ID to not be present in ConfigMap data when file doesn't exist, but it was found with value '%s'", buildID) + } - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, + // Verify other expected fields are present + expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} + for _, field := range expectedFields { + if _, exists := capturedData[field]; !exists { + t.Errorf("expected field %s not found in ConfigMap data", field) } + } +} - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } +// ============================================================================= +// New Functionality Tests +// ============================================================================= - // And mock that global values.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } +func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + handler.shims = NewShims() + handler.configHandler = config.NewMockConfigHandler() + return handler + } - // 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", + t.Run("WithRenderedDataOnly", func(t *testing.T) { + // Given a handler with rendered patch data only + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "test-namespace", }, - SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ - { - Kind: "ConfigMap", - Name: "existing-config", - Optional: true, - }, + "data": map[string]any{ + "key": "value", }, }, } - - // 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") + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil } - - // 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)) + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) } - if result.Spec.PostBuild.Substitute["VAR1"] != "value1" { - t.Errorf("expected VAR1 to be 'value1', got '%s'", result.Spec.PostBuild.Substitute["VAR1"]) + if target == nil { + t.Error("Expected target to be extracted") } - if result.Spec.PostBuild.Substitute["VAR2"] != "value2" { - t.Errorf("expected VAR2 to be 'value2', got '%s'", result.Spec.PostBuild.Substitute["VAR2"]) + if target.Kind != "ConfigMap" { + t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) } - - // And it should have the correct SubstituteFrom references - commonValuesFound := false - existingConfigFound := false - - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-common" { - commonValuesFound = 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 target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + if target.Namespace != "test-namespace" { + t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) } + }) - if !commonValuesFound { - t.Error("expected values-common ConfigMap reference to be present") + t.Run("WithNoData", func(t *testing.T) { + // Given a handler with no data + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") } - if !existingConfigFound { - t.Error("expected existing-config ConfigMap reference to be preserved") + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then empty content and nil target should be returned + if content != "" { + t.Errorf("Expected empty content, got = '%s'", content) + } + if target != nil { + t.Error("Expected target to be nil") } }) - t.Run("WithoutValuesConfigMaps", func(t *testing.T) { - // Given a handler + t.Run("WithYamlExtension", func(t *testing.T) { + // Given a handler with patch path containing .yaml extension 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", + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, }, } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that no config.yaml files exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil } - - // 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 resolving patch from path with .yaml extension + content, target := handler.resolvePatchFromPath("test.yaml", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with only common ConfigMap reference - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") + if target == nil { + t.Error("Expected target to be extracted") } + }) - // And it should only have the common ConfigMap reference - if len(result.Spec.PostBuild.SubstituteFrom) != 1 { - t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + t.Run("WithYmlExtension", func(t *testing.T) { + // Given a handler with patch path containing .yml extension + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + }, + }, } - - ref := result.Spec.PostBuild.SubstituteFrom[0] - if ref.Kind != "ConfigMap" { - t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("test yaml"), nil } - if ref.Name != "values-common" { - t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) + // When resolving patch from path with .yml extension + content, target := handler.resolvePatchFromPath("test.yml", "default-namespace") + // Then content should be returned and target should be extracted + if content != "test yaml" { + t.Errorf("Expected content = 'test yaml', got = '%s'", content) } - if ref.Optional != false { - t.Errorf("expected Optional to be false, got %v", ref.Optional) + if target == nil { + t.Error("Expected target to be extracted") } }) - t.Run("ConfigRootError", func(t *testing.T) { - // Given a handler + t.Run("WithBothRenderedAndUserDataMerge", func(t *testing.T) { + // Given a handler with both rendered and user data that can be merged 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", + handler.kustomizeData = map[string]any{ + "kustomize/patches/test": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "rendered-config", + "namespace": "rendered-namespace", + }, + "data": map[string]any{ + "rendered-key": "rendered-value", + }, }, } - - // And mock config root that fails - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "", os.ErrNotExist + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - - // 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], + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: user-config + namespace: user-namespace +data: + user-key: user-value`), nil } - - // 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") + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "user-config", + "namespace": "user-namespace", + }, + "data": map[string]any{ + "user-key": "user-value", + }, + } + return nil } - - // 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)) + handler.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("merged yaml"), nil } - - ref := result.Spec.PostBuild.SubstituteFrom[0] - if ref.Kind != "ConfigMap" { - t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + // When resolving patch from path + content, target := handler.resolvePatchFromPath("test", "default-namespace") + // Then merged content should be returned and target should be extracted from merged data + if content != "merged yaml" { + t.Errorf("Expected content = 'merged yaml', got = '%s'", content) } - if ref.Name != "values-common" { - t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "user-config" { + t.Errorf("Expected target name = 'user-config', got = '%s'", target.Name) + } + if target.Namespace != "user-namespace" { + t.Errorf("Expected target namespace = 'user-namespace', got = '%s'", target.Namespace) } }) } -func TestBaseBlueprintHandler_toFluxKustomization_Comprehensive(t *testing.T) { - // Given a handler with mocks +func TestBaseBlueprintHandler_extractTargetFromPatchData(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 - handler.kubernetesManager = mocks.KubernetesManager + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) return handler } - t.Run("BasicKustomizationConversion", func(t *testing.T) { - // Given a handler + t.Run("ValidPatchData", func(t *testing.T) { + // Given valid patch data with all required fields 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 a basic 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 correct basic fields - 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) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "test-namespace", + }, } - if result.Spec.Path != "test/path" { - t.Errorf("expected Path to be 'test/path', got '%s'", result.Spec.Path) + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be extracted correctly + if target == nil { + t.Error("Expected target to be extracted") } - if result.Spec.SourceRef.Name != "test-source" { - t.Errorf("expected SourceRef.Name to be 'test-source', got '%s'", result.Spec.SourceRef.Name) + if target.Kind != "ConfigMap" { + t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) } - if result.Spec.SourceRef.Kind != "GitRepository" { - t.Errorf("expected SourceRef.Kind to be 'GitRepository', got '%s'", result.Spec.SourceRef.Kind) + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) } - if result.Spec.Interval.Duration != 5*time.Minute { - t.Errorf("expected Interval to be 5 minutes, got %v", result.Spec.Interval.Duration) + if target.Namespace != "test-namespace" { + t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) } - if result.Spec.RetryInterval.Duration != 1*time.Minute { - t.Errorf("expected RetryInterval to be 1 minute, got %v", result.Spec.RetryInterval.Duration) + }) + + t.Run("WithCustomNamespace", func(t *testing.T) { + // Given patch data with custom namespace + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "test-config", + "namespace": "custom-namespace", + }, } - if result.Spec.Timeout.Duration != 10*time.Minute { - t.Errorf("expected Timeout to be 10 minutes, got %v", result.Spec.Timeout.Duration) + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then custom namespace should be used + if target.Namespace != "custom-namespace" { + t.Errorf("Expected target namespace = 'custom-namespace', got = '%s'", target.Namespace) } - if result.Spec.Force != false { - t.Errorf("expected Force to be false, got %v", result.Spec.Force) + }) + + t.Run("MissingKind", func(t *testing.T) { + // Given patch data missing kind field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "metadata": map[string]any{ + "name": "test-config", + }, } - if result.Spec.Wait != false { - t.Errorf("expected Wait to be false, got %v", result.Spec.Wait) + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when kind is missing") } - if result.Spec.Prune != true { - t.Errorf("expected Prune to be true, got %v", result.Spec.Prune) + }) + + t.Run("MissingMetadata", func(t *testing.T) { + // Given patch data missing metadata field + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", } - if result.Spec.DeletionPolicy != "WaitForTermination" { - t.Errorf("expected DeletionPolicy to be 'WaitForTermination', got '%s'", result.Spec.DeletionPolicy) + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when metadata is missing") } }) - t.Run("WithDependsOn", func(t *testing.T) { - // Given a handler + t.Run("MissingName", func(t *testing.T) { + // Given patch data missing name field 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", - }, + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{}, } - - // And a kustomization with dependencies - 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], - DependsOn: []string{"dependency1", "dependency2"}, + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when name is missing") } + }) - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have correct dependencies - if len(result.Spec.DependsOn) != 2 { - t.Errorf("expected 2 dependencies, got %d", len(result.Spec.DependsOn)) + t.Run("InvalidKindType", func(t *testing.T) { + // Given patch data with invalid kind type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": 42, + "metadata": map[string]any{ + "name": "test-config", + }, } - - expectedDeps := map[string]bool{ - "dependency1": false, - "dependency2": false, + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when kind type is invalid") } + }) - for _, dep := range result.Spec.DependsOn { - if dep.Namespace != "test-namespace" { - t.Errorf("expected dependency namespace to be 'test-namespace', got '%s'", dep.Namespace) - } - expectedDeps[dep.Name] = true + t.Run("InvalidMetadataType", func(t *testing.T) { + // Given patch data with invalid metadata type + handler := setup(t) + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": "not a map", } - - for depName, found := range expectedDeps { - if !found { - t.Errorf("expected dependency '%s' not found", depName) - } + // When extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when metadata type is invalid") } }) - t.Run("WithOCISource", func(t *testing.T) { - // Given a handler + t.Run("InvalidNameType", func(t *testing.T) { + // Given patch data with invalid name type handler := setup(t) - - // And initialize the blueprint with OCI repository - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "oci://registry.example.com/test/repo", + patchData := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": 42, }, } - - // And a kustomization with OCI source - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "oci://registry.example.com/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 extracting target from patch data + target := handler.extractTargetFromPatchData(patchData, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when name type is invalid") } + }) +} - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") +func TestBaseBlueprintHandler_extractTargetFromPatchContent(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } - // Then it should have OCI source type - if result.Spec.SourceRef.Kind != "OCIRepository" { - t.Errorf("expected SourceRef.Kind to be 'OCIRepository', got '%s'", result.Spec.SourceRef.Kind) + t.Run("ValidYamlContent", func(t *testing.T) { + // Given valid YAML content + handler := setup(t) + content := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: test-namespace` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be extracted correctly + if target == nil { + t.Error("Expected target to be extracted") } - if result.Spec.SourceRef.Name != "oci://registry.example.com/test/source" { - t.Errorf("expected SourceRef.Name to be 'oci://registry.example.com/test/source', got '%s'", result.Spec.SourceRef.Name) + if target.Name != "test-config" { + t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + } + }) + + t.Run("MultipleDocuments", func(t *testing.T) { + // Given YAML with multiple documents + handler := setup(t) + content := `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: first-config +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: second-config` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then first valid target should be extracted + if target == nil { + t.Error("Expected target to be extracted") + } + if target.Name != "first-config" { + t.Errorf("Expected target name = 'first-config', got = '%s'", target.Name) + } + }) + + t.Run("InvalidYamlContent", func(t *testing.T) { + // Given invalid YAML content + handler := setup(t) + content := `invalid: yaml: content: with: colons: everywhere` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil for invalid YAML") } }) - t.Run("WithDestroyPolicy", func(t *testing.T) { - // Given a handler + t.Run("EmptyContent", func(t *testing.T) { + // Given empty content 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 a kustomization with destroy policy - destroy := true - 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], - Destroy: &destroy, + content := "" + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil for empty content") } + }) - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have WaitForTermination deletion policy - if result.Spec.DeletionPolicy != "WaitForTermination" { - t.Errorf("expected DeletionPolicy to be 'WaitForTermination', got '%s'", result.Spec.DeletionPolicy) + t.Run("NoValidTargets", func(t *testing.T) { + // Given YAML with no valid targets + handler := setup(t) + content := `apiVersion: v1 +kind: ConfigMap +# Missing metadata.name` + // When extracting target from patch content + target := handler.extractTargetFromPatchContent(content, "default-namespace") + // Then target should be nil + if target != nil { + t.Error("Expected target to be nil when no valid targets") } }) +} - t.Run("WithPatchFromFile", func(t *testing.T) { - // Given a handler - handler := setup(t) +func TestBaseBlueprintHandler_hasComponentValues(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + handler.shims = NewShims() + handler.configHandler = config.NewMockConfigHandler() + return handler + } - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", + t.Run("TemplateComponentExists", func(t *testing.T) { + // Given handler with component in template data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/values": map[string]any{ + "test-component": map[string]any{ + "key": "value", + }, }, } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in template data") + } + }) - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { + t.Run("UserComponentExists", func(t *testing.T) { + // Given handler with component in user file + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { return "/test/config", nil } - - // And mock patch file content - patchContent := `apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config - namespace: test-namespace -data: - key: value` - + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "config.yaml", isDir: false}, nil + } handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "patch.yaml") { - return []byte(patchContent), nil + return []byte(`test-component: + key: value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "test-component": map[string]any{ + "key": "value", + }, } - return nil, os.ErrNotExist + return nil + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in user file") } + }) - // And a kustomization with patch from file - 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], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "patch.yaml", + t.Run("BothTemplateAndUserExist", func(t *testing.T) { + // Given handler with component in both template and user data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "kustomize/values": map[string]any{ + "test-component": map[string]any{ + "template-key": "template-value", }, }, } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the patch - if len(result.Spec.Patches) != 1 { - t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - - patch := result.Spec.Patches[0] - if patch.Patch != patchContent { - t.Errorf("expected patch content to match, got '%s'", patch.Patch) + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "config.yaml", isDir: false}, nil } - - if patch.Target == nil { - t.Error("expected patch target to be set") - } else { - if patch.Target.Kind != "ConfigMap" { - t.Errorf("expected target kind to be 'ConfigMap', got '%s'", patch.Target.Kind) - } - if patch.Target.Name != "test-config" { - t.Errorf("expected target name to be 'test-config', got '%s'", patch.Target.Name) - } - if patch.Target.Namespace != "test-namespace" { - t.Errorf("expected target namespace to be 'test-namespace', got '%s'", patch.Target.Namespace) + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte(`test-component: + user-key: user-value`), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + values := v.(*map[string]any) + *values = map[string]any{ + "test-component": map[string]any{ + "user-key": "user-value", + }, } + return nil + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return true + if !exists { + t.Error("Expected component to exist in both sources") } }) - t.Run("WithInlinePatch", func(t *testing.T) { - // Given a handler + t.Run("NoComponentExists", func(t *testing.T) { + // Given handler with no component data 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", - }, + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - - // And a kustomization with inline patch - inlinePatch := `apiVersion: v1 -kind: ConfigMap -metadata: - name: inline-config -data: - inline: value` - - 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], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Patch: inlinePatch, - }, - }, + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist") + } + }) - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the inline patch - if len(result.Spec.Patches) != 1 { - t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) + t.Run("ConfigRootError", func(t *testing.T) { + // Given handler with config root error + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when config root fails") } + }) - patch := result.Spec.Patches[0] - if patch.Patch != inlinePatch { - t.Errorf("expected patch content to match, got '%s'", patch.Patch) + t.Run("FileNotExists", func(t *testing.T) { + // Given handler with file not existing + handler := setup(t) + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - - if patch.Target != nil { - t.Error("expected patch target to be nil for inline patch") + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when file doesn't exist") } }) - t.Run("WithPatchTarget", func(t *testing.T) { - // Given a handler + t.Run("InvalidValuesFile", func(t *testing.T) { + // Given handler with invalid values file 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", - }, + handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - - // And a kustomization with patch target - 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], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Patch: "patch content", - Target: &kustomize.Selector{ - Kind: "Deployment", - Name: "test-deployment", - Namespace: "custom-namespace", - }, - }, - }, + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "config.yaml", isDir: false}, nil + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + return []byte("invalid yaml"), nil + } + handler.shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("invalid yaml") + } + // When checking if component values exist + exists := handler.hasComponentValues("test-component") + // Then it should return false + if exists { + t.Error("Expected component to not exist when values file is invalid") } + }) +} - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") +func TestBaseBlueprintHandler_deepMergeMaps(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler + } - // Then it should have the patch with target - if len(result.Spec.Patches) != 1 { - t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) + t.Run("SimpleMerge", func(t *testing.T) { + // Given base and overlay maps with simple values + handler := setup(t) + base := map[string]any{ + "key1": "base-value1", + "key2": "base-value2", } - - patch := result.Spec.Patches[0] - if patch.Patch != "patch content" { - t.Errorf("expected patch content to match, got '%s'", patch.Patch) + overlay := map[string]any{ + "key2": "overlay-value2", + "key3": "overlay-value3", } - - if patch.Target == nil { - t.Error("expected patch target to be set") - } else { - if patch.Target.Kind != "Deployment" { - t.Errorf("expected target kind to be 'Deployment', got '%s'", patch.Target.Kind) - } - if patch.Target.Name != "test-deployment" { - t.Errorf("expected target name to be 'test-deployment', got '%s'", patch.Target.Name) - } - if patch.Target.Namespace != "custom-namespace" { - t.Errorf("expected target namespace to be 'custom-namespace', got '%s'", patch.Target.Namespace) - } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then result should contain merged values + if result["key1"] != "base-value1" { + t.Errorf("Expected key1 = 'base-value1', got = '%v'", result["key1"]) + } + if result["key2"] != "overlay-value2" { + t.Errorf("Expected key2 = 'overlay-value2', got = '%v'", result["key2"]) + } + if result["key3"] != "overlay-value3" { + t.Errorf("Expected key3 = 'overlay-value3', got = '%v'", result["key3"]) } }) - t.Run("WithMultiplePatches", func(t *testing.T) { - // Given a handler + t.Run("NestedMapMerge", func(t *testing.T) { + // Given base and overlay maps with nested maps 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", + base := map[string]any{ + "nested": map[string]any{ + "base-key": "base-value", }, } - - // And a kustomization with multiple patches - 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], - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Patch: "patch1", - }, - { - Patch: "patch2", - Target: &kustomize.Selector{ - Kind: "Service", - Name: "test-service", - }, - }, + overlay := map[string]any{ + "nested": map[string]any{ + "overlay-key": "overlay-value", }, } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have both patches - if len(result.Spec.Patches) != 2 { - t.Errorf("expected 2 patches, got %d", len(result.Spec.Patches)) + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then nested maps should be merged + nested := result["nested"].(map[string]any) + if nested["base-key"] != "base-value" { + t.Errorf("Expected nested.base-key = 'base-value', got = '%v'", nested["base-key"]) } - - if result.Spec.Patches[0].Patch != "patch1" { - t.Errorf("expected first patch content to be 'patch1', got '%s'", result.Spec.Patches[0].Patch) + if nested["overlay-key"] != "overlay-value" { + t.Errorf("Expected nested.overlay-key = 'overlay-value', got = '%v'", nested["overlay-key"]) } + }) - if result.Spec.Patches[1].Patch != "patch2" { - t.Errorf("expected second patch content to be 'patch2', got '%s'", result.Spec.Patches[1].Patch) + t.Run("OverlayPrecedence", func(t *testing.T) { + // Given base and overlay maps with conflicting keys + handler := setup(t) + base := map[string]any{ + "key": "base-value", } - - if result.Spec.Patches[1].Target == nil { - t.Error("expected second patch target to be set") - } else { - 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 != "test-service" { - t.Errorf("expected second patch target name to be 'test-service', got '%s'", result.Spec.Patches[1].Target.Name) - } + overlay := map[string]any{ + "key": "overlay-value", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then overlay value should take precedence + if result["key"] != "overlay-value" { + t.Errorf("Expected key = 'overlay-value', got = '%v'", result["key"]) } }) - t.Run("WithComponents", func(t *testing.T) { - // Given a handler + t.Run("DeepNestedMerge", func(t *testing.T) { + // Given base and overlay maps with deeply nested maps handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", + base := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "base-key": "base-value", + }, }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", + } + overlay := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "overlay-key": "overlay-value", + }, }, } - - // And a kustomization with components - 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], - Components: []string{"component1", "component2"}, + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then deeply nested maps should be merged + level1 := result["level1"].(map[string]any) + level2 := level1["level2"].(map[string]any) + if level2["base-key"] != "base-value" { + t.Errorf("Expected level2.base-key = 'base-value', got = '%v'", level2["base-key"]) } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the components - if len(result.Spec.Components) != 2 { - t.Errorf("expected 2 components, got %d", len(result.Spec.Components)) + if level2["overlay-key"] != "overlay-value" { + t.Errorf("Expected level2.overlay-key = 'overlay-value', got = '%v'", level2["overlay-key"]) } + }) - expectedComponents := map[string]bool{ - "component1": false, - "component2": false, + t.Run("EmptyMaps", func(t *testing.T) { + // Given empty base and overlay maps + handler := setup(t) + base := map[string]any{} + overlay := map[string]any{} + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then result should be empty + if len(result) != 0 { + t.Errorf("Expected empty result, got %d items", len(result)) } + }) - for _, component := range result.Spec.Components { - expectedComponents[component] = true + t.Run("NonMapOverlay", func(t *testing.T) { + // Given base map and non-map overlay value + handler := setup(t) + base := map[string]any{ + "key": map[string]any{ + "nested": "value", + }, } - - for componentName, found := range expectedComponents { - if !found { - t.Errorf("expected component '%s' not found", componentName) - } + overlay := map[string]any{ + "key": "string-value", + } + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then overlay value should replace base value + if result["key"] != "string-value" { + t.Errorf("Expected key = 'string-value', got = '%v'", result["key"]) } }) - t.Run("WithCustomPrune", func(t *testing.T) { - // Given a handler + t.Run("MixedTypes", func(t *testing.T) { + // Given base and overlay maps with mixed types handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", + base := map[string]any{ + "string": "base-string", + "number": 42, + "nested": map[string]any{ + "key": "base-nested", }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", + } + overlay := map[string]any{ + "string": "overlay-string", + "bool": true, + "nested": map[string]any{ + "overlay-key": "overlay-nested", }, } - - // And a kustomization with custom prune setting - prune := false - 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], - Prune: &prune, + // When merging maps + result := handler.deepMergeMaps(base, overlay) + // Then all values should be merged correctly + if result["string"] != "overlay-string" { + t.Errorf("Expected string = 'overlay-string', got = '%v'", result["string"]) } - - // When converting to Flux kustomization - result := handler.toFluxKustomization(kustomization, "test-namespace") - - // Then it should have the custom prune setting - if result.Spec.Prune != false { - t.Errorf("expected Prune to be false, got %v", result.Spec.Prune) + if result["number"] != 42 { + t.Errorf("Expected number = 42, got = '%v'", result["number"]) + } + if result["bool"] != true { + t.Errorf("Expected bool = true, got = '%v'", result["bool"]) + } + nested := result["nested"].(map[string]any) + if nested["key"] != "base-nested" { + t.Errorf("Expected nested.key = 'base-nested', got = '%v'", nested["key"]) + } + if nested["overlay-key"] != "overlay-nested" { + t.Errorf("Expected nested.overlay-key = 'overlay-nested', got = '%v'", nested["overlay-key"]) } }) } -func TestBaseBlueprintHandler_applyConfigMap_WithBuildID(t *testing.T) { - mocks := setupMocks(t, &SetupOptions{ - ConfigStr: ` -contexts: - test: - id: "test-id" - dns: - domain: "test.com" - network: - loadbalancer_ips: - start: "10.0.0.1" - end: "10.0.0.10" - docker: - registry_url: "registry.test" - cluster: - workers: - volumes: ["/tmp:/data"] -`, - }) - - handler := NewBlueprintHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("failed to initialize handler: %v", err) - } - - // Set up build ID by mocking the file system - testBuildID := "build-1234567890" - projectRoot, err := mocks.Shell.GetProjectRoot() - if err != nil { - t.Fatalf("failed to get project root: %v", err) +func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBlueprintHandler(injector) + return handler } - buildIDPath := filepath.Join(projectRoot, ".windsor", ".build-id") - // Mock the file system to return our test build ID - handler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == buildIDPath { - return mockFileInfo{name: ".build-id", isDir: false}, nil + t.Run("SetData", func(t *testing.T) { + // Given a handler with no existing data + handler := setup(t) + data := map[string]any{ + "key1": "value1", + "key2": 42, } - return nil, os.ErrNotExist - } - handler.shims.ReadFile = func(path string) ([]byte, error) { - if path == buildIDPath { - return []byte(testBuildID), nil + // When setting rendered kustomize data + handler.SetRenderedKustomizeData(data) + // Then data should be stored + if !reflect.DeepEqual(handler.kustomizeData, data) { + t.Errorf("Expected kustomizeData = %v, got = %v", data, handler.kustomizeData) } - return []byte{}, nil - } - - // Mock the kubernetes manager to capture the ConfigMap data - var capturedData map[string]string - mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - capturedData = data - return nil - } - - // Call applyValuesConfigMaps - if err := handler.applyValuesConfigMaps(); err != nil { - t.Fatalf("failed to apply ConfigMap: %v", err) - } - - // Verify BUILD_ID is included in the ConfigMap data - if capturedData == nil { - t.Fatal("ConfigMap data was not captured") - } - - buildID, exists := capturedData["BUILD_ID"] - if !exists { - t.Fatal("BUILD_ID not found in ConfigMap data") - } + }) - if buildID != testBuildID { - t.Errorf("expected BUILD_ID to be %s, got %s", testBuildID, buildID) - } + t.Run("OverwriteData", func(t *testing.T) { + // Given a handler with existing data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "existing": "data", + } + newData := map[string]any{ + "new": "data", + } + // When setting new rendered kustomize data + handler.SetRenderedKustomizeData(newData) + // Then new data should overwrite existing data + if !reflect.DeepEqual(handler.kustomizeData, newData) { + t.Errorf("Expected kustomizeData = %v, got = %v", newData, handler.kustomizeData) + } + }) - // Verify other expected fields are present - expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} - for _, field := range expectedFields { - if _, exists := capturedData[field]; !exists { - t.Errorf("expected field %s not found in ConfigMap data", field) + t.Run("EmptyData", func(t *testing.T) { + // Given a handler with existing data + handler := setup(t) + handler.kustomizeData = map[string]any{ + "existing": "data", } - } -} + emptyData := map[string]any{} + // When setting empty rendered kustomize data + handler.SetRenderedKustomizeData(emptyData) + // Then empty data should be stored + if !reflect.DeepEqual(handler.kustomizeData, emptyData) { + t.Errorf("Expected kustomizeData = %v, got = %v", emptyData, handler.kustomizeData) + } + }) -func TestBaseBlueprintHandler_applyConfigMap_WithoutBuildID(t *testing.T) { - mocks := setupMocks(t, &SetupOptions{ - ConfigStr: ` -contexts: - test: - id: "test-id" - dns: - domain: "test.com" - network: - loadbalancer_ips: - start: "10.0.0.1" - end: "10.0.0.10" - docker: - registry_url: "registry.test" - cluster: - workers: - volumes: ["/tmp:/data"] -`, + t.Run("ComplexData", func(t *testing.T) { + // Given a handler with no existing data + handler := setup(t) + complexData := map[string]any{ + "nested": map[string]any{ + "level1": map[string]any{ + "level2": []any{ + "string1", + 123, + map[string]any{"key": "value"}, + }, + }, + }, + "array": []any{ + "item1", + 456, + map[string]any{"nested": "data"}, + }, + } + // When setting complex rendered kustomize data + handler.SetRenderedKustomizeData(complexData) + // Then complex data should be stored + if !reflect.DeepEqual(handler.kustomizeData, complexData) { + t.Errorf("Expected kustomizeData = %v, got = %v", complexData, handler.kustomizeData) + } }) +} - handler := NewBlueprintHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("failed to initialize handler: %v", err) - } +// ============================================================================= +// Context Values Tests +// ============================================================================= - // Mock the file system to simulate missing .build-id file - projectRoot, err := mocks.Shell.GetProjectRoot() - if err != nil { - t.Fatalf("failed to get project root: %v", err) +func TestBaseBlueprintHandler_loadAndMergeContextValues(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := &BaseBlueprintHandler{ + shell: mocks.Shell, + configHandler: mocks.ConfigHandler, + shims: mocks.Shims, + } + return handler, mocks } - buildIDPath := filepath.Join(projectRoot, ".windsor", ".build-id") - // Mock the file system to return file not found for .build-id - handler.shims.Stat = func(path string) (os.FileInfo, error) { - if path == buildIDPath { - return nil, os.ErrNotExist + t.Run("ReturnsNilWhenNoValuesFilesExist", func(t *testing.T) { + handler, mocks := setup(t) + + // Mock shell to return project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/test", nil } - return nil, os.ErrNotExist - } - handler.shims.ReadFile = func(path string) ([]byte, error) { - if path == buildIDPath { + + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/tmp/test/contexts/test-context", nil + } + } + + // Mock file system - no files exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } - return []byte{}, nil - } - // Mock the kubernetes manager to capture the ConfigMap data - var capturedData map[string]string - mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - capturedData = data - return nil - } + result, err := handler.loadAndMergeContextValues() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } - // Call applyValuesConfigMaps - this should not cause an error - if err := handler.applyValuesConfigMaps(); err != nil { - t.Fatalf("failed to apply ConfigMap: %v", err) - } + if result == nil { + t.Error("Expected result to not be nil") + } - // Verify BUILD_ID is not included in the ConfigMap data when file doesn't exist - if capturedData == nil { - t.Fatal("ConfigMap data was not captured") - } + if len(result.TopLevel) != 0 || len(result.Substitution) != 0 { + t.Error("Expected empty context values when no files exist") + } + }) - buildID, exists := capturedData["BUILD_ID"] - if exists { - t.Errorf("expected BUILD_ID to not be present in ConfigMap data when file doesn't exist, but it was found with value '%s'", buildID) - } + t.Run("LoadsOnlyTemplateValuesWhenNoContext", func(t *testing.T) { + handler, mocks := setup(t) - // Verify other expected fields are present - expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} - for _, field := range expectedFields { - if _, exists := capturedData[field]; !exists { - t.Errorf("expected field %s not found in ConfigMap data", field) + // Mock shell to return project root + projectRoot := filepath.Join("tmp", "test") + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil } - } -} - -// ============================================================================= -// New Functionality Tests -// ============================================================================= -func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - handler.shims = NewShims() - handler.configHandler = config.NewMockConfigHandler() - return handler - } + // Mock config handler to return empty context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "" + } + } - t.Run("WithRenderedDataOnly", func(t *testing.T) { - // Given a handler with rendered patch data only - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - "namespace": "test-namespace", - }, - "data": map[string]any{ - "key": "value", - }, - }, + // Mock file system - only template values exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { + return []byte(` +external_domain: template.test +substitution: + common: + registry_url: registry.template.test +`), nil + } + return nil, os.ErrNotExist } - // When resolving patch from path - content, target := handler.resolvePatchFromPath("test", "default-namespace") - // Then content should be returned and target should be extracted - if content != "test yaml" { - t.Errorf("Expected content = 'test yaml', got = '%s'", content) + + result, err := handler.loadAndMergeContextValues() + if err != nil { + t.Errorf("Expected no error, got %v", err) } - if target == nil { - t.Error("Expected target to be extracted") + + if result == nil { + t.Fatal("Expected result to not be nil") } - if target.Kind != "ConfigMap" { - t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) + + // Check top-level values + if result.TopLevel["external_domain"] != "template.test" { + t.Errorf("Expected external_domain to be 'template.test', got %v", result.TopLevel["external_domain"]) } - if target.Name != "test-config" { - t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + + // Check substitution values + common, exists := result.Substitution["common"].(map[string]any) + if !exists { + t.Fatal("Expected 'common' section to exist in substitution") } - if target.Namespace != "test-namespace" { - t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) + if common["registry_url"] != "registry.template.test" { + t.Errorf("Expected registry_url to be 'registry.template.test', got %v", common["registry_url"]) } }) - t.Run("WithNoData", func(t *testing.T) { - // Given a handler with no data - handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("config root error") - } - // When resolving patch from path - content, target := handler.resolvePatchFromPath("test", "default-namespace") - // Then empty content and nil target should be returned - if content != "" { - t.Errorf("Expected empty content, got = '%s'", content) - } - if target != nil { - t.Error("Expected target to be nil") + t.Run("MergesTemplateAndContextValues", func(t *testing.T) { + handler, mocks := setup(t) + + // Mock shell to return project root + projectRoot := filepath.Join("tmp", "test") + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return projectRoot, nil } - }) - t.Run("WithYamlExtension", func(t *testing.T) { - // Given a handler with patch path containing .yaml extension - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, + // Mock config handler to return context + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return filepath.Join(projectRoot, "contexts", "test-context"), nil + } } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + + // Mock file system - both template and context values exist + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") || + name == filepath.Join(projectRoot, "contexts", "test-context", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } + return nil, os.ErrNotExist } - // When resolving patch from path with .yaml extension - content, target := handler.resolvePatchFromPath("test.yaml", "default-namespace") - // Then content should be returned and target should be extracted - if content != "test yaml" { - t.Errorf("Expected content = 'test yaml', got = '%s'", content) + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { + return []byte(` +external_domain: template.test +registry_url: registry.template.test +template_only: template_value +nested: + template_key: template_value + shared_key: template_value +substitution: + common: + template_sub: template_sub_value + shared_sub: template_sub_value +`), nil + } + if name == filepath.Join(projectRoot, "contexts", "test-context", "values.yaml") { + return []byte(` +external_domain: context.test +context_only: context_value +nested: + context_key: context_value + shared_key: context_value +substitution: + common: + context_sub: context_sub_value + shared_sub: context_sub_value + context_section: + context_specific: context_specific_value +`), nil + } + return nil, os.ErrNotExist } - if target == nil { - t.Error("Expected target to be extracted") + + result, err := handler.loadAndMergeContextValues() + if err != nil { + t.Errorf("Expected no error, got %v", err) } - }) - t.Run("WithYmlExtension", func(t *testing.T) { - // Given a handler with patch path containing .yml extension - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - }, + if result == nil { + t.Fatal("Expected result to not be nil") } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test yaml"), nil + + // Check that context values override template values + if result.TopLevel["external_domain"] != "context.test" { + t.Errorf("Expected external_domain to be 'context.test', got %v", result.TopLevel["external_domain"]) } - // When resolving patch from path with .yml extension - content, target := handler.resolvePatchFromPath("test.yml", "default-namespace") - // Then content should be returned and target should be extracted - if content != "test yaml" { - t.Errorf("Expected content = 'test yaml', got = '%s'", content) + + // Check that template-only values are preserved + if result.TopLevel["template_only"] != "template_value" { + t.Errorf("Expected template_only to be 'template_value', got %v", result.TopLevel["template_only"]) } - if target == nil { - t.Error("Expected target to be extracted") + + // Check that context-only values are added + if result.TopLevel["context_only"] != "context_value" { + t.Errorf("Expected context_only to be 'context_value', got %v", result.TopLevel["context_only"]) } - }) - t.Run("WithBothRenderedAndUserDataMerge", func(t *testing.T) { - // Given a handler with both rendered and user data that can be merged - handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/patches/test": map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "rendered-config", - "namespace": "rendered-namespace", - }, - "data": map[string]any{ - "rendered-key": "rendered-value", - }, - }, + // Check nested map merging + nested, ok := result.TopLevel["nested"].(map[string]any) + if !ok { + t.Fatal("Expected nested to be a map") } - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + if nested["template_key"] != "template_value" { + t.Errorf("Expected nested.template_key to be 'template_value', got %v", nested["template_key"]) } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`apiVersion: v1 -kind: ConfigMap -metadata: - name: user-config - namespace: user-namespace -data: - user-key: user-value`), nil + if nested["context_key"] != "context_value" { + t.Errorf("Expected nested.context_key to be 'context_value', got %v", nested["context_key"]) } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "user-config", - "namespace": "user-namespace", - }, - "data": map[string]any{ - "user-key": "user-value", - }, - } - return nil + if nested["shared_key"] != "context_value" { + t.Errorf("Expected nested.shared_key to be 'context_value' (context should override), got %v", nested["shared_key"]) + } + + // Check substitution merging + common, exists := result.Substitution["common"].(map[string]any) + if !exists { + t.Fatal("Expected 'common' section to exist in substitution") } - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("merged yaml"), nil + if common["template_sub"] != "template_sub_value" { + t.Errorf("Expected template_sub to be 'template_sub_value', got %v", common["template_sub"]) } - // When resolving patch from path - content, target := handler.resolvePatchFromPath("test", "default-namespace") - // Then merged content should be returned and target should be extracted from merged data - if content != "merged yaml" { - t.Errorf("Expected content = 'merged yaml', got = '%s'", content) + if common["context_sub"] != "context_sub_value" { + t.Errorf("Expected context_sub to be 'context_sub_value', got %v", common["context_sub"]) } - if target == nil { - t.Error("Expected target to be extracted") + if common["shared_sub"] != "context_sub_value" { + t.Errorf("Expected shared_sub to be 'context_sub_value' (context should override), got %v", common["shared_sub"]) } - if target.Name != "user-config" { - t.Errorf("Expected target name = 'user-config', got = '%s'", target.Name) + + // Check context-specific substitution section + contextSection, exists := result.Substitution["context_section"].(map[string]any) + if !exists { + t.Fatal("Expected 'context_section' to exist in substitution") } - if target.Namespace != "user-namespace" { - t.Errorf("Expected target namespace = 'user-namespace', got = '%s'", target.Namespace) + if contextSection["context_specific"] != "context_specific_value" { + t.Errorf("Expected context_specific to be 'context_specific_value', got %v", contextSection["context_specific"]) } }) -} -func TestBaseBlueprintHandler_extractTargetFromPatchData(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - return handler - } + t.Run("ReturnsErrorWhenTemplateValuesFileCannotBeRead", func(t *testing.T) { + handler, mocks := setup(t) - t.Run("ValidPatchData", func(t *testing.T) { - // Given valid patch data with all required fields - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - "namespace": "test-namespace", - }, + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/test", nil } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be extracted correctly - if target == nil { - t.Error("Expected target to be extracted") + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "" + } } - if target.Kind != "ConfigMap" { - t.Errorf("Expected target kind = 'ConfigMap', got = '%s'", target.Kind) + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "values.yaml"}, nil } - if target.Name != "test-config" { - t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return nil, fmt.Errorf("read error") } - if target.Namespace != "test-namespace" { - t.Errorf("Expected target namespace = 'test-namespace', got = '%s'", target.Namespace) + + _, err := handler.loadAndMergeContextValues() + if err == nil { + t.Error("Expected error when template values file cannot be read") + } + if !strings.Contains(err.Error(), "failed to read template values.yaml") { + t.Errorf("Expected error about reading template values, got: %v", err) } }) - t.Run("WithCustomNamespace", func(t *testing.T) { - // Given patch data with custom namespace - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - "namespace": "custom-namespace", - }, + t.Run("ReturnsErrorWhenTemplateValuesFileCannotBeParsed", func(t *testing.T) { + handler, mocks := setup(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/test", nil } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then custom namespace should be used - if target.Namespace != "custom-namespace" { - t.Errorf("Expected target namespace = 'custom-namespace', got = '%s'", target.Namespace) + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "" + } } - }) - t.Run("MissingKind", func(t *testing.T) { - // Given patch data missing kind field - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "metadata": map[string]any{ - "name": "test-config", - }, + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "values.yaml"}, nil } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when kind is missing") + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("invalid: yaml: content: ["), nil } - }) - t.Run("MissingMetadata", func(t *testing.T) { - // Given patch data missing metadata field - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", + _, err := handler.loadAndMergeContextValues() + if err == nil { + t.Error("Expected error when template values file cannot be parsed") } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when metadata is missing") + if !strings.Contains(err.Error(), "failed to parse template values.yaml") { + t.Errorf("Expected error about parsing template values, got: %v", err) } }) - t.Run("MissingName", func(t *testing.T) { - // Given patch data missing name field - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{}, + t.Run("ReturnsErrorWhenContextValuesFileCannotBeRead", func(t *testing.T) { + handler, mocks := setup(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/test", nil } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when name is missing") + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/tmp/test/contexts/test-context", nil + } + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "values.yaml"}, nil + } + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + if strings.Contains(name, "_template") { + return []byte("external_domain: template.test"), nil + } + return nil, fmt.Errorf("read error") + } + + _, err := handler.loadAndMergeContextValues() + if err == nil { + t.Error("Expected error when context values file cannot be read") + } + if !strings.Contains(err.Error(), "failed to read context values.yaml") { + t.Errorf("Expected error about reading context values, got: %v", err) } }) - t.Run("InvalidKindType", func(t *testing.T) { - // Given patch data with invalid kind type - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": 42, - "metadata": map[string]any{ - "name": "test-config", - }, + t.Run("ReturnsErrorWhenContextValuesFileCannotBeParsed", func(t *testing.T) { + handler, mocks := setup(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/test", nil } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when kind type is invalid") + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/tmp/test/contexts/test-context", nil + } + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: "values.yaml"}, nil + } + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + if strings.Contains(name, "_template") { + return []byte("external_domain: template.test"), nil + } + return []byte("invalid: yaml: content: ["), nil + } + + _, err := handler.loadAndMergeContextValues() + if err == nil { + t.Error("Expected error when context values file cannot be parsed") + } + if !strings.Contains(err.Error(), "failed to parse context values.yaml") { + t.Errorf("Expected error about parsing context values, got: %v", err) } }) - t.Run("InvalidMetadataType", func(t *testing.T) { - // Given patch data with invalid metadata type - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": "not a map", + t.Run("ReturnsErrorWhenGetProjectRootFails", func(t *testing.T) { + handler, mocks := setup(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when metadata type is invalid") + + _, err := handler.loadAndMergeContextValues() + if err == nil { + t.Error("Expected error when GetProjectRoot fails") + } + if !strings.Contains(err.Error(), "failed to get project root") { + t.Errorf("Expected error about project root, got: %v", err) } }) - t.Run("InvalidNameType", func(t *testing.T) { - // Given patch data with invalid name type - handler := setup(t) - patchData := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": 42, - }, + t.Run("ReturnsErrorWhenGetConfigRootFails", func(t *testing.T) { + handler, mocks := setup(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/test", nil } - // When extracting target from patch data - target := handler.extractTargetFromPatchData(patchData, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when name type is invalid") + if mockConfigHandler, ok := mocks.ConfigHandler.(*config.MockConfigHandler); ok { + mockConfigHandler.GetContextFunc = func() string { + return "test-context" + } + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + } + + _, err := handler.loadAndMergeContextValues() + if err == nil { + t.Error("Expected error when GetConfigRoot fails") + } + if !strings.Contains(err.Error(), "failed to get config root") { + t.Errorf("Expected error about config root, got: %v", err) } }) } -func TestBaseBlueprintHandler_extractTargetFromPatchContent(t *testing.T) { +func TestBaseBlueprintHandler_separateValues(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - return handler + return &BaseBlueprintHandler{} } - t.Run("ValidYamlContent", func(t *testing.T) { - // Given valid YAML content + t.Run("SeparatesTopLevelAndSubstitutionValues", func(t *testing.T) { handler := setup(t) - content := `apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config - namespace: test-namespace` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be extracted correctly - if target == nil { - t.Error("Expected target to be extracted") + + input := map[string]any{ + "external_domain": "test.local", + "registry_url": "registry.test.local", + "substitution": map[string]any{ + "common": map[string]any{ + "sub_domain": "sub.test.local", + }, + "csi": map[string]any{ + "volume_path": "/volumes", + }, + }, } - if target.Name != "test-config" { - t.Errorf("Expected target name = 'test-config', got = '%s'", target.Name) + + result, err := handler.separateValues(input) + if err != nil { + t.Errorf("Expected no error, got %v", err) } - }) - t.Run("MultipleDocuments", func(t *testing.T) { - // Given YAML with multiple documents - handler := setup(t) - content := `--- -apiVersion: v1 -kind: ConfigMap -metadata: - name: first-config ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: second-config` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then first valid target should be extracted - if target == nil { - t.Error("Expected target to be extracted") + // Check top-level values + if result.TopLevel["external_domain"] != "test.local" { + t.Errorf("Expected external_domain to be 'test.local', got %v", result.TopLevel["external_domain"]) + } + if result.TopLevel["registry_url"] != "registry.test.local" { + t.Errorf("Expected registry_url to be 'registry.test.local', got %v", result.TopLevel["registry_url"]) + } + + // Substitution should not be in top-level + if _, exists := result.TopLevel["substitution"]; exists { + t.Error("Expected substitution to not be in top-level values") + } + + // Check substitution values + common, exists := result.Substitution["common"].(map[string]any) + if !exists { + t.Fatal("Expected 'common' section to exist in substitution") + } + if common["sub_domain"] != "sub.test.local" { + t.Errorf("Expected sub_domain to be 'sub.test.local', got %v", common["sub_domain"]) + } + + csi, exists := result.Substitution["csi"].(map[string]any) + if !exists { + t.Fatal("Expected 'csi' section to exist in substitution") } - if target.Name != "first-config" { - t.Errorf("Expected target name = 'first-config', got = '%s'", target.Name) + if csi["volume_path"] != "/volumes" { + t.Errorf("Expected volume_path to be '/volumes', got %v", csi["volume_path"]) } }) - t.Run("InvalidYamlContent", func(t *testing.T) { - // Given invalid YAML content + t.Run("HandlesEmptyInput", func(t *testing.T) { handler := setup(t) - content := `invalid: yaml: content: with: colons: everywhere` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil for invalid YAML") + + result, err := handler.separateValues(map[string]any{}) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(result.TopLevel) != 0 { + t.Error("Expected empty top-level values") + } + if len(result.Substitution) != 0 { + t.Error("Expected empty substitution values") } }) - t.Run("EmptyContent", func(t *testing.T) { - // Given empty content + t.Run("HandlesNoSubstitutionSection", func(t *testing.T) { handler := setup(t) - content := "" - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil for empty content") + + input := map[string]any{ + "external_domain": "test.local", + "registry_url": "registry.test.local", + } + + result, err := handler.separateValues(input) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Check top-level values + if result.TopLevel["external_domain"] != "test.local" { + t.Errorf("Expected external_domain to be 'test.local', got %v", result.TopLevel["external_domain"]) + } + + // Should have empty substitution + if len(result.Substitution) != 0 { + t.Error("Expected empty substitution values when no substitution section") } }) - t.Run("NoValidTargets", func(t *testing.T) { - // Given YAML with no valid targets + t.Run("HandlesInvalidSubstitutionType", func(t *testing.T) { handler := setup(t) - content := `apiVersion: v1 -kind: ConfigMap -# Missing metadata.name` - // When extracting target from patch content - target := handler.extractTargetFromPatchContent(content, "default-namespace") - // Then target should be nil - if target != nil { - t.Error("Expected target to be nil when no valid targets") + + input := map[string]any{ + "external_domain": "test.local", + "substitution": "invalid_type", // Should be map[string]any + } + + result, err := handler.separateValues(input) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Should still process top-level values + if result.TopLevel["external_domain"] != "test.local" { + t.Errorf("Expected external_domain to be 'test.local', got %v", result.TopLevel["external_domain"]) + } + + // Should have empty substitution due to invalid type + if len(result.Substitution) != 0 { + t.Error("Expected empty substitution values when substitution has invalid type") } }) } -func TestBaseBlueprintHandler_hasComponentValues(t *testing.T) { +func TestBaseBlueprintHandler_deepMergeValues(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - handler.shims = NewShims() - handler.configHandler = config.NewMockConfigHandler() - return handler + return &BaseBlueprintHandler{} } - t.Run("TemplateComponentExists", func(t *testing.T) { - // Given handler with component in template data + t.Run("MergesSimpleMaps", func(t *testing.T) { handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/values": map[string]any{ - "test-component": map[string]any{ - "key": "value", - }, - }, + + base := map[string]any{ + "key1": "base_value1", + "key2": "base_value2", } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return true - if !exists { - t.Error("Expected component to exist in template data") + + overlay := map[string]any{ + "key2": "overlay_value2", + "key3": "overlay_value3", + } + + result := handler.deepMergeValues(base, overlay) + + if result["key1"] != "base_value1" { + t.Errorf("Expected key1 to be 'base_value1', got %v", result["key1"]) + } + if result["key2"] != "overlay_value2" { + t.Errorf("Expected key2 to be 'overlay_value2' (overlay should win), got %v", result["key2"]) + } + if result["key3"] != "overlay_value3" { + t.Errorf("Expected key3 to be 'overlay_value3', got %v", result["key3"]) } }) - t.Run("UserComponentExists", func(t *testing.T) { - // Given handler with component in user file + t.Run("MergesNestedMaps", func(t *testing.T) { handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + + base := map[string]any{ + "nested": map[string]any{ + "base_key": "base_value", + "shared_key": "base_shared", + }, + "top_level": "base_top", } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "config.yaml", isDir: false}, nil + + overlay := map[string]any{ + "nested": map[string]any{ + "overlay_key": "overlay_value", + "shared_key": "overlay_shared", + }, + "overlay_top": "overlay_top_value", } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`test-component: - key: value`), nil + + result := handler.deepMergeValues(base, overlay) + + // Check top-level merging + if result["top_level"] != "base_top" { + t.Errorf("Expected top_level to be 'base_top', got %v", result["top_level"]) } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "test-component": map[string]any{ - "key": "value", - }, - } - return nil + if result["overlay_top"] != "overlay_top_value" { + t.Errorf("Expected overlay_top to be 'overlay_top_value', got %v", result["overlay_top"]) } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return true - if !exists { - t.Error("Expected component to exist in user file") + + // Check nested map merging + nested, ok := result["nested"].(map[string]any) + if !ok { + t.Fatal("Expected nested to be a map") + } + + if nested["base_key"] != "base_value" { + t.Errorf("Expected base_key to be 'base_value', got %v", nested["base_key"]) + } + if nested["overlay_key"] != "overlay_value" { + t.Errorf("Expected overlay_key to be 'overlay_value', got %v", nested["overlay_key"]) + } + if nested["shared_key"] != "overlay_shared" { + t.Errorf("Expected shared_key to be 'overlay_shared' (overlay should win), got %v", nested["shared_key"]) } }) - t.Run("BothTemplateAndUserExist", func(t *testing.T) { - // Given handler with component in both template and user data + t.Run("HandlesDeepNestedMaps", func(t *testing.T) { handler := setup(t) - handler.kustomizeData = map[string]any{ - "kustomize/values": map[string]any{ - "test-component": map[string]any{ - "template-key": "template-value", + + base := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": map[string]any{ + "base_deep": "base_value", + "shared": "base_shared", + }, }, }, } - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + + overlay := map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": map[string]any{ + "overlay_deep": "overlay_value", + "shared": "overlay_shared", + }, + }, + }, } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "config.yaml", isDir: false}, nil + + result := handler.deepMergeValues(base, overlay) + + // Navigate to deep nested map + level1, ok := result["level1"].(map[string]any) + if !ok { + t.Fatal("Expected level1 to be a map") } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`test-component: - user-key: user-value`), nil + level2, ok := level1["level2"].(map[string]any) + if !ok { + t.Fatal("Expected level2 to be a map") } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "test-component": map[string]any{ - "user-key": "user-value", - }, - } - return nil + level3, ok := level2["level3"].(map[string]any) + if !ok { + t.Fatal("Expected level3 to be a map") } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return true - if !exists { - t.Error("Expected component to exist in both sources") + + if level3["base_deep"] != "base_value" { + t.Errorf("Expected base_deep to be 'base_value', got %v", level3["base_deep"]) + } + if level3["overlay_deep"] != "overlay_value" { + t.Errorf("Expected overlay_deep to be 'overlay_value', got %v", level3["overlay_deep"]) + } + if level3["shared"] != "overlay_shared" { + t.Errorf("Expected shared to be 'overlay_shared', got %v", level3["shared"]) } }) - t.Run("NoComponentExists", func(t *testing.T) { - // Given handler with no component data + t.Run("HandlesNonMapOverlay", func(t *testing.T) { handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + + base := map[string]any{ + "nested": map[string]any{ + "key": "value", + }, } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + + overlay := map[string]any{ + "nested": "string_value", // Not a map } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist") + + result := handler.deepMergeValues(base, overlay) + + // Non-map overlay should replace the entire nested map + if result["nested"] != "string_value" { + t.Errorf("Expected nested to be 'string_value', got %v", result["nested"]) } }) - t.Run("ConfigRootError", func(t *testing.T) { - // Given handler with config root error + t.Run("HandlesEmptyMaps", func(t *testing.T) { handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("config root error") + + base := map[string]any{} + overlay := map[string]any{ + "key": "value", } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist when config root fails") + + result := handler.deepMergeValues(base, overlay) + + if result["key"] != "value" { + t.Errorf("Expected key to be 'value', got %v", result["key"]) + } + + // Test reverse + base2 := map[string]any{ + "key": "value", + } + overlay2 := map[string]any{} + + result2 := handler.deepMergeValues(base2, overlay2) + + if result2["key"] != "value" { + t.Errorf("Expected key to be 'value', got %v", result2["key"]) } }) +} - t.Run("FileNotExists", func(t *testing.T) { - // Given handler with file not existing +// ============================================================================= +// Validation Tests +// ============================================================================= + +func TestBaseBlueprintHandler_validateValuesForSubstitution(t *testing.T) { + setup := func(t *testing.T) *BaseBlueprintHandler { + t.Helper() + return &BaseBlueprintHandler{} + } + + t.Run("AcceptsValidScalarValues", func(t *testing.T) { handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + + values := map[string]any{ + "string_value": "test", + "int_value": 42, + "int8_value": int8(8), + "int16_value": int16(16), + "int32_value": int32(32), + "int64_value": int64(64), + "uint_value": uint(42), + "uint8_value": uint8(8), + "uint16_value": uint16(16), + "uint32_value": uint32(32), + "uint64_value": uint64(64), + "float32_value": float32(3.14), + "float64_value": 3.14159, + "bool_value": true, + } + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for valid scalar values, got: %v", err) } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist + }) + + t.Run("AcceptsOneLevelOfMapWithScalarValues", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "top_level_string": "value", + "scalar_map": map[string]any{ + "nested_string": "nested_value", + "nested_int": 123, + "nested_bool": false, + }, + "another_top_level": 456, } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist when file doesn't exist") + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for map with scalar values, got: %v", err) } }) - t.Run("InvalidValuesFile", func(t *testing.T) { - // Given handler with invalid values file + t.Run("RejectsNestedMaps", func(t *testing.T) { handler := setup(t) - handler.configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "/test/config", nil + + values := map[string]any{ + "top_level": map[string]any{ + "second_level": map[string]any{ + "third_level": "value", + }, + }, } - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return &mockFileInfo{name: "config.yaml", isDir: false}, nil + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for nested maps") } - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte("invalid yaml"), nil + + if !strings.Contains(err.Error(), "can only contain scalar values in maps") { + t.Errorf("Expected error about scalar values only in maps, got: %v", err) } - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - return fmt.Errorf("invalid yaml") + if !strings.Contains(err.Error(), "top_level.second_level") { + t.Errorf("Expected error to mention the nested key path, got: %v", err) } - // When checking if component values exist - exists := handler.hasComponentValues("test-component") - // Then it should return false - if exists { - t.Error("Expected component to not exist when values file is invalid") + }) + + t.Run("RejectsSlices", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "valid_string": "test", + "invalid_slice": []any{"item1", "item2", "item3"}, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for slice values") + } + + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices, got: %v", err) + } + if !strings.Contains(err.Error(), "invalid_slice") { + t.Errorf("Expected error to mention the slice key, got: %v", err) } }) -} - -func TestBaseBlueprintHandler_deepMergeMaps(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - return handler - } - t.Run("SimpleMerge", func(t *testing.T) { - // Given base and overlay maps with simple values + t.Run("RejectsSlicesInNestedMaps", func(t *testing.T) { handler := setup(t) - base := map[string]any{ - "key1": "base-value1", - "key2": "base-value2", + + values := map[string]any{ + "nested_map": map[string]any{ + "valid_value": "test", + "invalid_slice": []any{"item1", "item2"}, // Use []any to match the type check + }, } - overlay := map[string]any{ - "key2": "overlay-value2", - "key3": "overlay-value3", + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for slice in nested map") } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then result should contain merged values - if result["key1"] != "base-value1" { - t.Errorf("Expected key1 = 'base-value1', got = '%v'", result["key1"]) + + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices, got: %v", err) } - if result["key2"] != "overlay-value2" { - t.Errorf("Expected key2 = 'overlay-value2', got = '%v'", result["key2"]) + if !strings.Contains(err.Error(), "nested_map.invalid_slice") { + t.Errorf("Expected error to mention the nested slice key path, got: %v", err) } - if result["key3"] != "overlay-value3" { - t.Errorf("Expected key3 = 'overlay-value3', got = '%v'", result["key3"]) + }) + + t.Run("RejectsTypedSlices", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "string_slice": []string{"item1", "item2"}, + "int_slice": []int{1, 2, 3}, + } + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for typed slices") + } + + // After the fix, typed slices should now get the specific slice error message + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices for typed slices, got: %v", err) } }) - t.Run("NestedMapMerge", func(t *testing.T) { - // Given base and overlay maps with nested maps + t.Run("RejectsUnsupportedTypes", func(t *testing.T) { handler := setup(t) - base := map[string]any{ - "nested": map[string]any{ - "base-key": "base-value", - }, + + // Test with a struct (unsupported type) + type customStruct struct { + Field string } - overlay := map[string]any{ - "nested": map[string]any{ - "overlay-key": "overlay-value", - }, + + values := map[string]any{ + "valid_string": "test", + "invalid_struct": customStruct{Field: "value"}, + "invalid_function": func() {}, } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then nested maps should be merged - nested := result["nested"].(map[string]any) - if nested["base-key"] != "base-value" { - t.Errorf("Expected nested.base-key = 'base-value', got = '%v'", nested["base-key"]) + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for unsupported types") } - if nested["overlay-key"] != "overlay-value" { - t.Errorf("Expected nested.overlay-key = 'overlay-value', got = '%v'", nested["overlay-key"]) + + if !strings.Contains(err.Error(), "can only contain strings, numbers, booleans, or maps of scalar types") { + t.Errorf("Expected error about unsupported types, got: %v", err) } }) - t.Run("OverlayPrecedence", func(t *testing.T) { - // Given base and overlay maps with conflicting keys + t.Run("RejectsUnsupportedTypesInNestedMaps", func(t *testing.T) { handler := setup(t) - base := map[string]any{ - "key": "base-value", + + values := map[string]any{ + "nested_map": map[string]any{ + "valid_value": "test", + "invalid_value": make(chan int), // Channel is unsupported + }, } - overlay := map[string]any{ - "key": "overlay-value", + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for unsupported type in nested map") } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then overlay value should take precedence - if result["key"] != "overlay-value" { - t.Errorf("Expected key = 'overlay-value', got = '%v'", result["key"]) + + if !strings.Contains(err.Error(), "can only contain scalar values in maps") { + t.Errorf("Expected error about scalar values only in maps, got: %v", err) + } + if !strings.Contains(err.Error(), "nested_map.invalid_value") { + t.Errorf("Expected error to mention the nested key path, got: %v", err) } }) - t.Run("DeepNestedMerge", func(t *testing.T) { - // Given base and overlay maps with deeply nested maps + t.Run("RejectsSlicesInMaps", func(t *testing.T) { handler := setup(t) - base := map[string]any{ - "level1": map[string]any{ - "level2": map[string]any{ - "base-key": "base-value", - }, + + values := map[string]any{ + "config": map[string]any{ + "valid_key": "test", + "slice_key": []string{"item1", "item2"}, }, } - overlay := map[string]any{ - "level1": map[string]any{ - "level2": map[string]any{ - "overlay-key": "overlay-value", - }, - }, + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for slices in maps") } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then deeply nested maps should be merged - level1 := result["level1"].(map[string]any) - level2 := level1["level2"].(map[string]any) - if level2["base-key"] != "base-value" { - t.Errorf("Expected level2.base-key = 'base-value', got = '%v'", level2["base-key"]) + + if !strings.Contains(err.Error(), "cannot contain slices") { + t.Errorf("Expected error about slices, got: %v", err) } - if level2["overlay-key"] != "overlay-value" { - t.Errorf("Expected level2.overlay-key = 'overlay-value', got = '%v'", level2["overlay-key"]) + if !strings.Contains(err.Error(), "config.slice_key") { + t.Errorf("Expected error to mention the nested slice key path, got: %v", err) } }) - t.Run("EmptyMaps", func(t *testing.T) { - // Given empty base and overlay maps + t.Run("HandlesEmptyValues", func(t *testing.T) { handler := setup(t) - base := map[string]any{} - overlay := map[string]any{} - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then result should be empty - if len(result) != 0 { - t.Errorf("Expected empty result, got %d items", len(result)) + + values := map[string]any{} + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for empty values, got: %v", err) } }) - t.Run("NonMapOverlay", func(t *testing.T) { - // Given base map and non-map overlay value + t.Run("HandlesEmptyNestedMaps", func(t *testing.T) { handler := setup(t) - base := map[string]any{ - "key": map[string]any{ - "nested": "value", - }, - } - overlay := map[string]any{ - "key": "string-value", + + values := map[string]any{ + "empty_nested": map[string]any{}, + "valid_value": "test", } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then overlay value should replace base value - if result["key"] != "string-value" { - t.Errorf("Expected key = 'string-value', got = '%v'", result["key"]) + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for empty nested maps, got: %v", err) } }) - t.Run("MixedTypes", func(t *testing.T) { - // Given base and overlay maps with mixed types + t.Run("HandlesNilValues", func(t *testing.T) { handler := setup(t) - base := map[string]any{ - "string": "base-string", - "number": 42, - "nested": map[string]any{ - "key": "base-nested", - }, + + values := map[string]any{ + "nil_value": nil, + "valid_value": "test", } - overlay := map[string]any{ - "string": "overlay-string", - "bool": true, - "nested": map[string]any{ - "overlay-key": "overlay-nested", - }, + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for nil values") } - // When merging maps - result := handler.deepMergeMaps(base, overlay) - // Then all values should be merged correctly - if result["string"] != "overlay-string" { - t.Errorf("Expected string = 'overlay-string', got = '%v'", result["string"]) + + if !strings.Contains(err.Error(), "cannot contain nil values") { + t.Errorf("Expected error about nil values, got: %v", err) } - if result["number"] != 42 { - t.Errorf("Expected number = 42, got = '%v'", result["number"]) + }) + + t.Run("HandlesNilValuesInMaps", func(t *testing.T) { + handler := setup(t) + + values := map[string]any{ + "config": map[string]any{ + "valid_key": "test", + "nil_key": nil, + }, } - if result["bool"] != true { - t.Errorf("Expected bool = true, got = '%v'", result["bool"]) + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for nil values in maps") } - nested := result["nested"].(map[string]any) - if nested["key"] != "base-nested" { - t.Errorf("Expected nested.key = 'base-nested', got = '%v'", nested["key"]) + + if !strings.Contains(err.Error(), "cannot contain nil values") { + t.Errorf("Expected error about nil values, got: %v", err) } - if nested["overlay-key"] != "overlay-nested" { - t.Errorf("Expected nested.overlay-key = 'overlay-nested', got = '%v'", nested["overlay-key"]) + if !strings.Contains(err.Error(), "config.nil_key") { + t.Errorf("Expected error to mention the nested nil key path, got: %v", err) } }) -} - -func TestBaseBlueprintHandler_SetRenderedKustomizeData(t *testing.T) { - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - injector := di.NewInjector() - handler := NewBlueprintHandler(injector) - return handler - } - t.Run("SetData", func(t *testing.T) { - // Given a handler with no existing data + t.Run("ValidatesComplexScenario", func(t *testing.T) { handler := setup(t) - data := map[string]any{ - "key1": "value1", - "key2": 42, + + values := map[string]any{ + "app_name": "my-app", + "app_version": "1.2.3", + "replicas": 3, + "enabled": true, + "config": map[string]any{ + "database_url": "postgres://localhost:5432/mydb", + "cache_enabled": true, + "max_connections": 100, + "timeout_seconds": 30.5, + "debug_mode": false, + }, + "resources": map[string]any{ + "cpu_limit": "500m", + "memory_limit": "512Mi", + "cpu_request": "100m", + "memory_request": "128Mi", + }, } - // When setting rendered kustomize data - handler.SetRenderedKustomizeData(data) - // Then data should be stored - if !reflect.DeepEqual(handler.kustomizeData, data) { - t.Errorf("Expected kustomizeData = %v, got = %v", data, handler.kustomizeData) + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for complex valid scenario, got: %v", err) } }) - t.Run("OverwriteData", func(t *testing.T) { - // Given a handler with existing data + t.Run("RejectsComplexScenarioWithInvalidNesting", func(t *testing.T) { handler := setup(t) - handler.kustomizeData = map[string]any{ - "existing": "data", + + values := map[string]any{ + "app_name": "my-app", + "config": map[string]any{ + "database": map[string]any{ // Maps cannot contain other maps + "host": "localhost", + "port": 5432, + }, + }, } - newData := map[string]any{ - "new": "data", + + err := handler.validateValuesForSubstitution(values) + if err == nil { + t.Error("Expected error for invalid nesting in complex scenario") } - // When setting new rendered kustomize data - handler.SetRenderedKustomizeData(newData) - // Then new data should overwrite existing data - if !reflect.DeepEqual(handler.kustomizeData, newData) { - t.Errorf("Expected kustomizeData = %v, got = %v", newData, handler.kustomizeData) + + if !strings.Contains(err.Error(), "can only contain scalar values in maps") { + t.Errorf("Expected error about scalar values only in maps, got: %v", err) + } + if !strings.Contains(err.Error(), "config.database") { + t.Errorf("Expected error to mention the nested path, got: %v", err) } }) - t.Run("EmptyData", func(t *testing.T) { - // Given a handler with existing data + t.Run("HandlesSpecialNumericTypes", func(t *testing.T) { handler := setup(t) - handler.kustomizeData = map[string]any{ - "existing": "data", + + values := map[string]any{ + "zero_int": 0, + "negative_int": -42, + "zero_float": 0.0, + "negative_float": -3.14, + "large_uint64": uint64(18446744073709551615), // Max uint64 + "small_int8": int8(-128), // Min int8 } - emptyData := map[string]any{} - // When setting empty rendered kustomize data - handler.SetRenderedKustomizeData(emptyData) - // Then empty data should be stored - if !reflect.DeepEqual(handler.kustomizeData, emptyData) { - t.Errorf("Expected kustomizeData = %v, got = %v", emptyData, handler.kustomizeData) + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for special numeric types, got: %v", err) } }) - t.Run("ComplexData", func(t *testing.T) { - // Given a handler with no existing data + t.Run("HandlesSpecialStringValues", func(t *testing.T) { handler := setup(t) - complexData := map[string]any{ - "nested": map[string]any{ - "level1": map[string]any{ - "level2": []any{ - "string1", - 123, - map[string]any{"key": "value"}, - }, - }, - }, - "array": []any{ - "item1", - 456, - map[string]any{"nested": "data"}, - }, + + values := map[string]any{ + "empty_string": "", + "whitespace": " ", + "newlines": "line1\nline2", + "unicode": "Hello δΈ–η•Œ 🌍", + "special_chars": "!@#$%^&*()_+-={}[]|\\:;\"'<>?,./", } - // When setting complex rendered kustomize data - handler.SetRenderedKustomizeData(complexData) - // Then complex data should be stored - if !reflect.DeepEqual(handler.kustomizeData, complexData) { - t.Errorf("Expected kustomizeData = %v, got = %v", complexData, handler.kustomizeData) + + err := handler.validateValuesForSubstitution(values) + if err != nil { + t.Errorf("Expected no error for special string values, got: %v", err) } }) } diff --git a/pkg/generators/kustomize_generator.go b/pkg/generators/kustomize_generator.go index b617259e9..1a8edf0f4 100644 --- a/pkg/generators/kustomize_generator.go +++ b/pkg/generators/kustomize_generator.go @@ -68,7 +68,7 @@ func (g *KustomizeGenerator) Generate(data map[string]any, overwrite ...bool) er kustomizeData := make(map[string]any) for key, values := range data { - if strings.HasPrefix(key, "kustomize/") { + if strings.HasPrefix(key, "patches/") || key == "substitution" { if err := g.validateKustomizeData(key, values); err != nil { return fmt.Errorf("invalid kustomize data for key %s: %w", key, err) } @@ -87,10 +87,12 @@ func (g *KustomizeGenerator) Generate(data map[string]any, overwrite ...bool) er // Private Methods // ============================================================================= -// validateKustomizeData validates kustomize template data based on the key type. -// Validates patches as Kubernetes manifests and values for post-build substitution compatibility. +// validateKustomizeData validates kustomize template data for supported kustomize keys. +// For patch keys, validates the value as a Kubernetes manifest. For values keys, accepts map[string]any or +// YAML bytes and validates for post-build substitution compatibility. Returns an error if the data is invalid +// or of unsupported type. func (g *KustomizeGenerator) validateKustomizeData(key string, values any) error { - if strings.HasPrefix(key, "kustomize/patches/") { + if strings.HasPrefix(key, "patches/") { valuesMap, ok := values.(map[string]any) if !ok { return fmt.Errorf("patch values must be a map, got %T", values) @@ -98,10 +100,17 @@ func (g *KustomizeGenerator) validateKustomizeData(key string, values any) error return g.validateKubernetesManifest(valuesMap) } - if key == "kustomize/values" { - valuesMap, ok := values.(map[string]any) - if !ok { - return fmt.Errorf("values must be a map, got %T", values) + if key == "substitution" { + var valuesMap map[string]any + switch v := values.(type) { + case map[string]any: + valuesMap = v + case []byte: + if err := g.shims.YamlUnmarshal(v, &valuesMap); err != nil { + return fmt.Errorf("failed to unmarshal values YAML: %w", err) + } + default: + return fmt.Errorf("values must be a map or YAML bytes, got %T", values) } return g.validatePostBuildValues(valuesMap, "", 0) } diff --git a/pkg/generators/kustomize_generator_test.go b/pkg/generators/kustomize_generator_test.go index fc5cd1096..07c6fdc0e 100644 --- a/pkg/generators/kustomize_generator_test.go +++ b/pkg/generators/kustomize_generator_test.go @@ -27,14 +27,14 @@ func TestKustomizeGenerator_Generate_InMemory(t *testing.T) { generator.blueprintHandler = mockBlueprintHandler data := map[string]any{ - "kustomize/patches/test": map[string]any{ + "patches/test/configmap": map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ "name": "test-config", }, }, - "kustomize/values": map[string]any{ + "substitution": map[string]any{ "environment": "test", }, "other/file": "should be ignored", @@ -50,11 +50,11 @@ func TestKustomizeGenerator_Generate_InMemory(t *testing.T) { if len(setData) != 2 { t.Errorf("expected 2 kustomize items, got %d", len(setData)) } - if _, exists := setData["kustomize/patches/test"]; !exists { - t.Error("expected kustomize/patches/test to be stored") + if _, exists := setData["patches/test/configmap"]; !exists { + t.Error("expected patches/test/configmap to be stored") } - if _, exists := setData["kustomize/values"]; !exists { - t.Error("expected kustomize/values to be stored") + if _, exists := setData["substitution"]; !exists { + t.Error("expected substitution to be stored") } if _, exists := setData["other/file"]; exists { t.Error("expected non-kustomize data to be filtered out") @@ -96,7 +96,7 @@ func TestKustomizeGenerator_Generate_InMemory(t *testing.T) { generator.blueprintHandler = mockBlueprintHandler data := map[string]any{ - "kustomize/patches/test": "invalid data - should be map", + "patches/test/configmap": "invalid data - should be map", } err := generator.Generate(data, false) @@ -135,7 +135,7 @@ func TestKustomizeGenerator_validateKustomizeData(t *testing.T) { }, } - err := generator.validateKustomizeData("kustomize/patches/test", data) + err := generator.validateKustomizeData("patches/test/configmap", data) if err != nil { t.Errorf("expected valid patch to pass validation, got: %v", err) } @@ -147,7 +147,7 @@ func TestKustomizeGenerator_validateKustomizeData(t *testing.T) { // Missing apiVersion and metadata.name } - err := generator.validateKustomizeData("kustomize/patches/test", data) + err := generator.validateKustomizeData("patches/test/configmap", data) if err == nil { t.Error("expected invalid patch to fail validation") } @@ -160,7 +160,7 @@ func TestKustomizeGenerator_validateKustomizeData(t *testing.T) { "enabled": true, } - err := generator.validateKustomizeData("kustomize/values", data) + err := generator.validateKustomizeData("substitution", data) if err != nil { t.Errorf("expected valid values to pass validation, got: %v", err) } @@ -171,14 +171,14 @@ func TestKustomizeGenerator_validateKustomizeData(t *testing.T) { "invalid": []string{"slice", "not", "allowed"}, } - err := generator.validateKustomizeData("kustomize/values", data) + err := generator.validateKustomizeData("substitution", data) if err == nil { t.Error("expected invalid values to fail validation") } }) t.Run("NonMapData", func(t *testing.T) { - err := generator.validateKustomizeData("kustomize/patches/test", "not a map") + err := generator.validateKustomizeData("patches/test/configmap", "not a map") if err == nil { t.Error("expected non-map data to fail validation") } @@ -189,7 +189,7 @@ func TestKustomizeGenerator_validateKustomizeData(t *testing.T) { t.Run("UnknownKey", func(t *testing.T) { data := map[string]any{"test": "value"} - err := generator.validateKustomizeData("kustomize/unknown", data) + err := generator.validateKustomizeData("unknown/key", data) if err != nil { t.Errorf("expected unknown key to pass (no validation), got: %v", err) } @@ -490,3 +490,100 @@ func TestKustomizeGenerator_validatePostBuildValues(t *testing.T) { } }) } + +func TestKustomizeGenerator_Generate_ValuesHandling(t *testing.T) { + t.Run("HandlesKustomizeValuesAsYAMLBytes", func(t *testing.T) { + // Given a kustomize generator with YAML values data + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) + + // Mock blueprint handler + mockBlueprintHandler := &blueprint.MockBlueprintHandler{} + var setData map[string]any + mockBlueprintHandler.SetRenderedKustomizeDataFunc = func(data map[string]any) { + setData = data + } + + // Initialize with mock + generator.blueprintHandler = mockBlueprintHandler + + // Create YAML values data + yamlData := []byte(`common: + external_domain: test.example.com + registry_url: registry.test.com +logging: + enabled: true +monitoring: + enabled: false`) + + data := map[string]any{ + "substitution": yamlData, + } + + // When Generate is called + err := generator.Generate(data, false) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Verify the data was stored + if len(setData) != 1 { + t.Errorf("Expected 1 substitution item, got %d", len(setData)) + } + if _, exists := setData["substitution"]; !exists { + t.Error("Expected substitution to be stored") + } + }) + + t.Run("HandlesKustomizeValuesAsMap", func(t *testing.T) { + // Given a kustomize generator with map values data + injector := di.NewInjector() + generator := NewKustomizeGenerator(injector) + + // Mock blueprint handler + mockBlueprintHandler := &blueprint.MockBlueprintHandler{} + var setData map[string]any + mockBlueprintHandler.SetRenderedKustomizeDataFunc = func(data map[string]any) { + setData = data + } + + // Initialize with mock + generator.blueprintHandler = mockBlueprintHandler + + // Create map values data + mapData := map[string]any{ + "common": map[string]any{ + "external_domain": "test.example.com", + "registry_url": "registry.test.com", + }, + "logging": map[string]any{ + "enabled": true, + }, + "monitoring": map[string]any{ + "enabled": false, + }, + } + + data := map[string]any{ + "substitution": mapData, + } + + // When Generate is called + err := generator.Generate(data, false) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Verify the data was stored + if len(setData) != 1 { + t.Errorf("Expected 1 substitution item, got %d", len(setData)) + } + if _, exists := setData["substitution"]; !exists { + t.Error("Expected substitution to be stored") + } + }) +} diff --git a/pkg/pipelines/init.go b/pkg/pipelines/init.go index 4df614212..f2af3ce2f 100644 --- a/pkg/pipelines/init.go +++ b/pkg/pipelines/init.go @@ -429,63 +429,6 @@ func (p *InitPipeline) processPlatformConfiguration(_ context.Context) error { return nil } -// processTemplateData renders and processes template data for the InitPipeline. -// Renders all templates using the template renderer, and loads blueprint data from the rendered output if present. -// Returns the rendered template data map or an error if rendering or blueprint loading fails. -func (p *InitPipeline) processTemplateData(templateData map[string][]byte) (map[string]any, error) { - var renderedData map[string]any - if p.templateRenderer != nil && len(templateData) > 0 { - renderedData = make(map[string]any) - if err := p.templateRenderer.Process(templateData, renderedData); err != nil { - return nil, fmt.Errorf("failed to process template data: %w", err) - } - if blueprintData, exists := renderedData["blueprint"]; exists { - ctx := context.Background() - if err := p.loadBlueprintFromTemplate(ctx, map[string]any{"blueprint": blueprintData}); err != nil { - return nil, fmt.Errorf("failed to load blueprint from template: %w", err) - } - } - } - return renderedData, nil -} - -// loadBlueprintFromTemplate loads blueprint data from rendered template data. If the "blueprint" key exists -// in renderedData and is a map, attempts to parse OCI artifact info from the context's "blueprint" value. -// Delegates loading to blueprintHandler.LoadData with the parsed blueprint map and optional OCI info. -func (p *InitPipeline) loadBlueprintFromTemplate(ctx context.Context, renderedData map[string]any) error { - if blueprintData, exists := renderedData["blueprint"]; exists { - if blueprintMap, ok := blueprintData.(map[string]any); ok { - if kustomizeData, exists := blueprintMap["kustomize"]; exists { - if kustomizeList, ok := kustomizeData.([]any); ok { - for _, k := range kustomizeList { - if kustomizeMap, ok := k.(map[string]any); ok { - if _, exists := kustomizeMap["patches"]; exists { - // Patches exist in this kustomization - } - } - } - } - } - - var ociInfo *artifact.OCIArtifactInfo - if blueprintCtx := ctx.Value("blueprint"); blueprintCtx != nil { - if blueprintValue, ok := blueprintCtx.(string); ok { - var err error - ociInfo, err = artifact.ParseOCIReference(blueprintValue) - if err != nil { - return err - } - } - } - - if err := p.blueprintHandler.LoadData(blueprintMap, ociInfo); err != nil { - return fmt.Errorf("failed to load blueprint data: %w", err) - } - } - } - return nil -} - // writeConfigurationFiles writes configuration files for all managed components in the InitPipeline. // It sequentially invokes WriteManifest or WriteConfig on the tools manager, each registered service, // the virtual machine, and the container runtime if present. Returns an error if any write operation fails. diff --git a/pkg/pipelines/install_test.go b/pkg/pipelines/install_test.go index 2887e2d65..e93bfcb39 100644 --- a/pkg/pipelines/install_test.go +++ b/pkg/pipelines/install_test.go @@ -413,7 +413,7 @@ func TestInstallPipeline_Execute(t *testing.T) { mocks.BlueprintHandler.GetLocalTemplateDataFunc = func() (map[string][]byte, error) { t.Log("GetLocalTemplateData called") return map[string][]byte{ - "kustomize/values.jsonnet": []byte(`{"common": {"domain": "test.com"}}`), + "blueprint.jsonnet": []byte(`{"kustomize": [{"name": "test"}]}`), }, nil } @@ -451,7 +451,7 @@ func TestInstallPipeline_Execute(t *testing.T) { // Mock blueprint handler to return template data mocks.BlueprintHandler.GetLocalTemplateDataFunc = func() (map[string][]byte, error) { return map[string][]byte{ - "kustomize/values.jsonnet": []byte(`{"common": {"domain": "test.com"}}`), + "blueprint.jsonnet": []byte(`{"kustomize": [{"name": "test"}]}`), }, nil } @@ -492,7 +492,7 @@ func TestInstallPipeline_Execute(t *testing.T) { // Mock blueprint handler to return template data mocks.BlueprintHandler.GetLocalTemplateDataFunc = func() (map[string][]byte, error) { return map[string][]byte{ - "kustomize/values.jsonnet": []byte(`{"common": {"domain": "test.com"}}`), + "blueprint.jsonnet": []byte(`{"kustomize": [{"name": "test"}]}`), }, nil } @@ -546,7 +546,7 @@ func TestInstallPipeline_Execute(t *testing.T) { // Mock blueprint handler to return template data mocks.BlueprintHandler.GetLocalTemplateDataFunc = func() (map[string][]byte, error) { return map[string][]byte{ - "kustomize/values.jsonnet": []byte(`{"common": {"domain": "test.com"}}`), + "blueprint.jsonnet": []byte(`{"kustomize": [{"name": "test"}]}`), }, nil } @@ -592,7 +592,7 @@ func TestInstallPipeline_Execute(t *testing.T) { // Mock blueprint handler to return template data mocks.BlueprintHandler.GetLocalTemplateDataFunc = func() (map[string][]byte, error) { return map[string][]byte{ - "kustomize/values.jsonnet": []byte(`{"common": {"domain": "test.com"}}`), + "blueprint.jsonnet": []byte(`{"kustomize": [{"name": "test"}]}`), }, nil } @@ -650,7 +650,7 @@ func TestInstallPipeline_Execute(t *testing.T) { // Mock blueprint handler to return template data mocks.BlueprintHandler.GetLocalTemplateDataFunc = func() (map[string][]byte, error) { return map[string][]byte{ - "kustomize/values.jsonnet": []byte(`{"common": {"domain": "test.com"}}`), + "blueprint.jsonnet": []byte(`{"kustomize": [{"name": "test"}]}`), }, nil } @@ -713,7 +713,7 @@ func TestInstallPipeline_Execute(t *testing.T) { // Mock blueprint handler to return template data mocks.BlueprintHandler.GetLocalTemplateDataFunc = func() (map[string][]byte, error) { return map[string][]byte{ - "kustomize/values.jsonnet": []byte(`{"common": {"domain": "test.com"}}`), + "blueprint.jsonnet": []byte(`{"kustomize": [{"name": "test"}]}`), }, nil } diff --git a/pkg/template/jsonnet_template.go b/pkg/template/jsonnet_template.go index 6209d90c7..7951ea48a 100644 --- a/pkg/template/jsonnet_template.go +++ b/pkg/template/jsonnet_template.go @@ -2,6 +2,7 @@ package template import ( "fmt" + "maps" "strings" "github.com/windsorcli/cli/pkg/config" @@ -72,30 +73,16 @@ func (t *JsonnetTemplate) Process(templateData map[string][]byte, renderedData m 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 strings.HasSuffix(templatePath, "/values.jsonnet") || templatePath == "kustomize/values.jsonnet" { - if !valuesSet[templatePath] { - continue - } - } else if !patchSet[templatePath] { - continue - } - } - if strings.HasPrefix(templatePath, "values/") { - if !valuesSet[templatePath] { + if strings.HasPrefix(templatePath, "patches/") { + if !patchSet[templatePath] { continue } } @@ -107,12 +94,13 @@ func (t *JsonnetTemplate) Process(templateData map[string][]byte, renderedData m return nil } -// processJsonnetTemplate evaluates a Jsonnet template string using the Windsor context and returns the resulting data as a map. +// processJsonnetTemplate evaluates a Jsonnet template string using the Windsor context and values data. // The Windsor configuration is marshaled to YAML, converted to a map, and augmented with context and project name metadata. // The context is serialized to JSON and injected into the Jsonnet VM as an external variable, along with helper functions and the effective blueprint URL. -// The template is evaluated, and the output is unmarshaled from JSON into a map for downstream use. +// If values data is provided, it is merged into the context map before serialization. +// The template is evaluated, and the output is unmarshaled from JSON into a map. // Returns the resulting map or an error if any step fails. -func (t *JsonnetTemplate) processJsonnetTemplate(templateContent string) (map[string]any, error) { +func (t *JsonnetTemplate) processJsonnetTemplate(templateContent string, valuesData []byte) (map[string]any, error) { config := t.configHandler.GetConfig() contextYAML, err := t.shims.YamlMarshal(config) if err != nil { @@ -129,6 +117,15 @@ func (t *JsonnetTemplate) processJsonnetTemplate(templateContent string) (map[st contextName := t.configHandler.GetContext() contextMap["name"] = contextName contextMap["projectName"] = t.shims.FilepathBase(projectRoot) + + if valuesData != nil { + var valuesMap map[string]any + if err := t.shims.YamlUnmarshal(valuesData, &valuesMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal values YAML: %w", err) + } + maps.Copy(contextMap, valuesMap) + } + contextJSON, err := t.shims.JsonMarshal(contextMap) if err != nil { return nil, fmt.Errorf("failed to marshal context map to JSON: %w", err) @@ -203,10 +200,13 @@ func (t *JsonnetTemplate) isEmptyValue(value any) bool { // Recognized mappings: // - "blueprint.jsonnet" β†’ "blueprint" // - "terraform/*.jsonnet" β†’ "terraform/*" (without .jsonnet extension) -// - "kustomize/*.jsonnet" β†’ "kustomize/patches/*" (without .jsonnet extension) -// - "kustomize/values.jsonnet" β†’ "kustomize/values" -// - "kustomize//values.jsonnet" β†’ "kustomize/values" (merged into single values file) -// - "values/*.jsonnet" β†’ "values/*" (without .jsonnet extension) +// - "patches//*.jsonnet" β†’ "patches//*" (without .jsonnet extension) +// +// Templates exclusively contain: +// - blueprint.jsonnet +// - terraform/.jsonnet +// - patches//*.jsonnet +// - values.yaml (processed separately, not here) // // 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 { @@ -219,26 +219,22 @@ func (t *JsonnetTemplate) processTemplate(templatePath string, templateData map[ switch { case templatePath == "blueprint.jsonnet": outputKey = "blueprint" + case templatePath == "substitution.jsonnet": + outputKey = "substitution" case strings.HasPrefix(templatePath, "terraform/") && strings.HasSuffix(templatePath, ".jsonnet"): outputKey = strings.TrimSuffix(templatePath, ".jsonnet") - case strings.HasPrefix(templatePath, "kustomize/") && strings.HasSuffix(templatePath, ".jsonnet"): - if templatePath == "kustomize/values.jsonnet" || strings.HasSuffix(templatePath, "/values.jsonnet") { - outputKey = "kustomize/values" - } else { - pathWithoutExt := strings.TrimSuffix(templatePath, ".jsonnet") - if prefix, ok := strings.CutPrefix(pathWithoutExt, "kustomize/"); ok { - outputKey = "kustomize/patches/" + prefix - } else { - outputKey = strings.TrimSuffix(templatePath, ".jsonnet") - } - } - case strings.HasPrefix(templatePath, "values/") && strings.HasSuffix(templatePath, ".jsonnet"): + case strings.HasPrefix(templatePath, "patches/") && strings.HasSuffix(templatePath, ".jsonnet"): outputKey = strings.TrimSuffix(templatePath, ".jsonnet") default: return nil } - values, err := t.processJsonnetTemplate(string(content)) + var valuesData []byte + if data, exists := templateData["values"]; exists { + valuesData = data + } + + values, err := t.processJsonnetTemplate(string(content), valuesData) if err != nil { return fmt.Errorf("failed to process template %s: %w", templatePath, err) } @@ -253,7 +249,7 @@ func (t *JsonnetTemplate) processTemplate(templatePath string, templateData map[ // extractPatchReferences returns a slice of template file paths for patch references found in the rendered blueprint within renderedData. // The function inspects the "blueprint" key in renderedData, extracts the "kustomize" array, and collects patch paths from each kustomization's "patches" field. -// Patch paths are normalized to "kustomize/.jsonnet" if not already fully qualified. Returns an empty slice if the blueprint or kustomize section is missing or malformed. +// Patch paths are normalized to "patches//.jsonnet" format. Returns an empty slice if the blueprint or kustomize section is missing or malformed. func (t *JsonnetTemplate) extractPatchReferences(renderedData map[string]any) []string { var templatePaths []string blueprintData, ok := renderedData["blueprint"] @@ -273,6 +269,10 @@ func (t *JsonnetTemplate) extractPatchReferences(renderedData map[string]any) [] if !ok { continue } + kustomizationName, ok := kMap["name"].(string) + if !ok { + continue + } patches, ok := kMap["patches"].([]any) if !ok { continue @@ -283,12 +283,7 @@ func (t *JsonnetTemplate) extractPatchReferences(renderedData map[string]any) [] continue } if path, ok := pMap["path"].(string); ok && path != "" { - var templatePath string - if strings.HasPrefix(path, "kustomize/") { - templatePath = path - } else { - templatePath = "kustomize/" + path + ".jsonnet" - } + templatePath := "patches/" + kustomizationName + "/" + path + ".jsonnet" templatePaths = append(templatePaths, templatePath) } } @@ -296,32 +291,6 @@ 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 centralized values template ("kustomize/values.jsonnet") which contains both common and component-specific values. -// 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 - } - kustomizeArr, ok := blueprintMap["kustomize"].([]any) - if !ok { - return templatePaths - } - - // Only include values template if there are kustomize components to process - if len(kustomizeArr) > 0 { - templatePaths = append(templatePaths, "kustomize/values.jsonnet") - } - - 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 7155416f2..63601805a 100644 --- a/pkg/template/jsonnet_template_test.go +++ b/pkg/template/jsonnet_template_test.go @@ -3,7 +3,6 @@ package template import ( "fmt" "os" - "path/filepath" "strings" "testing" "time" @@ -239,11 +238,11 @@ func TestJsonnetTemplate_Process(t *testing.T) { // Given a jsonnet template template, _ := setup(t) - // And template data containing blueprint and kustomize/ .jsonnet files with subdirectory structure + // And template data containing blueprint and patches/ .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" }] }] }`), - "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" } }`), + "blueprint.jsonnet": []byte(`{ kustomize: [{ name: "ingress", patches: [{ path: "nginx" }] }, { name: "dns", patches: [{ path: "coredns" }] }] }`), + "patches/ingress/nginx.jsonnet": []byte(`local context = std.extVar("context"); { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "nginx-config" } }`), + "patches/dns/coredns.jsonnet": []byte(`local context = std.extVar("context"); { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "coredns-config" } }`), } renderedData := make(map[string]any) @@ -253,7 +252,7 @@ func TestJsonnetTemplate_Process(t *testing.T) { EvaluateFunc: func(filename, snippet string) (string, error) { if strings.Contains(snippet, `kustomize:`) && strings.Contains(snippet, `patches:`) { // This is the blueprint template - return `{"kustomize": [{"name": "ingress", "patches": [{"path": "ingress/patches/nginx"}]}, {"name": "dns", "patches": [{"path": "dns/patches/coredns"}]}]}`, nil + return `{"kustomize": [{"name": "ingress", "patches": [{"path": "nginx"}]}, {"name": "dns", "patches": [{"path": "coredns"}]}]}`, nil } if strings.Contains(snippet, `nginx-config`) { return `{"apiVersion": "v1", "kind": "ConfigMap", "metadata": {"name": "nginx-config"}}`, nil @@ -277,7 +276,7 @@ func TestJsonnetTemplate_Process(t *testing.T) { t.Fatalf("Expected no error, got: %v", err) } - // And the rendered data should contain the blueprint and patch manifests with preserved path structure + // And the rendered data should contain the blueprint and patch manifests if len(renderedData) != 3 { t.Errorf("Expected 3 rendered items (blueprint + 2 patches), got %d", len(renderedData)) } @@ -287,17 +286,17 @@ func TestJsonnetTemplate_Process(t *testing.T) { t.Error("Expected blueprint to be rendered") } - // Verify that the full path structure is preserved (not flattened) - if _, exists := renderedData["kustomize/patches/ingress/patches/nginx"]; !exists { - t.Error("Expected kustomize/patches/ingress/patches/nginx to be rendered with preserved path structure") + // Verify that patches are rendered with correct paths + if _, exists := renderedData["patches/ingress/nginx"]; !exists { + t.Error("Expected patches/ingress/nginx to be rendered") } - if _, exists := renderedData["kustomize/patches/dns/patches/coredns"]; !exists { - t.Error("Expected kustomize/patches/dns/patches/coredns to be rendered with preserved path structure") + if _, exists := renderedData["patches/dns/coredns"]; !exists { + t.Error("Expected patches/dns/coredns to be rendered") } // Verify the content is correctly processed - nginxPatch, ok := renderedData["kustomize/patches/ingress/patches/nginx"].(map[string]any) + nginxPatch, ok := renderedData["patches/ingress/nginx"].(map[string]any) if !ok { t.Error("Expected nginx patch to be a map") } else { @@ -309,7 +308,7 @@ func TestJsonnetTemplate_Process(t *testing.T) { } } - corednsPatch, ok := renderedData["kustomize/patches/dns/patches/coredns"].(map[string]any) + corednsPatch, ok := renderedData["patches/dns/coredns"].(map[string]any) if !ok { t.Error("Expected coredns patch to be a map") } else { @@ -515,7 +514,7 @@ func TestJsonnetTemplate_processJsonnetTemplate(t *testing.T) { templateContent := `local context = std.extVar("context"); { key: "value", number: 42 }` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -543,7 +542,7 @@ func TestJsonnetTemplate_processJsonnetTemplate(t *testing.T) { templateContent := `local context = std.extVar("context"); { key: "value" }` // When processing the jsonnet template - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) // Then an error should be returned if err == nil { @@ -571,7 +570,7 @@ func TestJsonnetTemplate_processJsonnetTemplate(t *testing.T) { templateContent := `local context = std.extVar("context"); { key: "value" }` // When processing the jsonnet template - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) // Then an error should be returned if err == nil { @@ -599,7 +598,7 @@ func TestJsonnetTemplate_processJsonnetTemplate(t *testing.T) { templateContent := `local context = std.extVar("context"); { key: "value" }` // When processing the jsonnet template - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) // Then an error should be returned if err == nil { @@ -627,7 +626,7 @@ func TestJsonnetTemplate_processJsonnetTemplate(t *testing.T) { templateContent := `local context = std.extVar("context"); { key: "value" }` // When processing the jsonnet template - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) // Then an error should be returned if err == nil { @@ -660,7 +659,7 @@ func TestJsonnetTemplate_processJsonnetTemplate(t *testing.T) { templateContent := `invalid jsonnet syntax` // When processing the jsonnet template - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) // Then an error should be returned if err == nil { @@ -693,7 +692,7 @@ func TestJsonnetTemplate_processJsonnetTemplate(t *testing.T) { templateContent := `local context = std.extVar("context"); "not an object"` // When processing the jsonnet template - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) // Then an error should be returned if err == nil { @@ -752,7 +751,7 @@ contexts: templateContent := `local context = std.extVar("context"); { processed: true, name: context.name, projectName: context.projectName }` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -805,7 +804,7 @@ contexts: templateContent := `local context = std.extVar("context"); { minimal: true }` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -841,7 +840,7 @@ contexts: templateContent := `local context = std.extVar("context"); local helpers = std.extVar("helpers"); { test: "value" }` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -892,7 +891,7 @@ contexts: templateContent := `local context = std.extVar("context"); { test: "value" }` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -955,7 +954,7 @@ contexts: templateContent := `local context = std.extVar("context"); local helpers = std.extVar("helpers"); helpers.removeEmptyKeys({})` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1016,7 +1015,7 @@ hlp.removeEmptyKeys({ })` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1052,7 +1051,7 @@ hlp.removeEmptyKeys({ templateContent := `local context = std.extVar("context"); local helpers = std.extVar("helpers"); {"hasRemoveEmptyKeys": "removeEmptyKeys" in helpers}` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1092,7 +1091,7 @@ hlp.removeEmptyKeys({ templateContent := `local context = std.extVar("context"); local helpers = std.extVar("helpers"); helpers.removeEmptyKeys({test: "value", array: ["valid", "", null, "another"]})` // When processing the jsonnet template - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1134,7 +1133,7 @@ func TestJsonnetTemplate_RealShimsIntegration(t *testing.T) { // When processing a simple jsonnet template using real shims templateContent := `local context = std.extVar("context"); { result: "success", hasContext: std.objectHas(context, "name") }` - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1297,7 +1296,7 @@ func TestJsonnetTemplate_processJsonnetTemplateWithHelpers(t *testing.T) { templateContent := `local helpers = std.extVar("helpers"); local context = std.extVar("context"); { result: helpers.getString(context, "dns.domain", "default") }` // When processing the jsonnet template - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1382,7 +1381,7 @@ tags: } // When processing a template that uses helper functions - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1476,7 +1475,7 @@ local context = std.extVar("context"); totallyMissing: helpers.getString(context, "not.there.at.all", "default"), }` - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) // Then no error should be returned if err != nil { @@ -1553,7 +1552,7 @@ local helpers = std.extVar("helpers"); return []byte("contexts:\n mock-context: {}"), nil } - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -1576,7 +1575,7 @@ local helpers = std.extVar("helpers"); return []byte("contexts:\n mock-context: {}"), nil } - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -1599,7 +1598,7 @@ local helpers = std.extVar("helpers"); return []byte("contexts:\n mock-context: {}"), nil } - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -1622,7 +1621,7 @@ local helpers = std.extVar("helpers"); return []byte("contexts:\n mock-context: {}"), nil } - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -1655,7 +1654,7 @@ local helpers = std.extVar("helpers"); return mockVM } - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -1691,7 +1690,7 @@ local context = std.extVar("context"); return []byte("provider: aws"), nil } - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -1715,7 +1714,7 @@ local context = std.extVar("context"); return []byte("{}"), nil } - result, err := template.processJsonnetTemplate(templateContent) + result, err := template.processJsonnetTemplate(templateContent, nil) if err != nil { t.Fatalf("Expected no error, got: %v", err) @@ -1739,7 +1738,7 @@ local context = std.extVar("context"); return []byte("provider: 123"), nil } - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) if err == nil { t.Error("Expected error for wrong type, got none") @@ -1763,7 +1762,7 @@ local context = std.extVar("context"); return []byte("vm:\n cores: \"not-a-number\""), nil } - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) if err == nil { t.Error("Expected error for wrong type, got none") @@ -1787,7 +1786,7 @@ local context = std.extVar("context"); return []byte("feature:\n enabled: \"yes\""), nil } - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) if err == nil { t.Error("Expected error for wrong type, got none") @@ -1811,7 +1810,7 @@ local context = std.extVar("context"); return []byte("cluster: \"not-an-object\""), nil } - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) if err == nil { t.Error("Expected error for wrong type, got none") @@ -1835,7 +1834,7 @@ local context = std.extVar("context"); return []byte("tags: \"not-an-array\""), nil } - _, err := template.processJsonnetTemplate(templateContent) + _, err := template.processJsonnetTemplate(templateContent, nil) if err == nil { t.Error("Expected error for wrong type, got none") @@ -1847,325 +1846,6 @@ 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 only the centralized values template - expected := []string{ - "kustomize/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 the centralized values template (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 - // 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") - } - - // When extracting values references - result := template.extractValuesReferences(renderedData) - - // Then should include only the centralized values template - expected := []string{ - "kustomize/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 // ============================================================================= @@ -2292,11 +1972,11 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { } }) - t.Run("KustomizePatchJsonnet", func(t *testing.T) { - // Given a template and kustomize patch content + t.Run("PatchJsonnet", func(t *testing.T) { + // Given a template and patch content template, _ := setup(t) templateData := map[string][]byte{ - "kustomize/ingress/patch.jsonnet": []byte(`{ apiVersion: "v1", kind: "ConfigMap" }`), + "patches/ingress/patch.jsonnet": []byte(`{ apiVersion: "v1", kind: "ConfigMap" }`), } renderedData := map[string]any{} @@ -2309,15 +1989,15 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { } } - // When processing kustomize patch - err := template.processTemplate("kustomize/ingress/patch.jsonnet", templateData, renderedData) + // When processing patch + err := template.processTemplate("patches/ingress/patch.jsonnet", templateData, renderedData) - // Then should succeed and add kustomize data + // Then should succeed and add patch data if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["kustomize/patches/ingress/patch"] == nil { - t.Error("expected kustomize patch data to be added") + if renderedData["patches/ingress/patch"] == nil { + t.Error("expected patch data to be added") } }) @@ -2329,24 +2009,15 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { } 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 + // When processing global values (should be skipped) err := template.processTemplate("kustomize/values.jsonnet", templateData, renderedData) - // Then should succeed and add values/global data + // Then should succeed but not add any data (skipped) if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["kustomize/values"] == nil { - t.Error("expected kustomize/values data to be added") + if renderedData["kustomize/values"] != nil { + t.Error("expected kustomize/values to be skipped, but data was added") } }) @@ -2358,24 +2029,15 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { } 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 + // When processing component values (should be skipped) err := template.processTemplate("kustomize/ingress/values.jsonnet", templateData, renderedData) - // Then should succeed and add values/ingress data + // Then should succeed but not add any data (skipped) if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["kustomize/values"] == nil { - t.Error("expected kustomize/values data to be added") + if renderedData["kustomize/values"] != nil { + t.Error("expected kustomize/values to be skipped, but data was added") } }) @@ -2387,53 +2049,15 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { } 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 + // When processing values subdirectory (should be skipped) 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["kustomize/values"] == nil { - t.Error("expected kustomize/values 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 + // Then should succeed but not add any data (skipped) if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["values/global"] == nil { - t.Error("expected values data to be added") + if renderedData["kustomize/values"] != nil { + t.Error("expected kustomize/values to be skipped, but data was added") } }) @@ -2512,24 +2136,171 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { } renderedData := map[string]any{} - // Mock jsonnet processing + // When processing complex kustomize values path (should be skipped) + err := template.processTemplate("kustomize/ingress/nginx/values.jsonnet", templateData, renderedData) + + // Then should succeed but not add any data (skipped) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if renderedData["kustomize/values"] != nil { + t.Error("expected kustomize/values to be skipped, but data was added") + } + }) + + t.Run("InjectsValuesDataIntoTemplates", func(t *testing.T) { + // Given a jsonnet template with values data + template, mocks := setup(t) + + // Mock config handler returns basic config + template.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte(`contexts: + test: + name: test-context`), nil + } + + // Mock shell returns project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/test/project", nil + } + + // Mock jsonnet VM to capture context injection (which now includes values) + var capturedContext string + template.shims.NewJsonnetVM = func() JsonnetVM { + mockVM := &mockJsonnetVM{ + EvaluateFunc: func(filename, snippet string) (string, error) { + return `{"processed": true, "hasValues": true}`, nil + }, + } + mockVM.ExtCodeFunc = func(key, val string) { + if key == "context" { + capturedContext = val + } + mockVM.ExtCalls = append(mockVM.ExtCalls, struct{ Key, Val string }{key, val}) + } + return mockVM + } + + // Set up template data with values + templateData := map[string][]byte{ + "blueprint.jsonnet": []byte(`local context = std.extVar("context"); { processed: true, domain: context.common.external_domain }`), + "values": []byte(`common: + external_domain: test.example.com + registry_url: registry.test.example.com`), + } + + renderedData := make(map[string]any) + + // When processing templates + err := template.Process(templateData, renderedData) + + // Then no error should occur + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // And context should have been captured (which now includes values) + if capturedContext == "" { + t.Error("Expected context to be injected into template") + } + + // And context should contain expected values data + if !strings.Contains(capturedContext, "external_domain") { + t.Errorf("Expected context to contain 'external_domain', got: %s", capturedContext) + } + if !strings.Contains(capturedContext, "test.example.com") { + t.Errorf("Expected context to contain 'test.example.com', got: %s", capturedContext) + } + + // And blueprint should be processed + if blueprint, exists := renderedData["blueprint"]; exists { + if blueprintMap, ok := blueprint.(map[string]any); ok { + if processed, ok := blueprintMap["processed"].(bool); !ok || !processed { + t.Error("Expected blueprint to be processed") + } + } + } else { + t.Error("Expected blueprint to be in rendered data") + } + }) + + t.Run("ProcessesSubstitutionJsonnetTemplate", func(t *testing.T) { + // Given a template with substitution.jsonnet + template, mocks := setup(t) + + // Mock config handler returns basic config + template.shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte(`contexts: + test: + name: test-context`), nil + } + + // Mock shell returns project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/test/project", nil + } + + // Mock jsonnet VM to return substitution values template.shims.NewJsonnetVM = func() JsonnetVM { return &mockJsonnetVM{ EvaluateFunc: func(filename, snippet string) (string, error) { - return `{"port":80}`, nil + return `{"common": {"external_domain": "test.example.com", "registry_url": "registry.test.com"}, "app_config": {"replicas": 3}}`, nil }, } } - // When processing complex kustomize values path - err := template.processTemplate("kustomize/ingress/nginx/values.jsonnet", templateData, renderedData) + // Set up template data with substitution.jsonnet + templateData := map[string][]byte{ + "substitution.jsonnet": []byte(`local context = std.extVar("context"); +{ + common: { + external_domain: context.external_domain, + registry_url: context.registry_url + }, + app_config: { + replicas: context.replicas || 3 + } +}`), + } + + renderedData := make(map[string]any) + + // When processing templates + err := template.Process(templateData, renderedData) - // Then should succeed and add the full path as key + // Then no error should occur if err != nil { - t.Fatalf("expected no error, got: %v", err) + t.Fatalf("Expected no error, got: %v", err) } - if renderedData["kustomize/values"] == nil { - t.Error("expected kustomize/values data to be added") + + // And substitution values should be captured in substitution + if substitutionValues, exists := renderedData["substitution"]; exists { + if substitutionMap, ok := substitutionValues.(map[string]any); ok { + // Verify common section + if common, exists := substitutionMap["common"].(map[string]any); exists { + if common["external_domain"] != "test.example.com" { + t.Errorf("Expected external_domain to be 'test.example.com', got %v", common["external_domain"]) + } + if common["registry_url"] != "registry.test.com" { + t.Errorf("Expected registry_url to be 'registry.test.com', got %v", common["registry_url"]) + } + } else { + t.Error("Expected 'common' section to exist in substitution values") + } + + // Verify app_config section + if appConfig, exists := substitutionMap["app_config"].(map[string]any); exists { + if appConfig["replicas"] != float64(3) { + t.Errorf("Expected replicas to be 3, got %v", appConfig["replicas"]) + } + } else { + t.Error("Expected 'app_config' section to exist in substitution values") + } + } else { + t.Error("Expected substitution values to be a map") + } + } else { + t.Error("Expected 'substitution' to exist in rendered data") } }) }