diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index b1f41b437..c99671ac4 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -528,20 +528,22 @@ func (k *Kustomization) ToFluxKustomization(namespace string, defaultSourceName patches := make([]kustomize.Patch, 0, len(k.Patches)) for _, p := range k.Patches { - if p.Patch != "" { - var target *kustomize.Selector - if p.Target != nil { - target = &kustomize.Selector{ - Kind: p.Target.Kind, - Name: p.Target.Name, - Namespace: p.Target.Namespace, - } + patchContent := p.Patch + if patchContent == "" && p.Path == "" { + continue + } + var target *kustomize.Selector + if p.Target != nil { + target = &kustomize.Selector{ + Kind: p.Target.Kind, + Name: p.Target.Name, + Namespace: p.Target.Namespace, } - patches = append(patches, kustomize.Patch{ - Patch: p.Patch, - Target: target, - }) } + patches = append(patches, kustomize.Patch{ + Patch: patchContent, + Target: target, + }) } var postBuild *kustomizev1.PostBuild @@ -625,6 +627,7 @@ func (b *Blueprint) strategicMergeTerraformComponent(component TerraformComponen // strategicMergeKustomization performs a strategic merge of the provided Kustomization into the Blueprint. // It merges unique components and dependencies, updates fields if provided, and maintains dependency order. +// Patches from the provided kustomization are appended to existing patches. // Returns an error if a dependency cycle is detected during sorting. func (b *Blueprint) strategicMergeKustomization(kustomization Kustomization) error { for i, existing := range b.Kustomizations { @@ -649,6 +652,9 @@ func (b *Blueprint) strategicMergeKustomization(kustomization Kustomization) err if kustomization.Destroy != nil { existing.Destroy = kustomization.Destroy } + if len(kustomization.Patches) > 0 { + existing.Patches = append(existing.Patches, kustomization.Patches...) + } b.Kustomizations[i] = existing return b.sortKustomize() } diff --git a/pkg/composer/blueprint/blueprint_handler.go b/pkg/composer/blueprint/blueprint_handler.go index 0342455cf..070709155 100644 --- a/pkg/composer/blueprint/blueprint_handler.go +++ b/pkg/composer/blueprint/blueprint_handler.go @@ -165,6 +165,7 @@ func (b *BaseBlueprintHandler) LoadBlueprint() error { // the file is only written if it does not already exist. The method ensures the target directory exists, // marshals the blueprint to YAML, and writes the file using the configured shims. // Terraform inputs and kustomization substitutions are manually cleared to prevent them from appearing in the final blueprint.yaml. +// Also writes patches from Features and local templates to the context patches directory. func (b *BaseBlueprintHandler) Write(overwrite ...bool) error { shouldOverwrite := false if len(overwrite) > 0 { @@ -178,12 +179,6 @@ func (b *BaseBlueprintHandler) Write(overwrite ...bool) error { yamlPath := filepath.Join(configRoot, "blueprint.yaml") - if !shouldOverwrite { - if _, err := b.shims.Stat(yamlPath); err == nil { - return nil - } - } - if err := b.shims.MkdirAll(filepath.Dir(yamlPath), 0755); err != nil { return fmt.Errorf("error creating directory: %w", err) } @@ -192,10 +187,30 @@ func (b *BaseBlueprintHandler) Write(overwrite ...bool) error { return fmt.Errorf("error setting repository defaults: %w", err) } + for _, kustomization := range b.blueprint.Kustomizations { + strategicMergePatchesToWrite, _ := b.categorizePatches(kustomization) + if len(strategicMergePatchesToWrite) > 0 { + kustomizationWithPatches := kustomization + kustomizationWithPatches.Patches = strategicMergePatchesToWrite + if err := b.writeLocalTemplatePatches(kustomizationWithPatches, shouldOverwrite); err != nil { + return fmt.Errorf("error writing patches to context: %w", err) + } + } + } + + if !shouldOverwrite { + if _, err := b.shims.Stat(yamlPath); err == nil { + return nil + } + } + cleanedBlueprint := b.blueprint.DeepCopy() for i := range cleanedBlueprint.TerraformComponents { cleanedBlueprint.TerraformComponents[i].Inputs = map[string]any{} } + for i := range cleanedBlueprint.Kustomizations { + cleanedBlueprint.Kustomizations[i].Patches = nil + } data, err := b.shims.YamlMarshal(cleanedBlueprint) if err != nil { @@ -267,6 +282,96 @@ func (b *BaseBlueprintHandler) Generate() *blueprintv1alpha1.Blueprint { if subs, exists := b.featureSubstitutions[generated.Kustomizations[i].Name]; exists { generated.Kustomizations[i].Substitutions = maps.Clone(subs) } + + configRoot := b.runtime.ConfigRoot + if configRoot != "" { + patchesDir := filepath.Join(configRoot, "patches", generated.Kustomizations[i].Name) + if _, err := b.shims.Stat(patchesDir); err == nil { + var discoveredPatches []blueprintv1alpha1.BlueprintPatch + err := b.shims.Walk(patchesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(strings.ToLower(info.Name()), ".yaml") && !strings.HasSuffix(strings.ToLower(info.Name()), ".yml") { + return nil + } + data, err := b.shims.ReadFile(path) + if err != nil { + return nil + } + patchContent := string(data) + relPath, err := filepath.Rel(patchesDir, path) + if err != nil { + return nil + } + defaultNamespace := b.runtime.ConfigHandler.GetContext() + isJSON6902, target := func() (bool, *kustomize.Selector) { + decoder := yaml.NewDecoder(strings.NewReader(patchContent)) + for { + var doc map[string]any + if err := decoder.Decode(&doc); err != nil { + if err == io.EOF { + break + } + continue + } + if doc == nil { + continue + } + hasAPIVersion := false + hasKind := false + hasMetadata := false + if _, ok := doc["apiVersion"]; ok { + hasAPIVersion = true + } + if _, ok := doc["kind"]; ok { + hasKind = true + } + if _, ok := doc["metadata"]; ok { + hasMetadata = true + } + if hasAPIVersion && hasKind && hasMetadata { + for key, v := range doc { + if key == "patch" || key == "patches" { + if arr, ok := v.([]any); ok { + if len(arr) > 0 { + if firstItem, ok := arr[0].(map[string]any); ok { + if _, ok := firstItem["op"].(string); ok { + if _, hasPath := firstItem["path"]; hasPath { + return true, b.extractTargetFromPatchData(doc, defaultNamespace) + } + } + } + } + } + } + } + return false, nil + } + } + return false, nil + }() + patch := blueprintv1alpha1.BlueprintPatch{ + Path: relPath, + Patch: patchContent, + } + if isJSON6902 && target != nil { + patch.Target = target + } + discoveredPatches = append(discoveredPatches, patch) + return nil + }) + if err == nil && len(discoveredPatches) > 0 { + for j := range discoveredPatches { + discoveredPatches[j].Path = "" + } + generated.Kustomizations[i].Patches = append(generated.Kustomizations[i].Patches, discoveredPatches...) + } + } + } } mergedCommonValues := make(map[string]string) @@ -698,6 +803,16 @@ func (b *BaseBlueprintHandler) processFeatures(templateData map[string][]byte, c maps.Copy(b.featureSubstitutions[kustomizationCopy.Name], evaluatedSubstitutions) } + for j := range kustomizationCopy.Patches { + if kustomizationCopy.Patches[j].Patch != "" { + evaluated, err := b.featureEvaluator.InterpolateString(kustomizationCopy.Patches[j].Patch, config, feature.Path) + if err != nil { + return fmt.Errorf("failed to evaluate patch for kustomization '%s': %w", kustomizationCopy.Name, err) + } + kustomizationCopy.Patches[j].Patch = evaluated + } + } + // Clear substitutions as they are used for ConfigMap generation and should not appear in the final blueprint kustomizationCopy.Substitutions = nil @@ -1056,6 +1171,182 @@ func (b *BaseBlueprintHandler) isOCISource(sourceNameOrURL string) bool { return false } +// categorizePatches categorizes patches into strategic merge patches to write and inline patches to keep in-memory. +// Returns strategic merge patches to write and inline patches (JSON 6902 or OCI patches). +func (b *BaseBlueprintHandler) categorizePatches(kustomization blueprintv1alpha1.Kustomization) ([]blueprintv1alpha1.BlueprintPatch, []blueprintv1alpha1.BlueprintPatch) { + strategicMergePatchesToWrite := make([]blueprintv1alpha1.BlueprintPatch, 0) + inlinePatches := make([]blueprintv1alpha1.BlueprintPatch, 0) + + for _, patch := range kustomization.Patches { + isLocalTemplatePatch := false + if patch.Path != "" && !strings.HasPrefix(patch.Path, "patches/") { + patchFilePath := filepath.Join(b.runtime.TemplateRoot, patch.Path) + if _, err := b.shims.Stat(patchFilePath); err == nil { + isLocalTemplatePatch = true + } + } + + if patch.Target != nil { + if isLocalTemplatePatch && patch.Patch == "" && patch.Path != "" { + patchFilePath := filepath.Join(b.runtime.TemplateRoot, patch.Path) + data, err := b.shims.ReadFile(patchFilePath) + if err == nil { + patch.Patch = string(data) + patch.Path = "" + } + } + inlinePatches = append(inlinePatches, patch) + } else if patch.Patch != "" { + strategicMergePatchesToWrite = append(strategicMergePatchesToWrite, patch) + } else if isLocalTemplatePatch { + strategicMergePatchesToWrite = append(strategicMergePatchesToWrite, patch) + } else { + inlinePatches = append(inlinePatches, patch) + } + } + + return strategicMergePatchesToWrite, inlinePatches +} + +// writeLocalTemplatePatches writes patches from local _template to the context patches directory. +// Patches are written to contexts//patches// as individual YAML files. +// Each patch file is named using kind-name.yaml format extracted from the patch metadata. +// Patch content is evaluated (jsonnet expressions are processed) before writing. +// If overwrite is false, existing patch files are not overwritten. +// Returns an error if directory creation or file writing fails. +func (b *BaseBlueprintHandler) writeLocalTemplatePatches(kustomization blueprintv1alpha1.Kustomization, overwrite bool) error { + if len(kustomization.Patches) == 0 { + return nil + } + + configRoot := b.runtime.ConfigRoot + if configRoot == "" { + return nil + } + + patchesDir := filepath.Join(configRoot, "patches", kustomization.Name) + if err := b.shims.MkdirAll(patchesDir, 0755); err != nil { + return fmt.Errorf("failed to create patches directory: %w", err) + } + + config := make(map[string]any) + contextValues, err := b.runtime.ConfigHandler.GetContextValues() + if err == nil { + for k, v := range contextValues { + if k != "substitutions" { + config[k] = v + } + } + } + + patchMap := make(map[string]string) + for i, patch := range kustomization.Patches { + if patch.Path == "" && patch.Patch == "" { + continue + } + + var patchContent string + + if patch.Patch != "" { + patchContent = patch.Patch + } else if patch.Path != "" { + patchFileFullPath := filepath.Join(b.runtime.TemplateRoot, patch.Path) + data, err := b.shims.ReadFile(patchFileFullPath) + if err != nil { + continue + } + patchContent = string(data) + evaluated, err := b.featureEvaluator.InterpolateString(patchContent, config, filepath.Dir(patchFileFullPath)) + if err != nil { + return fmt.Errorf("failed to evaluate patch file %s: %w", patch.Path, err) + } + patchContent = evaluated + } else { + continue + } + + if strings.TrimSpace(patchContent) == "" { + continue + } + + var patchFileName string + var doc map[string]any + if err := b.shims.YamlUnmarshal([]byte(patchContent), &doc); err == nil { + if kind, ok := doc["kind"].(string); ok { + if metadata, ok := doc["metadata"].(map[string]any); ok { + if name, ok := metadata["name"].(string); ok { + filename := fmt.Sprintf("%s-%s.yaml", strings.ToLower(kind), name) + invalidChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} + for _, char := range invalidChars { + filename = strings.ReplaceAll(filename, char, "-") + } + patchFileName = filename + } + } + } + } + if patchFileName == "" { + patchFileName = fmt.Sprintf("%d.yaml", i) + } + existingContent, exists := patchMap[patchFileName] + if exists { + var existingDoc, newDoc map[string]any + if err := b.shims.YamlUnmarshal([]byte(existingContent), &existingDoc); err != nil { + return fmt.Errorf("failed to parse existing patch: %w", err) + } + if err := b.shims.YamlUnmarshal([]byte(patchContent), &newDoc); err != nil { + return fmt.Errorf("failed to parse new patch: %w", err) + } + existingKind, existingKindOk := existingDoc["kind"].(string) + var existingName string + var existingNameOk bool + if metadata, ok := existingDoc["metadata"].(map[string]any); ok { + if name, ok := metadata["name"].(string); ok { + existingName = name + existingNameOk = true + } + } + newKind, newKindOk := newDoc["kind"].(string) + var newName string + var newNameOk bool + if metadata, ok := newDoc["metadata"].(map[string]any); ok { + if name, ok := metadata["name"].(string); ok { + newName = name + newNameOk = true + } + } + if existingKindOk && existingNameOk && newKindOk && newNameOk && + existingKind == newKind && existingName == newName { + merged := b.deepMergeMaps(existingDoc, newDoc) + mergedYAML, err := b.shims.YamlMarshal(merged) + if err != nil { + return fmt.Errorf("failed to marshal merged patch: %w", err) + } + patchMap[patchFileName] = strings.TrimSpace(string(mergedYAML)) + } else { + patchMap[patchFileName] = strings.TrimSpace(existingContent) + "\n---\n" + strings.TrimSpace(patchContent) + } + } else { + patchMap[patchFileName] = strings.TrimSpace(patchContent) + } + } + + for patchFileName, patchContent := range patchMap { + patchFilePath := filepath.Join(patchesDir, patchFileName) + if !overwrite { + if _, err := b.shims.Stat(patchFilePath); err == nil { + continue + } + } + + if err := b.shims.WriteFile(patchFilePath, []byte(patchContent), 0644); err != nil { + return fmt.Errorf("failed to write patch file %s: %w", patchFilePath, err) + } + } + + return nil +} + // mergeLegacySpecialVariables merges legacy special variables into the common values map for backward compatibility. // These variables were previously extracted from config and kustomize/values.yaml and are now merged from the config handler. // This method can be removed when legacy variable support is no longer needed. diff --git a/pkg/composer/blueprint/blueprint_handler_private_test.go b/pkg/composer/blueprint/blueprint_handler_private_test.go index f6048285e..2fb46028c 100644 --- a/pkg/composer/blueprint/blueprint_handler_private_test.go +++ b/pkg/composer/blueprint/blueprint_handler_private_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/fluxcd/pkg/apis/kustomize" "github.com/goccy/go-yaml" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/composer/artifact" @@ -3103,8 +3104,8 @@ terraform: `) templateData := map[string][]byte{ - "blueprint": baseBlueprint, - "features/replace.yaml": replaceFeature, + "blueprint": baseBlueprint, + "features/replace.yaml": replaceFeature, } config := map[string]any{} @@ -3170,8 +3171,8 @@ terraform: `) templateData := map[string][]byte{ - "blueprint": baseBlueprint, - "features/merge.yaml": mergeFeature, + "blueprint": baseBlueprint, + "features/merge.yaml": mergeFeature, } config := map[string]any{} @@ -3235,8 +3236,8 @@ kustomize: `) templateData := map[string][]byte{ - "blueprint": baseBlueprint, - "features/replace.yaml": replaceFeature, + "blueprint": baseBlueprint, + "features/replace.yaml": replaceFeature, } config := map[string]any{} @@ -3304,8 +3305,8 @@ kustomize: `) templateData := map[string][]byte{ - "blueprint": baseBlueprint, - "features/merge.yaml": mergeFeature, + "blueprint": baseBlueprint, + "features/merge.yaml": mergeFeature, } config := map[string]any{} @@ -4288,3 +4289,851 @@ func TestNewShims(t *testing.T) { } }) } + +func TestBaseBlueprintHandler_writeLocalTemplatePatches(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler, mocks + } + + t.Run("WritesPatchWithKindAndName", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + mocks.Runtime.TemplateRoot = "/test/template" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key: value`, + }, + }, + } + + var writtenPath string + var writtenContent []byte + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenPath = name + writtenContent = data + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + mocks.Shims.YamlMarshal = yaml.Marshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + expectedPath := filepath.Join(mocks.Runtime.ConfigRoot, "patches", "test-kustomization", "configmap-test-config.yaml") + if filepath.ToSlash(writtenPath) != filepath.ToSlash(expectedPath) { + t.Errorf("Expected patch file at %s, got %s", expectedPath, writtenPath) + } + + if len(writtenContent) == 0 { + t.Error("Expected patch content to be written") + } + }) + + t.Run("HandlesEmptyPatches", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{}, + } + + var writeFileCalled bool + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writeFileCalled = true + } + return nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if writeFileCalled { + t.Error("Expected WriteFile not to be called for empty patches") + } + }) + + t.Run("HandlesEmptyConfigRoot", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, + }, + }, + } + + var writeFileCalled bool + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writeFileCalled = true + } + return nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if writeFileCalled { + t.Error("Expected WriteFile not to be called when ConfigRoot is empty") + } + }) + + t.Run("HandlesMkdirAllError", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, + }, + }, + } + + expectedError := fmt.Errorf("mkdir error") + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + normalizedPath := filepath.ToSlash(path) + if strings.Contains(normalizedPath, "patches/") { + return expectedError + } + return nil + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err == nil { + t.Fatal("Expected error from MkdirAll, got nil") + } + + if !strings.Contains(err.Error(), "failed to create patches directory") { + t.Errorf("Expected error about creating patches directory, got: %v", err) + } + }) + + t.Run("HandlesWriteFileError", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, + }, + }, + } + + expectedError := fmt.Errorf("write file error") + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + return expectedError + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err == nil { + t.Fatal("Expected error from WriteFile, got nil") + } + + if !strings.Contains(err.Error(), "failed to write patch file") { + t.Errorf("Expected error about writing patch file, got: %v", err) + } + }) + + t.Run("RespectsOverwriteFlag", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, + }, + }, + } + + var writeFileCalled bool + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writeFileCalled = true + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + return &mockFileInfo{name: "configmap-test-config.yaml", isDir: false}, nil + } + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, false) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if writeFileCalled { + t.Error("Expected WriteFile not to be called when overwrite is false and file exists") + } + + writeFileCalled = false + err = handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if !writeFileCalled { + t.Error("Expected WriteFile to be called when overwrite is true") + } + }) + + t.Run("EvaluatesPatchContentFromPath", func(t *testing.T) { + handler, mocks := setup(t) + tmpDir := t.TempDir() + mocks.Runtime.ConfigRoot = "/test/config" + mocks.Runtime.TemplateRoot = tmpDir + + patchFile := filepath.Join(tmpDir, "kustomize", "patches", "test-patch.yaml") + if err := os.MkdirAll(filepath.Dir(patchFile), 0755); err != nil { + t.Fatalf("Failed to create patch directory: %v", err) + } + + patchContent := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config` + if err := os.WriteFile(patchFile, []byte(patchContent), 0644); err != nil { + t.Fatalf("Failed to write patch file: %v", err) + } + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/patches/test-patch.yaml", + }, + }, + } + + var writtenContent []byte + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenContent = data + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.ReadFile = os.ReadFile + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + mocks.Shims.YamlMarshal = yaml.Marshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(writtenContent) == 0 { + t.Error("Expected patch content to be written") + } + + if !strings.Contains(string(writtenContent), "test-config") { + t.Error("Expected written content to contain patch data") + } + }) + + t.Run("MergesPatchesWithSameKindAndName", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key1: value1`, + }, + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key2: value2`, + }, + }, + } + + var writtenContent []byte + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenContent = data + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + mocks.Shims.YamlMarshal = yaml.Marshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(writtenContent) == 0 { + t.Fatal("Expected merged patch content to be written") + } + + var mergedDoc map[string]any + if err := yaml.Unmarshal(writtenContent, &mergedDoc); err != nil { + t.Fatalf("Failed to unmarshal merged patch: %v", err) + } + + if data, ok := mergedDoc["data"].(map[string]any); ok { + if data["key1"] != "value1" { + t.Error("Expected key1 to be in merged patch") + } + if data["key2"] != "value2" { + t.Error("Expected key2 to be in merged patch") + } + } else { + t.Error("Expected data section in merged patch") + } + }) + + t.Run("AppendsPatchesWithDifferentKindOrName", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: config-1 +data: + key1: value1`, + }, + { + Patch: `apiVersion: v1 +kind: Deployment +metadata: + name: deployment-1 +spec: + replicas: 3`, + }, + }, + } + + var writtenFiles = make(map[string][]byte) + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenFiles[name] = data + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + mocks.Shims.YamlMarshal = yaml.Marshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(writtenFiles) == 0 { + t.Fatal("Expected patch files to be written") + } + + foundConfigMap := false + foundDeployment := false + for path, content := range writtenFiles { + contentStr := string(content) + if strings.Contains(path, "configmap-config-1.yaml") { + foundConfigMap = true + if !strings.Contains(contentStr, "config-1") { + t.Error("Expected config-1 patch content") + } + } + if strings.Contains(path, "deployment-deployment-1.yaml") { + foundDeployment = true + if !strings.Contains(contentStr, "deployment-1") { + t.Error("Expected deployment-1 patch content") + } + } + } + + if !foundConfigMap { + t.Error("Expected ConfigMap patch file to be written") + } + if !foundDeployment { + t.Error("Expected Deployment patch file to be written") + } + }) + + t.Run("SanitizesInvalidFilenameCharacters", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test/config:name*with?invalid"chars<| +data: + key: value`, + }, + }, + } + + var writtenPath string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenPath = name + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if writtenPath == "" { + t.Fatal("Expected patch file to be written") + } + + fileName := filepath.Base(writtenPath) + invalidChars := []string{"/", "\\", ":", "*", "?", "\"", "<", "|"} + for _, char := range invalidChars { + if strings.Contains(fileName, char) { + t.Errorf("Expected invalid character '%s' to be sanitized from filename, got: %s", char, fileName) + } + } + + if !strings.Contains(fileName, "configmap-test") { + t.Errorf("Expected sanitized filename to contain 'configmap-test', got: %s", fileName) + } + + if !strings.HasSuffix(fileName, ".yaml") { + t.Errorf("Expected filename to end with .yaml, got: %s", fileName) + } + }) + + t.Run("SkipsPatchWhenReadFileFails", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + mocks.Runtime.TemplateRoot = "/test/template" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/patches/test-patch.yaml", + }, + }, + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/test-patch.yaml") { + return nil, fmt.Errorf("read file error") + } + return nil, os.ErrNotExist + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + var writeFileCalled bool + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writeFileCalled = true + } + return nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error (patch skipped), got %v", err) + } + + if writeFileCalled { + t.Error("Expected WriteFile not to be called when patch file cannot be read") + } + }) + + t.Run("UsesIndexWhenNoMetadata", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +data: + key: value`, + }, + }, + } + + var writtenPath string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenPath = name + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlUnmarshal = yaml.Unmarshal + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + err := handler.writeLocalTemplatePatches(kustomization, true) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if !strings.Contains(writtenPath, "0.yaml") { + t.Errorf("Expected filename with index 0, got: %s", filepath.Base(writtenPath)) + } + }) +} + +func TestBaseBlueprintHandler_categorizePatches(t *testing.T) { + setup := func(t *testing.T) (*BaseBlueprintHandler, *BlueprintTestMocks) { + t.Helper() + mocks := setupBlueprintMocks(t) + mockArtifactBuilder := artifact.NewMockArtifact() + handler, err := NewBlueprintHandler(mocks.Runtime, mockArtifactBuilder) + if err != nil { + t.Fatalf("NewBlueprintHandler() failed: %v", err) + } + handler.shims = mocks.Shims + return handler, mocks + } + + t.Run("CategorizesStrategicMergePatches", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.TemplateRoot = "/test/template" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, + }, + }, + } + + strategicMerge, inline := handler.categorizePatches(kustomization) + + if len(strategicMerge) != 1 { + t.Errorf("Expected 1 strategic merge patch, got %d", len(strategicMerge)) + } + + if len(inline) != 0 { + t.Errorf("Expected 0 inline patches, got %d", len(inline)) + } + }) + + t.Run("CategorizesInlinePatches", func(t *testing.T) { + handler, _ := setup(t) + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Target: &kustomize.Selector{ + Kind: "ConfigMap", + Name: "test-config", + }, + Patch: `[{"op": "replace", "path": "/data/key", "value": "newvalue"}]`, + }, + }, + } + + strategicMerge, inline := handler.categorizePatches(kustomization) + + if len(strategicMerge) != 0 { + t.Errorf("Expected 0 strategic merge patches, got %d", len(strategicMerge)) + } + + if len(inline) != 1 { + t.Errorf("Expected 1 inline patch, got %d", len(inline)) + } + }) + + t.Run("CategorizesJSON6902Patches", func(t *testing.T) { + handler, _ := setup(t) + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Target: &kustomize.Selector{ + Kind: "Deployment", + Name: "test-deployment", + Namespace: "default", + }, + Patch: `[{"op": "replace", "path": "/spec/replicas", "value": 5}]`, + }, + }, + } + + strategicMerge, inline := handler.categorizePatches(kustomization) + + if len(strategicMerge) != 0 { + t.Errorf("Expected 0 strategic merge patches, got %d", len(strategicMerge)) + } + + if len(inline) != 1 { + t.Errorf("Expected 1 inline patch, got %d", len(inline)) + } + + if inline[0].Target == nil { + t.Error("Expected target to be set for JSON6902 patch") + } + }) + + t.Run("CategorizesLocalTemplatePatches", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.TemplateRoot = "/test/template" + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Path: "kustomize/patches/test-patch.yaml", + }, + }, + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "template/kustomize/patches/test-patch.yaml") { + return &mockFileInfo{name: "test-patch.yaml", isDir: false}, nil + } + return nil, os.ErrNotExist + } + + strategicMerge, inline := handler.categorizePatches(kustomization) + + if len(strategicMerge) != 1 { + t.Errorf("Expected 1 strategic merge patch for local template, got %d", len(strategicMerge)) + } + + if len(inline) != 0 { + t.Errorf("Expected 0 inline patches, got %d", len(inline)) + } + }) + + t.Run("HandlesEmptyPatches", func(t *testing.T) { + handler, _ := setup(t) + + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{}, + } + + strategicMerge, inline := handler.categorizePatches(kustomization) + + if len(strategicMerge) != 0 { + t.Errorf("Expected 0 strategic merge patches, got %d", len(strategicMerge)) + } + + if len(inline) != 0 { + t.Errorf("Expected 0 inline patches, got %d", len(inline)) + } + }) +} diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index 1ee53bb9f..9f1c208d4 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -11,6 +11,7 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + "github.com/fluxcd/pkg/apis/kustomize" "github.com/goccy/go-yaml" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/composer/artifact" @@ -2055,6 +2056,153 @@ func TestBlueprintHandler_Write(t *testing.T) { t.Errorf("Expected error from WriteFile, got nil") } }) + + t.Run("WritesStrategicMergePatchesToDisk", func(t *testing.T) { + // Given a blueprint handler with kustomization containing strategic merge patches + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + mocks.Runtime.TemplateRoot = "/test/template" + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Kind: "Blueprint", + ApiVersion: "v1alpha1", + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key: value`, + }, + }, + }, + }, + } + + var writtenPatchPaths []string + var writtenPatchContents []string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenPatchPaths = append(writtenPatchPaths, name) + writtenPatchContents = append(writtenPatchContents, string(data)) + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + if strings.Contains(name, "blueprint.yaml") { + return nil, os.ErrNotExist + } + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("kind: Blueprint\n"), nil + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), 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 patch file should be written + if len(writtenPatchPaths) == 0 { + t.Error("Expected patch file to be written, but no patch files were written") + } + + expectedPatchPath := filepath.Join(mocks.Runtime.ConfigRoot, "patches", "test-kustomization", "configmap-test-config.yaml") + found := false + for _, path := range writtenPatchPaths { + if filepath.ToSlash(path) == filepath.ToSlash(expectedPatchPath) { + found = true + break + } + } + if !found { + t.Errorf("Expected patch file to be written at %s, but got: %v", expectedPatchPath, writtenPatchPaths) + } + }) + + t.Run("SkipsWritingWhenNoStrategicMergePatches", func(t *testing.T) { + // Given a blueprint handler with kustomization containing only inline patches + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Kind: "Blueprint", + ApiVersion: "v1alpha1", + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Target: &kustomize.Selector{ + Kind: "ConfigMap", + Name: "test-config", + }, + Patch: `[{"op": "replace", "path": "/data/key", "value": "newvalue"}]`, + }, + }, + }, + }, + } + + var writtenPatchPaths []string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writtenPatchPaths = append(writtenPatchPaths, name) + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return nil + } + + mocks.Shims.YamlMarshal = func(v any) ([]byte, error) { + return []byte("kind: 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 no patch files should be written + if len(writtenPatchPaths) != 0 { + t.Errorf("Expected no patch files to be written, but got: %v", writtenPatchPaths) + } + }) } func TestBlueprintHandler_LoadBlueprint(t *testing.T) { @@ -4626,4 +4774,406 @@ csi: t.Errorf("Expected storage_class 'fast-ssd' from context, got '%s'", csiKustomization.Substitutions["storage_class"]) } }) + + t.Run("DoesNotWritePatchesDuringGeneration", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "/test/config" + mocks.Runtime.TemplateRoot = "/test/template" + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + key: value`, + }, + }, + }, + }, + } + + var writeFileCalls []string + mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/") { + writeFileCalls = append(writeFileCalls, name) + } + return nil + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return make(map[string]any), nil + } + + generated := handler.Generate() + + if generated == nil { + t.Fatal("Expected non-nil generated blueprint") + } + + if len(writeFileCalls) > 0 { + t.Errorf("Expected no patch files to be written during Generate(), but got %d writes: %v", len(writeFileCalls), writeFileCalls) + } + + if len(generated.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(generated.Kustomizations)) + } + + if len(generated.Kustomizations[0].Patches) == 0 { + t.Error("Expected patches to be preserved in generated blueprint") + } + }) + + t.Run("DiscoversPatchesFromDisk", func(t *testing.T) { + handler, mocks := setup(t) + tmpDir := t.TempDir() + mocks.Runtime.ConfigRoot = filepath.Join(tmpDir, "contexts", "test-context") + patchesDir := filepath.Join(mocks.Runtime.ConfigRoot, "patches", "test-kustomization") + if err := os.MkdirAll(patchesDir, 0755); err != nil { + t.Fatalf("Failed to create patches directory: %v", err) + } + + patchContent := `apiVersion: v1 +kind: ConfigMap +metadata: + name: discovered-config +data: + key: value` + patchFile := filepath.Join(patchesDir, "configmap-discovered-config.yaml") + if err := os.WriteFile(patchFile, []byte(patchContent), 0644); err != nil { + t.Fatalf("Failed to write patch file: %v", err) + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/test-kustomization") { + return &mockFileInfo{name: "test-kustomization", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + mocks.Shims.Walk = filepath.Walk + mocks.Shims.ReadFile = os.ReadFile + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextFunc = func() string { + return "test-context" + } + + generated := handler.Generate() + + if generated == nil { + t.Fatal("Expected non-nil generated blueprint") + } + + if len(generated.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(generated.Kustomizations)) + } + + if len(generated.Kustomizations[0].Patches) == 0 { + t.Error("Expected discovered patches to be added to kustomization") + } + + found := false + for _, patch := range generated.Kustomizations[0].Patches { + if strings.Contains(patch.Patch, "discovered-config") { + found = true + if patch.Path != "" { + t.Error("Expected discovered patch Path to be cleared") + } + break + } + } + if !found { + t.Error("Expected to find discovered patch in generated blueprint") + } + }) + + t.Run("AppliesFeatureSubstitutions", func(t *testing.T) { + handler, _ := setup(t) + handler.featureSubstitutions = map[string]map[string]string{ + "test-kustomization": { + "domain": "example.com", + "region": "us-west-2", + }, + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } + + generated := handler.Generate() + + if generated == nil { + t.Fatal("Expected non-nil generated blueprint") + } + + if len(generated.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(generated.Kustomizations)) + } + + subs := generated.Kustomizations[0].Substitutions + if subs == nil { + t.Fatal("Expected substitutions to be set") + } + + if subs["domain"] != "example.com" { + t.Errorf("Expected domain 'example.com', got '%s'", subs["domain"]) + } + + if subs["region"] != "us-west-2" { + t.Errorf("Expected region 'us-west-2', got '%s'", subs["region"]) + } + }) + + t.Run("DiscoversJSON6902Patches", func(t *testing.T) { + handler, mocks := setup(t) + tmpDir := t.TempDir() + mocks.Runtime.ConfigRoot = filepath.Join(tmpDir, "contexts", "test-context") + patchesDir := filepath.Join(mocks.Runtime.ConfigRoot, "patches", "test-kustomization") + if err := os.MkdirAll(patchesDir, 0755); err != nil { + t.Fatalf("Failed to create patches directory: %v", err) + } + + json6902PatchContent := `apiVersion: v1 +kind: Deployment +metadata: + name: test-deployment + namespace: default +patch: +- op: replace + path: /spec/replicas + value: 5` + patchFile := filepath.Join(patchesDir, "deployment-patch.yaml") + if err := os.WriteFile(patchFile, []byte(json6902PatchContent), 0644); err != nil { + t.Fatalf("Failed to write patch file: %v", err) + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/test-kustomization") { + return &mockFileInfo{name: "test-kustomization", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + mocks.Shims.Walk = filepath.Walk + mocks.Shims.ReadFile = os.ReadFile + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextFunc = func() string { + return "test-context" + } + + generated := handler.Generate() + + if generated == nil { + t.Fatal("Expected non-nil generated blueprint") + } + + if len(generated.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(generated.Kustomizations)) + } + + found := false + for _, patch := range generated.Kustomizations[0].Patches { + if patch.Target != nil { + found = true + if patch.Target.Kind != "Deployment" { + t.Errorf("Expected target kind 'Deployment', got '%s'", patch.Target.Kind) + } + if patch.Target.Name != "test-deployment" { + t.Errorf("Expected target name 'test-deployment', got '%s'", patch.Target.Name) + } + break + } + } + if !found { + t.Error("Expected to find JSON6902 patch with target in generated blueprint") + } + }) + + t.Run("HandlesPatchDiscoveryErrors", func(t *testing.T) { + handler, mocks := setup(t) + tmpDir := t.TempDir() + mocks.Runtime.ConfigRoot = filepath.Join(tmpDir, "contexts", "test-context") + patchesDir := filepath.Join(mocks.Runtime.ConfigRoot, "patches", "test-kustomization") + if err := os.MkdirAll(patchesDir, 0755); err != nil { + t.Fatalf("Failed to create patches directory: %v", err) + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } + + walkError := fmt.Errorf("walk error") + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/test-kustomization") { + return &mockFileInfo{name: "patches", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + mocks.Shims.Walk = func(root string, fn filepath.WalkFunc) error { + return walkError + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextFunc = func() string { + return "test-context" + } + + generated := handler.Generate() + + if generated == nil { + t.Fatal("Expected non-nil generated blueprint") + } + + if len(generated.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(generated.Kustomizations)) + } + + if len(generated.Kustomizations[0].Patches) != 0 { + t.Error("Expected no patches when Walk returns error") + } + }) + + t.Run("SkipsNonYamlFilesInPatchDiscovery", func(t *testing.T) { + handler, mocks := setup(t) + tmpDir := t.TempDir() + mocks.Runtime.ConfigRoot = filepath.Join(tmpDir, "contexts", "test-context") + patchesDir := filepath.Join(mocks.Runtime.ConfigRoot, "patches", "test-kustomization") + if err := os.MkdirAll(patchesDir, 0755); err != nil { + t.Fatalf("Failed to create patches directory: %v", err) + } + + patchContent := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config` + if err := os.WriteFile(filepath.Join(patchesDir, "configmap-test-config.yaml"), []byte(patchContent), 0644); err != nil { + t.Fatalf("Failed to write patch file: %v", err) + } + + if err := os.WriteFile(filepath.Join(patchesDir, "not-a-patch.txt"), []byte("not yaml"), 0644); err != nil { + t.Fatalf("Failed to write non-patch file: %v", err) + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } + + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + normalizedName := filepath.ToSlash(name) + if strings.Contains(normalizedName, "patches/test-kustomization") { + return &mockFileInfo{name: "test-kustomization", isDir: true}, nil + } + return nil, os.ErrNotExist + } + + mocks.Shims.Walk = filepath.Walk + mocks.Shims.ReadFile = os.ReadFile + + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextFunc = func() string { + return "test-context" + } + + generated := handler.Generate() + + if generated == nil { + t.Fatal("Expected non-nil generated blueprint") + } + + if len(generated.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(generated.Kustomizations)) + } + + if len(generated.Kustomizations[0].Patches) != 1 { + t.Errorf("Expected 1 discovered patch (yaml file only), got %d", len(generated.Kustomizations[0].Patches)) + } + }) + + t.Run("HandlesEmptyConfigRoot", func(t *testing.T) { + handler, mocks := setup(t) + mocks.Runtime.ConfigRoot = "" + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } + + generated := handler.Generate() + + if generated == nil { + t.Fatal("Expected non-nil generated blueprint") + } + + if len(generated.Kustomizations) != 1 { + t.Fatalf("Expected 1 kustomization, got %d", len(generated.Kustomizations)) + } + + if len(generated.Kustomizations[0].Patches) != 0 { + t.Error("Expected no patches when ConfigRoot is empty") + } + }) } diff --git a/pkg/composer/blueprint/feature_evaluator.go b/pkg/composer/blueprint/feature_evaluator.go index cdf4cf903..b6db79255 100644 --- a/pkg/composer/blueprint/feature_evaluator.go +++ b/pkg/composer/blueprint/feature_evaluator.go @@ -331,7 +331,7 @@ func (e *FeatureEvaluator) evaluateDefaultValue(value any, config map[string]any return e.EvaluateValue(expr, config, featurePath) } if strings.Contains(v, "${") { - return e.interpolateString(v, config, featurePath) + return e.InterpolateString(v, config, featurePath) } return v, nil @@ -385,8 +385,9 @@ func (e *FeatureEvaluator) extractExpression(s string) string { return "" } -// interpolateString replaces all ${expression} occurrences in a string with their evaluated values. -func (e *FeatureEvaluator) interpolateString(s string, config map[string]any, featurePath string) (string, error) { +// InterpolateString replaces all ${expression} occurrences in a string with their evaluated values. +// This is used to process template expressions in patch content and other string values. +func (e *FeatureEvaluator) InterpolateString(s string, config map[string]any, featurePath string) (string, error) { result := s for strings.Contains(result, "${") { @@ -409,7 +410,16 @@ func (e *FeatureEvaluator) interpolateString(s string, config map[string]any, fe if value == nil { replacement = "" } else { - replacement = fmt.Sprintf("%v", value) + switch value.(type) { + case map[string]any, []any: + yamlData, err := e.shims.YamlMarshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal expression result to YAML: %w", err) + } + replacement = strings.TrimSpace(string(yamlData)) + default: + replacement = fmt.Sprintf("%v", value) + } } result = result[:start] + replacement + result[end+1:]