diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index fb204f24a..733daee61 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "maps" "os" "os/signal" "path/filepath" @@ -300,12 +301,6 @@ func (b *BaseBlueprintHandler) Install() error { } } - if err := b.applyConfigMap(); err != nil { - spin.Stop() - fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("failed to apply configmap: %w", err) - } - if err := b.applyValuesConfigMaps(); err != nil { spin.Stop() fmt.Fprintf(os.Stderr, "✗%s - \033[31mFailed\033[0m\n", spin.Suffix) @@ -414,7 +409,7 @@ func (b *BaseBlueprintHandler) GetKustomizations() []blueprintv1alpha1.Kustomiza SubstituteFrom: []blueprintv1alpha1.SubstituteReference{ { Kind: "ConfigMap", - Name: "blueprint", + Name: "values-common", Optional: false, }, }, @@ -958,40 +953,6 @@ func (b *BaseBlueprintHandler) applyOCIRepository(source blueprintv1alpha1.Sourc return b.kubernetesManager.ApplyOCIRepository(ociRepo) } -// applyConfigMap creates or updates a ConfigMap in the cluster containing context-specific -// configuration values used by the blueprint's resources, such as domain names, IP ranges, -// and volume paths. -func (b *BaseBlueprintHandler) applyConfigMap() error { - domain := b.configHandler.GetString("dns.domain") - context := b.configHandler.GetContext() - lbStart := b.configHandler.GetString("network.loadbalancer_ips.start") - lbEnd := b.configHandler.GetString("network.loadbalancer_ips.end") - registryURL := b.configHandler.GetString("docker.registry_url") - localVolumePaths := b.configHandler.GetStringSlice("cluster.workers.volumes") - - loadBalancerIPRange := fmt.Sprintf("%s-%s", lbStart, lbEnd) - - var localVolumePath string - if len(localVolumePaths) > 0 { - localVolumePath = strings.Split(localVolumePaths[0], ":")[1] - } else { - localVolumePath = "" - } - - data := map[string]string{ - "DOMAIN": domain, - "CONTEXT": context, - "CONTEXT_ID": b.configHandler.GetString("id"), - "LOADBALANCER_IP_RANGE": loadBalancerIPRange, - "LOADBALANCER_IP_START": lbStart, - "LOADBALANCER_IP_END": lbEnd, - "REGISTRY_URL": registryURL, - "LOCAL_VOLUME_PATH": localVolumePath, - } - - return b.kubernetesManager.ApplyConfigMap("blueprint", constants.DEFAULT_FLUX_SYSTEM_NAMESPACE, data) -} - // checkKustomizationStatus verifies the readiness of specified kustomizations by first checking // the git repository status and then polling each kustomization's status. Returns true if all // kustomizations are ready, false otherwise, along with any errors encountered during the checks. @@ -1093,14 +1054,12 @@ func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { return maxPathTime } -// toFluxKustomization generates a Flux Kustomization resource from a blueprintv1alpha1.Kustomization definition. -// It performs the following operations: -// - Translates blueprint kustomization fields to their Flux equivalents, including name, namespace, path, interval, prune, wait, force, and timeout. -// - Resolves and maps dependencies (DependsOn) to Flux NamespacedObjectReference objects for correct dependency graph construction. -// - Processes patch definitions: for each patch with a file path, reads the patch file from the kustomize directory, decodes YAML documents, and extracts resource selectors (kind, name, namespace) to build kustomize.Selector objects. Patch content is attached to the Flux patch specification. -// - Handles post-build configuration by mapping substituteFrom entries to Flux SubstituteReference objects, supporting ConfigMap and Secret sources. -// - Determines the source reference type (GitRepository or OCIRepository) based on the blueprint's SourceRef.Kind, and sets the appropriate reference in the Flux Kustomization spec. -// - Assembles and returns a kustomizev1.Kustomization object fully populated for Flux consumption, ready for application to a Kubernetes cluster. +// toFluxKustomization constructs a Flux Kustomization resource from the given +// blueprintv1alpha1.Kustomization and namespace. Maps blueprint fields to Flux equivalents, +// resolves dependencies, processes patch definitions (including reading and decoding patch files +// to extract selectors), configures post-build variable substitution using ConfigMaps and Secrets, +// determines the source reference type (GitRepository or OCIRepository), and sets all required +// Flux Kustomization fields for cluster application. func (b *BaseBlueprintHandler) toFluxKustomization(k blueprintv1alpha1.Kustomization, namespace string) kustomizev1.Kustomization { dependsOn := make([]meta.NamespacedObjectReference, len(k.DependsOn)) for i, dep := range k.DependsOn { @@ -1180,31 +1139,28 @@ func (b *BaseBlueprintHandler) toFluxKustomization(k blueprintv1alpha1.Kustomiza substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ Kind: "ConfigMap", - Name: "blueprint", + Name: "values-common", Optional: false, }) configRoot, err := b.configHandler.GetConfigRoot() if err == nil { - globalValuesPath := filepath.Join(configRoot, "kustomize", "values.yaml") - if _, err := b.shims.Stat(globalValuesPath); err == nil { - substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ - Kind: "ConfigMap", - Name: "values-global", - Optional: false, - }) - } - } - - configMapName := fmt.Sprintf("values-%s", k.Name) - if err == nil { - valuesPath := filepath.Join(configRoot, "kustomize", k.Name, "values.yaml") + valuesPath := filepath.Join(configRoot, "kustomize", "values.yaml") if _, err := b.shims.Stat(valuesPath); err == nil { - substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ - Kind: "ConfigMap", - Name: configMapName, - Optional: false, - }) + data, err := b.shims.ReadFile(valuesPath) + if err == nil { + var values map[string]any + if err := b.shims.YamlUnmarshal(data, &values); err == nil { + configMapName := fmt.Sprintf("values-%s", k.Name) + if _, hasComponent := values[k.Name]; hasComponent { + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: configMapName, + Optional: false, + }) + } + } + } } } @@ -1302,42 +1258,94 @@ func (b *BaseBlueprintHandler) isOCISource(sourceNameOrURL string) bool { return false } -// applyValuesConfigMaps creates ConfigMaps from values.yaml files in the kustomize directory for post-build variable substitution. -// It generates a ConfigMap for the global values.yaml if present, and for each component subdirectory containing a values.yaml file. -// The resulting ConfigMaps can be referenced in PostBuild.SubstituteFrom for variable substitution. +// applyValuesConfigMaps creates ConfigMaps for post-build variable substitution using the centralized +// values.yaml in the kustomize directory. It generates a ConfigMap for the "common" section and for +// each component section in values.yaml. 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) + + domain := b.configHandler.GetString("dns.domain") + context := b.configHandler.GetContext() + lbStart := b.configHandler.GetString("network.loadbalancer_ips.start") + lbEnd := b.configHandler.GetString("network.loadbalancer_ips.end") + registryURL := b.configHandler.GetString("docker.registry_url") + localVolumePaths := b.configHandler.GetStringSlice("cluster.workers.volumes") + + loadBalancerIPRange := fmt.Sprintf("%s-%s", lbStart, lbEnd) + + var localVolumePath string + if len(localVolumePaths) > 0 { + localVolumePath = strings.Split(localVolumePaths[0], ":")[1] + } else { + localVolumePath = "" + } + + mergedCommonValues["DOMAIN"] = domain + mergedCommonValues["CONTEXT"] = context + mergedCommonValues["CONTEXT_ID"] = b.configHandler.GetString("id") + mergedCommonValues["LOADBALANCER_IP_RANGE"] = loadBalancerIPRange + mergedCommonValues["LOADBALANCER_IP_START"] = lbStart + mergedCommonValues["LOADBALANCER_IP_END"] = lbEnd + mergedCommonValues["REGISTRY_URL"] = registryURL + mergedCommonValues["LOCAL_VOLUME_PATH"] = localVolumePath + kustomizeDir := filepath.Join(configRoot, "kustomize") if _, err := b.shims.Stat(kustomizeDir); os.IsNotExist(err) { + if len(mergedCommonValues) > 0 { + if err := b.createConfigMap(mergedCommonValues, "values-common"); err != nil { + return fmt.Errorf("failed to create merged common values ConfigMap: %w", err) + } + } return nil } - globalValuesPath := filepath.Join(kustomizeDir, "values.yaml") - if _, err := b.shims.Stat(globalValuesPath); err == nil { - if err := b.createConfigMapFromValues(globalValuesPath, "values-global"); err != nil { - return fmt.Errorf("failed to create global values ConfigMap: %w", err) + valuesPath := filepath.Join(kustomizeDir, "values.yaml") + if _, err := b.shims.Stat(valuesPath); os.IsNotExist(err) { + if len(mergedCommonValues) > 0 { + if err := b.createConfigMap(mergedCommonValues, "values-common"); err != nil { + return fmt.Errorf("failed to create merged common values ConfigMap: %w", err) + } } + return nil } - entries, err := b.shims.ReadDir(kustomizeDir) + data, err := b.shims.ReadFile(valuesPath) if err != nil { - return fmt.Errorf("failed to read kustomize directory: %w", err) + return fmt.Errorf("failed to read values file %s: %w", valuesPath, err) } - for _, entry := range entries { - if !entry.IsDir() { + var values map[string]any + if err := b.shims.YamlUnmarshal(data, &values); err != nil { + return fmt.Errorf("failed to unmarshal values file %s: %w", valuesPath, err) + } + + if commonValues, exists := values["common"]; exists { + if commonMap, ok := commonValues.(map[string]any); ok { + maps.Copy(mergedCommonValues, commonMap) + } + } + + 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) + } + } + + for componentName, componentValues := range values { + if componentName == "common" { continue } - componentValuesPath := filepath.Join(kustomizeDir, entry.Name(), "values.yaml") - if _, err := b.shims.Stat(componentValuesPath); err == nil { - configMapName := fmt.Sprintf("values-%s", entry.Name()) - if err := b.createConfigMapFromValues(componentValuesPath, configMapName); err != nil { - return fmt.Errorf("failed to create ConfigMap for component %s: %w", entry.Name(), err) + if componentMap, ok := componentValues.(map[string]any); ok { + configMapName := fmt.Sprintf("values-%s", componentName) + if err := b.createConfigMap(componentMap, configMapName); err != nil { + return fmt.Errorf("failed to create ConfigMap for component %s: %w", componentName, err) } } } @@ -1362,21 +1370,11 @@ func (b *BaseBlueprintHandler) validateValuesForSubstitution(values map[string]a return nil } -// createConfigMapFromValues reads a values.yaml file from valuesPath, unmarshals its contents, and creates a ConfigMap named configMapName in the "flux-system" namespace for post-build variable substitution. +// createConfigMap creates a ConfigMap named configMapName in the "flux-system" namespace for post-build variable substitution. // Only scalar values (string, int, float, bool) are supported. Complex types are rejected. The resulting ConfigMap data is a map of string keys to string values. -func (b *BaseBlueprintHandler) createConfigMapFromValues(valuesPath, configMapName string) error { - data, err := b.shims.ReadFile(valuesPath) - if err != nil { - return fmt.Errorf("failed to read values file %s: %w", valuesPath, err) - } - - var values map[string]any - if err := b.shims.YamlUnmarshal(data, &values); err != nil { - return fmt.Errorf("failed to unmarshal values file %s: %w", valuesPath, err) - } - +func (b *BaseBlueprintHandler) createConfigMap(values map[string]any, configMapName string) error { if err := b.validateValuesForSubstitution(values); err != nil { - return fmt.Errorf("invalid values in %s: %w", valuesPath, err) + return fmt.Errorf("invalid values in %s: %w", configMapName, err) } stringValues := make(map[string]string) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index c709ee817..ed008111a 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -1168,8 +1169,8 @@ func TestBlueprintHandler_Install(t *testing.T) { if err == nil { t.Error("Expected error, got nil") } - if !strings.Contains(err.Error(), "failed to apply configmap") { - t.Errorf("Expected configmap error, got: %v", err) + if !strings.Contains(err.Error(), "failed to apply values configmaps") { + t.Errorf("Expected values configmaps error, got: %v", err) } }) @@ -3544,11 +3545,36 @@ func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { // Given a handler handler := setup(t) - // And mock config root + // 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 values.yaml handler.shims.Stat = func(name string) (os.FileInfo, error) { @@ -3561,12 +3587,13 @@ func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { return nil, os.ErrNotExist } - // And mock file read for global values + // 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(`domain: example.com -port: 80 -enabled: true`), nil + return []byte(`common: + domain: example.com + port: 80 + enabled: true`), nil } return nil, os.ErrNotExist } @@ -3575,9 +3602,11 @@ enabled: true`), nil handler.shims.YamlUnmarshal = func(data []byte, v any) error { values := v.(*map[string]any) *values = map[string]any{ - "domain": "example.com", - "port": 80, - "enabled": true, + "common": map[string]any{ + "domain": "example.com", + "port": 80, + "enabled": true, + }, } return nil } @@ -3598,12 +3627,12 @@ enabled: true`), nil t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) } - // And it should apply the global values ConfigMap + // 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-global" { - t.Errorf("expected ConfigMap name to be 'values-global', got '%s'", appliedConfigMaps[0]) + if appliedConfigMaps[0] != "values-common" { + t.Errorf("expected ConfigMap name to be 'values-common', got '%s'", appliedConfigMaps[0]) } }) @@ -3611,38 +3640,56 @@ enabled: true`), nil // Given a handler handler := setup(t) - // And mock config root + // 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 component values + // 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", "ingress", "values.yaml") { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist } - // And mock directory read - handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { - if name == filepath.Join("/test/config", "kustomize") { - return []os.DirEntry{ - &mockDirEntry{name: "ingress", isDir: true}, - }, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for component values + // And mock file read for centralized values handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { - return []byte(`host: ingress.example.com -ssl: true`), nil + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return []byte(`common: + domain: example.com +ingress: + host: ingress.example.com + ssl: true`), nil } return nil, os.ErrNotExist } @@ -3651,8 +3698,13 @@ ssl: true`), nil handler.shims.YamlUnmarshal = func(data []byte, v any) error { values := v.(*map[string]any) *values = map[string]any{ - "host": "ingress.example.com", - "ssl": true, + "common": map[string]any{ + "domain": "example.com", + }, + "ingress": map[string]any{ + "host": "ingress.example.com", + "ssl": true, + }, } return nil } @@ -3673,12 +3725,27 @@ ssl: true`), nil t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) } - // And it should apply the component values ConfigMap - if len(appliedConfigMaps) != 1 { - t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) + // 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 appliedConfigMaps[0] != "values-ingress" { - t.Errorf("expected ConfigMap name to be 'values-ingress', got '%s'", appliedConfigMaps[0]) + if !commonFound { + t.Error("expected values-common ConfigMap to be applied") + } + if !ingressFound { + t.Error("expected values-ingress ConfigMap to be applied") } }) @@ -3728,27 +3795,58 @@ ssl: true`), nil } }) - t.Run("ReadDirError", func(t *testing.T) { + t.Run("ReadFileError", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock config root + // 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 exists + // And mock kustomize directory and values.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", "values.yaml") { + return &mockFileInfo{name: "values.yaml"}, nil + } return nil, os.ErrNotExist } - // And mock ReadDir that fails - handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { - return nil, os.ErrPermission + // And mock ReadFile that fails + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return nil, os.ErrPermission + } + return nil, os.ErrNotExist } // When applying values ConfigMaps @@ -3756,10 +3854,10 @@ ssl: true`), nil // Then it should fail if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with ReadDir error") + t.Fatal("expected applyValuesConfigMaps to fail with ReadFile error") } - if !strings.Contains(err.Error(), "failed to read kustomize directory") { - t.Errorf("expected error about reading kustomize directory, got: %v", err) + if !strings.Contains(err.Error(), "failed to read values file") { + t.Errorf("expected error about reading values file, got: %v", err) } }) @@ -3767,37 +3865,55 @@ ssl: true`), nil // Given a handler handler := setup(t) - // And mock config root + // 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 component values + // 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", "ingress", "values.yaml") { + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { return &mockFileInfo{name: "values.yaml"}, nil } return nil, os.ErrNotExist } - // And mock directory read - handler.shims.ReadDir = func(name string) ([]os.DirEntry, error) { - if name == filepath.Join("/test/config", "kustomize") { - return []os.DirEntry{ - &mockDirEntry{name: "ingress", isDir: true}, - }, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for component values + // And mock file read for centralized values handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { - return []byte(`host: ingress.example.com`), nil + if name == filepath.Join("/test/config", "kustomize", "values.yaml") { + return []byte(`common: + domain: example.com +ingress: + host: ingress.example.com`), nil } return nil, os.ErrNotExist } @@ -3806,7 +3922,12 @@ ssl: true`), nil handler.shims.YamlUnmarshal = func(data []byte, v any) error { values := v.(*map[string]any) *values = map[string]any{ - "host": "ingress.example.com", + "common": map[string]any{ + "domain": "example.com", + }, + "ingress": map[string]any{ + "host": "ingress.example.com", + }, } return nil } @@ -3824,13 +3945,17 @@ ssl: true`), nil if err == nil { t.Fatal("expected applyValuesConfigMaps to fail with ConfigMap error") } - if !strings.Contains(err.Error(), "failed to create ConfigMap for component ingress") { - t.Errorf("expected error about component ConfigMap creation, got: %v", err) + if !strings.Contains(err.Error(), "failed to create merged common values ConfigMap") { + t.Errorf("expected error about common ConfigMap creation, got: %v", err) } }) } -func TestBaseBlueprintHandler_createConfigMapFromValues(t *testing.T) { +// ============================================================================= +// toFluxKustomization ConfigMap Tests +// ============================================================================= + +func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testing.T) { // Given a handler with mocks setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() @@ -3842,302 +3967,372 @@ func TestBaseBlueprintHandler_createConfigMapFromValues(t *testing.T) { return handler } - t.Run("SuccessWithStringValues", func(t *testing.T) { + t.Run("WithGlobalValuesConfigMap", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock file read - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`domain: example.com -environment: production`), nil + // And 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 YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "domain": "example.com", - "environment": "production", + // 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 + return nil, os.ErrNotExist } - // And mock Kubernetes manager - var appliedName, appliedNamespace string - var appliedData map[string]string - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedName = name - appliedNamespace = namespace - appliedData = data - return nil + // 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 creating ConfigMap from values - err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") - // Then it should succeed - if err != nil { - t.Fatalf("expected createConfigMapFromValues to succeed, got: %v", err) + // Then it should have PostBuild with ConfigMap references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") } - // And it should apply the ConfigMap with correct data - if appliedName != "test-config" { - t.Errorf("expected ConfigMap name to be 'test-config', got '%s'", appliedName) - } - if appliedNamespace != "system-gitops" { - t.Errorf("expected namespace to be 'system-gitops', got '%s'", appliedNamespace) - } - if len(appliedData) != 2 { - t.Errorf("expected 2 values in ConfigMap, got %d", len(appliedData)) + // And it should have the blueprint ConfigMap reference + if len(result.Spec.PostBuild.SubstituteFrom) < 1 { + t.Fatal("expected at least 1 SubstituteFrom reference") } - if appliedData["domain"] != "example.com" { - t.Errorf("expected domain to be 'example.com', got '%s'", appliedData["domain"]) + + 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 appliedData["environment"] != "production" { - t.Errorf("expected environment to be 'production', got '%s'", appliedData["environment"]) + + if !commonValuesFound { + t.Error("expected values-common ConfigMap reference to be present") } }) - t.Run("SuccessWithMixedTypes", func(t *testing.T) { + t.Run("WithComponentValuesConfigMap", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock file read - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`port: 80 -enabled: true -host: localhost`), nil - } - - // And mock YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "port": 80, - "enabled": true, - "host": "localhost", - } - return nil - } - - // And mock Kubernetes manager - var appliedData map[string]string - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedData = data - return nil + // 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 creating ConfigMap from values - err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") - - // Then it should succeed - if err != nil { - t.Fatalf("expected createConfigMapFromValues to succeed, got: %v", err) + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - // And it should convert all types to strings - if appliedData["port"] != "80" { - t.Errorf("expected port to be '80', got '%s'", appliedData["port"]) - } - if appliedData["enabled"] != "true" { - t.Errorf("expected enabled to be 'true', got '%s'", appliedData["enabled"]) - } - if appliedData["host"] != "localhost" { - t.Errorf("expected host to be 'localhost', got '%s'", appliedData["host"]) + // 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 } - }) - - t.Run("ReadFileError", func(t *testing.T) { - // Given a handler - handler := setup(t) - // And mock file read that fails + // 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 } - // When creating ConfigMap from values - err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") - - // Then it should fail - if err == nil { - t.Fatal("expected createConfigMapFromValues to fail with ReadFile error") + 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 } - if !strings.Contains(err.Error(), "failed to read values file") { - t.Errorf("expected error about reading values file, got: %v", err) + + // 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], } - }) - t.Run("YamlUnmarshalError", func(t *testing.T) { - // Given a handler - handler := setup(t) + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") - // And mock file read - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`invalid: yaml: content`), nil + // Then it should have PostBuild with ConfigMap references + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") } - // And mock YAML unmarshal that fails - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - return os.ErrInvalid + // 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 + } } - // When creating ConfigMap from values - err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") - - // Then it should fail - if err == nil { - t.Fatal("expected createConfigMapFromValues to fail with YamlUnmarshal error") - } - if !strings.Contains(err.Error(), "failed to unmarshal values file") { - t.Errorf("expected error about unmarshaling values file, got: %v", err) + if !componentValuesFound { + t.Error("expected values-ingress ConfigMap reference to be present") } }) - t.Run("UnsupportedValueType", func(t *testing.T) { + t.Run("WithExistingPostBuild", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock file read - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`complex: [1, 2, 3]`), nil + // And 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 YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "complex": []any{1, 2, 3}, - } - return nil + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - // When creating ConfigMap from values - err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") - - // Then it should fail with validation error - if err == nil { - t.Fatal("expected createConfigMapFromValues to fail with validation error") + // 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 !strings.Contains(err.Error(), "complex types") { - t.Errorf("expected error to mention complex types, got: %v", err) + + 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("ValidationWithComplexTypes", func(t *testing.T) { + t.Run("WithoutValuesConfigMaps", func(t *testing.T) { // Given a handler handler := setup(t) - // And test cases for validation - testCases := []struct { - name string - values map[string]any - expectError bool - errorMsg string - }{ - { - name: "ValidScalarTypes", - values: map[string]any{ - "string": "value", - "int": 42, - "float": 3.14, - "bool": true, - }, - expectError: false, - }, - { - name: "InvalidMapType", - values: map[string]any{ - "nested": map[string]any{"key": "value"}, - }, - expectError: true, - errorMsg: "complex types", - }, - { - name: "InvalidSliceType", - values: map[string]any{ - "array": []any{1, 2, 3}, - }, - expectError: true, - errorMsg: "complex types", + // And initialize the blueprint + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", }, - { - name: "MixedValidAndInvalid", - values: map[string]any{ - "valid": "string", - "invalid": map[string]any{"nested": "value"}, - }, - expectError: true, - errorMsg: "complex types", + Repository: blueprintv1alpha1.Repository{ + Url: "https://github.com/test/repo.git", }, } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // When validating values - err := handler.validateValuesForSubstitution(tc.values) + // And mock config root + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "/test/config", nil + } - // Then check expected result - if tc.expectError { - if err == nil { - t.Fatal("expected validation to fail") - } - if !strings.Contains(err.Error(), tc.errorMsg) { - t.Errorf("expected error to contain '%s', got: %v", tc.errorMsg, err) - } - } else { - if err != nil { - t.Errorf("expected validation to pass, got: %v", err) - } - } - }) + // And mock that no values.yaml files exist + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And a kustomization + kustomization := blueprintv1alpha1.Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "test-source", + Interval: &metav1.Duration{Duration: 5 * time.Minute}, + RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 10 * time.Minute}, + Force: &[]bool{false}[0], + Wait: &[]bool{false}[0], + } + + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") + + // Then it should have PostBuild with only common ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // 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)) + } + + 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) + } + if ref.Optional != false { + t.Errorf("expected Optional to be false, got %v", ref.Optional) } }) - t.Run("ApplyConfigMapError", func(t *testing.T) { + t.Run("ConfigRootError", func(t *testing.T) { // Given a handler handler := setup(t) - // And mock file read - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`domain: example.com`), nil + // And 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 YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "domain": "example.com", - } - return nil + // And mock config root that fails + mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) + mockConfigHandler.GetConfigRootFunc = func() (string, error) { + return "", os.ErrNotExist } - // 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 + // 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 creating ConfigMap from values - err := handler.createConfigMapFromValues("/test/values.yaml", "test-config") + // When converting to Flux kustomization + result := handler.toFluxKustomization(kustomization, "test-namespace") - // Then it should fail - if err == nil { - t.Fatal("expected createConfigMapFromValues to fail with ApplyConfigMap error") + // Then it should still have PostBuild with only blueprint ConfigMap reference + if result.Spec.PostBuild == nil { + t.Fatal("expected PostBuild to be set") + } + + // And it should only have the blueprint ConfigMap reference (no values ConfigMaps due to error) + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + + ref := result.Spec.PostBuild.SubstituteFrom[0] + if ref.Kind != "ConfigMap" { + t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) } - if !strings.Contains(err.Error(), "failed to apply ConfigMap test-config") { - t.Errorf("expected error about applying ConfigMap, got: %v", err) + if ref.Name != "values-common" { + t.Errorf("expected Name to be 'values-common', got '%s'", ref.Name) } }) } -// ============================================================================= -// toFluxKustomization ConfigMap Tests -// ============================================================================= - -func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testing.T) { +func TestBaseBlueprintHandler_toFluxKustomization_Comprehensive(t *testing.T) { // Given a handler with mocks setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() @@ -4149,7 +4344,7 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin return handler } - t.Run("WithGlobalValuesConfigMap", func(t *testing.T) { + t.Run("BasicKustomizationConversion", func(t *testing.T) { // Given a handler handler := setup(t) @@ -4163,21 +4358,75 @@ 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 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], } - // 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 + // 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) } + }) - // And a kustomization + t.Run("WithDependsOn", 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 kustomization with dependencies kustomization := blueprintv1alpha1.Kustomization{ Name: "test-kustomization", Path: "test/path", @@ -4187,47 +4436,112 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin Timeout: &metav1.Duration{Duration: 10 * time.Minute}, Force: &[]bool{false}[0], Wait: &[]bool{false}[0], + DependsOn: []string{"dependency1", "dependency2"}, } // 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 correct dependencies + if len(result.Spec.DependsOn) != 2 { + t.Errorf("expected 2 dependencies, got %d", len(result.Spec.DependsOn)) } - // And it should have the blueprint ConfigMap reference - if len(result.Spec.PostBuild.SubstituteFrom) < 1 { - t.Fatal("expected at least 1 SubstituteFrom reference") + expectedDeps := map[string]bool{ + "dependency1": false, + "dependency2": false, } - blueprintFound := false - globalValuesFound := false - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "blueprint" { - blueprintFound = true - if ref.Optional != false { - t.Errorf("expected blueprint ConfigMap to be Optional=false, got %v", ref.Optional) - } + for _, dep := range result.Spec.DependsOn { + if dep.Namespace != "test-namespace" { + t.Errorf("expected dependency namespace to be 'test-namespace', got '%s'", dep.Namespace) } - if ref.Kind == "ConfigMap" && ref.Name == "values-global" { - globalValuesFound = true - if ref.Optional != false { - t.Errorf("expected values-global ConfigMap to be Optional=false, got %v", ref.Optional) - } + expectedDeps[dep.Name] = true + } + + for depName, found := range expectedDeps { + if !found { + t.Errorf("expected dependency '%s' not found", depName) } } + }) - if !blueprintFound { - t.Error("expected blueprint ConfigMap reference to be present") + 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 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 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 !globalValuesFound { - t.Error("expected values-global ConfigMap reference to be present") + 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) } }) - t.Run("WithComponentValuesConfigMap", func(t *testing.T) { + t.Run("WithDestroyPolicy", 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 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, + } + + // 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("WithPatchFromFile", func(t *testing.T) { // Given a handler handler := setup(t) @@ -4247,52 +4561,68 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin return "/test/config", nil } - // And mock that component values.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "ingress", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil + // And mock patch file content + patchContent := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: test-namespace +data: + key: value` + + handler.shims.ReadFile = func(name string) ([]byte, error) { + if name == filepath.Join("/test/config", "kustomize", "patch.yaml") { + return []byte(patchContent), nil } return nil, os.ErrNotExist } - // And a kustomization with component name + // And a kustomization with patch from file kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress/path", + 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", + }, + }, } // 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 patch + if len(result.Spec.Patches) != 1 { + t.Errorf("expected 1 patch, got %d", len(result.Spec.Patches)) } - // 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 - } + patch := result.Spec.Patches[0] + if patch.Patch != patchContent { + t.Errorf("expected patch content to match, got '%s'", patch.Patch) } - if !componentValuesFound { - t.Error("expected values-ingress ConfigMap reference to be present") + 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) + } } }) - t.Run("WithExistingPostBuild", func(t *testing.T) { + t.Run("WithInlinePatch", func(t *testing.T) { // Given a handler handler := setup(t) @@ -4306,21 +4636,63 @@ 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 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, + }, + }, } - // 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 + // 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)) } - // And a kustomization with existing PostBuild + 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("WithPatchTarget", 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 kustomization with patch target kustomization := blueprintv1alpha1.Kustomization{ Name: "test-kustomization", Path: "test/path", @@ -4330,16 +4702,13 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin 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, + Patches: []blueprintv1alpha1.BlueprintPatch{ + { + Patch: "patch content", + Target: &kustomize.Selector{ + Kind: "Deployment", + Name: "test-deployment", + Namespace: "custom-namespace", }, }, }, @@ -4348,54 +4717,32 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin // 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") + // 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)) } - // 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)) + patch := result.Spec.Patches[0] + if patch.Patch != "patch content" { + t.Errorf("expected patch content to match, got '%s'", patch.Patch) } - if result.Spec.PostBuild.Substitute["VAR1"] != "value1" { - t.Errorf("expected VAR1 to be 'value1', got '%s'", result.Spec.PostBuild.Substitute["VAR1"]) - } - if result.Spec.PostBuild.Substitute["VAR2"] != "value2" { - t.Errorf("expected VAR2 to be 'value2', got '%s'", result.Spec.PostBuild.Substitute["VAR2"]) - } - - // And it should have the correct SubstituteFrom references - blueprintFound := false - globalValuesFound := false - existingConfigFound := false - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "blueprint" { - blueprintFound = true + if 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 ref.Kind == "ConfigMap" && ref.Name == "values-global" { - globalValuesFound = true + if patch.Target.Name != "test-deployment" { + t.Errorf("expected target name to be 'test-deployment', got '%s'", patch.Target.Name) } - 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 patch.Target.Namespace != "custom-namespace" { + t.Errorf("expected target namespace to be 'custom-namespace', got '%s'", patch.Target.Namespace) } } - - if !blueprintFound { - t.Error("expected blueprint ConfigMap reference to be present") - } - if !globalValuesFound { - t.Error("expected values-global ConfigMap reference to be present") - } - if !existingConfigFound { - t.Error("expected existing-config ConfigMap reference to be preserved") - } }) - t.Run("WithoutValuesConfigMaps", func(t *testing.T) { + t.Run("WithMultiplePatches", func(t *testing.T) { // Given a handler handler := setup(t) @@ -4409,18 +4756,7 @@ 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 no values.yaml files exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And a kustomization + // And a kustomization with multiple patches kustomization := blueprintv1alpha1.Kustomization{ Name: "test-kustomization", Path: "test/path", @@ -4430,34 +4766,49 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin 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", + }, + }, + }, } // When converting to Flux kustomization result := handler.toFluxKustomization(kustomization, "test-namespace") - // Then it should have PostBuild with only blueprint ConfigMap reference - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") + // Then it should have both patches + if len(result.Spec.Patches) != 2 { + t.Errorf("expected 2 patches, got %d", len(result.Spec.Patches)) } - // And it should only have the blueprint ConfigMap reference - if len(result.Spec.PostBuild.SubstituteFrom) != 1 { - t.Errorf("expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + if result.Spec.Patches[0].Patch != "patch1" { + t.Errorf("expected first patch content to be 'patch1', got '%s'", result.Spec.Patches[0].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 != "blueprint" { - t.Errorf("expected Name to be 'blueprint', got '%s'", ref.Name) + if result.Spec.Patches[1].Patch != "patch2" { + t.Errorf("expected second patch content to be 'patch2', got '%s'", result.Spec.Patches[1].Patch) } - if ref.Optional != false { - t.Errorf("expected Optional to be false, got %v", ref.Optional) + + 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("ConfigRootError", func(t *testing.T) { + t.Run("WithComponents", func(t *testing.T) { // Given a handler handler := setup(t) @@ -4471,13 +4822,7 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin }, } - // And mock config root that fails - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "", os.ErrNotExist - } - - // And a kustomization + // And a kustomization with components kustomization := blueprintv1alpha1.Kustomization{ Name: "test-kustomization", Path: "test/path", @@ -4487,27 +4832,67 @@ func TestBaseBlueprintHandler_toFluxKustomization_WithValuesConfigMaps(t *testin Timeout: &metav1.Duration{Duration: 10 * time.Minute}, Force: &[]bool{false}[0], Wait: &[]bool{false}[0], + Components: []string{"component1", "component2"}, } // 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") + // Then it should have the components + if len(result.Spec.Components) != 2 { + t.Errorf("expected 2 components, got %d", len(result.Spec.Components)) } - // 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)) + expectedComponents := map[string]bool{ + "component1": false, + "component2": false, } - ref := result.Spec.PostBuild.SubstituteFrom[0] - if ref.Kind != "ConfigMap" { - t.Errorf("expected Kind to be 'ConfigMap', got '%s'", ref.Kind) + for _, component := range result.Spec.Components { + expectedComponents[component] = true } - if ref.Name != "blueprint" { - t.Errorf("expected Name to be 'blueprint', got '%s'", ref.Name) + + for componentName, found := range expectedComponents { + if !found { + t.Errorf("expected component '%s' not found", componentName) + } + } + }) + + t.Run("WithCustomPrune", 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 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 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) } }) } diff --git a/pkg/generators/kustomize_generator.go b/pkg/generators/kustomize_generator.go index f4f700e50..c137a0e00 100644 --- a/pkg/generators/kustomize_generator.go +++ b/pkg/generators/kustomize_generator.go @@ -89,11 +89,11 @@ func (g *KustomizeGenerator) Generate(data map[string]any, overwrite ...bool) er } for key, values := range data { - if strings.HasPrefix(key, "kustomize/") { + if strings.HasPrefix(key, "kustomize/patches/") { if err := g.generatePatchFile(key, values, configRoot, shouldOverwrite); err != nil { return err } - } else if strings.HasPrefix(key, "values/") { + } else if key == "kustomize/values" { if err := g.generateValuesFile(key, values, configRoot, shouldOverwrite); err != nil { return err } @@ -108,23 +108,23 @@ func (g *KustomizeGenerator) Generate(data map[string]any, overwrite ...bool) er // ============================================================================= // generatePatchFile generates patch files for kustomizations based on the provided key and values. -// It validates the kustomization name and patch directory, ensures values are a map, constructs the full patch file path, -// validates the path, and delegates file generation to generatePatchFiles. Returns an error if any step fails. +// It validates the patch path, ensures values are a map, constructs the full patch file path, +// validates the path, validates the Kubernetes manifest, and writes the patch file. Returns an error if any step fails. func (g *KustomizeGenerator) generatePatchFile(key string, values any, configRoot string, overwrite bool) error { - patchPath := strings.TrimPrefix(key, "kustomize/") + patchPath := strings.TrimPrefix(key, "kustomize/patches/") if err := g.validateKustomizationName(patchPath); err != nil { - return fmt.Errorf("invalid kustomization name %s: %w", patchPath, err) + return fmt.Errorf("invalid patch path %s: %w", patchPath, err) } - patchesDir := filepath.Join(configRoot, "kustomize") + patchesDir := filepath.Join(configRoot, "kustomize", "patches") if err := g.validatePath(patchesDir, configRoot); err != nil { return fmt.Errorf("invalid patches directory path %s: %w", patchesDir, err) } valuesMap, ok := values.(map[string]any) if !ok { - return fmt.Errorf("values for kustomization %s must be a map, got %T", patchPath, values) + return fmt.Errorf("values for patch %s must be a map, got %T", patchPath, values) } fullPatchPath := filepath.Join(patchesDir, patchPath) @@ -135,22 +135,20 @@ func (g *KustomizeGenerator) generatePatchFile(key string, values any, configRoo return fmt.Errorf("invalid patch file path %s: %w", fullPatchPath, err) } - if err := g.generatePatchFiles(fullPatchPath, valuesMap, overwrite); err != nil { - return fmt.Errorf("failed to generate patch files for %s: %w", patchPath, err) + if err := g.validateKubernetesManifest(valuesMap); err != nil { + return fmt.Errorf("invalid Kubernetes manifest for %s: %w", patchPath, err) } - return nil + return g.writeYamlFile(fullPatchPath, valuesMap, overwrite) } -// generateValuesFile creates values.yaml files for post-build variable substitution in kustomize workflows. -// Accepts a key, values map, configuration root, and overwrite flag. Validates the values file name and directory, -// ensures the values are a map with only scalar types, determines the correct file path for global or component-specific values, -// validates the final path, and writes the values file. Returns an error if any validation or file operation fails. +// generateValuesFile writes a centralized values.yaml for kustomize post-build substitution. +// Accepts only "kustomize/values" as key. Validates that values is a map with only scalar types or one-level nested maps. +// Merges with any existing values.yaml, overwriting keys with new values. Writes the result to values.yaml in the kustomize directory. +// Returns error on invalid key, type, path, or file operation. func (g *KustomizeGenerator) generateValuesFile(key string, values any, configRoot string, overwrite bool) error { - valuesPath := strings.TrimPrefix(key, "values/") - - if err := g.validateKustomizationName(valuesPath); err != nil { - return fmt.Errorf("invalid values name %s: %w", valuesPath, err) + if key != "kustomize/values" { + return fmt.Errorf("invalid values key %s, expected 'kustomize/values'", key) } valuesDir := filepath.Join(configRoot, "kustomize") @@ -160,29 +158,32 @@ func (g *KustomizeGenerator) generateValuesFile(key string, values any, configRo valuesMap, ok := values.(map[string]any) if !ok { - return fmt.Errorf("values for kustomization %s must be a map, got %T", valuesPath, values) - } - - if err := g.validateValuesForSubstitution(valuesMap); err != nil { - return fmt.Errorf("invalid values for post-build substitution %s: %w", valuesPath, err) + return fmt.Errorf("values must be a map, got %T", values) } - var fullValuesPath string - if valuesPath == "global" { - fullValuesPath = filepath.Join(valuesDir, "values.yaml") - } else { - fullValuesPath = filepath.Join(valuesDir, valuesPath, "values.yaml") + if err := g.validatePostBuildValues(valuesMap, "", 0); err != nil { + return fmt.Errorf("invalid values for post-build substitution: %w", err) } + fullValuesPath := filepath.Join(valuesDir, "values.yaml") if err := g.validatePath(fullValuesPath, configRoot); err != nil { return fmt.Errorf("invalid values file path %s: %w", fullValuesPath, err) } - if err := g.generateValuesFiles(fullValuesPath, valuesMap, overwrite); err != nil { - return fmt.Errorf("failed to generate values files for %s: %w", valuesPath, err) + existingValues := make(map[string]any) + if _, err := g.shims.Stat(fullValuesPath); err == nil { + if data, err := g.shims.ReadFile(fullValuesPath); err == nil { + if err := g.shims.YamlUnmarshal(data, &existingValues); err != nil { + return fmt.Errorf("failed to unmarshal existing values file %s: %w", fullValuesPath, err) + } + } } - return nil + for k, v := range valuesMap { + existingValues[k] = v + } + + return g.writeYamlFile(fullValuesPath, existingValues, overwrite) } // validateKustomizationName validates that a kustomization name is safe and valid. @@ -197,12 +198,10 @@ func (g *KustomizeGenerator) validateKustomizationName(name string) error { return fmt.Errorf("kustomization name cannot contain path traversal characters") } - components := strings.Split(name, "/") - for _, component := range components { + for _, component := range strings.Split(name, "/") { if component == "" { return fmt.Errorf("kustomization name cannot contain empty path components") } - for _, char := range component { if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_') { return fmt.Errorf("kustomization name component '%s' contains invalid character '%c'", component, char) @@ -213,18 +212,31 @@ func (g *KustomizeGenerator) validateKustomizationName(name string) error { return nil } -// validateValuesForSubstitution checks that all values are valid for Flux post-build variable substitution. -// Permitted types are string, numeric, and boolean. Complex types (maps, slices) are rejected. -// Returns an error if any value is not a supported type. -func (g *KustomizeGenerator) validateValuesForSubstitution(values map[string]any) error { +// validatePostBuildValues checks if the values map is valid for Flux post-build substitution. +// Permitted types: string, numeric, boolean. Allows one map nesting if all nested values are scalar. +// Slices and nested complex types are not allowed. parentKey is for error reporting (e.g. "ingress.ip"). +// depth tracks nesting (0 = top, 1 = one level deep). Returns error if unsupported type or excess nesting. +func (g *KustomizeGenerator) validatePostBuildValues(values map[string]any, parentKey string, depth int) error { for key, value := range values { + currentKey := key + if parentKey != "" { + currentKey = parentKey + "." + key + } + switch v := value.(type) { case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: continue - case map[string]any, []any: - return fmt.Errorf("values for post-build substitution cannot contain complex types (maps or slices), key '%s' has type %T", key, v) + case map[string]any: + if depth >= 1 { + return fmt.Errorf("values for post-build substitution cannot contain nested complex types, key '%s' has type %T", currentKey, v) + } + if err := g.validatePostBuildValues(v, currentKey, depth+1); err != nil { + return err + } + 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, and booleans, key '%s' has unsupported type %T", key, v) + 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) } } return nil @@ -282,71 +294,33 @@ func (g *KustomizeGenerator) validateKubernetesManifest(content any) error { return nil } -// generatePatchFiles writes a YAML patch file to patchPath using the provided values map. -// patchPath must be a file path. If it doesn't have a .yaml or .yml extension, .yaml will be automatically appended. -// For Jsonnet format, values is a direct object. If overwrite is false, existing files are not replaced. -// The content must be a valid Kubernetes manifest map with non-empty "apiVersion", "kind", and "metadata.name" fields. -// Returns an error on validation, marshalling, or file operation failure. -func (g *KustomizeGenerator) generatePatchFiles(patchPath string, values map[string]any, overwrite bool) error { - if !strings.HasSuffix(patchPath, ".yaml") && !strings.HasSuffix(patchPath, ".yml") { - patchPath = patchPath + ".yaml" - } - - dir := filepath.Dir(patchPath) - if err := g.shims.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - - if !overwrite { - if _, err := g.shims.Stat(patchPath); err == nil { - return nil - } - } - - if err := g.validateKubernetesManifest(values); err != nil { - return fmt.Errorf("invalid Kubernetes manifest for %s: %w", patchPath, err) - } - - yamlData, err := g.shims.MarshalYAML(values) - if err != nil { - return fmt.Errorf("failed to marshal content to YAML for %s: %w", patchPath, err) - } - - if err := g.shims.WriteFile(patchPath, yamlData, 0644); err != nil { - return fmt.Errorf("failed to write patch file %s: %w", patchPath, err) - } - - return nil -} - -// generateValuesFiles writes a YAML values file to valuesPath using the provided values map. -// valuesPath must be a file path. If it doesn't have a .yaml or .yml extension, .yaml will be automatically appended. -// For Jsonnet format, values is a direct object. If overwrite is false, existing files are not replaced. -// The content must be a valid YAML map structure suitable for post-build variable substitution. -// Returns an error on validation, marshalling, or file operation failure. -func (g *KustomizeGenerator) generateValuesFiles(valuesPath string, values map[string]any, overwrite bool) error { - if !strings.HasSuffix(valuesPath, ".yaml") && !strings.HasSuffix(valuesPath, ".yml") { - valuesPath = valuesPath + ".yaml" +// writeYamlFile writes a YAML file to filePath using the provided values map. +// filePath must be a file path. If it doesn't have a .yaml or .yml extension, .yaml will be automatically appended. +// If overwrite is false, existing files are not replaced. +// Returns an error on marshalling or file operation failure. +func (g *KustomizeGenerator) writeYamlFile(filePath string, values map[string]any, overwrite bool) error { + if !strings.HasSuffix(filePath, ".yaml") && !strings.HasSuffix(filePath, ".yml") { + filePath = filePath + ".yaml" } - dir := filepath.Dir(valuesPath) + dir := filepath.Dir(filePath) if err := g.shims.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory %s: %w", dir, err) } if !overwrite { - if _, err := g.shims.Stat(valuesPath); err == nil { + if _, err := g.shims.Stat(filePath); err == nil { return nil } } yamlData, err := g.shims.MarshalYAML(values) if err != nil { - return fmt.Errorf("failed to marshal content to YAML for %s: %w", valuesPath, err) + return fmt.Errorf("failed to marshal content to YAML for %s: %w", filePath, err) } - if err := g.shims.WriteFile(valuesPath, yamlData, 0644); err != nil { - return fmt.Errorf("failed to write values file %s: %w", valuesPath, err) + if err := g.shims.WriteFile(filePath, yamlData, 0644); err != nil { + return fmt.Errorf("failed to write file %s: %w", filePath, err) } return nil diff --git a/pkg/generators/kustomize_generator_test.go b/pkg/generators/kustomize_generator_test.go index 30ea67fd6..082ce8d33 100644 --- a/pkg/generators/kustomize_generator_test.go +++ b/pkg/generators/kustomize_generator_test.go @@ -149,7 +149,7 @@ func TestKustomizeGenerator_Generate(t *testing.T) { } data := map[string]any{ - "kustomize/test-patch": map[string]any{ + "kustomize/patches/test-patch": map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ @@ -183,12 +183,21 @@ func TestKustomizeGenerator_Generate(t *testing.T) { mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { return nil } + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test yaml"), nil + } + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + // Mock unmarshaling to return an empty map + values := v.(*map[string]any) + *values = make(map[string]any) + return nil + } mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { return []byte("test yaml"), nil } data := map[string]any{ - "values/global": map[string]any{ + "kustomize/values": map[string]any{ "domain": "example.com", "port": 80, "enabled": true, @@ -210,7 +219,7 @@ func TestKustomizeGenerator_Generate(t *testing.T) { return "", fmt.Errorf("config root error") } - data := map[string]any{"kustomize/test": "value"} + data := map[string]any{"kustomize/patches/test": "value"} err := generator.Generate(data) if err == nil { t.Fatal("expected Generate to fail with config root error") @@ -244,7 +253,7 @@ func TestKustomizeGenerator_generatePatchFile(t *testing.T) { return []byte("test yaml"), nil } - key := "kustomize/test-patch" + key := "kustomize/patches/test-patch" values := map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", @@ -262,15 +271,15 @@ func TestKustomizeGenerator_generatePatchFile(t *testing.T) { t.Run("InvalidKustomizationName", func(t *testing.T) { generator, _ := setupKustomizeGeneratorMocks(t) - key := "kustomize/invalid@name" + key := "kustomize/patches/invalid@name" values := map[string]any{"test": "value"} err := generator.generatePatchFile(key, values, "/test/config", false) if err == nil { t.Fatal("expected generatePatchFile to fail with invalid name") } - if !strings.Contains(err.Error(), "invalid kustomization name") { - t.Errorf("expected error about invalid name, got: %v", err) + if !strings.Contains(err.Error(), "invalid patch path") { + t.Errorf("expected error about invalid patch path, got: %v", err) } }) @@ -287,7 +296,7 @@ func TestKustomizeGenerator_generatePatchFile(t *testing.T) { return nil, fmt.Errorf("path error") } - key := "kustomize/test-patch" + key := "kustomize/patches/test-patch" values := map[string]any{"test": "value"} err := generator.generatePatchFile(key, values, "/test/config", false) @@ -298,7 +307,7 @@ func TestKustomizeGenerator_generatePatchFile(t *testing.T) { } func TestKustomizeGenerator_generateValuesFile(t *testing.T) { - t.Run("SuccessGlobal", func(t *testing.T) { + t.Run("SuccessCommon", func(t *testing.T) { generator, mocks := setupKustomizeGeneratorMocks(t) // Mock config handler @@ -316,11 +325,20 @@ func TestKustomizeGenerator_generateValuesFile(t *testing.T) { mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { return nil } + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test yaml"), nil + } + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + // Mock unmarshaling to return an empty map + values := v.(*map[string]any) + *values = make(map[string]any) + return nil + } mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { return []byte("test yaml"), nil } - key := "values/global" + key := "kustomize/values" values := map[string]any{ "domain": "example.com", "port": 80, @@ -351,11 +369,20 @@ func TestKustomizeGenerator_generateValuesFile(t *testing.T) { mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { return nil } + mocks.Shims.ReadFile = func(name string) ([]byte, error) { + return []byte("test yaml"), nil + } + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + // Mock unmarshaling to return an empty map + values := v.(*map[string]any) + *values = make(map[string]any) + return nil + } mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { return []byte("test yaml"), nil } - key := "values/ingress" + key := "kustomize/values" values := map[string]any{ "host": "example.com", "tls": true, @@ -367,21 +394,6 @@ func TestKustomizeGenerator_generateValuesFile(t *testing.T) { } }) - t.Run("InvalidValuesName", func(t *testing.T) { - generator, _ := setupKustomizeGeneratorMocks(t) - - key := "values/invalid@name" - values := map[string]any{"test": "value"} - - err := generator.generateValuesFile(key, values, "/test/config", false) - if err == nil { - t.Fatal("expected generateValuesFile to fail with invalid name") - } - if !strings.Contains(err.Error(), "invalid values name") { - t.Errorf("expected error about invalid name, got: %v", err) - } - }) - t.Run("InvalidValuesType", func(t *testing.T) { generator, mocks := setupKustomizeGeneratorMocks(t) @@ -390,7 +402,7 @@ func TestKustomizeGenerator_generateValuesFile(t *testing.T) { return "/test/config", nil } - key := "values/global" + key := "kustomize/values" values := "not a map" err := generator.generateValuesFile(key, values, "/test/config", false) @@ -410,163 +422,48 @@ func TestKustomizeGenerator_generateValuesFile(t *testing.T) { return "/test/config", nil } - key := "values/global" - values := map[string]any{ - "valid": "string", - "invalid": map[string]any{"nested": "value"}, - } - - err := generator.generateValuesFile(key, values, "/test/config", false) - if err == nil { - t.Fatal("expected generateValuesFile to fail with invalid values") - } - if !strings.Contains(err.Error(), "complex types") { - t.Errorf("expected error about complex types, got: %v", err) + // Mock config handler + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/test/config", nil } - }) -} - -func TestKustomizeGenerator_generatePatchFiles(t *testing.T) { - t.Run("Success", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) // Mock shims + mocks.Shims.Stat = func(name string) (os.FileInfo, error) { + return &mockFileInfo{name: filepath.Base(name), isDir: false}, nil + } mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { return nil } mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { return nil } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { + mocks.Shims.ReadFile = func(name string) ([]byte, error) { return []byte("test yaml"), nil } - - patchPath := "/test/patch.yaml" - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.generatePatchFiles(patchPath, values, false) - if err != nil { - t.Fatalf("expected generatePatchFiles to succeed, got: %v", err) - } - }) - - t.Run("InvalidManifest", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock shims to avoid file system issues - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - - patchPath := "/test/patch.yaml" - values := map[string]any{ - "invalid": "manifest", - } - - err := generator.generatePatchFiles(patchPath, values, false) - if err == nil { - t.Fatal("expected generatePatchFiles to fail with invalid manifest") - } - if !strings.Contains(err.Error(), "invalid Kubernetes manifest") { - t.Errorf("expected error about invalid manifest, got: %v", err) - } - }) - - t.Run("MkdirAllError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock shims to fail - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return fmt.Errorf("mkdir error") - } - - patchPath := "/test/patch.yaml" - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.generatePatchFiles(patchPath, values, false) - if err == nil { - t.Fatal("expected generatePatchFiles to fail with mkdir error") - } - if !strings.Contains(err.Error(), "failed to create directory") { - t.Errorf("expected error about directory creation, got: %v", err) - } - }) - - t.Run("MarshalYAMLError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock shims - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { - return nil - } - mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { - return nil, fmt.Errorf("marshal error") - } - - patchPath := "/test/patch.yaml" - values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, - } - - err := generator.generatePatchFiles(patchPath, values, false) - if err == nil { - t.Fatal("expected generatePatchFiles to fail with marshal error") - } - if !strings.Contains(err.Error(), "failed to marshal content to YAML") { - t.Errorf("expected error about YAML marshaling, got: %v", err) - } - }) - - t.Run("WriteFileError", func(t *testing.T) { - generator, mocks := setupKustomizeGeneratorMocks(t) - - // Mock shims - mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + // Mock unmarshaling to return an empty map + values := v.(*map[string]any) + *values = make(map[string]any) return nil } mocks.Shims.MarshalYAML = func(v any) ([]byte, error) { return []byte("test yaml"), nil } - mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error { - return fmt.Errorf("write error") - } - patchPath := "/test/patch.yaml" + key := "kustomize/values" values := map[string]any{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]any{ - "name": "test-config", - }, + "valid": "string", + "valid_nested": map[string]any{"nested": "value"}, } - err := generator.generatePatchFiles(patchPath, values, false) - if err == nil { - t.Fatal("expected generatePatchFiles to fail with write error") - } - if !strings.Contains(err.Error(), "failed to write patch file") { - t.Errorf("expected error about file writing, got: %v", err) + err := generator.generateValuesFile(key, values, "/test/config", false) + if err != nil { + t.Fatalf("expected generateValuesFile to succeed with valid nested values, got: %v", err) } }) } -func TestKustomizeGenerator_generateValuesFiles(t *testing.T) { +func TestKustomizeGenerator_writeYamlFile(t *testing.T) { t.Run("Success", func(t *testing.T) { generator, mocks := setupKustomizeGeneratorMocks(t) @@ -588,9 +485,9 @@ func TestKustomizeGenerator_generateValuesFiles(t *testing.T) { "enabled": true, } - err := generator.generateValuesFiles(valuesPath, values, false) + err := generator.writeYamlFile(valuesPath, values, false) if err != nil { - t.Fatalf("expected generateValuesFiles to succeed, got: %v", err) + t.Fatalf("expected writeYamlFile to succeed, got: %v", err) } }) @@ -605,9 +502,9 @@ func TestKustomizeGenerator_generateValuesFiles(t *testing.T) { valuesPath := "/test/values.yaml" values := map[string]any{"test": "value"} - err := generator.generateValuesFiles(valuesPath, values, false) + err := generator.writeYamlFile(valuesPath, values, false) if err == nil { - t.Fatal("expected generateValuesFiles to fail with mkdir error") + t.Fatal("expected writeYamlFile to fail with mkdir error") } if !strings.Contains(err.Error(), "failed to create directory") { t.Errorf("expected error about directory creation, got: %v", err) @@ -628,9 +525,9 @@ func TestKustomizeGenerator_generateValuesFiles(t *testing.T) { valuesPath := "/test/values.yaml" values := map[string]any{"test": "value"} - err := generator.generateValuesFiles(valuesPath, values, false) + err := generator.writeYamlFile(valuesPath, values, false) if err == nil { - t.Fatal("expected generateValuesFiles to fail with marshal error") + t.Fatal("expected writeYamlFile to fail with marshal error") } if !strings.Contains(err.Error(), "failed to marshal content to YAML") { t.Errorf("expected error about YAML marshaling, got: %v", err) @@ -654,11 +551,11 @@ func TestKustomizeGenerator_generateValuesFiles(t *testing.T) { valuesPath := "/test/values.yaml" values := map[string]any{"test": "value"} - err := generator.generateValuesFiles(valuesPath, values, false) + err := generator.writeYamlFile(valuesPath, values, false) if err == nil { - t.Fatal("expected generateValuesFiles to fail with write error") + t.Fatal("expected writeYamlFile to fail with write error") } - if !strings.Contains(err.Error(), "failed to write values file") { + if !strings.Contains(err.Error(), "failed to write file") { t.Errorf("expected error about file writing, got: %v", err) } }) @@ -768,7 +665,7 @@ func TestKustomizeGenerator_validateKustomizationName(t *testing.T) { } } -func TestKustomizeGenerator_validateValuesForSubstitution(t *testing.T) { +func TestKustomizeGenerator_validatePostBuildValues(t *testing.T) { // Given a generator generator, _ := setupKustomizeGeneratorMocks(t) @@ -799,12 +696,11 @@ func TestKustomizeGenerator_validateValuesForSubstitution(t *testing.T) { expectError: false, }, { - name: "InvalidMapType", + name: "ValidNestedMapType", values: map[string]any{ "nested": map[string]any{"key": "value"}, }, - expectError: true, - errorMsg: "complex types", + expectError: false, }, { name: "InvalidSliceType", @@ -812,16 +708,35 @@ func TestKustomizeGenerator_validateValuesForSubstitution(t *testing.T) { "array": []any{1, 2, 3}, }, expectError: true, - errorMsg: "complex types", + errorMsg: "slices", + }, + { + name: "MixedValidAndValidNested", + values: map[string]any{ + "valid": "string", + "valid_nested": map[string]any{"nested": "value"}, + }, + expectError: false, + }, + { + name: "InvalidDeeplyNestedMap", + values: map[string]any{ + "nested": map[string]any{ + "deeply_nested": map[string]any{"key": "value"}, + }, + }, + expectError: true, + errorMsg: "nested complex types", }, { - name: "MixedValidAndInvalid", + name: "InvalidNestedSlice", values: map[string]any{ - "valid": "string", - "invalid": map[string]any{"nested": "value"}, + "nested": map[string]any{ + "array": []any{1, 2, 3}, + }, }, expectError: true, - errorMsg: "complex types", + errorMsg: "slices", }, { name: "UnsupportedType", @@ -836,7 +751,7 @@ func TestKustomizeGenerator_validateValuesForSubstitution(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // When validating values - err := generator.validateValuesForSubstitution(tc.values) + err := generator.validatePostBuildValues(tc.values, "", 0) // Then check expected result if tc.expectError { diff --git a/pkg/template/jsonnet_template.go b/pkg/template/jsonnet_template.go index 7df971667..45853b36a 100644 --- a/pkg/template/jsonnet_template.go +++ b/pkg/template/jsonnet_template.go @@ -2,7 +2,6 @@ package template import ( "fmt" - "path/filepath" "strings" "github.com/windsorcli/cli/pkg/config" @@ -87,7 +86,7 @@ func (t *JsonnetTemplate) Process(templateData map[string][]byte, renderedData m continue } if strings.HasPrefix(templatePath, "kustomize/") { - if strings.HasSuffix(templatePath, "/values.jsonnet") { + if strings.HasSuffix(templatePath, "/values.jsonnet") || templatePath == "kustomize/values.jsonnet" { if !valuesSet[templatePath] { continue } @@ -152,12 +151,12 @@ func (t *JsonnetTemplate) processJsonnetTemplate(templateContent string) (map[st // processTemplate processes a single template file and stores the result in renderedData under a key determined by the template path. // Recognized mappings: -// - "blueprint.jsonnet" maps to "blueprint" -// - "terraform/*.jsonnet" maps to "terraform/*" (without .jsonnet extension) -// - "kustomize/*.jsonnet" maps to "kustomize/*" (without .jsonnet extension) -// - "kustomize//values.jsonnet" maps to "values/" -// - "kustomize/values.jsonnet" maps to "values/global" -// - "values/*.jsonnet" maps to "values/*" (without .jsonnet extension) +// - "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) // // 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 { @@ -167,31 +166,25 @@ func (t *JsonnetTemplate) processTemplate(templatePath string, templateData map[ } var outputKey string - if templatePath == "blueprint.jsonnet" { + switch { + case templatePath == "blueprint.jsonnet": outputKey = "blueprint" - } else if strings.HasPrefix(templatePath, "terraform/") && strings.HasSuffix(templatePath, ".jsonnet") { + case strings.HasPrefix(templatePath, "terraform/") && strings.HasSuffix(templatePath, ".jsonnet"): outputKey = strings.TrimSuffix(templatePath, ".jsonnet") - } else if strings.HasPrefix(templatePath, "kustomize/") && strings.HasSuffix(templatePath, ".jsonnet") { - if strings.HasSuffix(templatePath, "/values.jsonnet") { - pathParts := strings.Split(templatePath, "/") - if len(pathParts) == 3 && pathParts[0] == "kustomize" && pathParts[2] == "values.jsonnet" { - component := pathParts[1] - if component == "values" { - outputKey = "values/global" - } else { - outputKey = "values/" + component - } - } else if len(pathParts) == 2 && pathParts[0] == "kustomize" && pathParts[1] == "values.jsonnet" { - outputKey = "values/global" + 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") } - } else { - outputKey = strings.TrimSuffix(templatePath, ".jsonnet") } - } else if strings.HasPrefix(templatePath, "values/") && strings.HasSuffix(templatePath, ".jsonnet") { + case strings.HasPrefix(templatePath, "values/") && strings.HasSuffix(templatePath, ".jsonnet"): outputKey = strings.TrimSuffix(templatePath, ".jsonnet") - } else { + default: return nil } @@ -250,7 +243,7 @@ func (t *JsonnetTemplate) extractPatchReferences(renderedData map[string]any) [] } // extractValuesReferences returns a slice of values template file paths found in the kustomize directory structure. -// Always includes the global values template ("kustomize/values.jsonnet") and automatically discovers component-specific values templates. +// 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 @@ -267,26 +260,9 @@ func (t *JsonnetTemplate) extractValuesReferences(renderedData map[string]any) [ return templatePaths } - // Always include global values template + // Always include values template templatePaths = append(templatePaths, "kustomize/values.jsonnet") - // Automatically discover component-specific values templates from kustomize directory - projectRoot, err := t.shell.GetProjectRoot() - if err == nil { - templateDir := filepath.Join(projectRoot, "contexts", "_template", "kustomize") - if entries, err := t.shims.ReadDir(templateDir); err == nil { - for _, entry := range entries { - if entry.IsDir() { - componentValuesPath := filepath.Join(templateDir, entry.Name(), "values.jsonnet") - if _, err := t.shims.Stat(componentValuesPath); err == nil { - templatePath := fmt.Sprintf("kustomize/%s/values.jsonnet", entry.Name()) - templatePaths = append(templatePaths, templatePath) - } - } - } - } - } - return templatePaths } diff --git a/pkg/template/jsonnet_template_test.go b/pkg/template/jsonnet_template_test.go index 5ef5ea5f4..b8bad70b4 100644 --- a/pkg/template/jsonnet_template_test.go +++ b/pkg/template/jsonnet_template_test.go @@ -288,16 +288,16 @@ func TestJsonnetTemplate_Process(t *testing.T) { } // Verify that the full path structure is preserved (not flattened) - if _, exists := renderedData["kustomize/ingress/patches/nginx"]; !exists { - t.Error("Expected kustomize/ingress/patches/nginx to be rendered with preserved path structure") + if _, exists := renderedData["kustomize/patches/ingress/patches/nginx"]; !exists { + t.Error("Expected kustomize/patches/ingress/patches/nginx to be rendered with preserved path structure") } - if _, exists := renderedData["kustomize/dns/patches/coredns"]; !exists { - t.Error("Expected kustomize/dns/patches/coredns to be rendered with preserved path structure") + if _, exists := renderedData["kustomize/patches/dns/patches/coredns"]; !exists { + t.Error("Expected kustomize/patches/dns/patches/coredns to be rendered with preserved path structure") } // Verify the content is correctly processed - nginxPatch, ok := renderedData["kustomize/ingress/patches/nginx"].(map[string]any) + nginxPatch, ok := renderedData["kustomize/patches/ingress/patches/nginx"].(map[string]any) if !ok { t.Error("Expected nginx patch to be a map") } else { @@ -309,7 +309,7 @@ func TestJsonnetTemplate_Process(t *testing.T) { } } - corednsPatch, ok := renderedData["kustomize/dns/patches/coredns"].(map[string]any) + corednsPatch, ok := renderedData["kustomize/patches/dns/patches/coredns"].(map[string]any) if !ok { t.Error("Expected coredns patch to be a map") } else { @@ -1666,11 +1666,9 @@ func TestJsonnetTemplate_extractValuesReferences(t *testing.T) { // When extracting values references result := template.extractValuesReferences(renderedData) - // Then should include global values and discovered component values + // Then should include only the centralized values template expected := []string{ "kustomize/values.jsonnet", - "kustomize/ingress/values.jsonnet", - "kustomize/database/values.jsonnet", } if len(result) != len(expected) { t.Errorf("expected %d items, got %d", len(expected), len(result)) @@ -1709,7 +1707,7 @@ func TestJsonnetTemplate_extractValuesReferences(t *testing.T) { // When extracting values references result := template.extractValuesReferences(renderedData) - // Then should only include global values (no discovery) + // 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)) @@ -1841,11 +1839,9 @@ func TestJsonnetTemplate_extractValuesReferences(t *testing.T) { // When extracting values references result := template.extractValuesReferences(renderedData) - // Then should include global values and only existing component values + // Then should include only the centralized values template expected := []string{ "kustomize/values.jsonnet", - "kustomize/ingress/values.jsonnet", - "kustomize/database/values.jsonnet", } if len(result) != len(expected) { t.Errorf("expected %d items, got %d", len(expected), len(result)) @@ -2012,7 +2008,7 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["kustomize/ingress/patch"] == nil { + if renderedData["kustomize/patches/ingress/patch"] == nil { t.Error("expected kustomize patch data to be added") } }) @@ -2041,8 +2037,8 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["values/global"] == nil { - t.Error("expected values/global data to be added") + if renderedData["kustomize/values"] == nil { + t.Error("expected kustomize/values data to be added") } }) @@ -2070,8 +2066,8 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["values/ingress"] == nil { - t.Error("expected values/ingress data to be added") + if renderedData["kustomize/values"] == nil { + t.Error("expected kustomize/values data to be added") } }) @@ -2099,8 +2095,8 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["values/global"] == nil { - t.Error("expected values/global data to be added") + if renderedData["kustomize/values"] == nil { + t.Error("expected kustomize/values data to be added") } }) @@ -2224,8 +2220,8 @@ func TestJsonnetTemplate_processTemplate(t *testing.T) { if err != nil { t.Fatalf("expected no error, got: %v", err) } - if renderedData["kustomize/ingress/nginx/values"] == nil { - t.Error("expected complex path data to be added") + if renderedData["kustomize/values"] == nil { + t.Error("expected kustomize/values data to be added") } }) }