diff --git a/api/v1alpha1/azure/azure_config_test.go b/api/v1alpha1/azure/azure_config_test.go index 66a8fe3f0..0631cc274 100644 --- a/api/v1alpha1/azure/azure_config_test.go +++ b/api/v1alpha1/azure/azure_config_test.go @@ -6,37 +6,185 @@ import ( func TestAzureConfig(t *testing.T) { t.Run("Merge", func(t *testing.T) { - base := &AzureConfig{ - Enabled: boolPtr(false), - } - overlay := &AzureConfig{ - Enabled: boolPtr(true), + tests := []struct { + name string + base *AzureConfig + overlay *AzureConfig + expected *AzureConfig + }{ + { + name: "AllFields", + base: &AzureConfig{ + Enabled: boolPtr(false), + SubscriptionID: stringPtr("old-sub"), + TenantID: stringPtr("old-tenant"), + Environment: stringPtr("old-env"), + }, + overlay: &AzureConfig{ + Enabled: boolPtr(true), + SubscriptionID: stringPtr("new-sub"), + TenantID: stringPtr("new-tenant"), + Environment: stringPtr("new-env"), + }, + expected: &AzureConfig{ + Enabled: boolPtr(true), + SubscriptionID: stringPtr("new-sub"), + TenantID: stringPtr("new-tenant"), + Environment: stringPtr("new-env"), + }, + }, + { + name: "PartialOverlay", + base: &AzureConfig{ + Enabled: boolPtr(false), + SubscriptionID: stringPtr("old-sub"), + TenantID: stringPtr("old-tenant"), + Environment: stringPtr("old-env"), + }, + overlay: &AzureConfig{ + Enabled: boolPtr(true), + }, + expected: &AzureConfig{ + Enabled: boolPtr(true), + SubscriptionID: stringPtr("old-sub"), + TenantID: stringPtr("old-tenant"), + Environment: stringPtr("old-env"), + }, + }, + { + name: "NilOverlay", + base: &AzureConfig{ + Enabled: boolPtr(false), + SubscriptionID: stringPtr("old-sub"), + TenantID: stringPtr("old-tenant"), + Environment: stringPtr("old-env"), + }, + overlay: nil, + expected: &AzureConfig{ + Enabled: boolPtr(false), + SubscriptionID: stringPtr("old-sub"), + TenantID: stringPtr("old-tenant"), + Environment: stringPtr("old-env"), + }, + }, } - base.Merge(overlay) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.base.Merge(tt.overlay) + + if tt.base.Enabled == nil || tt.expected.Enabled == nil { + if tt.base.Enabled != tt.expected.Enabled { + t.Errorf("Expected Enabled to be %v, got %v", tt.expected.Enabled, tt.base.Enabled) + } + } else if *tt.base.Enabled != *tt.expected.Enabled { + t.Errorf("Expected Enabled to be %v, got %v", *tt.expected.Enabled, *tt.base.Enabled) + } + + if tt.base.SubscriptionID == nil || tt.expected.SubscriptionID == nil { + if tt.base.SubscriptionID != tt.expected.SubscriptionID { + t.Errorf("Expected SubscriptionID to be %v, got %v", tt.expected.SubscriptionID, tt.base.SubscriptionID) + } + } else if *tt.base.SubscriptionID != *tt.expected.SubscriptionID { + t.Errorf("Expected SubscriptionID to be %v, got %v", *tt.expected.SubscriptionID, *tt.base.SubscriptionID) + } - if *base.Enabled != true { - t.Errorf("Expected Enabled to be true, got %v", *base.Enabled) + if tt.base.TenantID == nil || tt.expected.TenantID == nil { + if tt.base.TenantID != tt.expected.TenantID { + t.Errorf("Expected TenantID to be %v, got %v", tt.expected.TenantID, tt.base.TenantID) + } + } else if *tt.base.TenantID != *tt.expected.TenantID { + t.Errorf("Expected TenantID to be %v, got %v", *tt.expected.TenantID, *tt.base.TenantID) + } + + if tt.base.Environment == nil || tt.expected.Environment == nil { + if tt.base.Environment != tt.expected.Environment { + t.Errorf("Expected Environment to be %v, got %v", tt.expected.Environment, tt.base.Environment) + } + } else if *tt.base.Environment != *tt.expected.Environment { + t.Errorf("Expected Environment to be %v, got %v", *tt.expected.Environment, *tt.base.Environment) + } + }) } }) t.Run("Copy", func(t *testing.T) { - original := &AzureConfig{ - Enabled: boolPtr(true), + tests := []struct { + name string + original *AzureConfig + }{ + { + name: "AllFields", + original: &AzureConfig{ + Enabled: boolPtr(true), + SubscriptionID: stringPtr("sub"), + TenantID: stringPtr("tenant"), + Environment: stringPtr("env"), + }, + }, + { + name: "SomeFields", + original: &AzureConfig{ + Enabled: boolPtr(true), + }, + }, + { + name: "Nil", + original: nil, + }, } - copy := original.Copy() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + copy := tt.original.Copy() - if copy == nil { - t.Fatal("Expected non-nil copy") - } + if tt.original == nil { + if copy != nil { + t.Error("Expected nil copy for nil original") + } + return + } - if copy == original { - t.Error("Expected copy to be a new instance") - } + if copy == nil { + t.Fatal("Expected non-nil copy") + } - if *copy.Enabled != *original.Enabled { - t.Errorf("Expected Enabled to be %v, got %v", *original.Enabled, *copy.Enabled) + if copy == tt.original { + t.Error("Expected copy to be a new instance") + } + + if tt.original.Enabled == nil { + if copy.Enabled != nil { + t.Error("Expected Enabled to be nil") + } + } else if copy.Enabled == nil || *copy.Enabled != *tt.original.Enabled { + t.Errorf("Expected Enabled to be %v, got %v", tt.original.Enabled, copy.Enabled) + } + + if tt.original.SubscriptionID == nil { + if copy.SubscriptionID != nil { + t.Error("Expected SubscriptionID to be nil") + } + } else if copy.SubscriptionID == nil || *copy.SubscriptionID != *tt.original.SubscriptionID { + t.Errorf("Expected SubscriptionID to be %v, got %v", tt.original.SubscriptionID, copy.SubscriptionID) + } + + if tt.original.TenantID == nil { + if copy.TenantID != nil { + t.Error("Expected TenantID to be nil") + } + } else if copy.TenantID == nil || *copy.TenantID != *tt.original.TenantID { + t.Errorf("Expected TenantID to be %v, got %v", tt.original.TenantID, copy.TenantID) + } + + if tt.original.Environment == nil { + if copy.Environment != nil { + t.Error("Expected Environment to be nil") + } + } else if copy.Environment == nil || *copy.Environment != *tt.original.Environment { + t.Errorf("Expected Environment to be %v, got %v", tt.original.Environment, copy.Environment) + } + }) } }) } @@ -44,3 +192,7 @@ func TestAzureConfig(t *testing.T) { func boolPtr(b bool) *bool { return &b } + +func stringPtr(s string) *string { + return &s +} diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index 3cb39de8e..3d96962b2 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -117,6 +117,10 @@ type TerraformComponent struct { // Values are configuration values for the module. Values map[string]any `yaml:"values,omitempty"` + + // Destroy determines if the component should be destroyed during down operations. + // Defaults to true if not specified. + Destroy *bool `yaml:"destroy,omitempty"` } // Kustomization defines a kustomization config in a blueprint. @@ -230,6 +234,7 @@ func (b *Blueprint) DeepCopy() *Blueprint { Path: component.Path, FullPath: component.FullPath, Values: valuesCopy, + Destroy: component.Destroy, } } @@ -360,6 +365,11 @@ func (b *Blueprint) Merge(overlay *Blueprint) { if overlayComponent.FullPath != "" { mergedComponent.FullPath = overlayComponent.FullPath } + + if overlayComponent.Destroy != nil { + mergedComponent.Destroy = overlayComponent.Destroy + } + b.TerraformComponents = append(b.TerraformComponents, mergedComponent) } else { b.TerraformComponents = append(b.TerraformComponents, overlayComponent) diff --git a/api/v1alpha1/blueprint_types_test.go b/api/v1alpha1/blueprint_types_test.go index c4ad4aa9a..7d9f5ea06 100644 --- a/api/v1alpha1/blueprint_types_test.go +++ b/api/v1alpha1/blueprint_types_test.go @@ -4,8 +4,6 @@ import ( "reflect" "sort" "testing" - - "github.com/fluxcd/pkg/apis/kustomize" ) func TestBlueprint_Merge(t *testing.T) { @@ -40,6 +38,7 @@ func TestBlueprint_Merge(t *testing.T) { Path: "module/path1", Values: map[string]any{"key1": "value1"}, FullPath: "original/full/path", + Destroy: ptrBool(true), }, }, Kustomizations: []Kustomization{ @@ -85,12 +84,14 @@ func TestBlueprint_Merge(t *testing.T) { Path: "module/path1", Values: map[string]any{"key2": "value2"}, FullPath: "updated/full/path", + Destroy: ptrBool(false), }, { Source: "source2", Path: "module/path2", Values: map[string]any{"key3": "value3"}, FullPath: "new/full/path", + Destroy: ptrBool(true), }, }, Kustomizations: []Kustomization{ @@ -169,6 +170,10 @@ func TestBlueprint_Merge(t *testing.T) { if component1.FullPath != "updated/full/path" { t.Errorf("Expected FullPath to be 'updated/full/path', but got '%s'", component1.FullPath) } + if component1.Destroy == nil || *component1.Destroy != false { + t.Errorf("Expected Destroy to be false, but got %v", component1.Destroy) + } + component2 := dst.TerraformComponents[1] if component2.Values == nil || len(component2.Values) != 1 || component2.Values["key3"] != "value3" { t.Errorf("Expected Values to contain 'key3', but got %v", component2.Values) @@ -176,6 +181,9 @@ func TestBlueprint_Merge(t *testing.T) { if component2.FullPath != "new/full/path" { t.Errorf("Expected FullPath to be 'new/full/path', but got '%s'", component2.FullPath) } + if component2.Destroy == nil || *component2.Destroy != true { + t.Errorf("Expected Destroy to be true, but got %v", component2.Destroy) + } if len(dst.Kustomizations) != 1 { t.Fatalf("Expected 1 Kustomization, but got %d", len(dst.Kustomizations)) @@ -212,6 +220,7 @@ func TestBlueprint_Merge(t *testing.T) { Path: "module/path1", Values: nil, // Initialize with nil FullPath: "original/full/path", + Destroy: ptrBool(true), }, }, } @@ -225,6 +234,7 @@ func TestBlueprint_Merge(t *testing.T) { "key1": "value1", }, FullPath: "overlay/full/path", + Destroy: ptrBool(false), }, }, } @@ -242,597 +252,148 @@ func TestBlueprint_Merge(t *testing.T) { if component.FullPath != "overlay/full/path" { t.Errorf("Expected FullPath to be 'overlay/full/path', but got '%s'", component.FullPath) } + if component.Destroy == nil || *component.Destroy != false { + t.Errorf("Expected Destroy to be false, but got %v", component.Destroy) + } }) t.Run("NoMergeWhenSrcIsNil", func(t *testing.T) { dst := &Blueprint{ Kind: "Blueprint", ApiVersion: "v1alpha1", - Metadata: Metadata{ - Name: "original", - Description: "original description", - Authors: []string{"author1"}, - }, - Sources: []Source{ - { - Name: "source1", - Url: "http://example.com/source1", - PathPrefix: "prefix1", - Ref: Reference{ - Branch: "main", - }, - }, - }, TerraformComponents: []TerraformComponent{ { Source: "source1", - Path: "path1", - Values: nil, + Path: "module/path1", + Values: map[string]any{"key1": "value1"}, FullPath: "original/full/path", - }, - }, - Kustomizations: []Kustomization{ - { - Name: "kustomization1", - Path: "kustomize/path1", - Components: []string{"component1"}, - PostBuild: &PostBuild{ - SubstituteFrom: []SubstituteReference{ - {Kind: "ConfigMap", Name: "config1"}, - }, - }, + Destroy: ptrBool(true), }, }, } dst.Merge(nil) - if dst.Metadata.Name != "original" { - t.Errorf("Expected Metadata.Name to remain 'original', but got '%s'", dst.Metadata.Name) - } - if dst.Metadata.Description != "original description" { - t.Errorf("Expected Metadata.Description to remain 'original description', but got '%s'", dst.Metadata.Description) - } - if len(dst.Sources) != 1 || dst.Sources[0].Name != "source1" { - t.Errorf("Expected Sources to remain unchanged, but got %v", dst.Sources) - } - if len(dst.TerraformComponents) != 1 || dst.TerraformComponents[0].Source != "source1" { - t.Errorf("Expected TerraformComponents to remain unchanged, but got %v", dst.TerraformComponents) - } - if len(dst.Kustomizations) != 1 || dst.Kustomizations[0].Name != "kustomization1" { - t.Errorf("Expected Kustomizations to remain unchanged, but got %v", dst.Kustomizations) - } - }) - - t.Run("MatchingPathNotSource", func(t *testing.T) { - dst := &Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - TerraformComponents: []TerraformComponent{ - { - Source: "source1", - Path: "module/path1", - Values: map[string]any{ - "key1": "value1", - }, - FullPath: "original/full/path", - }, - }, - } - - overlay := &Blueprint{ - TerraformComponents: []TerraformComponent{ - { - Source: "source2", // Different source - Path: "module/path1", // Same path - Values: map[string]any{ - "key2": "value2", - }, - FullPath: "updated/full/path", - }, - }, - } - - dst.Merge(overlay) - - if len(dst.TerraformComponents) != 1 { - t.Fatalf("Expected 1 TerraformComponent, but got %d", len(dst.TerraformComponents)) - } - - component := dst.TerraformComponents[0] - if component.Source != "source2" { - t.Errorf("Expected Source to be 'source2', but got '%s'", component.Source) - } - if component.Values == nil || len(component.Values) != 1 || component.Values["key2"] != "value2" { - t.Errorf("Expected Values to contain 'key2', but got %v", component.Values) - } - if component.FullPath != "updated/full/path" { - t.Errorf("Expected FullPath to be 'updated/full/path', but got '%s'", component.FullPath) - } - }) - - t.Run("OverlayWithoutComponents", func(t *testing.T) { - dst := &Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - TerraformComponents: []TerraformComponent{ - { - Source: "source1", - Path: "module/path1", - Values: map[string]any{ - "key1": "value1", - }, - FullPath: "original/full/path", - }, - }, - } - - overlay := &Blueprint{ - TerraformComponents: []TerraformComponent{}, - } - - dst.Merge(overlay) - if len(dst.TerraformComponents) != 1 { t.Fatalf("Expected 1 TerraformComponent, but got %d", len(dst.TerraformComponents)) } component := dst.TerraformComponents[0] - if component.Source != "source1" { - t.Errorf("Expected Source to be 'source1', but got '%s'", component.Source) - } if component.Values == nil || len(component.Values) != 1 || component.Values["key1"] != "value1" { t.Errorf("Expected Values to contain 'key1', but got %v", component.Values) } if component.FullPath != "original/full/path" { t.Errorf("Expected FullPath to be 'original/full/path', but got '%s'", component.FullPath) } - }) - - t.Run("EmptyDstWithOverlayComponents", func(t *testing.T) { - dst := &Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - TerraformComponents: []TerraformComponent{}, - } - - overlay := &Blueprint{ - TerraformComponents: []TerraformComponent{ - { - Source: "source1", - Path: "module/path1", - Values: map[string]any{ - "key1": "value1", - }, - FullPath: "overlay/full/path", - }, - }, - } - - dst.Merge(overlay) - - if len(dst.TerraformComponents) != 1 { - t.Fatalf("Expected 1 TerraformComponent, but got %d", len(dst.TerraformComponents)) - } - - component := dst.TerraformComponents[0] - if component.Source != "source1" { - t.Errorf("Expected Source to be 'source1', but got '%s'", component.Source) - } - if component.Values == nil || len(component.Values) != 1 || component.Values["key1"] != "value1" { - t.Errorf("Expected Values to contain 'key1', but got %v", component.Values) - } - if component.FullPath != "overlay/full/path" { - t.Errorf("Expected FullPath to be 'overlay/full/path', but got '%s'", component.FullPath) + if component.Destroy == nil || *component.Destroy != true { + t.Errorf("Expected Destroy to be true, but got %v", component.Destroy) } }) - t.Run("MergeUniqueKustomizePatches", func(t *testing.T) { - dst := &Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - Kustomizations: []Kustomization{ - { - Name: "kustomization1", - Path: "kustomize/path1", - Components: []string{"component1"}, - Patches: []kustomize.Patch{ - {Patch: "patch1", Target: &kustomize.Selector{Group: "group1", Version: "v1", Kind: "Kind1", Namespace: "namespace1", Name: "name1"}}, - }, - PostBuild: &PostBuild{ - SubstituteFrom: []SubstituteReference{ - {Kind: "ConfigMap", Name: "config1"}, - }, - }, - }, + t.Run("DestroyFieldMerge", func(t *testing.T) { + tests := []struct { + name string + dst *bool + overlay *bool + expected *bool + }{ + { + name: "BothNil", + dst: nil, + overlay: nil, + expected: nil, }, - } - - overlay := &Blueprint{ - Kustomizations: []Kustomization{ - { - Name: "kustomization1", - Path: "kustomize/path1", - Components: []string{"component2"}, // New component - Patches: []kustomize.Patch{ - {Patch: "patch2", Target: &kustomize.Selector{Group: "group2", Version: "v2", Kind: "Kind2", Namespace: "namespace2", Name: "name2"}}, - }, - PostBuild: &PostBuild{ - SubstituteFrom: []SubstituteReference{ - {Kind: "Secret", Name: "secret1"}, - }, - }, - }, - { - Name: "kustomization2", - Path: "kustomize/path2", - Components: []string{"component3"}, - Patches: []kustomize.Patch{ - {Patch: "patch3", Target: &kustomize.Selector{Group: "group3", Version: "v3", Kind: "Kind3", Namespace: "namespace3", Name: "name3"}}, - }, - PostBuild: &PostBuild{ - SubstituteFrom: []SubstituteReference{ - {Kind: "ConfigMap", Name: "config2"}, - }, - }, - }, + { + name: "DstNilOverlayTrue", + dst: nil, + overlay: ptrBool(true), + expected: ptrBool(true), }, - } - - dst.Merge(overlay) - - if len(dst.Kustomizations) != 2 { - t.Fatalf("Expected 2 Kustomizations, but got %d", len(dst.Kustomizations)) - } - - kustomization1 := dst.Kustomizations[0] - if len(kustomization1.Components) != 1 || kustomization1.Components[0] != "component2" { - t.Errorf("Expected Kustomization1 Components to be ['component2'], but got %v", kustomization1.Components) - } - if len(kustomization1.Patches) != 1 || kustomization1.Patches[0].Patch != "patch2" { - t.Errorf("Expected Kustomization1 Patches to be ['patch2'], but got %v", kustomization1.Patches) - } - if len(kustomization1.PostBuild.SubstituteFrom) != 1 || kustomization1.PostBuild.SubstituteFrom[0].Kind != "Secret" || kustomization1.PostBuild.SubstituteFrom[0].Name != "secret1" { - t.Errorf("Expected Kustomization1 SubstituteFrom to be ['Secret:secret1'], but got %v", kustomization1.PostBuild.SubstituteFrom) - } - - kustomization2 := dst.Kustomizations[1] - if len(kustomization2.Components) != 1 || kustomization2.Components[0] != "component3" { - t.Errorf("Expected Kustomization2 Components to be ['component3'], but got %v", kustomization2.Components) - } - if len(kustomization2.Patches) != 1 || kustomization2.Patches[0].Patch != "patch3" { - t.Errorf("Expected Kustomization2 Patches to be ['patch3'], but got %v", kustomization2.Patches) - } - if len(kustomization2.PostBuild.SubstituteFrom) != 1 || kustomization2.PostBuild.SubstituteFrom[0].Kind != "ConfigMap" || kustomization2.PostBuild.SubstituteFrom[0].Name != "config2" { - t.Errorf("Expected Kustomization2 SubstituteFrom to be ['ConfigMap:config2'], but got %v", kustomization2.PostBuild.SubstituteFrom) - } - }) - - t.Run("MergeUniqueComponents", func(t *testing.T) { - dst := &Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - TerraformComponents: []TerraformComponent{ - { - Source: "source1", - Path: "module/path1", - Values: map[string]any{ - "key1": "value1", - }, - FullPath: "original/full/path", - }, + { + name: "DstNilOverlayFalse", + dst: nil, + overlay: ptrBool(false), + expected: ptrBool(false), }, - } - - overlay := &Blueprint{ - TerraformComponents: []TerraformComponent{ - { - Source: "source1", - Path: "module/path1", - Values: map[string]any{ - "key2": "value2", - }, - FullPath: "updated/full/path", - }, - { - Source: "source2", - Path: "module/path2", - Values: map[string]any{ - "key3": "value3", - }, - FullPath: "new/full/path", - }, + { + name: "DstTrueOverlayNil", + dst: ptrBool(true), + overlay: nil, + expected: ptrBool(true), }, - } - - dst.Merge(overlay) - - if len(dst.TerraformComponents) != 2 { - t.Fatalf("Expected 2 TerraformComponents, but got %d", len(dst.TerraformComponents)) - } - - component1 := dst.TerraformComponents[0] - if component1.Values == nil || len(component1.Values) != 2 || component1.Values["key1"] != "value1" || component1.Values["key2"] != "value2" { - t.Errorf("Expected Values to contain both 'key1' and 'key2', but got %v", component1.Values) - } - if component1.FullPath != "updated/full/path" { - t.Errorf("Expected FullPath to be 'updated/full/path', but got '%s'", component1.FullPath) - } - - component2 := dst.TerraformComponents[1] - if component2.Values == nil || len(component2.Values) != 1 || component2.Values["key3"] != "value3" { - t.Errorf("Expected Values to contain 'key3', but got %v", component2.Values) - } - if component2.FullPath != "new/full/path" { - t.Errorf("Expected FullPath to be 'new/full/path', but got '%s'", component2.FullPath) - } - }) - - t.Run("RepositoryMerge", func(t *testing.T) { - tests := []struct { - name string - dst *Blueprint - overlay *Blueprint - expectedCommit string - expectedName string - expectedSemVer string - expectedTag string - expectedBranch string - expectedSecret string - }{ { - name: "OverlayWithCommit", - dst: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Commit: "originalCommit", - Name: "originalName", - SemVer: "originalSemVer", - Tag: "originalTag", - Branch: "originalBranch", - }, - SecretName: "originalSecret", - }, - }, - overlay: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Commit: "newCommit", - }, - SecretName: "newSecret", - }, - }, - expectedCommit: "newCommit", - expectedName: "originalName", - expectedSemVer: "originalSemVer", - expectedTag: "originalTag", - expectedBranch: "originalBranch", - expectedSecret: "newSecret", + name: "DstFalseOverlayNil", + dst: ptrBool(false), + overlay: nil, + expected: ptrBool(false), }, { - name: "OverlayWithName", - dst: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Name: "originalName", - SemVer: "originalSemVer", - Tag: "originalTag", - Branch: "originalBranch", - }, - SecretName: "originalSecret", - }, - }, - overlay: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Name: "newName", - }, - SecretName: "newSecret", - }, - }, - expectedCommit: "", - expectedName: "newName", - expectedSemVer: "originalSemVer", - expectedTag: "originalTag", - expectedBranch: "originalBranch", - expectedSecret: "newSecret", + name: "DstTrueOverlayTrue", + dst: ptrBool(true), + overlay: ptrBool(true), + expected: ptrBool(true), }, { - name: "OverlayWithSemVer", - dst: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - SemVer: "originalSemVer", - Tag: "originalTag", - Branch: "originalBranch", - }, - SecretName: "originalSecret", - }, - }, - overlay: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - SemVer: "newSemVer", - }, - SecretName: "newSecret", - }, - }, - expectedCommit: "", - expectedName: "", - expectedSemVer: "newSemVer", - expectedTag: "originalTag", - expectedBranch: "originalBranch", - expectedSecret: "newSecret", + name: "DstTrueOverlayFalse", + dst: ptrBool(true), + overlay: ptrBool(false), + expected: ptrBool(false), }, { - name: "OverlayWithTag", - dst: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Tag: "originalTag", - Branch: "originalBranch", - }, - SecretName: "originalSecret", - }, - }, - overlay: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Tag: "newTag", - }, - SecretName: "newSecret", - }, - }, - expectedCommit: "", - expectedName: "", - expectedSemVer: "", - expectedTag: "newTag", - expectedBranch: "originalBranch", - expectedSecret: "newSecret", + name: "DstFalseOverlayTrue", + dst: ptrBool(false), + overlay: ptrBool(true), + expected: ptrBool(true), }, { - name: "OverlayWithBranch", - dst: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Branch: "originalBranch", - }, - SecretName: "originalSecret", - }, - }, - overlay: &Blueprint{ - Repository: Repository{ - Ref: Reference{ - Branch: "newBranch", - }, - SecretName: "newSecret", - }, - }, - expectedCommit: "", - expectedName: "", - expectedSemVer: "", - expectedTag: "", - expectedBranch: "newBranch", - expectedSecret: "newSecret", + name: "DstFalseOverlayFalse", + dst: ptrBool(false), + overlay: ptrBool(false), + expected: ptrBool(false), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.dst.Merge(tt.overlay) - - if tt.dst.Repository.Ref.Commit != tt.expectedCommit { - t.Errorf("Expected Commit to be '%s', but got '%s'", tt.expectedCommit, tt.dst.Repository.Ref.Commit) - } - if tt.dst.Repository.Ref.Name != tt.expectedName { - t.Errorf("Expected Name to be '%s', but got '%s'", tt.expectedName, tt.dst.Repository.Ref.Name) - } - if tt.dst.Repository.Ref.SemVer != tt.expectedSemVer { - t.Errorf("Expected SemVer to be '%s', but got '%s'", tt.expectedSemVer, tt.dst.Repository.Ref.SemVer) - } - if tt.dst.Repository.Ref.Tag != tt.expectedTag { - t.Errorf("Expected Tag to be '%s', but got '%s'", tt.expectedTag, tt.dst.Repository.Ref.Tag) - } - if tt.dst.Repository.Ref.Branch != tt.expectedBranch { - t.Errorf("Expected Branch to be '%s', but got '%s'", tt.expectedBranch, tt.dst.Repository.Ref.Branch) - } - if tt.dst.Repository.SecretName != tt.expectedSecret { - t.Errorf("Expected SecretName to be '%s', but got '%s'", tt.expectedSecret, tt.dst.Repository.SecretName) - } - }) - } - }) - - t.Run("PostBuildMerge", func(t *testing.T) { - dst := &Blueprint{ - Kind: "Blueprint", - ApiVersion: "v1alpha1", - Kustomizations: []Kustomization{ - { - Name: "kustomization1", - PostBuild: &PostBuild{ - Substitute: map[string]string{ - "key1": "value1", - }, - SubstituteFrom: []SubstituteReference{ - {Kind: "ConfigMap", Name: "config1"}, + dst := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + Destroy: tt.dst, }, }, - }, - }, - } + } - overlay := &Blueprint{ - Kustomizations: []Kustomization{ - { - Name: "kustomization1", - PostBuild: &PostBuild{ - Substitute: map[string]string{ - "key2": "value2", - }, - SubstituteFrom: []SubstituteReference{ - {Kind: "Secret", Name: "secret1"}, + overlay := &Blueprint{ + TerraformComponents: []TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + Destroy: tt.overlay, }, }, - }, - }, - } - - dst.Merge(overlay) - - if len(dst.Kustomizations) != 1 { - t.Fatalf("Expected 1 Kustomization, but got %d", len(dst.Kustomizations)) - } - - postBuild := dst.Kustomizations[0].PostBuild - if postBuild == nil { - t.Fatalf("Expected PostBuild to be non-nil") - } - - if len(postBuild.Substitute) != 1 || postBuild.Substitute["key2"] != "value2" { - t.Errorf("Expected Substitute to contain ['key2'], but got %v", postBuild.Substitute) - } - - if len(postBuild.SubstituteFrom) != 1 { - t.Errorf("Expected SubstituteFrom to contain 1 item, but got %d", len(postBuild.SubstituteFrom)) - } - }) - - t.Run("KindAndApiVersionMerge", func(t *testing.T) { - dst := &Blueprint{ - Kind: "OldKind", - ApiVersion: "old/v1", - } - - overlay := &Blueprint{ - Kind: "NewKind", - ApiVersion: "new/v2", - } - - dst.Merge(overlay) - - if dst.Kind != "NewKind" { - t.Errorf("Expected Kind to be 'NewKind', but got '%s'", dst.Kind) - } - - if dst.ApiVersion != "new/v2" { - t.Errorf("Expected ApiVersion to be 'new/v2', but got '%s'", dst.ApiVersion) - } - - // Test with empty values which shouldn't override - emptyOverlay := &Blueprint{ - Kind: "", - ApiVersion: "", - } + } - dst.Merge(emptyOverlay) + dst.Merge(overlay) - if dst.Kind != "NewKind" { - t.Errorf("Expected Kind to remain 'NewKind', but got '%s'", dst.Kind) - } + if len(dst.TerraformComponents) != 1 { + t.Fatalf("Expected 1 TerraformComponent, but got %d", len(dst.TerraformComponents)) + } - if dst.ApiVersion != "new/v2" { - t.Errorf("Expected ApiVersion to remain 'new/v2', but got '%s'", dst.ApiVersion) + component := dst.TerraformComponents[0] + if tt.expected == nil { + if component.Destroy != nil { + t.Errorf("Expected Destroy to be nil, but got %v", component.Destroy) + } + } else { + if component.Destroy == nil { + t.Errorf("Expected Destroy to be %v, but got nil", *tt.expected) + } else if *component.Destroy != *tt.expected { + t.Errorf("Expected Destroy to be %v, but got %v", *tt.expected, *component.Destroy) + } + } + }) } }) } diff --git a/cmd/down.go b/cmd/down.go index 61227f8e2..a01eda6c4 100644 --- a/cmd/down.go +++ b/cmd/down.go @@ -29,6 +29,8 @@ var downCmd = &cobra.Command{ VM: true, Containers: true, Network: true, + Blueprint: true, + Stack: true, CommandName: cmd.Name(), Flags: map[string]bool{ "verbose": verbose, @@ -46,6 +48,15 @@ var downCmd = &cobra.Command{ shell := controller.ResolveShell() configHandler := controller.ResolveConfigHandler() + // Tear down the stack components + stack := controller.ResolveStack() + if stack == nil { + return fmt.Errorf("No stack found") + } + if err := stack.Down(); err != nil { + return fmt.Errorf("Error running stack Down command: %w", err) + } + // Determine if the container runtime is enabled containerRuntimeEnabled := configHandler.GetBool("docker.enabled") diff --git a/cmd/down_test.go b/cmd/down_test.go index 9e336d26b..6d0a36613 100644 --- a/cmd/down_test.go +++ b/cmd/down_test.go @@ -7,6 +7,7 @@ import ( "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/controller" + "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/virt" ) @@ -18,6 +19,7 @@ import ( type DownMocks struct { *Mocks ContainerRuntime *virt.MockVirt + Stack *stack.MockStack } // ============================================================================= @@ -53,12 +55,17 @@ contexts: containerRuntime.DownFunc = func() error { return nil } mocks.Injector.Register("containerRuntime", containerRuntime) + mockStack := stack.NewMockStack(mocks.Injector) + mockStack.DownFunc = func() error { return nil } + mocks.Injector.Register("stack", mockStack) + mocks.Controller.SetEnvironmentVariablesFunc = func() error { return nil } mocks.Controller.InitializeWithRequirementsFunc = func(req controller.Requirements) error { return nil } return &DownMocks{ Mocks: mocks, ContainerRuntime: containerRuntime, + Stack: mockStack, } } @@ -109,6 +116,88 @@ func TestDownCmd(t *testing.T) { } }) + t.Run("ErrorNilStack", func(t *testing.T) { + mocks := setupDownMocks(t) + mocks.Controller.ResolveStackFunc = func() stack.Stack { + return nil + } + + rootCmd.SetArgs([]string{"down"}) + err := Execute(mocks.Controller) + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "No stack found" { + t.Errorf("Expected 'No stack found', got '%v'", err) + } + }) + + t.Run("ErrorStackDown", func(t *testing.T) { + mocks := setupDownMocks(t) + mocks.Stack.DownFunc = func() error { + return fmt.Errorf("test error") + } + + rootCmd.SetArgs([]string{"down"}) + err := Execute(mocks.Controller) + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "Error running stack Down command") { + t.Errorf("Expected error to contain 'Error running stack Down command', got: %v", err) + } + }) + + t.Run("StackDownCalledWithRequirements", func(t *testing.T) { + mocks := setupDownMocks(t) + stackDownCalled := false + mocks.Stack.DownFunc = func() error { + stackDownCalled = true + return nil + } + + // Verify requirements are set correctly + mocks.Controller.InitializeWithRequirementsFunc = func(req controller.Requirements) error { + if !req.ConfigLoaded { + t.Error("Expected ConfigLoaded to be true") + } + if !req.Trust { + t.Error("Expected Trust to be true") + } + if !req.Env { + t.Error("Expected Env to be true") + } + if !req.VM { + t.Error("Expected VM to be true") + } + if !req.Containers { + t.Error("Expected Containers to be true") + } + if !req.Network { + t.Error("Expected Network to be true") + } + if !req.Blueprint { + t.Error("Expected Blueprint to be true") + } + if !req.Stack { + t.Error("Expected Stack to be true") + } + if req.CommandName != "down" { + t.Errorf("Expected CommandName to be 'down', got %s", req.CommandName) + } + return nil + } + + rootCmd.SetArgs([]string{"down"}) + err := Execute(mocks.Controller) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if !stackDownCalled { + t.Error("Expected stack.Down() to be called") + } + }) + t.Run("ErrorNilContainerRuntime", func(t *testing.T) { mocks := setupDownMocks(t) mocks.Controller.ResolveContainerRuntimeFunc = func() virt.ContainerRuntime { diff --git a/pkg/blueprint/templates/default.jsonnet b/pkg/blueprint/templates/default.jsonnet index 45907f257..b4b91bd1c 100644 --- a/pkg/blueprint/templates/default.jsonnet +++ b/pkg/blueprint/templates/default.jsonnet @@ -227,6 +227,7 @@ local terraformConfig = if platform == "local" || platform == "metal" then [ { path: "gitops/flux", source: "core", + destroy: false, values: if platform == "local" then { git_username: "local", git_password: "local", diff --git a/pkg/stack/mock_stack.go b/pkg/stack/mock_stack.go index 8c5aa87b1..8e725c2b5 100644 --- a/pkg/stack/mock_stack.go +++ b/pkg/stack/mock_stack.go @@ -15,6 +15,7 @@ import "github.com/windsorcli/cli/pkg/di" type MockStack struct { InitializeFunc func() error UpFunc func() error + DownFunc func() error } // ============================================================================= @@ -46,5 +47,13 @@ func (m *MockStack) Up() error { return nil } +// Down is a mock implementation of the Down method. +func (m *MockStack) Down() error { + if m.DownFunc != nil { + return m.DownFunc() + } + return nil +} + // Ensure MockStack implements Stack var _ Stack = (*MockStack)(nil) diff --git a/pkg/stack/mock_stack_test.go b/pkg/stack/mock_stack_test.go index a5d5a15b9..9de778c56 100644 --- a/pkg/stack/mock_stack_test.go +++ b/pkg/stack/mock_stack_test.go @@ -77,3 +77,36 @@ func TestMockStack_Up(t *testing.T) { } }) } + +func TestMockStack_Down(t *testing.T) { + mockDownErr := fmt.Errorf("mock down error") + + t.Run("WithFuncSet", func(t *testing.T) { + // Given a new MockStack with a custom DownFunc that returns an error + mock := NewMockStack(nil) + mock.DownFunc = func() error { + return mockDownErr + } + + // When Down is called + err := mock.Down() + + // Then the custom error should be returned + if err != mockDownErr { + t.Errorf("Expected error = %v, got = %v", mockDownErr, err) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a new MockStack without a custom DownFunc + mock := NewMockStack(nil) + + // When Down is called + err := mock.Down() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected error = %v, got = %v", nil, err) + } + }) +} diff --git a/pkg/stack/stack.go b/pkg/stack/stack.go index 3fc22e5bc..7a3e9be38 100644 --- a/pkg/stack/stack.go +++ b/pkg/stack/stack.go @@ -23,6 +23,7 @@ import ( type Stack interface { Initialize() error Up() error + Down() error } // ============================================================================= @@ -89,5 +90,10 @@ func (s *BaseStack) Up() error { return nil } +// Down destroys a stack of components. +func (s *BaseStack) Down() error { + return nil +} + // Ensure BaseStack implements Stack var _ Stack = (*BaseStack)(nil) diff --git a/pkg/stack/stack_test.go b/pkg/stack/stack_test.go index d6c4f5974..39d6ada95 100644 --- a/pkg/stack/stack_test.go +++ b/pkg/stack/stack_test.go @@ -338,6 +338,54 @@ func TestStack_Up(t *testing.T) { }) } +func TestStack_Down(t *testing.T) { + setup := func(t *testing.T) (*BaseStack, *Mocks) { + t.Helper() + mocks := setupMocks(t) + stack := NewBaseStack(mocks.Injector) + stack.shims = mocks.Shims + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given safe mock components + stack, _ := setup(t) + + // When a new BaseStack is created and initialized + if err := stack.Initialize(); err != nil { + t.Fatalf("Expected no error during initialization, got %v", err) + } + + // And when Down is called + if err := stack.Down(); err != nil { + // Then no error should occur + t.Errorf("Expected Down to return nil, got %v", err) + } + }) + + t.Run("UninitializedStack", func(t *testing.T) { + // Given a new BaseStack without initialization + stack, _ := setup(t) + + // When Down is called without initializing + if err := stack.Down(); err != nil { + // Then no error should occur since base implementation is empty + t.Errorf("Expected Down to return nil even without initialization, got %v", err) + } + }) + + t.Run("NilInjector", func(t *testing.T) { + // Given a BaseStack with nil injector + stack := NewBaseStack(nil) + + // When Down is called + if err := stack.Down(); err != nil { + // Then no error should occur since base implementation is empty + t.Errorf("Expected Down to return nil even with nil injector, got %v", err) + } + }) +} + func TestStack_Interface(t *testing.T) { t.Run("BaseStackImplementsStack", func(t *testing.T) { // Given a type assertion for Stack interface diff --git a/pkg/stack/windsor_stack.go b/pkg/stack/windsor_stack.go index 13e1d15bb..755dc8004 100644 --- a/pkg/stack/windsor_stack.go +++ b/pkg/stack/windsor_stack.go @@ -41,35 +41,31 @@ func NewWindsorStack(injector di.Injector) *WindsorStack { // Public Methods // ============================================================================= -// Up creates a new stack of components. +// Up creates a new stack of components by initializing and applying Terraform configurations. +// It processes components in order, setting up environment variables, running Terraform init, +// plan, and apply operations, and cleaning up backend override files. +// The method ensures proper directory management and environment setup for each component. func (s *WindsorStack) Up() error { - // Store the current directory currentDir, err := s.shims.Getwd() if err != nil { return fmt.Errorf("error getting current directory: %v", err) } - // Ensure we change back to the original directory once the function completes defer func() { _ = s.shims.Chdir(currentDir) }() - // Get the Terraform components from the blueprint components := s.blueprintHandler.GetTerraformComponents() - // Iterate over the components for _, component := range components { - // Ensure the directory exists if _, err := s.shims.Stat(component.FullPath); os.IsNotExist(err) { return fmt.Errorf("directory %s does not exist", component.FullPath) } - // Change to the component directory if err := s.shims.Chdir(component.FullPath); err != nil { return fmt.Errorf("error changing to directory %s: %v", component.FullPath, err) } - // Iterate over all envPrinters and load the environment variables for _, envPrinter := range s.envPrinters { envVars, err := envPrinter.GetEnvVars() if err != nil { @@ -80,31 +76,99 @@ func (s *WindsorStack) Up() error { return fmt.Errorf("error setting environment variable %s: %v", key, err) } } - // Run the post environment hook if err := envPrinter.PostEnvHook(); err != nil { return fmt.Errorf("error running post environment hook: %v", err) } } - // Execute 'terraform init' in the dirPath _, err = s.shell.ExecProgress(fmt.Sprintf("🌎 Initializing Terraform in %s", component.Path), "terraform", "init", "-migrate-state", "-upgrade", "-force-copy") if err != nil { return fmt.Errorf("error initializing Terraform in %s: %w", component.FullPath, err) } - // Execute 'terraform plan' in the dirPath _, err = s.shell.ExecProgress(fmt.Sprintf("🌎 Planning Terraform changes in %s", component.Path), "terraform", "plan") if err != nil { return fmt.Errorf("error planning Terraform changes in %s: %w", component.FullPath, err) } - // Execute 'terraform apply' in the dirPath _, err = s.shell.ExecProgress(fmt.Sprintf("🌎 Applying Terraform changes in %s", component.Path), "terraform", "apply") if err != nil { return fmt.Errorf("error applying Terraform changes in %s: %w", component.FullPath, err) } - // Attempt to clean up 'backend_override.tf' if it exists + backendOverridePath := filepath.Join(component.FullPath, "backend_override.tf") + if _, err := s.shims.Stat(backendOverridePath); err == nil { + if err := s.shims.Remove(backendOverridePath); err != nil { + return fmt.Errorf("error removing backend_override.tf in %s: %v", component.FullPath, err) + } + } + } + + return nil +} + +// Down destroys a stack of components by executing Terraform destroy operations in reverse order. +// It processes components in reverse order, skipping any marked with destroy: false. +// For each component, it sets up environment variables, runs Terraform init, plan -destroy, +// and destroy operations, and cleans up backend override files. +// The method ensures proper directory management and environment setup for each component. +func (s *WindsorStack) Down() error { + currentDir, err := s.shims.Getwd() + if err != nil { + return fmt.Errorf("error getting current directory: %v", err) + } + + defer func() { + _ = s.shims.Chdir(currentDir) + }() + + components := s.blueprintHandler.GetTerraformComponents() + + for i := len(components) - 1; i >= 0; i-- { + component := components[i] + + if component.Destroy != nil && !*component.Destroy { + continue + } + + if _, err := s.shims.Stat(component.FullPath); os.IsNotExist(err) { + return fmt.Errorf("directory %s does not exist", component.FullPath) + } + + if err := s.shims.Chdir(component.FullPath); err != nil { + return fmt.Errorf("error changing to directory %s: %v", component.FullPath, err) + } + + for _, envPrinter := range s.envPrinters { + envVars, err := envPrinter.GetEnvVars() + if err != nil { + return fmt.Errorf("error getting environment variables: %v", err) + } + for key, value := range envVars { + if err := s.shims.Setenv(key, value); err != nil { + return fmt.Errorf("error setting environment variable %s: %v", key, err) + } + } + if err := envPrinter.PostEnvHook(); err != nil { + return fmt.Errorf("error running post environment hook: %v", err) + } + } + + _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Initializing Terraform in %s", component.Path), "terraform", "init", "-migrate-state", "-upgrade", "-force-copy") + if err != nil { + return fmt.Errorf("error initializing Terraform in %s: %w", component.FullPath, err) + } + + _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Planning Terraform destruction in %s", component.Path), "terraform", "plan", "-destroy") + if err != nil { + return fmt.Errorf("error planning Terraform destruction in %s: %w", component.FullPath, err) + } + + _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Destroying Terraform resources in %s", component.Path), "terraform", "destroy", "-auto-approve") + if err != nil { + return fmt.Errorf("error destroying Terraform resources in %s: %w", component.FullPath, err) + } + backendOverridePath := filepath.Join(component.FullPath, "backend_override.tf") if _, err := s.shims.Stat(backendOverridePath); err == nil { if err := s.shims.Remove(backendOverridePath); err != nil { diff --git a/pkg/stack/windsor_stack_test.go b/pkg/stack/windsor_stack_test.go index b64dd1661..f69879022 100644 --- a/pkg/stack/windsor_stack_test.go +++ b/pkg/stack/windsor_stack_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/env" ) @@ -331,3 +332,227 @@ func TestWindsorStack_Up(t *testing.T) { } }) } + +func TestWindsorStack_Down(t *testing.T) { + setup := func(t *testing.T) (*WindsorStack, *Mocks) { + t.Helper() + mocks := setupWindsorStackMocks(t) + stack := NewWindsorStack(mocks.Injector) + stack.shims = mocks.Shims + if err := stack.Initialize(); err != nil { + t.Fatalf("Expected no error during initialization, got %v", err) + } + + // Set up default components for the blueprint handler + mocks.Blueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path"), + }, + } + } + + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + stack, _ := setup(t) + + // And when the stack is brought down + if err := stack.Down(); err != nil { + // Then no error should occur + t.Errorf("Expected Down to return nil, got %v", err) + } + }) + + t.Run("ErrorGettingCurrentDirectory", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Getwd = func() (string, error) { + return "", fmt.Errorf("mock error getting current directory") + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error getting current directory" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorCheckingDirectoryExists", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + // And when Down is called + err := stack.Down() + if err == nil { + t.Fatalf("Expected an error, but got nil") + } + + // Then the expected error is contained in err + expectedError := "directory" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorChangingDirectory", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Chdir = func(_ string) error { + return fmt.Errorf("mock error changing directory") + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error changing to directory" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorGettingEnvVars", func(t *testing.T) { + stack, mocks := setup(t) + mocks.EnvPrinter.GetEnvVarsFunc = func() (map[string]string, error) { + return nil, fmt.Errorf("mock error getting environment variables") + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error getting environment variables" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorSettingEnvVars", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Setenv = func(_ string, _ string) error { + return fmt.Errorf("mock error setting environment variable") + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error setting environment variable" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningPostEnvHook", func(t *testing.T) { + stack, mocks := setup(t) + mocks.EnvPrinter.PostEnvHookFunc = func() error { + return fmt.Errorf("mock error running post environment hook") + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error running post environment hook" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningTerraformInit", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && args[0] == "init" { + return "", fmt.Errorf("mock error running terraform init") + } + return "", nil + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error initializing Terraform" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningTerraformPlanDestroy", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && args[0] == "plan" && len(args) > 1 && args[1] == "-destroy" { + return "", fmt.Errorf("mock error running terraform plan -destroy") + } + return "", nil + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error planning Terraform destruction" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningTerraformDestroy", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && args[0] == "destroy" { + return "", fmt.Errorf("mock error running terraform destroy") + } + return "", nil + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error destroying Terraform resources" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRemovingBackendOverride", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Remove = func(_ string) error { + return fmt.Errorf("mock error removing backend override") + } + + // And when Down is called + err := stack.Down() + // Then the expected error is contained in err + expectedError := "error removing backend_override.tf" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("SkipComponentWithDestroyFalse", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Blueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path"), + Destroy: ptrBool(false), + }, + } + } + + // And when Down is called + err := stack.Down() + // Then no error should occur + if err != nil { + t.Errorf("Expected Down to return nil, got %v", err) + } + }) +} + +// Helper functions to create pointers for basic types +func ptrBool(b bool) *bool { + return &b +}