Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 10 additions & 41 deletions api/v1alpha1/blueprint_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,38 +246,7 @@ func (b *Blueprint) DeepCopy() *Blueprint {

kustomizationsCopy := make([]Kustomization, len(b.Kustomizations))
for i, kustomization := range b.Kustomizations {
var substituteFromCopy []SubstituteReference
if kustomization.PostBuild != nil {
substituteFromCopy = make([]SubstituteReference, len(kustomization.PostBuild.SubstituteFrom))
copy(substituteFromCopy, kustomization.PostBuild.SubstituteFrom)
}

postBuildCopy := &PostBuild{
Substitute: make(map[string]string),
SubstituteFrom: substituteFromCopy,
}
if kustomization.PostBuild != nil {
for key, value := range kustomization.PostBuild.Substitute {
postBuildCopy.Substitute[key] = value
}
}

kustomizationsCopy[i] = Kustomization{
Name: kustomization.Name,
Path: kustomization.Path,
Source: kustomization.Source,
DependsOn: slices.Clone(kustomization.DependsOn),
Interval: kustomization.Interval,
RetryInterval: kustomization.RetryInterval,
Timeout: kustomization.Timeout,
Patches: slices.Clone(kustomization.Patches),
Wait: kustomization.Wait,
Force: kustomization.Force,
Prune: kustomization.Prune,
Components: slices.Clone(kustomization.Components),
Cleanup: slices.Clone(kustomization.Cleanup),
PostBuild: postBuildCopy,
}
kustomizationsCopy[i] = *kustomization.DeepCopy()
}

return &Blueprint{
Expand Down Expand Up @@ -366,9 +335,7 @@ func (b *Blueprint) Merge(overlay *Blueprint) {
if mergedComponent.Values == nil {
mergedComponent.Values = make(map[string]any)
}
for k, v := range overlayComponent.Values {
mergedComponent.Values[k] = v
}
maps.Copy(mergedComponent.Values, overlayComponent.Values)

if overlayComponent.FullPath != "" {
mergedComponent.FullPath = overlayComponent.FullPath
Expand Down Expand Up @@ -402,12 +369,14 @@ func (k *Kustomization) DeepCopy() *Kustomization {

var postBuildCopy *PostBuild
if k.PostBuild != nil {
postBuildCopy = &PostBuild{
Substitute: make(map[string]string),
SubstituteFrom: slices.Clone(k.PostBuild.SubstituteFrom),
}
for key, value := range k.PostBuild.Substitute {
postBuildCopy.Substitute[key] = value
substituteCopy := maps.Clone(k.PostBuild.Substitute)
substituteFromCopy := slices.Clone(k.PostBuild.SubstituteFrom)

if len(substituteCopy) > 0 || len(substituteFromCopy) > 0 {
postBuildCopy = &PostBuild{
Substitute: substituteCopy,
SubstituteFrom: substituteFromCopy,
}
}
}

Expand Down
51 changes: 51 additions & 0 deletions api/v1alpha1/blueprint_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1023,3 +1023,54 @@ func TestBlueprint_DeepCopy(t *testing.T) {
}
})
}

// TestPostBuildOmitEmpty verifies that empty PostBuild objects are omitted from YAML serialization
func TestPostBuildOmitEmpty(t *testing.T) {
t.Run("EmptyPostBuildOmitted", func(t *testing.T) {
kustomization := Kustomization{
Name: "test-kustomization",
Path: "test/path",
PostBuild: &PostBuild{
Substitute: map[string]string{},
SubstituteFrom: []SubstituteReference{},
},
}

// Create a copy using DeepCopy which should omit empty PostBuild
copied := kustomization.DeepCopy()

// Verify that PostBuild is nil for empty content
if copied.PostBuild != nil {
t.Errorf("Expected PostBuild to be nil for empty content, but got %v", copied.PostBuild)
}
})

t.Run("NonEmptyPostBuildPreserved", func(t *testing.T) {
kustomization := Kustomization{
Name: "test-kustomization",
Path: "test/path",
PostBuild: &PostBuild{
Substitute: map[string]string{
"key": "value",
},
SubstituteFrom: []SubstituteReference{
{Kind: "ConfigMap", Name: "test"},
},
},
}

// Create a copy using DeepCopy which should preserve non-empty PostBuild
copied := kustomization.DeepCopy()

// Verify that PostBuild is preserved for non-empty content
if copied.PostBuild == nil {
t.Error("Expected PostBuild to be preserved for non-empty content, but got nil")
}
if copied.PostBuild.Substitute["key"] != "value" {
t.Errorf("Expected substitute key to be 'value', but got %s", copied.PostBuild.Substitute["key"])
}
if len(copied.PostBuild.SubstituteFrom) != 1 {
t.Errorf("Expected 1 substitute reference, but got %d", len(copied.PostBuild.SubstituteFrom))
}
})
}
18 changes: 15 additions & 3 deletions pkg/blueprint/blueprint_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ func (b *BaseBlueprintHandler) LoadData(data map[string]any, ociInfo ...*artifac
// If overwrite is true, the file is overwritten regardless of existence. If overwrite is false or omitted,
// the file is only written if it does not already exist. The method ensures the target directory exists,
// marshals the blueprint to YAML, and writes the file using the configured shims.
// Terraform variables are filtered out to prevent them from appearing in the final blueprint.yaml.
func (b *BaseBlueprintHandler) Write(overwrite ...bool) error {
shouldOverwrite := false
if len(overwrite) > 0 {
Expand All @@ -185,7 +186,9 @@ func (b *BaseBlueprintHandler) Write(overwrite ...bool) error {
return fmt.Errorf("error creating directory: %w", err)
}

data, err := b.shims.YamlMarshal(&b.blueprint)
cleanedBlueprint := b.createCleanedBlueprint()

data, err := b.shims.YamlMarshal(cleanedBlueprint)
if err != nil {
return fmt.Errorf("error marshalling blueprint data: %w", err)
}
Expand Down Expand Up @@ -581,8 +584,17 @@ func (b *BaseBlueprintHandler) Down() error {
// Private Methods
// =============================================================================

// walkAndCollectTemplates recursively walks through the template directory and collects only .jsonnet files.
// It maintains the relative path structure from the template directory root.
// createCleanedBlueprint returns a deep copy of the blueprint with all Terraform component Values fields removed.
// All Values maps are cleared, as these should not be persisted in the final blueprint.yaml.
func (b *BaseBlueprintHandler) createCleanedBlueprint() *blueprintv1alpha1.Blueprint {
cleaned := b.blueprint.DeepCopy()
for i := range cleaned.TerraformComponents {
cleaned.TerraformComponents[i].Values = map[string]any{}
}
return cleaned
}

// walkAndCollectTemplates recursively walks through template directories and collects .jsonnet files.
func (b *BaseBlueprintHandler) walkAndCollectTemplates(templateDir, templateRoot string, templateData map[string][]byte) error {
entries, err := b.shims.ReadDir(templateDir)
if err != nil {
Expand Down
87 changes: 87 additions & 0 deletions pkg/blueprint/blueprint_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
helmv2 "github.com/fluxcd/helm-controller/api/v2"
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
"github.com/goccy/go-yaml"
blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1"
"github.com/windsorcli/cli/pkg/artifact"
"github.com/windsorcli/cli/pkg/config"
Expand Down Expand Up @@ -3250,6 +3251,92 @@ func TestBlueprintHandler_Write(t *testing.T) {
}
})

t.Run("ClearsAllValues", func(t *testing.T) {
// Given a blueprint handler with terraform components containing values
handler, mocks := setup(t)

// Set up a terraform component with both values and terraform variables
handler.blueprint = blueprintv1alpha1.Blueprint{
Kind: "Blueprint",
ApiVersion: "v1alpha1",
Metadata: blueprintv1alpha1.Metadata{
Name: "test-blueprint",
},
TerraformComponents: []blueprintv1alpha1.TerraformComponent{
{
Source: "core",
Path: "cluster/talos",
Values: map[string]any{
"cluster_name": "test-cluster", // Should be kept (not a terraform variable)
"cluster_endpoint": "https://test:6443", // Should be filtered if it's a terraform variable
"controlplanes": []string{"node1"}, // Should be filtered if it's a terraform variable
"custom_config": "some-value", // Should be kept (not a terraform variable)
},
},
},
}

// Set up file system mocks
var writtenContent []byte
mocks.Shims.WriteFile = func(name string, data []byte, perm os.FileMode) error {
writtenContent = data
return nil
}

mocks.Shims.Stat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist // blueprint.yaml doesn't exist
}

mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error {
return nil
}

mocks.Shims.YamlMarshal = func(v any) ([]byte, error) {
return yaml.Marshal(v)
}

// When Write is called
err := handler.Write()

// Then no error should be returned
if err != nil {
t.Errorf("Expected no error, got %v", err)
}

// And the written content should have all values cleared
if len(writtenContent) == 0 {
t.Errorf("Expected content to be written, got empty content")
}

// Parse the written YAML to verify all values are cleared
var writtenBlueprint blueprintv1alpha1.Blueprint
err = yaml.Unmarshal(writtenContent, &writtenBlueprint)
if err != nil {
t.Errorf("Failed to unmarshal written YAML: %v", err)
}

// Verify that the terraform component exists
if len(writtenBlueprint.TerraformComponents) != 1 {
t.Errorf("Expected 1 terraform component, got %d", len(writtenBlueprint.TerraformComponents))
}

component := writtenBlueprint.TerraformComponents[0]

// Verify all values are cleared from the blueprint.yaml
if len(component.Values) != 0 {
t.Errorf("Expected all values to be cleared, but got %d values: %v", len(component.Values), component.Values)
}

// Also verify kustomizations have postBuild cleared
if len(writtenBlueprint.Kustomizations) > 0 {
for i, kustomization := range writtenBlueprint.Kustomizations {
if kustomization.PostBuild != nil {
t.Errorf("Expected PostBuild to be cleared for kustomization %d, but got %v", i, kustomization.PostBuild)
}
}
}
})

t.Run("ErrorWritingFile", func(t *testing.T) {
// Given a blueprint handler
handler, mocks := setup(t)
Expand Down
Loading