From 22cbafc019b93cd34902d0a9fce0e5fed7a40378 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:49:44 -0500 Subject: [PATCH 1/3] fix(blueprint): Re-implement postBuild substitutions During the recent architecture migration, the post build substitution functionality was inadvertently dropped. This functionality was rebuilt. Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- api/v1alpha1/blueprint_types.go | 39 +- api/v1alpha1/blueprint_types_test.go | 4 +- pkg/composer/blueprint/blueprint_handler.go | 145 ++++++- .../blueprint_handler_public_test.go | 365 ++++++++++++++++++ .../kubernetes/kubernetes_manager.go | 33 +- .../kubernetes_manager_public_test.go | 284 ++++++++++++++ 6 files changed, 840 insertions(+), 30 deletions(-) diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index 6f0d241b2..f15ac656a 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -36,6 +36,10 @@ type Blueprint struct { // Kustomizations are kustomization configs in the blueprint. Kustomizations []Kustomization `yaml:"kustomize"` + + // ConfigMaps are standalone ConfigMaps to be created, not tied to specific kustomizations. + // These ConfigMaps are referenced by all kustomizations in PostBuild substitution. + ConfigMaps map[string]map[string]string `yaml:"configMaps,omitempty"` } // Metadata describes a blueprint. @@ -283,6 +287,13 @@ func (b *Blueprint) DeepCopy() *Blueprint { kustomizationsCopy[i] = *kustomization.DeepCopy() } + configMapsCopy := make(map[string]map[string]string) + if b.ConfigMaps != nil { + for name, data := range b.ConfigMaps { + configMapsCopy[name] = maps.Clone(data) + } + } + return &Blueprint{ Kind: b.Kind, ApiVersion: b.ApiVersion, @@ -291,6 +302,7 @@ func (b *Blueprint) DeepCopy() *Blueprint { Sources: sourcesCopy, TerraformComponents: terraformComponentsCopy, Kustomizations: kustomizationsCopy, + ConfigMaps: configMapsCopy, } } @@ -362,6 +374,18 @@ func (b *Blueprint) StrategicMerge(overlays ...*Blueprint) error { return err } } + + if overlay.ConfigMaps != nil { + if b.ConfigMaps == nil { + b.ConfigMaps = make(map[string]map[string]string) + } + for name, data := range overlay.ConfigMaps { + if b.ConfigMaps[name] == nil { + b.ConfigMaps[name] = make(map[string]string) + } + maps.Copy(b.ConfigMaps[name], data) + } + } } return nil } @@ -597,6 +621,7 @@ func (k *Kustomization) DeepCopy() *Kustomization { // ToFluxKustomization converts a blueprint Kustomization to a Flux Kustomization. // It takes the namespace for the kustomization, the default source name to use if no source is specified, // and the list of sources to determine the source kind (GitRepository or OCIRepository). +// PostBuild is constructed based on the kustomization's Substitutions field. func (k *Kustomization) ToFluxKustomization(namespace string, defaultSourceName string, sources []Source) kustomizev1.Kustomization { dependsOn := make([]kustomizev1.DependencyReference, len(k.DependsOn)) for idx, dep := range k.DependsOn { @@ -684,15 +709,15 @@ func (k *Kustomization) ToFluxKustomization(namespace string, defaultSourceName var postBuild *kustomizev1.PostBuild if len(k.Substitutions) > 0 { - substituteFrom := make([]kustomizev1.SubstituteReference, 0) configMapName := fmt.Sprintf("values-%s", k.Name) - substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ - Kind: "ConfigMap", - Name: configMapName, - Optional: false, - }) postBuild = &kustomizev1.PostBuild{ - SubstituteFrom: substituteFrom, + SubstituteFrom: []kustomizev1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: configMapName, + Optional: false, + }, + }, } } diff --git a/api/v1alpha1/blueprint_types_test.go b/api/v1alpha1/blueprint_types_test.go index 98ae502ba..4fcc56f10 100644 --- a/api/v1alpha1/blueprint_types_test.go +++ b/api/v1alpha1/blueprint_types_test.go @@ -839,7 +839,7 @@ func TestKustomization_ToFluxKustomization(t *testing.T) { t.Fatalf("Expected 1 SubstituteFrom reference (values-test-kustomization), got %d", len(result.Spec.PostBuild.SubstituteFrom)) } if result.Spec.PostBuild.SubstituteFrom[0].Name != "values-test-kustomization" { - t.Errorf("Expected values-test-kustomization ConfigMap reference, got '%s'", result.Spec.PostBuild.SubstituteFrom[0].Name) + t.Errorf("Expected SubstituteFrom to be values-test-kustomization, got '%s'", result.Spec.PostBuild.SubstituteFrom[0].Name) } }) @@ -938,8 +938,6 @@ func TestKustomization_ToFluxKustomization(t *testing.T) { if len(result.Spec.PostBuild.SubstituteFrom) != 1 { t.Fatalf("Expected 1 SubstituteFrom reference (values-test-kustomization), got %d", len(result.Spec.PostBuild.SubstituteFrom)) } - - // Should have component-specific ConfigMap if result.Spec.PostBuild.SubstituteFrom[0].Name != "values-test-kustomization" { t.Errorf("Expected SubstituteFrom to be values-test-kustomization, got '%s'", result.Spec.PostBuild.SubstituteFrom[0].Name) } diff --git a/pkg/composer/blueprint/blueprint_handler.go b/pkg/composer/blueprint/blueprint_handler.go index bfcdb5672..3ca5348f5 100644 --- a/pkg/composer/blueprint/blueprint_handler.go +++ b/pkg/composer/blueprint/blueprint_handler.go @@ -46,6 +46,7 @@ type BaseBlueprintHandler struct { shims *Shims kustomizeData map[string]any featureSubstitutions map[string]map[string]string + commonSubstitutions map[string]string configLoaded bool } @@ -59,6 +60,7 @@ func NewBlueprintHandler(rt *runtime.Runtime, artifactBuilder artifact.Artifact, shims: NewShims(), kustomizeData: make(map[string]any), featureSubstitutions: make(map[string]map[string]string), + commonSubstitutions: make(map[string]string), } if len(opts) > 0 && opts[0] != nil { @@ -218,24 +220,23 @@ func (b *BaseBlueprintHandler) GetTerraformComponents() []blueprintv1alpha1.Terr return resolvedBlueprint.TerraformComponents } -// Generate returns the fully processed blueprint with all defaults resolved, -// paths processed, and generation logic applied - equivalent to what would be deployed. -// It applies the same processing logic as getKustomizations() but for the entire blueprint structure. +// Generate returns a fully processed blueprint with all defaults resolved, paths updated, +// and generation logic applied. The returned blueprint is ready for deployment and reflects +// the complete, concrete state including kustomization and terraform component details, +// restored feature substitutions, and merged common or legacy variables as ConfigMaps. +// This function performs logic equivalent to getKustomizations() but applies it to the entire blueprint. func (b *BaseBlueprintHandler) Generate() *blueprintv1alpha1.Blueprint { generated := b.blueprint.DeepCopy() - // Process kustomizations with the same logic as getKustomizations() for i := range generated.Kustomizations { if generated.Kustomizations[i].Source == "" { generated.Kustomizations[i].Source = generated.Metadata.Name } - if generated.Kustomizations[i].Path == "" { generated.Kustomizations[i].Path = "kustomize" } else { generated.Kustomizations[i].Path = "kustomize/" + strings.ReplaceAll(generated.Kustomizations[i].Path, "\\", "/") } - if generated.Kustomizations[i].Interval == nil || generated.Kustomizations[i].Interval.Duration == 0 { generated.Kustomizations[i].Interval = &metav1.Duration{Duration: constants.DefaultFluxKustomizationInterval} } @@ -259,10 +260,29 @@ func (b *BaseBlueprintHandler) Generate() *blueprintv1alpha1.Blueprint { } } - // Process terraform components with source resolution b.resolveComponentSources(generated) b.resolveComponentPaths(generated) + for i := range generated.Kustomizations { + if subs, exists := b.featureSubstitutions[generated.Kustomizations[i].Name]; exists { + generated.Kustomizations[i].Substitutions = maps.Clone(subs) + } + } + + mergedCommonValues := make(map[string]string) + if b.commonSubstitutions != nil { + maps.Copy(mergedCommonValues, b.commonSubstitutions) + } + + b.mergeLegacySpecialVariables(mergedCommonValues) + + if len(mergedCommonValues) > 0 { + if generated.ConfigMaps == nil { + generated.ConfigMaps = make(map[string]map[string]string) + } + generated.ConfigMaps["values-common"] = mergedCommonValues + } + return generated } @@ -332,19 +352,50 @@ func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) templateData["blueprint"] = composedBlueprintYAML } + var substitutionValues map[string]any if contextValues != nil { - if substitutionValues, ok := contextValues["substitutions"].(map[string]any); ok && len(substitutionValues) > 0 { - if existingValues, exists := templateData["substitutions"]; exists { - var ociSubstitutionValues map[string]any - if err := b.shims.YamlUnmarshal(existingValues, &ociSubstitutionValues); err == nil { - substitutionValues = b.deepMergeMaps(ociSubstitutionValues, substitutionValues) - } + if contextSubs, ok := contextValues["substitutions"].(map[string]any); ok && len(contextSubs) > 0 { + substitutionValues = contextSubs + } + } + + if existingValues, exists := templateData["substitutions"]; exists { + var ociSubstitutionValues map[string]any + if err := b.shims.YamlUnmarshal(existingValues, &ociSubstitutionValues); err == nil { + if substitutionValues == nil { + substitutionValues = ociSubstitutionValues + } else { + substitutionValues = b.deepMergeMaps(ociSubstitutionValues, substitutionValues) } - substitutionYAML, err := b.shims.YamlMarshal(substitutionValues) - if err != nil { - return nil, fmt.Errorf("failed to marshal substitution values: %w", err) + } + } + + if len(substitutionValues) > 0 { + substitutionYAML, err := b.shims.YamlMarshal(substitutionValues) + if err != nil { + return nil, fmt.Errorf("failed to marshal substitution values: %w", err) + } + templateData["substitutions"] = substitutionYAML + + if commonSubs, ok := substitutionValues["common"].(map[string]any); ok && len(commonSubs) > 0 { + b.commonSubstitutions = make(map[string]string) + for k, v := range commonSubs { + b.commonSubstitutions[k] = fmt.Sprintf("%v", v) + } + } + + for key, value := range substitutionValues { + if key == "common" { + continue + } + if kustomizationSubs, ok := value.(map[string]any); ok && len(kustomizationSubs) > 0 { + if b.featureSubstitutions[key] == nil { + b.featureSubstitutions[key] = make(map[string]string) + } + for k, v := range kustomizationSubs { + b.featureSubstitutions[key][k] = fmt.Sprintf("%v", v) + } } - templateData["substitutions"] = substitutionYAML } } @@ -498,6 +549,7 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir string, templ } else if strings.HasSuffix(entry.Name(), ".jsonnet") || entry.Name() == "schema.yaml" || entry.Name() == "blueprint.yaml" || + entry.Name() == "substitutions" || (strings.HasPrefix(filepath.Dir(entryPath), filepath.Join(b.runtime.TemplateRoot, "features")) && strings.HasSuffix(entry.Name(), ".yaml")) { content, err := b.shims.ReadFile(filepath.Clean(entryPath)) if err != nil { @@ -508,6 +560,8 @@ func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir string, templ templateData["schema"] = content } else if entry.Name() == "blueprint.yaml" { templateData["blueprint"] = content + } else if entry.Name() == "substitutions" { + templateData["substitutions"] = content } else { relPath, err := filepath.Rel(b.runtime.TemplateRoot, entryPath) if err != nil { @@ -976,6 +1030,63 @@ func (b *BaseBlueprintHandler) isOCISource(sourceNameOrURL string) bool { return false } +// mergeLegacySpecialVariables merges legacy special variables into the common values map for backward compatibility. +// These variables were previously extracted from config and kustomize/values.yaml and are now merged from the config handler. +// This method can be removed when legacy variable support is no longer needed. +func (b *BaseBlueprintHandler) mergeLegacySpecialVariables(mergedCommonValues map[string]string) { + if b.runtime == nil || b.runtime.ConfigHandler == nil { + return + } + + domain := b.runtime.ConfigHandler.GetString("dns.domain") + context := b.runtime.ConfigHandler.GetContext() + contextID := b.runtime.ConfigHandler.GetString("id") + lbStart := b.runtime.ConfigHandler.GetString("network.loadbalancer_ips.start") + lbEnd := b.runtime.ConfigHandler.GetString("network.loadbalancer_ips.end") + registryURL := b.runtime.ConfigHandler.GetString("docker.registry_url") + localVolumePaths := b.runtime.ConfigHandler.GetStringSlice("cluster.workers.volumes") + + loadBalancerIPRange := fmt.Sprintf("%s-%s", lbStart, lbEnd) + + var localVolumePath string + if len(localVolumePaths) > 0 { + parts := strings.Split(localVolumePaths[0], ":") + if len(parts) > 1 { + localVolumePath = parts[1] + } + } + + if domain != "" { + mergedCommonValues["DOMAIN"] = domain + } + if context != "" { + mergedCommonValues["CONTEXT"] = context + } + if contextID != "" { + mergedCommonValues["CONTEXT_ID"] = contextID + } + if loadBalancerIPRange != "-" { + mergedCommonValues["LOADBALANCER_IP_RANGE"] = loadBalancerIPRange + } + if lbStart != "" { + mergedCommonValues["LOADBALANCER_IP_START"] = lbStart + } + if lbEnd != "" { + mergedCommonValues["LOADBALANCER_IP_END"] = lbEnd + } + if registryURL != "" { + mergedCommonValues["REGISTRY_URL"] = registryURL + } + if localVolumePath != "" { + mergedCommonValues["LOCAL_VOLUME_PATH"] = localVolumePath + } + + buildID, err := b.runtime.GetBuildID() + if err == nil && buildID != "" { + mergedCommonValues["BUILD_ID"] = buildID + } +} + // validateValuesForSubstitution checks that all values are valid for Flux post-build variable substitution. // Permitted types are string, numeric, and boolean. Allows one level of map nesting if all nested values are scalar. // Slices and nested complex types are not allowed. Returns an error if any value is not a supported type. diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index dcf6ad2e0..f42e10bd8 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -4250,4 +4250,369 @@ func TestBaseBlueprintHandler_Generate(t *testing.T) { t.Error("Generated blueprint should have defaults applied") } }) + + t.Run("WithCommonSubstitutions", func(t *testing.T) { + handler, _ := setup(t) + handler.commonSubstitutions = map[string]string{ + "domain": "example.com", + "region": "us-west-2", + } + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + } + + generated := handler.Generate() + + if generated.ConfigMaps == nil { + t.Fatal("Expected ConfigMaps to be set") + } + if len(generated.ConfigMaps) != 1 { + t.Fatalf("Expected 1 ConfigMap, got %d", len(generated.ConfigMaps)) + } + commonConfigMap, exists := generated.ConfigMaps["values-common"] + if !exists { + t.Fatal("Expected values-common ConfigMap") + } + if commonConfigMap["domain"] != "example.com" { + t.Errorf("Expected domain 'example.com', got '%s'", commonConfigMap["domain"]) + } + if commonConfigMap["region"] != "us-west-2" { + t.Errorf("Expected region 'us-west-2', got '%s'", commonConfigMap["region"]) + } + }) + + t.Run("WithLegacySpecialVariables", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "dns.domain": + return "test.example.com" + case "id": + return "test-id-123" + case "network.loadbalancer_ips.start": + return "192.168.1.1" + case "network.loadbalancer_ips.end": + return "192.168.1.100" + case "docker.registry_url": + return "registry.example.com" + default: + return "" + } + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextFunc = func() string { + return "test-context" + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { + if key == "cluster.workers.volumes" { + return []string{"/host:/var/local"} + } + return []string{} + } + + buildIDPath := filepath.Join(mocks.Runtime.ProjectRoot, ".windsor", ".build-id") + if err := os.MkdirAll(filepath.Dir(buildIDPath), 0755); err != nil { + t.Fatalf("Failed to create .windsor directory: %v", err) + } + if err := os.WriteFile(buildIDPath, []byte("build-123"), 0644); err != nil { + t.Fatalf("Failed to write build ID file: %v", err) + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + } + + generated := handler.Generate() + + if generated.ConfigMaps == nil { + t.Fatal("Expected ConfigMaps to be set") + } + commonConfigMap, exists := generated.ConfigMaps["values-common"] + if !exists { + t.Fatal("Expected values-common ConfigMap") + } + if commonConfigMap["DOMAIN"] != "test.example.com" { + t.Errorf("Expected DOMAIN 'test.example.com', got '%s'", commonConfigMap["DOMAIN"]) + } + if commonConfigMap["CONTEXT"] != "test-context" { + t.Errorf("Expected CONTEXT 'test-context', got '%s'", commonConfigMap["CONTEXT"]) + } + if commonConfigMap["CONTEXT_ID"] != "test-id-123" { + t.Errorf("Expected CONTEXT_ID 'test-id-123', got '%s'", commonConfigMap["CONTEXT_ID"]) + } + if commonConfigMap["LOADBALANCER_IP_RANGE"] != "192.168.1.1-192.168.1.100" { + t.Errorf("Expected LOADBALANCER_IP_RANGE '192.168.1.1-192.168.1.100', got '%s'", commonConfigMap["LOADBALANCER_IP_RANGE"]) + } + if commonConfigMap["REGISTRY_URL"] != "registry.example.com" { + t.Errorf("Expected REGISTRY_URL 'registry.example.com', got '%s'", commonConfigMap["REGISTRY_URL"]) + } + if commonConfigMap["LOCAL_VOLUME_PATH"] != "/var/local" { + t.Errorf("Expected LOCAL_VOLUME_PATH '/var/local', got '%s'", commonConfigMap["LOCAL_VOLUME_PATH"]) + } + if commonConfigMap["BUILD_ID"] != "build-123" { + t.Errorf("Expected BUILD_ID 'build-123', got '%s'", commonConfigMap["BUILD_ID"]) + } + }) + + t.Run("WithPerKustomizationSubstitutions", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitutions": map[string]any{ + "common": map[string]any{ + "domain": "example.com", + }, + "csi": map[string]any{ + "volume_path": "/custom/volumes", + "storage_class": "fast-ssd", + }, + "monitoring": map[string]any{ + "retention_days": "30", + }, + }, + }, nil + } + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "csi", + Path: "csi", + }, + { + Name: "monitoring", + Path: "monitoring", + }, + }, + } + + _, err := handler.GetLocalTemplateData() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + generated := handler.Generate() + + if generated.ConfigMaps == nil { + t.Fatal("Expected ConfigMaps to be set") + } + commonConfigMap, exists := generated.ConfigMaps["values-common"] + if !exists { + t.Fatal("Expected values-common ConfigMap") + } + if commonConfigMap["domain"] != "example.com" { + t.Errorf("Expected domain 'example.com', got '%s'", commonConfigMap["domain"]) + } + + var csiKustomization *blueprintv1alpha1.Kustomization + var monitoringKustomization *blueprintv1alpha1.Kustomization + for i := range generated.Kustomizations { + if generated.Kustomizations[i].Name == "csi" { + csiKustomization = &generated.Kustomizations[i] + } + if generated.Kustomizations[i].Name == "monitoring" { + monitoringKustomization = &generated.Kustomizations[i] + } + } + + if csiKustomization == nil { + t.Fatal("Expected csi kustomization") + } + if len(csiKustomization.Substitutions) != 2 { + t.Fatalf("Expected 2 substitutions for csi, got %d", len(csiKustomization.Substitutions)) + } + if csiKustomization.Substitutions["volume_path"] != "/custom/volumes" { + t.Errorf("Expected volume_path '/custom/volumes', got '%s'", csiKustomization.Substitutions["volume_path"]) + } + if csiKustomization.Substitutions["storage_class"] != "fast-ssd" { + t.Errorf("Expected storage_class 'fast-ssd', got '%s'", csiKustomization.Substitutions["storage_class"]) + } + + if monitoringKustomization == nil { + t.Fatal("Expected monitoring kustomization") + } + if len(monitoringKustomization.Substitutions) != 1 { + t.Fatalf("Expected 1 substitution for monitoring, got %d", len(monitoringKustomization.Substitutions)) + } + if monitoringKustomization.Substitutions["retention_days"] != "30" { + t.Errorf("Expected retention_days '30', got '%s'", monitoringKustomization.Substitutions["retention_days"]) + } + }) + + t.Run("WithOCISubstitutionsOnly", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{}, nil + } + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = yaml.Unmarshal + handler.shims.YamlMarshal = yaml.Marshal + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + ociSubstitutionsContent := `common: + domain: oci.example.com + region: us-east-1 +csi: + volume_path: /oci/volumes +` + if err := os.WriteFile(filepath.Join(mocks.Runtime.TemplateRoot, "substitutions"), []byte(ociSubstitutionsContent), 0644); err != nil { + t.Fatalf("Failed to write OCI substitutions file: %v", err) + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "csi", + Path: "csi", + }, + }, + } + + _, err := handler.GetLocalTemplateData() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + generated := handler.Generate() + + if generated.ConfigMaps == nil { + t.Fatal("Expected ConfigMaps to be set from OCI substitutions") + } + commonConfigMap, exists := generated.ConfigMaps["values-common"] + if !exists { + t.Fatal("Expected values-common ConfigMap from OCI") + } + if commonConfigMap["domain"] != "oci.example.com" { + t.Errorf("Expected domain 'oci.example.com', got '%s'", commonConfigMap["domain"]) + } + if commonConfigMap["region"] != "us-east-1" { + t.Errorf("Expected region 'us-east-1', got '%s'", commonConfigMap["region"]) + } + + var csiKustomization *blueprintv1alpha1.Kustomization + for i := range generated.Kustomizations { + if generated.Kustomizations[i].Name == "csi" { + csiKustomization = &generated.Kustomizations[i] + break + } + } + + if csiKustomization == nil { + t.Fatal("Expected csi kustomization") + } + if len(csiKustomization.Substitutions) != 1 { + t.Fatalf("Expected 1 substitution for csi from OCI, got %d", len(csiKustomization.Substitutions)) + } + if csiKustomization.Substitutions["volume_path"] != "/oci/volumes" { + t.Errorf("Expected volume_path '/oci/volumes', got '%s'", csiKustomization.Substitutions["volume_path"]) + } + }) + + t.Run("WithOCISubstitutionsMergedWithContext", func(t *testing.T) { + handler, mocks := setup(t) + mocks.ConfigHandler.(*config.MockConfigHandler).GetContextValuesFunc = func() (map[string]any, error) { + return map[string]any{ + "substitutions": map[string]any{ + "common": map[string]any{ + "region": "us-west-2", + }, + "csi": map[string]any{ + "storage_class": "fast-ssd", + }, + }, + }, nil + } + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = yaml.Unmarshal + handler.shims.YamlMarshal = yaml.Marshal + + tmpDir := t.TempDir() + mocks.Runtime.ProjectRoot = tmpDir + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + + ociSubstitutionsContent := `common: + domain: oci.example.com + region: us-east-1 +csi: + volume_path: /oci/volumes +` + if err := os.WriteFile(filepath.Join(mocks.Runtime.TemplateRoot, "substitutions"), []byte(ociSubstitutionsContent), 0644); err != nil { + t.Fatalf("Failed to write OCI substitutions file: %v", err) + } + + handler.blueprint = blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "csi", + Path: "csi", + }, + }, + } + + _, err := handler.GetLocalTemplateData() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + generated := handler.Generate() + + if generated.ConfigMaps == nil { + t.Fatal("Expected ConfigMaps to be set") + } + commonConfigMap, exists := generated.ConfigMaps["values-common"] + if !exists { + t.Fatal("Expected values-common ConfigMap") + } + if commonConfigMap["domain"] != "oci.example.com" { + t.Errorf("Expected domain 'oci.example.com' from OCI, got '%s'", commonConfigMap["domain"]) + } + if commonConfigMap["region"] != "us-west-2" { + t.Errorf("Expected region 'us-west-2' from context (overriding OCI), got '%s'", commonConfigMap["region"]) + } + + var csiKustomization *blueprintv1alpha1.Kustomization + for i := range generated.Kustomizations { + if generated.Kustomizations[i].Name == "csi" { + csiKustomization = &generated.Kustomizations[i] + break + } + } + + if csiKustomization == nil { + t.Fatal("Expected csi kustomization") + } + if len(csiKustomization.Substitutions) != 2 { + t.Fatalf("Expected 2 substitutions for csi (merged from OCI and context), got %d", len(csiKustomization.Substitutions)) + } + if csiKustomization.Substitutions["volume_path"] != "/oci/volumes" { + t.Errorf("Expected volume_path '/oci/volumes' from OCI, got '%s'", csiKustomization.Substitutions["volume_path"]) + } + if csiKustomization.Substitutions["storage_class"] != "fast-ssd" { + t.Errorf("Expected storage_class 'fast-ssd' from context, got '%s'", csiKustomization.Substitutions["storage_class"]) + } + }) } diff --git a/pkg/provisioner/kubernetes/kubernetes_manager.go b/pkg/provisioner/kubernetes/kubernetes_manager.go index ef5eeb2d1..8f37a44ec 100644 --- a/pkg/provisioner/kubernetes/kubernetes_manager.go +++ b/pkg/provisioner/kubernetes/kubernetes_manager.go @@ -594,9 +594,11 @@ func (k *BaseKubernetesManager) GetNodeReadyStatus(ctx context.Context, nodeName return k.client.GetNodeReadyStatus(ctx, nodeNames) } -// ApplyBlueprint applies an entire blueprint to the cluster. It creates the namespace, applies all source -// repositories (Git and OCI), and applies all kustomizations. This method orchestrates the complete -// blueprint installation process in the correct order. +// ApplyBlueprint applies the entire blueprint to the cluster in the proper sequence. +// It creates the target namespace, applies all blueprint source repositories (Git and OCI), +// applies all individual sources, applies any standalone ConfigMaps, and finally applies +// all kustomizations and their associated ConfigMaps. This orchestrates a complete +// blueprint installation following the intended order. Returns an error if any step fails. func (k *BaseKubernetesManager) ApplyBlueprint(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { if err := k.CreateNamespace(namespace); err != nil { return fmt.Errorf("failed to create namespace: %w", err) @@ -621,6 +623,15 @@ func (k *BaseKubernetesManager) ApplyBlueprint(blueprint *blueprintv1alpha1.Blue } defaultSourceName := blueprint.Metadata.Name + + if blueprint.ConfigMaps != nil { + for configMapName, data := range blueprint.ConfigMaps { + if err := k.ApplyConfigMap(configMapName, namespace, data); err != nil { + return fmt.Errorf("failed to create ConfigMap %s: %w", configMapName, err) + } + } + } + for _, kustomization := range blueprint.Kustomizations { if len(kustomization.Substitutions) > 0 { configMapName := fmt.Sprintf("values-%s", kustomization.Name) @@ -629,6 +640,22 @@ func (k *BaseKubernetesManager) ApplyBlueprint(blueprint *blueprintv1alpha1.Blue } } fluxKustomization := kustomization.ToFluxKustomization(namespace, defaultSourceName, blueprint.Sources) + + if len(blueprint.ConfigMaps) > 0 { + if fluxKustomization.Spec.PostBuild == nil { + fluxKustomization.Spec.PostBuild = &kustomizev1.PostBuild{ + SubstituteFrom: make([]kustomizev1.SubstituteReference, 0), + } + } + for configMapName := range blueprint.ConfigMaps { + fluxKustomization.Spec.PostBuild.SubstituteFrom = append(fluxKustomization.Spec.PostBuild.SubstituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: configMapName, + Optional: false, + }) + } + } + if err := k.ApplyKustomization(fluxKustomization); err != nil { return fmt.Errorf("failed to apply kustomization %s: %w", kustomization.Name, err) } diff --git a/pkg/provisioner/kubernetes/kubernetes_manager_public_test.go b/pkg/provisioner/kubernetes/kubernetes_manager_public_test.go index d9ef5ea39..dcf120613 100644 --- a/pkg/provisioner/kubernetes/kubernetes_manager_public_test.go +++ b/pkg/provisioner/kubernetes/kubernetes_manager_public_test.go @@ -3147,6 +3147,7 @@ func TestBaseKubernetesManager_ApplyBlueprint(t *testing.T) { Kustomizations: []blueprintv1alpha1.Kustomization{ { Name: "test-kustomization", + Path: "test/path", }, }, } @@ -3193,4 +3194,287 @@ func TestBaseKubernetesManager_ApplyBlueprint(t *testing.T) { t.Errorf("Expected no error, got %v", err) } }) + + t.Run("SuccessWithBlueprintConfigMaps", func(t *testing.T) { + manager := setup(t) + configMapApplied := false + kustomizationApplied := false + var appliedKustomization kustomizev1.Kustomization + kubernetesClient := client.NewMockKubernetesClient() + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + if obj.GetKind() == "ConfigMap" && obj.GetName() == "values-common" { + configMapApplied = true + } + if obj.GetKind() == "Kustomization" { + kustomizationApplied = true + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &appliedKustomization); err != nil { + t.Fatalf("Failed to convert kustomization: %v", err) + } + } + return obj, nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("not found") + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + ConfigMaps: map[string]map[string]string{ + "values-common": { + "domain": "example.com", + "region": "us-west-2", + }, + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Path: "test/path", + }, + }, + } + + err := manager.ApplyBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !configMapApplied { + t.Error("Expected values-common ConfigMap to be applied") + } + if !kustomizationApplied { + t.Error("Expected Kustomization to be applied") + } + if appliedKustomization.Spec.PostBuild == nil { + t.Fatal("Expected PostBuild to be set when blueprint has ConfigMaps") + } + if len(appliedKustomization.Spec.PostBuild.SubstituteFrom) != 1 { + t.Fatalf("Expected 1 SubstituteFrom reference (values-common), got %d", len(appliedKustomization.Spec.PostBuild.SubstituteFrom)) + } + if appliedKustomization.Spec.PostBuild.SubstituteFrom[0].Name != "values-common" { + t.Errorf("Expected SubstituteFrom to be values-common, got '%s'", appliedKustomization.Spec.PostBuild.SubstituteFrom[0].Name) + } + }) + + t.Run("SuccessWithBlueprintConfigMapsAndKustomizationSubstitutions", func(t *testing.T) { + manager := setup(t) + commonConfigMapApplied := false + kustomizationConfigMapApplied := false + kustomizationApplied := false + var appliedKustomization kustomizev1.Kustomization + kubernetesClient := client.NewMockKubernetesClient() + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + if obj.GetKind() == "ConfigMap" { + if obj.GetName() == "values-common" { + commonConfigMapApplied = true + } + if obj.GetName() == "values-test-kustomization" { + kustomizationConfigMapApplied = true + } + } + if obj.GetKind() == "Kustomization" { + kustomizationApplied = true + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &appliedKustomization); err != nil { + t.Fatalf("Failed to convert kustomization: %v", err) + } + } + return obj, nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("not found") + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + ConfigMaps: map[string]map[string]string{ + "values-common": { + "domain": "example.com", + }, + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Path: "test/path", + Substitutions: map[string]string{ + "key": "value", + }, + }, + }, + } + + err := manager.ApplyBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !commonConfigMapApplied { + t.Error("Expected values-common ConfigMap to be applied") + } + if !kustomizationConfigMapApplied { + t.Error("Expected values-test-kustomization ConfigMap to be applied") + } + if !kustomizationApplied { + t.Error("Expected Kustomization to be applied") + } + if appliedKustomization.Spec.PostBuild == nil { + t.Fatal("Expected PostBuild to be set") + } + if len(appliedKustomization.Spec.PostBuild.SubstituteFrom) != 2 { + t.Fatalf("Expected 2 SubstituteFrom references (values-common and values-test-kustomization), got %d", len(appliedKustomization.Spec.PostBuild.SubstituteFrom)) + } + foundCommon := false + foundKustomization := false + for _, ref := range appliedKustomization.Spec.PostBuild.SubstituteFrom { + if ref.Name == "values-common" { + foundCommon = true + } + if ref.Name == "values-test-kustomization" { + foundKustomization = true + } + } + if !foundCommon { + t.Error("Expected values-common in SubstituteFrom") + } + if !foundKustomization { + t.Error("Expected values-test-kustomization in SubstituteFrom") + } + }) + + t.Run("SuccessWithMultipleBlueprintConfigMaps", func(t *testing.T) { + manager := setup(t) + configMapsApplied := make(map[string]bool) + var appliedKustomization kustomizev1.Kustomization + kubernetesClient := client.NewMockKubernetesClient() + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + if obj.GetKind() == "ConfigMap" { + configMapsApplied[obj.GetName()] = true + } + if obj.GetKind() == "Kustomization" { + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &appliedKustomization); err != nil { + t.Fatalf("Failed to convert kustomization: %v", err) + } + } + return obj, nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("not found") + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + ConfigMaps: map[string]map[string]string{ + "values-common": { + "domain": "example.com", + }, + "values-shared": { + "shared_key": "shared_value", + }, + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Path: "test/path", + }, + }, + } + + err := manager.ApplyBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !configMapsApplied["values-common"] { + t.Error("Expected values-common ConfigMap to be applied") + } + if !configMapsApplied["values-shared"] { + t.Error("Expected values-shared ConfigMap to be applied") + } + if appliedKustomization.Spec.PostBuild == nil { + t.Fatal("Expected PostBuild to be set") + } + if len(appliedKustomization.Spec.PostBuild.SubstituteFrom) != 2 { + t.Fatalf("Expected 2 SubstituteFrom references, got %d", len(appliedKustomization.Spec.PostBuild.SubstituteFrom)) + } + foundCommon := false + foundShared := false + for _, ref := range appliedKustomization.Spec.PostBuild.SubstituteFrom { + if ref.Name == "values-common" { + foundCommon = true + } + if ref.Name == "values-shared" { + foundShared = true + } + } + if !foundCommon { + t.Error("Expected values-common in SubstituteFrom") + } + if !foundShared { + t.Error("Expected values-shared in SubstituteFrom") + } + }) + + t.Run("SuccessWithKustomizationSubstitutions", func(t *testing.T) { + manager := setup(t) + kustomizationConfigMapApplied := false + kustomizationApplied := false + var appliedKustomization kustomizev1.Kustomization + kubernetesClient := client.NewMockKubernetesClient() + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + if obj.GetKind() == "ConfigMap" && obj.GetName() == "values-csi" { + kustomizationConfigMapApplied = true + } + if obj.GetKind() == "Kustomization" { + kustomizationApplied = true + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &appliedKustomization); err != nil { + t.Fatalf("Failed to convert kustomization: %v", err) + } + } + return obj, nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, ns, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("not found") + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "csi", + Path: "csi", + Substitutions: map[string]string{ + "volume_path": "/custom/volumes", + "storage_class": "fast-ssd", + }, + }, + }, + } + + err := manager.ApplyBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !kustomizationConfigMapApplied { + t.Error("Expected values-csi ConfigMap to be applied") + } + if !kustomizationApplied { + t.Error("Expected Kustomization to be applied") + } + if appliedKustomization.Spec.PostBuild == nil { + t.Fatal("Expected PostBuild to be set when kustomization has substitutions") + } + if len(appliedKustomization.Spec.PostBuild.SubstituteFrom) != 1 { + t.Fatalf("Expected 1 SubstituteFrom reference (values-csi), got %d", len(appliedKustomization.Spec.PostBuild.SubstituteFrom)) + } + if appliedKustomization.Spec.PostBuild.SubstituteFrom[0].Name != "values-csi" { + t.Errorf("Expected SubstituteFrom to be values-csi, got '%s'", appliedKustomization.Spec.PostBuild.SubstituteFrom[0].Name) + } + }) } From 0aae5f887d65484c54235d45e5a552c34fb09c30 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:58:06 -0500 Subject: [PATCH 2/3] Fix on Windows Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- .../blueprint/blueprint_handler_public_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index f42e10bd8..c742ce288 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -4391,6 +4391,17 @@ func TestBaseBlueprintHandler_Generate(t *testing.T) { }, } + tmpDir := t.TempDir() + mocks.Runtime.TemplateRoot = filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(mocks.Runtime.TemplateRoot, 0755); err != nil { + t.Fatalf("Failed to create template directory: %v", err) + } + handler.shims.Stat = os.Stat + handler.shims.ReadDir = os.ReadDir + handler.shims.ReadFile = os.ReadFile + handler.shims.YamlUnmarshal = yaml.Unmarshal + handler.shims.YamlMarshal = yaml.Marshal + _, err := handler.GetLocalTemplateData() if err != nil { t.Fatalf("Expected no error, got %v", err) From 4dfa6b38351ba9c995b65c0617e18ed00cceeafc Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:58:15 -0500 Subject: [PATCH 3/3] Fix on Windows Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/blueprint/blueprint_handler_public_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index c742ce288..1ee53bb9f 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -4310,7 +4310,7 @@ func TestBaseBlueprintHandler_Generate(t *testing.T) { } return []string{} } - + buildIDPath := filepath.Join(mocks.Runtime.ProjectRoot, ".windsor", ".build-id") if err := os.MkdirAll(filepath.Dir(buildIDPath), 0755); err != nil { t.Fatalf("Failed to create .windsor directory: %v", err) @@ -4318,7 +4318,7 @@ func TestBaseBlueprintHandler_Generate(t *testing.T) { if err := os.WriteFile(buildIDPath, []byte("build-123"), 0644); err != nil { t.Fatalf("Failed to write build ID file: %v", err) } - + handler.blueprint = blueprintv1alpha1.Blueprint{ Metadata: blueprintv1alpha1.Metadata{ Name: "test-blueprint", @@ -4366,7 +4366,7 @@ func TestBaseBlueprintHandler_Generate(t *testing.T) { "domain": "example.com", }, "csi": map[string]any{ - "volume_path": "/custom/volumes", + "volume_path": "/custom/volumes", "storage_class": "fast-ssd", }, "monitoring": map[string]any{