From 955732ccbeaa8be5fa1f12d88103b24f1c134641 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Wed, 2 Jul 2025 22:41:26 -0400 Subject: [PATCH 1/3] feat(bundler): Add terraform bundler - Implement TerraformBundler for bundling Terraform files --- pkg/bundler/kustomize_bundler.go | 5 +- pkg/bundler/kustomize_bundler_test.go | 21 +- pkg/bundler/terraform_bundler.go | 75 +++++ pkg/bundler/terraform_bundler_test.go | 368 +++++++++++++++++++++++++ pkg/controller/controller.go | 18 ++ pkg/controller/controller_test.go | 137 ++++++++- pkg/controller/mock_controller.go | 3 + pkg/controller/mock_controller_test.go | 18 ++ 8 files changed, 633 insertions(+), 12 deletions(-) create mode 100644 pkg/bundler/terraform_bundler.go create mode 100644 pkg/bundler/terraform_bundler_test.go diff --git a/pkg/bundler/kustomize_bundler.go b/pkg/bundler/kustomize_bundler.go index cd3bd0fa0..9ad4a826e 100644 --- a/pkg/bundler/kustomize_bundler.go +++ b/pkg/bundler/kustomize_bundler.go @@ -36,14 +36,15 @@ func NewKustomizeBundler() *KustomizeBundler { // ============================================================================= // Bundle adds all files from kustomize directory to the artifact by recursively walking the directory tree. -// It validates that the kustomize directory exists, then walks through all files preserving the directory structure. +// It checks if the kustomize directory exists and returns nil if not found (graceful handling). +// If the directory exists, it walks through all files preserving the directory structure. // Each file is read and added to the artifact maintaining the original kustomize path structure. // Directories are skipped and only regular files are processed for bundling. func (k *KustomizeBundler) Bundle(artifact Artifact) error { kustomizeSource := "kustomize" if _, err := k.shims.Stat(kustomizeSource); os.IsNotExist(err) { - return fmt.Errorf("kustomize directory not found: %s", kustomizeSource) + return nil } return k.shims.Walk(kustomizeSource, func(path string, info os.FileInfo, err error) error { diff --git a/pkg/bundler/kustomize_bundler_test.go b/pkg/bundler/kustomize_bundler_test.go index e52beb388..a5cc9e873 100644 --- a/pkg/bundler/kustomize_bundler_test.go +++ b/pkg/bundler/kustomize_bundler_test.go @@ -134,7 +134,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { } }) - t.Run("ErrorWhenKustomizeDirectoryNotFound", func(t *testing.T) { + t.Run("HandlesWhenKustomizeDirectoryNotFound", func(t *testing.T) { // Given a kustomize bundler with missing kustomize directory bundler, mocks := setup(t) bundler.shims.Stat = func(name string) (os.FileInfo, error) { @@ -144,16 +144,23 @@ func TestKustomizeBundler_Bundle(t *testing.T) { return &mockFileInfo{name: name, isDir: true}, nil } + filesAdded := make([]string, 0) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded = append(filesAdded, path) + return nil + } + // When calling Bundle err := bundler.Bundle(mocks.Artifact) - // Then an error should be returned - if err == nil { - t.Error("Expected error when kustomize directory not found") + // Then no error should be returned (graceful handling) + if err != nil { + t.Errorf("Expected nil error when kustomize directory not found, got %v", err) } - expectedMsg := "kustomize directory not found: kustomize" - if err.Error() != expectedMsg { - t.Errorf("Expected error %q, got %q", expectedMsg, err.Error()) + + // And no files should be added + if len(filesAdded) != 0 { + t.Errorf("Expected 0 files added when directory not found, got %d", len(filesAdded)) } }) diff --git a/pkg/bundler/terraform_bundler.go b/pkg/bundler/terraform_bundler.go new file mode 100644 index 000000000..800dcc16a --- /dev/null +++ b/pkg/bundler/terraform_bundler.go @@ -0,0 +1,75 @@ +package bundler + +import ( + "fmt" + "os" + "path/filepath" +) + +// The TerraformBundler handles bundling of terraform manifests and related files. +// It copies all files from the terraform directory to the artifact build directory. +// The TerraformBundler ensures that all terraform resources are properly bundled +// for distribution with the artifact for infrastructure deployment. + +// ============================================================================= +// Types +// ============================================================================= + +// TerraformBundler handles bundling of terraform files +type TerraformBundler struct { + BaseBundler +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewTerraformBundler creates a new TerraformBundler instance +func NewTerraformBundler() *TerraformBundler { + return &TerraformBundler{ + BaseBundler: *NewBaseBundler(), + } +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Bundle adds all files from terraform directory to the artifact by recursively walking the directory tree. +// It checks if the terraform directory exists and returns nil if not found (graceful handling). +// If the directory exists, it walks through all files preserving the directory structure. +// Each file is read and added to the artifact maintaining the original terraform path structure. +// Directories are skipped and only regular files are processed for bundling. +func (t *TerraformBundler) Bundle(artifact Artifact) error { + terraformSource := "terraform" + + if _, err := t.shims.Stat(terraformSource); os.IsNotExist(err) { + return nil + } + + return t.shims.Walk(terraformSource, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + relPath, err := t.shims.FilepathRel(terraformSource, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + data, err := t.shims.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read terraform file %s: %w", path, err) + } + + artifactPath := "terraform/" + filepath.ToSlash(relPath) + return artifact.AddFile(artifactPath, data) + }) +} + +// Ensure TerraformBundler implements Bundler interface +var _ Bundler = (*TerraformBundler)(nil) diff --git a/pkg/bundler/terraform_bundler_test.go b/pkg/bundler/terraform_bundler_test.go new file mode 100644 index 000000000..b65e5a56f --- /dev/null +++ b/pkg/bundler/terraform_bundler_test.go @@ -0,0 +1,368 @@ +package bundler + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// ============================================================================= +// Test TerraformBundler +// ============================================================================= + +func TestTerraformBundler_NewTerraformBundler(t *testing.T) { + setup := func(t *testing.T) *TerraformBundler { + t.Helper() + return NewTerraformBundler() + } + + t.Run("CreatesInstanceWithBaseBundler", func(t *testing.T) { + // Given no preconditions + // When creating a new terraform bundler + bundler := setup(t) + + // Then it should not be nil + if bundler == nil { + t.Fatal("Expected non-nil bundler") + } + // And it should have inherited BaseBundler properties + if bundler.shims == nil { + t.Error("Expected shims to be inherited from BaseBundler") + } + // And other fields should be nil until Initialize + if bundler.shell != nil { + t.Error("Expected shell to be nil before Initialize") + } + if bundler.injector != nil { + t.Error("Expected injector to be nil before Initialize") + } + }) +} + +func TestTerraformBundler_Bundle(t *testing.T) { + setup := func(t *testing.T) (*TerraformBundler, *BundlerMocks) { + t.Helper() + mocks := setupBundlerMocks(t) + bundler := NewTerraformBundler() + bundler.shims = mocks.Shims + bundler.Initialize(mocks.Injector) + return bundler, mocks + } + + t.Run("SuccessWithValidTerraformFiles", func(t *testing.T) { + // Given a terraform bundler with valid terraform files + bundler, mocks := setup(t) + + // Set up mocks to simulate finding terraform files + filesAdded := make(map[string][]byte) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded[path] = content + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Simulate finding multiple files in terraform directory + // Use filepath.Join to ensure cross-platform compatibility + fn(filepath.Join("terraform", "main.tf"), &mockFileInfo{name: "main.tf", isDir: false}, nil) + fn(filepath.Join("terraform", "variables.tf"), &mockFileInfo{name: "variables.tf", isDir: false}, nil) + fn(filepath.Join("terraform", "outputs.tf"), &mockFileInfo{name: "outputs.tf", isDir: false}, nil) + fn(filepath.Join("terraform", "modules"), &mockFileInfo{name: "modules", isDir: true}, nil) + fn(filepath.Join("terraform", "modules", "vpc"), &mockFileInfo{name: "vpc", isDir: true}, nil) + fn(filepath.Join("terraform", "modules", "vpc", "main.tf"), &mockFileInfo{name: "main.tf", isDir: false}, nil) + fn(filepath.Join("terraform", "environments"), &mockFileInfo{name: "environments", isDir: true}, nil) + fn(filepath.Join("terraform", "environments", "prod"), &mockFileInfo{name: "prod", isDir: true}, nil) + fn(filepath.Join("terraform", "environments", "prod", "terraform.tfvars"), &mockFileInfo{name: "terraform.tfvars", isDir: false}, nil) + return nil + } + + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + switch targpath { + case filepath.Join("terraform", "main.tf"): + return "main.tf", nil + case filepath.Join("terraform", "variables.tf"): + return "variables.tf", nil + case filepath.Join("terraform", "outputs.tf"): + return "outputs.tf", nil + case filepath.Join("terraform", "modules", "vpc", "main.tf"): + return filepath.Join("modules", "vpc", "main.tf"), nil + case filepath.Join("terraform", "environments", "prod", "terraform.tfvars"): + return filepath.Join("environments", "prod", "terraform.tfvars"), nil + default: + return "", fmt.Errorf("unexpected path: %s", targpath) + } + } + + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + switch filename { + case filepath.Join("terraform", "main.tf"): + return []byte("resource \"aws_instance\" \"example\" {\n ami = \"ami-12345\"\n}"), nil + case filepath.Join("terraform", "variables.tf"): + return []byte("variable \"instance_type\" {\n type = string\n default = \"t2.micro\"\n}"), nil + case filepath.Join("terraform", "outputs.tf"): + return []byte("output \"instance_id\" {\n value = aws_instance.example.id\n}"), nil + case filepath.Join("terraform", "modules", "vpc", "main.tf"): + return []byte("resource \"aws_vpc\" \"main\" {\n cidr_block = \"10.0.0.0/16\"\n}"), nil + case filepath.Join("terraform", "environments", "prod", "terraform.tfvars"): + return []byte("instance_type = \"t3.medium\"\nregion = \"us-west-2\""), nil + default: + return nil, fmt.Errorf("unexpected file: %s", filename) + } + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And files should be added with correct paths + expectedFiles := map[string]string{ + "terraform/main.tf": "resource \"aws_instance\" \"example\" {\n ami = \"ami-12345\"\n}", + "terraform/variables.tf": "variable \"instance_type\" {\n type = string\n default = \"t2.micro\"\n}", + "terraform/outputs.tf": "output \"instance_id\" {\n value = aws_instance.example.id\n}", + "terraform/modules/vpc/main.tf": "resource \"aws_vpc\" \"main\" {\n cidr_block = \"10.0.0.0/16\"\n}", + "terraform/environments/prod/terraform.tfvars": "instance_type = \"t3.medium\"\nregion = \"us-west-2\"", + } + + for expectedPath, expectedContent := range expectedFiles { + if content, exists := filesAdded[expectedPath]; !exists { + t.Errorf("Expected file %s to be added", expectedPath) + } else if string(content) != expectedContent { + t.Errorf("Expected content %q for %s, got %q", expectedContent, expectedPath, string(content)) + } + } + + // And directories should be skipped (only 5 files should be added) + if len(filesAdded) != 5 { + t.Errorf("Expected 5 files to be added, got %d", len(filesAdded)) + } + }) + + t.Run("HandlesWhenTerraformDirectoryNotFound", func(t *testing.T) { + // Given a terraform bundler with missing terraform directory + bundler, mocks := setup(t) + bundler.shims.Stat = func(name string) (os.FileInfo, error) { + if name == "terraform" { + return nil, os.ErrNotExist + } + return &mockFileInfo{name: name, isDir: true}, nil + } + + filesAdded := make([]string, 0) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded = append(filesAdded, path) + return nil + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned (graceful handling) + if err != nil { + t.Errorf("Expected nil error when terraform directory not found, got %v", err) + } + + // And no files should be added + if len(filesAdded) != 0 { + t.Errorf("Expected 0 files added when directory not found, got %d", len(filesAdded)) + } + }) + + t.Run("ErrorWhenWalkFails", func(t *testing.T) { + // Given a terraform bundler with failing filesystem walk + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fmt.Errorf("permission denied") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the walk error should be returned + if err == nil { + t.Error("Expected error when walk fails") + } + if err.Error() != "permission denied" { + t.Errorf("Expected walk error, got: %v", err) + } + }) + + t.Run("ErrorWhenWalkCallbackFails", func(t *testing.T) { + // Given a terraform bundler with walk callback returning error + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Simulate walk callback being called with an error + return fn(filepath.Join("terraform", "test.tf"), &mockFileInfo{name: "test.tf", isDir: false}, fmt.Errorf("callback error")) + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the callback error should be returned + if err == nil { + t.Error("Expected error when walk callback fails") + } + if err.Error() != "callback error" { + t.Errorf("Expected callback error, got: %v", err) + } + }) + + t.Run("ErrorWhenFilepathRelFails", func(t *testing.T) { + // Given a terraform bundler with failing relative path calculation + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fn(filepath.Join("terraform", "test.tf"), &mockFileInfo{name: "test.tf", isDir: false}, nil) + } + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "", fmt.Errorf("relative path error") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the relative path error should be returned + if err == nil { + t.Error("Expected error when filepath rel fails") + } + expectedMsg := "failed to get relative path: relative path error" + if err.Error() != expectedMsg { + t.Errorf("Expected error %q, got %q", expectedMsg, err.Error()) + } + }) + + t.Run("ErrorWhenReadFileFails", func(t *testing.T) { + // Given a terraform bundler with failing file read + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fn(filepath.Join("terraform", "test.tf"), &mockFileInfo{name: "test.tf", isDir: false}, nil) + } + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "test.tf", nil + } + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + return nil, fmt.Errorf("read permission denied") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the read error should be returned + if err == nil { + t.Error("Expected error when read file fails") + } + expectedMsg := "failed to read terraform file " + filepath.Join("terraform", "test.tf") + ": read permission denied" + if err.Error() != expectedMsg { + t.Errorf("Expected error %q, got %q", expectedMsg, err.Error()) + } + }) + + t.Run("ErrorWhenArtifactAddFileFails", func(t *testing.T) { + // Given a terraform bundler with failing artifact add file + bundler, mocks := setup(t) + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + return fn(filepath.Join("terraform", "test.tf"), &mockFileInfo{name: "test.tf", isDir: false}, nil) + } + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "test.tf", nil + } + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("content"), nil + } + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + return fmt.Errorf("artifact storage full") + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then the add file error should be returned + if err == nil { + t.Error("Expected error when artifact add file fails") + } + if err.Error() != "artifact storage full" { + t.Errorf("Expected add file error, got: %v", err) + } + }) + + t.Run("SkipsDirectoriesInWalk", func(t *testing.T) { + // Given a terraform bundler with mix of files and directories + bundler, mocks := setup(t) + + filesAdded := make([]string, 0) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded = append(filesAdded, path) + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Mix of directories and files + fn(filepath.Join("terraform", "modules"), &mockFileInfo{name: "modules", isDir: true}, nil) + fn(filepath.Join("terraform", "main.tf"), &mockFileInfo{name: "main.tf", isDir: false}, nil) + fn(filepath.Join("terraform", "environments"), &mockFileInfo{name: "environments", isDir: true}, nil) + fn(filepath.Join("terraform", "variables.tf"), &mockFileInfo{name: "variables.tf", isDir: false}, nil) + return nil + } + + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + if targpath == filepath.Join("terraform", "main.tf") { + return "main.tf", nil + } + if targpath == filepath.Join("terraform", "variables.tf") { + return "variables.tf", nil + } + return "", nil + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And only files should be added (not directories) + expectedFiles := []string{"terraform/main.tf", "terraform/variables.tf"} + if len(filesAdded) != len(expectedFiles) { + t.Errorf("Expected %d files added, got %d", len(expectedFiles), len(filesAdded)) + } + + for i, expected := range expectedFiles { + if i < len(filesAdded) && filesAdded[i] != expected { + t.Errorf("Expected file %s at index %d, got %s", expected, i, filesAdded[i]) + } + } + }) + + t.Run("HandlesEmptyTerraformDirectory", func(t *testing.T) { + // Given a terraform bundler with empty terraform directory + bundler, mocks := setup(t) + + filesAdded := make([]string, 0) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded = append(filesAdded, path) + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // No files found in directory + return nil + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And no files should be added + if len(filesAdded) != 0 { + t.Errorf("Expected 0 files added, got %d", len(filesAdded)) + } + }) +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 0756a24f2..fda09da31 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -122,6 +122,7 @@ type ComponentConstructors struct { NewArtifactBuilder func(di.Injector) bundler.Artifact NewTemplateBundler func(di.Injector) bundler.Bundler NewKustomizeBundler func(di.Injector) bundler.Bundler + NewTerraformBundler func(di.Injector) bundler.Bundler } // Requirements defines the operational requirements for the controller @@ -294,6 +295,9 @@ func NewDefaultConstructors() ComponentConstructors { NewKustomizeBundler: func(injector di.Injector) bundler.Bundler { return bundler.NewKustomizeBundler() }, + NewTerraformBundler: func(injector di.Injector) bundler.Bundler { + return bundler.NewTerraformBundler() + }, } } @@ -1295,6 +1299,8 @@ func (c *BaseController) createBundlerComponents(req Requirements) error { existingBundlerNames["templateBundler"] = true } else if c.injector.Resolve("kustomizeBundler") == bundler { existingBundlerNames["kustomizeBundler"] = true + } else if c.injector.Resolve("terraformBundler") == bundler { + existingBundlerNames["terraformBundler"] = true } } @@ -1322,6 +1328,18 @@ func (c *BaseController) createBundlerComponents(req Requirements) error { c.injector.Register("kustomizeBundler", kustomizeBundler) } + // Create terraform bundler if not exists + if !existingBundlerNames["terraformBundler"] { + if c.constructors.NewTerraformBundler == nil { + return fmt.Errorf("NewTerraformBundler constructor is nil") + } + terraformBundler := c.constructors.NewTerraformBundler(c.injector) + if terraformBundler == nil { + return fmt.Errorf("NewTerraformBundler returned nil") + } + c.injector.Register("terraformBundler", terraformBundler) + } + return nil } diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index 608b488df..3aa35f123 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -362,6 +362,13 @@ func TestNewController(t *testing.T) { } return nil }, + "NewTerraformBundler": func() error { + bundler := controller.constructors.NewTerraformBundler(mocks.Injector) + if bundler == nil { + return fmt.Errorf("NewTerraformBundler returned nil") + } + return nil + }, } // When each constructor is tested for name, test := range constructorTests { @@ -4139,20 +4146,23 @@ func TestBaseController_ResolveAllBundlers(t *testing.T) { controller, mocks := setup(t) templateBundler := &bundler.MockBundler{} kustomizeBundler := &bundler.MockBundler{} + terraformBundler := &bundler.MockBundler{} mocks.Injector.Register("templateBundler", templateBundler) mocks.Injector.Register("kustomizeBundler", kustomizeBundler) + mocks.Injector.Register("terraformBundler", terraformBundler) // When resolving all bundlers bundlers := controller.ResolveAllBundlers() // Then all registered bundlers should be returned - if len(bundlers) != 2 { - t.Errorf("Expected 2 bundlers, got %d", len(bundlers)) + if len(bundlers) != 3 { + t.Errorf("Expected 3 bundlers, got %d", len(bundlers)) } - // And the bundlers should include both registered ones + // And the bundlers should include all registered ones foundTemplate := false foundKustomize := false + foundTerraform := false for _, b := range bundlers { if b == templateBundler { foundTemplate = true @@ -4160,6 +4170,9 @@ func TestBaseController_ResolveAllBundlers(t *testing.T) { if b == kustomizeBundler { foundKustomize = true } + if b == terraformBundler { + foundTerraform = true + } } if !foundTemplate { @@ -4168,6 +4181,9 @@ func TestBaseController_ResolveAllBundlers(t *testing.T) { if !foundKustomize { t.Error("Expected kustomize bundler to be in returned bundlers") } + if !foundTerraform { + t.Error("Expected terraform bundler to be in returned bundlers") + } }) t.Run("ReturnsEmptySliceWhenNoBundlersRegistered", func(t *testing.T) { @@ -4520,12 +4536,110 @@ func TestBaseController_createBundlerComponents(t *testing.T) { } }) + t.Run("CreatesTerraformBundlerWhenRequired", func(t *testing.T) { + // Given bundler is required and no existing terraform bundler + controller, mocks := setup(t) + mockTerraformBundler := &bundler.MockBundler{} + controller.constructors.NewTerraformBundler = func(di.Injector) bundler.Bundler { + return mockTerraformBundler + } + + // When creating bundler components + err := controller.createBundlerComponents(Requirements{ + Bundler: true, + }) + + // Then terraform bundler should be created and registered + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + resolvedBundler := mocks.Injector.Resolve("terraformBundler") + if resolvedBundler != mockTerraformBundler { + t.Error("Expected terraform bundler to be registered") + } + }) + + t.Run("DoesNotCreateTerraformBundlerWhenAlreadyExists", func(t *testing.T) { + // Given bundler is required and terraform bundler already exists + controller, mocks := setup(t) + existingBundler := &bundler.MockBundler{} + mocks.Injector.Register("terraformBundler", existingBundler) + + // Track if constructor is called + constructorCalled := false + controller.constructors.NewTerraformBundler = func(di.Injector) bundler.Bundler { + constructorCalled = true + return &bundler.MockBundler{} + } + + // When creating bundler components + err := controller.createBundlerComponents(Requirements{ + Bundler: true, + }) + + // Then constructor should not be called + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if constructorCalled { + t.Error("Expected terraform bundler constructor not to be called when bundler already exists") + } + + // And existing bundler should remain + resolvedBundler := mocks.Injector.Resolve("terraformBundler") + if resolvedBundler != existingBundler { + t.Error("Expected existing terraform bundler to remain") + } + }) + + t.Run("HandlesNilTerraformBundlerConstructor", func(t *testing.T) { + // Given bundler is required but terraform bundler constructor is nil + controller, _ := setup(t) + controller.constructors.NewTerraformBundler = nil + + // When creating bundler components + err := controller.createBundlerComponents(Requirements{ + Bundler: true, + }) + + // Then an error should be returned + if err == nil { + t.Error("Expected error when terraform bundler constructor is nil") + } + if !strings.Contains(err.Error(), "NewTerraformBundler constructor is nil") { + t.Errorf("Expected error about nil constructor, got %v", err) + } + }) + + t.Run("HandlesNilTerraformBundlerFromConstructor", func(t *testing.T) { + // Given terraform bundler constructor returns nil + controller, _ := setup(t) + controller.constructors.NewTerraformBundler = func(di.Injector) bundler.Bundler { + return nil + } + + // When creating bundler components + err := controller.createBundlerComponents(Requirements{ + Bundler: true, + }) + + // Then an error should be returned + if err == nil { + t.Error("Expected error when terraform bundler constructor returns nil") + } + if !strings.Contains(err.Error(), "NewTerraformBundler returned nil") { + t.Errorf("Expected error about nil terraform bundler, got %v", err) + } + }) + t.Run("CreatesAllBundlerComponentsSuccessfully", func(t *testing.T) { // Given bundler is required and all constructors are valid controller, mocks := setup(t) mockArtifactBuilder := &bundler.MockArtifact{} mockTemplateBundler := &bundler.MockBundler{} mockKustomizeBundler := &bundler.MockBundler{} + mockTerraformBundler := &bundler.MockBundler{} controller.constructors.NewArtifactBuilder = func(di.Injector) bundler.Artifact { return mockArtifactBuilder @@ -4536,6 +4650,9 @@ func TestBaseController_createBundlerComponents(t *testing.T) { controller.constructors.NewKustomizeBundler = func(di.Injector) bundler.Bundler { return mockKustomizeBundler } + controller.constructors.NewTerraformBundler = func(di.Injector) bundler.Bundler { + return mockTerraformBundler + } // When creating bundler components err := controller.createBundlerComponents(Requirements{ @@ -4557,6 +4674,9 @@ func TestBaseController_createBundlerComponents(t *testing.T) { if bundler := mocks.Injector.Resolve("kustomizeBundler"); bundler != mockKustomizeBundler { t.Error("Expected kustomize bundler to be registered") } + if bundler := mocks.Injector.Resolve("terraformBundler"); bundler != mockTerraformBundler { + t.Error("Expected terraform bundler to be registered") + } }) } @@ -4593,6 +4713,7 @@ func TestBaseController_BundlerRequirementIntegration(t *testing.T) { mockArtifactBuilder := &bundler.MockArtifact{} mockTemplateBundler := &bundler.MockBundler{} mockKustomizeBundler := &bundler.MockBundler{} + mockTerraformBundler := &bundler.MockBundler{} controller.constructors.NewArtifactBuilder = func(di.Injector) bundler.Artifact { return mockArtifactBuilder @@ -4603,6 +4724,9 @@ func TestBaseController_BundlerRequirementIntegration(t *testing.T) { controller.constructors.NewKustomizeBundler = func(di.Injector) bundler.Bundler { return mockKustomizeBundler } + controller.constructors.NewTerraformBundler = func(di.Injector) bundler.Bundler { + return mockTerraformBundler + } // Set requirements before creating components controller.SetRequirements(Requirements{ @@ -4628,6 +4752,9 @@ func TestBaseController_BundlerRequirementIntegration(t *testing.T) { if bundler := mocks.Injector.Resolve("kustomizeBundler"); bundler == nil { t.Error("Expected kustomize bundler to be created during CreateComponents") } + if bundler := mocks.Injector.Resolve("terraformBundler"); bundler == nil { + t.Error("Expected terraform bundler to be created during CreateComponents") + } }) t.Run("InitializeWithRequirementsHandlesBundlerRequirement", func(t *testing.T) { @@ -4636,6 +4763,7 @@ func TestBaseController_BundlerRequirementIntegration(t *testing.T) { mockArtifactBuilder := &bundler.MockArtifact{} mockTemplateBundler := &bundler.MockBundler{} mockKustomizeBundler := &bundler.MockBundler{} + mockTerraformBundler := &bundler.MockBundler{} controller.constructors.NewArtifactBuilder = func(di.Injector) bundler.Artifact { return mockArtifactBuilder @@ -4646,6 +4774,9 @@ func TestBaseController_BundlerRequirementIntegration(t *testing.T) { controller.constructors.NewKustomizeBundler = func(di.Injector) bundler.Bundler { return mockKustomizeBundler } + controller.constructors.NewTerraformBundler = func(di.Injector) bundler.Bundler { + return mockTerraformBundler + } // When initializing with bundler requirements err := controller.InitializeWithRequirements(Requirements{ diff --git a/pkg/controller/mock_controller.go b/pkg/controller/mock_controller.go index c40b3b03f..50234169f 100644 --- a/pkg/controller/mock_controller.go +++ b/pkg/controller/mock_controller.go @@ -216,6 +216,9 @@ func NewMockConstructors() ComponentConstructors { NewKustomizeBundler: func(injector di.Injector) bundler.Bundler { return bundler.NewMockBundler() }, + NewTerraformBundler: func(injector di.Injector) bundler.Bundler { + return bundler.NewMockBundler() + }, } } diff --git a/pkg/controller/mock_controller_test.go b/pkg/controller/mock_controller_test.go index b7ab9ddb9..6770c269c 100644 --- a/pkg/controller/mock_controller_test.go +++ b/pkg/controller/mock_controller_test.go @@ -543,5 +543,23 @@ func TestNewMockConstructors(t *testing.T) { if windsorStack == nil { t.Error("expected NewWindsorStack to return non-nil") } + + // Test bundler constructors + artifactBuilder := constructors.NewArtifactBuilder(injector) + if artifactBuilder == nil { + t.Error("expected NewArtifactBuilder to return non-nil") + } + templateBundler := constructors.NewTemplateBundler(injector) + if templateBundler == nil { + t.Error("expected NewTemplateBundler to return non-nil") + } + kustomizeBundler := constructors.NewKustomizeBundler(injector) + if kustomizeBundler == nil { + t.Error("expected NewKustomizeBundler to return non-nil") + } + terraformBundler := constructors.NewTerraformBundler(injector) + if terraformBundler == nil { + t.Error("expected NewTerraformBundler to return non-nil") + } }) } From 0276ed7aa6d877e028ae4c7aec2c4dac96b5bd7c Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Wed, 2 Jul 2025 22:58:16 -0400 Subject: [PATCH 2/3] Ignore select terraform files while bundling --- pkg/bundler/terraform_bundler.go | 55 ++++ pkg/bundler/terraform_bundler_test.go | 359 ++++++++++++++++++++++++-- 2 files changed, 395 insertions(+), 19 deletions(-) diff --git a/pkg/bundler/terraform_bundler.go b/pkg/bundler/terraform_bundler.go index 800dcc16a..ba3f3b508 100644 --- a/pkg/bundler/terraform_bundler.go +++ b/pkg/bundler/terraform_bundler.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // The TerraformBundler handles bundling of terraform manifests and related files. @@ -40,6 +41,13 @@ func NewTerraformBundler() *TerraformBundler { // If the directory exists, it walks through all files preserving the directory structure. // Each file is read and added to the artifact maintaining the original terraform path structure. // Directories are skipped and only regular files are processed for bundling. +// The bundler ignores .terraform directories and filters out common terraform files that should not be bundled: +// - *_override.tf and *.tf.json override files +// - *.tfstate and *.tfstate.* state files +// - *.tfvars and *.tfvars.json variable files (often contain sensitive data) +// - crash.log and crash.*.log files +// - .terraformrc and terraform.rc CLI config files +// - *.tfplan plan output files func (t *TerraformBundler) Bundle(artifact Artifact) error { terraformSource := "terraform" @@ -53,6 +61,13 @@ func (t *TerraformBundler) Bundle(artifact Artifact) error { } if info.IsDir() { + if info.Name() == ".terraform" { + return filepath.SkipDir + } + return nil + } + + if t.shouldSkipFile(info.Name()) { return nil } @@ -71,5 +86,45 @@ func (t *TerraformBundler) Bundle(artifact Artifact) error { }) } +// ============================================================================= +// Private Methods +// ============================================================================= + +// shouldSkipFile determines if a file should be excluded from bundling. +// Files are skipped to avoid including sensitive data, temporary files, and configuration overrides. +// This includes state files, variable files, plan files, override files, and crash logs. +func (t *TerraformBundler) shouldSkipFile(filename string) bool { + if strings.HasSuffix(filename, "_override.tf") || + strings.HasSuffix(filename, "_override.tf.json") || + filename == "override.tf" || + filename == "override.tf.json" { + return true + } + + if strings.HasSuffix(filename, ".tfstate") || + strings.Contains(filename, ".tfstate.") { + return true + } + + if strings.HasSuffix(filename, ".tfvars") || + strings.HasSuffix(filename, ".tfvars.json") { + return true + } + + if strings.HasSuffix(filename, ".tfplan") { + return true + } + + if filename == ".terraformrc" || filename == "terraform.rc" { + return true + } + + if filename == "crash.log" || strings.HasPrefix(filename, "crash.") && strings.HasSuffix(filename, ".log") { + return true + } + + return false +} + // Ensure TerraformBundler implements Bundler interface var _ Bundler = (*TerraformBundler)(nil) diff --git a/pkg/bundler/terraform_bundler_test.go b/pkg/bundler/terraform_bundler_test.go index b65e5a56f..39767143a 100644 --- a/pkg/bundler/terraform_bundler_test.go +++ b/pkg/bundler/terraform_bundler_test.go @@ -53,8 +53,6 @@ func TestTerraformBundler_Bundle(t *testing.T) { t.Run("SuccessWithValidTerraformFiles", func(t *testing.T) { // Given a terraform bundler with valid terraform files bundler, mocks := setup(t) - - // Set up mocks to simulate finding terraform files filesAdded := make(map[string][]byte) mocks.Artifact.AddFileFunc = func(path string, content []byte) error { filesAdded[path] = content @@ -62,8 +60,7 @@ func TestTerraformBundler_Bundle(t *testing.T) { } bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { - // Simulate finding multiple files in terraform directory - // Use filepath.Join to ensure cross-platform compatibility + fn("terraform", &mockFileInfo{name: "terraform", isDir: true}, nil) fn(filepath.Join("terraform", "main.tf"), &mockFileInfo{name: "main.tf", isDir: false}, nil) fn(filepath.Join("terraform", "variables.tf"), &mockFileInfo{name: "variables.tf", isDir: false}, nil) fn(filepath.Join("terraform", "outputs.tf"), &mockFileInfo{name: "outputs.tf", isDir: false}, nil) @@ -86,10 +83,8 @@ func TestTerraformBundler_Bundle(t *testing.T) { return "outputs.tf", nil case filepath.Join("terraform", "modules", "vpc", "main.tf"): return filepath.Join("modules", "vpc", "main.tf"), nil - case filepath.Join("terraform", "environments", "prod", "terraform.tfvars"): - return filepath.Join("environments", "prod", "terraform.tfvars"), nil default: - return "", fmt.Errorf("unexpected path: %s", targpath) + return "", fmt.Errorf("unexpected path (should have been filtered): %s", targpath) } } @@ -103,10 +98,200 @@ func TestTerraformBundler_Bundle(t *testing.T) { return []byte("output \"instance_id\" {\n value = aws_instance.example.id\n}"), nil case filepath.Join("terraform", "modules", "vpc", "main.tf"): return []byte("resource \"aws_vpc\" \"main\" {\n cidr_block = \"10.0.0.0/16\"\n}"), nil - case filepath.Join("terraform", "environments", "prod", "terraform.tfvars"): - return []byte("instance_type = \"t3.medium\"\nregion = \"us-west-2\""), nil default: - return nil, fmt.Errorf("unexpected file: %s", filename) + return nil, fmt.Errorf("unexpected file should not be read (should have been filtered): %s", filename) + } + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And files should be added with correct paths (excluding .tfvars files for security) + expectedFiles := map[string]string{ + "terraform/main.tf": "resource \"aws_instance\" \"example\" {\n ami = \"ami-12345\"\n}", + "terraform/variables.tf": "variable \"instance_type\" {\n type = string\n default = \"t2.micro\"\n}", + "terraform/outputs.tf": "output \"instance_id\" {\n value = aws_instance.example.id\n}", + "terraform/modules/vpc/main.tf": "resource \"aws_vpc\" \"main\" {\n cidr_block = \"10.0.0.0/16\"\n}", + } + + for expectedPath, expectedContent := range expectedFiles { + if content, exists := filesAdded[expectedPath]; !exists { + t.Errorf("Expected file %s to be added", expectedPath) + } else if string(content) != expectedContent { + t.Errorf("Expected content %q for %s, got %q", expectedContent, expectedPath, string(content)) + } + } + + // And directories should be skipped (only 4 files should be added, .tfvars files are filtered out) + if len(filesAdded) != 4 { + t.Errorf("Expected 4 files to be added, got %d", len(filesAdded)) + } + }) + + t.Run("SkipsTerraformDirectoriesAndOverrideFiles", func(t *testing.T) { + // Given a terraform bundler with .terraform directories and override files + bundler, mocks := setup(t) + filesAdded := make(map[string][]byte) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded[path] = content + return nil + } + + walkCallCount := 0 + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + walkCallCount++ + + // Simulate directory traversal with .terraform directories and override files + if err := fn("terraform", &mockFileInfo{name: "terraform", isDir: true}, nil); err != nil { + return err + } + if err := fn(filepath.Join("terraform", "main.tf"), &mockFileInfo{name: "main.tf", isDir: false}, nil); err != nil { + return err + } + if err := fn(filepath.Join("terraform", "backend_override.tf"), &mockFileInfo{name: "backend_override.tf", isDir: false}, nil); err != nil { + return err + } + if err := fn(filepath.Join("terraform", "local_override.tf"), &mockFileInfo{name: "local_override.tf", isDir: false}, nil); err != nil { + return err + } + // Test .terraform directory - this should be skipped + if err := fn(filepath.Join("terraform", ".terraform"), &mockFileInfo{name: ".terraform", isDir: true}, nil); err != nil { + if err == filepath.SkipDir { + // This is expected behavior - continue with the rest of the files + } else { + return err + } + } + if err := fn(filepath.Join("terraform", "variables.tf"), &mockFileInfo{name: "variables.tf", isDir: false}, nil); err != nil { + return err + } + return nil + } + + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + switch targpath { + case filepath.Join("terraform", "main.tf"): + return "main.tf", nil + case filepath.Join("terraform", "variables.tf"): + return "variables.tf", nil + case filepath.Join("terraform", "backend_override.tf"): + return "backend_override.tf", nil + case filepath.Join("terraform", "local_override.tf"): + return "local_override.tf", nil + default: + return "", fmt.Errorf("unexpected path: %s", targpath) + } + } + + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + switch filename { + case filepath.Join("terraform", "main.tf"): + return []byte("resource \"aws_instance\" \"example\" {}"), nil + case filepath.Join("terraform", "variables.tf"): + return []byte("variable \"instance_type\" {}"), nil + default: + return nil, fmt.Errorf("unexpected file should not be read: %s", filename) + } + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And only non-override files should be added (override files should be skipped) + expectedFiles := map[string]string{ + "terraform/main.tf": "resource \"aws_instance\" \"example\" {}", + "terraform/variables.tf": "variable \"instance_type\" {}", + } + + for expectedPath, expectedContent := range expectedFiles { + if content, exists := filesAdded[expectedPath]; !exists { + t.Errorf("Expected file %s to be added", expectedPath) + } else if string(content) != expectedContent { + t.Errorf("Expected content %q for %s, got %q", expectedContent, expectedPath, string(content)) + } + } + + // And override files should not be included + overrideFiles := []string{ + "terraform/backend_override.tf", + "terraform/local_override.tf", + } + for _, overrideFile := range overrideFiles { + if _, exists := filesAdded[overrideFile]; exists { + t.Errorf("Override file %s should not be added", overrideFile) + } + } + + // And only the expected files should be added (2 files) + if len(filesAdded) != 2 { + t.Errorf("Expected 2 files to be added, got %d", len(filesAdded)) + for path := range filesAdded { + t.Logf("Added file: %s", path) + } + } + }) + + t.Run("SkipsTerraformDirectoryCompletely", func(t *testing.T) { + // Given a terraform bundler with .terraform directory containing files + bundler, mocks := setup(t) + filesAdded := make(map[string][]byte) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded[path] = content + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Simulate directory traversal + if err := fn("terraform", &mockFileInfo{name: "terraform", isDir: true}, nil); err != nil { + return err + } + if err := fn(filepath.Join("terraform", "main.tf"), &mockFileInfo{name: "main.tf", isDir: false}, nil); err != nil { + return err + } + // Test .terraform directory - should return SkipDir to skip entire directory + if err := fn(filepath.Join("terraform", ".terraform"), &mockFileInfo{name: ".terraform", isDir: true}, nil); err != nil { + if err == filepath.SkipDir { + // .terraform directory should be skipped completely, don't traverse its contents + return nil + } + return err + } + // These files should NOT be called because .terraform directory should be skipped + // If they are called, the test should fail + if err := fn(filepath.Join("terraform", ".terraform", "providers"), &mockFileInfo{name: "providers", isDir: true}, nil); err != nil { + return err + } + if err := fn(filepath.Join("terraform", ".terraform", "terraform.tfstate"), &mockFileInfo{name: "terraform.tfstate", isDir: false}, nil); err != nil { + return err + } + return nil + } + + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + switch targpath { + case filepath.Join("terraform", "main.tf"): + return "main.tf", nil + default: + return "", fmt.Errorf("unexpected path: %s", targpath) + } + } + + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + switch filename { + case filepath.Join("terraform", "main.tf"): + return []byte("resource \"aws_instance\" \"example\" {}"), nil + default: + return nil, fmt.Errorf("unexpected file should not be read: %s", filename) } } @@ -118,13 +303,9 @@ func TestTerraformBundler_Bundle(t *testing.T) { t.Errorf("Expected nil error, got %v", err) } - // And files should be added with correct paths + // And only main.tf should be added (.terraform directory should be completely skipped) expectedFiles := map[string]string{ - "terraform/main.tf": "resource \"aws_instance\" \"example\" {\n ami = \"ami-12345\"\n}", - "terraform/variables.tf": "variable \"instance_type\" {\n type = string\n default = \"t2.micro\"\n}", - "terraform/outputs.tf": "output \"instance_id\" {\n value = aws_instance.example.id\n}", - "terraform/modules/vpc/main.tf": "resource \"aws_vpc\" \"main\" {\n cidr_block = \"10.0.0.0/16\"\n}", - "terraform/environments/prod/terraform.tfvars": "instance_type = \"t3.medium\"\nregion = \"us-west-2\"", + "terraform/main.tf": "resource \"aws_instance\" \"example\" {}", } for expectedPath, expectedContent := range expectedFiles { @@ -135,9 +316,149 @@ func TestTerraformBundler_Bundle(t *testing.T) { } } - // And directories should be skipped (only 5 files should be added) - if len(filesAdded) != 5 { - t.Errorf("Expected 5 files to be added, got %d", len(filesAdded)) + // And only 1 file should be added + if len(filesAdded) != 1 { + t.Errorf("Expected 1 file to be added, got %d", len(filesAdded)) + for path := range filesAdded { + t.Logf("Added file: %s", path) + } + } + }) + + t.Run("FiltersCommonTerraformIgnorePatterns", func(t *testing.T) { + // Given a terraform bundler with various terraform files including ones that should be filtered + bundler, mocks := setup(t) + filesAdded := make(map[string][]byte) + mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + filesAdded[path] = content + return nil + } + + bundler.shims.Walk = func(root string, fn filepath.WalkFunc) error { + // Simulate walking through terraform directory with various file types + files := []struct { + path string + name string + isDir bool + should string // "include" or "exclude" + }{ + {"terraform", "terraform", true, "skip-dir"}, + {"terraform/main.tf", "main.tf", false, "include"}, + {"terraform/variables.tf", "variables.tf", false, "include"}, + {"terraform/outputs.tf", "outputs.tf", false, "include"}, + {"terraform/.terraform.lock.hcl", ".terraform.lock.hcl", false, "include"}, // Lock files should be included! + {"terraform/terraform.tfvars", "terraform.tfvars", false, "exclude"}, + {"terraform/prod.tfvars", "prod.tfvars", false, "exclude"}, + {"terraform/secrets.tfvars.json", "secrets.tfvars.json", false, "exclude"}, + {"terraform/terraform.tfstate", "terraform.tfstate", false, "exclude"}, + {"terraform/terraform.tfstate.backup", "terraform.tfstate.backup", false, "exclude"}, + {"terraform/prod.tfstate", "prod.tfstate", false, "exclude"}, + {"terraform/plan.tfplan", "plan.tfplan", false, "exclude"}, + {"terraform/terraform.tfplan", "terraform.tfplan", false, "exclude"}, + {"terraform/backend_override.tf", "backend_override.tf", false, "exclude"}, + {"terraform/local_override.tf", "local_override.tf", false, "exclude"}, + {"terraform/override.tf", "override.tf", false, "exclude"}, + {"terraform/override.tf.json", "override.tf.json", false, "exclude"}, + {"terraform/test_override.tf.json", "test_override.tf.json", false, "exclude"}, + {"terraform/.terraformrc", ".terraformrc", false, "exclude"}, + {"terraform/terraform.rc", "terraform.rc", false, "exclude"}, + {"terraform/crash.log", "crash.log", false, "exclude"}, + {"terraform/crash.20241205.log", "crash.20241205.log", false, "exclude"}, + {"terraform/modules", "modules", true, "skip-dir"}, + {"terraform/modules/vpc", "vpc", true, "skip-dir"}, + {"terraform/modules/vpc/main.tf", "main.tf", false, "include"}, + } + + for _, file := range files { + err := fn(file.path, &mockFileInfo{name: file.name, isDir: file.isDir}, nil) + if err != nil && err != filepath.SkipDir { + return err + } + } + return nil + } + + bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { + // Return relative path for files that should be included + includeFiles := map[string]string{ + filepath.Join("terraform", "main.tf"): "main.tf", + filepath.Join("terraform", "variables.tf"): "variables.tf", + filepath.Join("terraform", "outputs.tf"): "outputs.tf", + filepath.Join("terraform", ".terraform.lock.hcl"): ".terraform.lock.hcl", + filepath.Join("terraform", "modules", "vpc", "main.tf"): "modules/vpc/main.tf", + } + if relPath, exists := includeFiles[targpath]; exists { + return relPath, nil + } + return "", fmt.Errorf("unexpected path (should have been filtered): %s", targpath) + } + + bundler.shims.ReadFile = func(filename string) ([]byte, error) { + // Return content for files that should be included + contentMap := map[string]string{ + filepath.Join("terraform", "main.tf"): "resource \"aws_instance\" \"example\" {}", + filepath.Join("terraform", "variables.tf"): "variable \"instance_type\" {}", + filepath.Join("terraform", "outputs.tf"): "output \"instance_id\" {}", + filepath.Join("terraform", ".terraform.lock.hcl"): "# Lock file content", + filepath.Join("terraform", "modules", "vpc", "main.tf"): "module vpc content", + } + if content, exists := contentMap[filename]; exists { + return []byte(content), nil + } + return nil, fmt.Errorf("unexpected file should not be read (should have been filtered): %s", filename) + } + + // When calling Bundle + err := bundler.Bundle(mocks.Artifact) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected nil error, got %v", err) + } + + // And only files that should be included are added + expectedFiles := map[string]string{ + "terraform/main.tf": "resource \"aws_instance\" \"example\" {}", + "terraform/variables.tf": "variable \"instance_type\" {}", + "terraform/outputs.tf": "output \"instance_id\" {}", + "terraform/.terraform.lock.hcl": "# Lock file content", + "terraform/modules/vpc/main.tf": "module vpc content", + } + + for expectedPath, expectedContent := range expectedFiles { + if content, exists := filesAdded[expectedPath]; !exists { + t.Errorf("Expected file %s to be added", expectedPath) + } else if string(content) != expectedContent { + t.Errorf("Expected content %q for %s, got %q", expectedContent, expectedPath, string(content)) + } + } + + // And no unwanted files should be included + if len(filesAdded) != len(expectedFiles) { + t.Errorf("Expected %d files to be added, got %d", len(expectedFiles), len(filesAdded)) + for path := range filesAdded { + t.Logf("Added file: %s", path) + } + } + + // Verify specific files are NOT included + excludedFiles := []string{ + "terraform/terraform.tfvars", + "terraform/prod.tfvars", + "terraform/secrets.tfvars.json", + "terraform/terraform.tfstate", + "terraform/terraform.tfstate.backup", + "terraform/plan.tfplan", + "terraform/backend_override.tf", + "terraform/override.tf", + "terraform/.terraformrc", + "terraform/crash.log", + } + + for _, excludedFile := range excludedFiles { + if _, exists := filesAdded[excludedFile]; exists { + t.Errorf("File %s should have been excluded but was included", excludedFile) + } } }) From ee47c0398659bc82a18750d31daafa04e417ab55 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Wed, 2 Jul 2025 23:03:54 -0400 Subject: [PATCH 3/3] Windows fix --- pkg/bundler/terraform_bundler_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/bundler/terraform_bundler_test.go b/pkg/bundler/terraform_bundler_test.go index 39767143a..66f2c5d0e 100644 --- a/pkg/bundler/terraform_bundler_test.go +++ b/pkg/bundler/terraform_bundler_test.go @@ -380,12 +380,19 @@ func TestTerraformBundler_Bundle(t *testing.T) { bundler.shims.FilepathRel = func(basepath, targpath string) (string, error) { // Return relative path for files that should be included + // Handle both Unix-style and Windows-style paths for cross-platform compatibility includeFiles := map[string]string{ filepath.Join("terraform", "main.tf"): "main.tf", filepath.Join("terraform", "variables.tf"): "variables.tf", filepath.Join("terraform", "outputs.tf"): "outputs.tf", filepath.Join("terraform", ".terraform.lock.hcl"): ".terraform.lock.hcl", filepath.Join("terraform", "modules", "vpc", "main.tf"): "modules/vpc/main.tf", + // Also handle forward-slash versions for cross-platform compatibility + "terraform/main.tf": "main.tf", + "terraform/variables.tf": "variables.tf", + "terraform/outputs.tf": "outputs.tf", + "terraform/.terraform.lock.hcl": ".terraform.lock.hcl", + "terraform/modules/vpc/main.tf": "modules/vpc/main.tf", } if relPath, exists := includeFiles[targpath]; exists { return relPath, nil @@ -395,12 +402,19 @@ func TestTerraformBundler_Bundle(t *testing.T) { bundler.shims.ReadFile = func(filename string) ([]byte, error) { // Return content for files that should be included + // Handle both Unix-style and Windows-style paths for cross-platform compatibility contentMap := map[string]string{ filepath.Join("terraform", "main.tf"): "resource \"aws_instance\" \"example\" {}", filepath.Join("terraform", "variables.tf"): "variable \"instance_type\" {}", filepath.Join("terraform", "outputs.tf"): "output \"instance_id\" {}", filepath.Join("terraform", ".terraform.lock.hcl"): "# Lock file content", filepath.Join("terraform", "modules", "vpc", "main.tf"): "module vpc content", + // Also handle forward-slash versions for cross-platform compatibility + "terraform/main.tf": "resource \"aws_instance\" \"example\" {}", + "terraform/variables.tf": "variable \"instance_type\" {}", + "terraform/outputs.tf": "output \"instance_id\" {}", + "terraform/.terraform.lock.hcl": "# Lock file content", + "terraform/modules/vpc/main.tf": "module vpc content", } if content, exists := contentMap[filename]; exists { return []byte(content), nil