From b2ee223e249d20eac51709078433183f46ce58b4 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 3 Jul 2025 15:50:14 -0400 Subject: [PATCH 1/2] feature(blueprint): Support OCI sources for terraform components Terraform components listed in the `blueprint.yaml` can now reference sources with OCI registries. This mechanism will extract the terraform modules to a local `.windsor/.oci_extracted` folder before creating the module shims. --- cmd/bundle_test.go | 7 +- cmd/push_test.go | 3 +- pkg/blueprint/blueprint_handler.go | 10 +- pkg/bundler/artifact.go | 40 +- pkg/bundler/artifact_test.go | 88 +- pkg/bundler/kustomize_bundler.go | 2 +- pkg/bundler/kustomize_bundler_test.go | 10 +- pkg/bundler/mock_artifact.go | 8 +- pkg/bundler/mock_artifact_test.go | 7 +- pkg/bundler/template_bundler.go | 2 +- pkg/bundler/template_bundler_test.go | 6 +- pkg/bundler/terraform_bundler.go | 2 +- pkg/bundler/terraform_bundler_test.go | 16 +- pkg/generators/generator_test.go | 7 +- pkg/generators/shims.go | 84 +- pkg/generators/terraform_generator.go | 351 +- pkg/generators/terraform_generator_test.go | 6517 ++++++++++++-------- 17 files changed, 4507 insertions(+), 2653 deletions(-) diff --git a/cmd/bundle_test.go b/cmd/bundle_test.go index 82f62f11d..044dc84df 100644 --- a/cmd/bundle_test.go +++ b/cmd/bundle_test.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "testing" "github.com/windsorcli/cli/pkg/bundler" @@ -40,7 +41,7 @@ contexts: // Create mock artifact builder artifactBuilder := bundler.NewMockArtifact() artifactBuilder.InitializeFunc = func(injector di.Injector) error { return nil } - artifactBuilder.AddFileFunc = func(path string, content []byte) error { return nil } + artifactBuilder.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { return nil } artifactBuilder.CreateFunc = func(outputPath string, tag string) (string, error) { if tag != "" { return "test-v1.0.0.tar.gz", nil @@ -351,7 +352,7 @@ func TestBundleCmd(t *testing.T) { // Create a fresh artifact builder to avoid state contamination freshArtifactBuilder := bundler.NewMockArtifact() freshArtifactBuilder.InitializeFunc = func(injector di.Injector) error { return nil } - freshArtifactBuilder.AddFileFunc = func(path string, content []byte) error { return nil } + freshArtifactBuilder.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { return nil } freshArtifactBuilder.CreateFunc = func(outputPath string, tag string) (string, error) { receivedOutputPath = outputPath return "test-result.tar.gz", nil @@ -386,7 +387,7 @@ func TestBundleCmd(t *testing.T) { // Create a fresh artifact builder to avoid state contamination freshArtifactBuilder := bundler.NewMockArtifact() freshArtifactBuilder.InitializeFunc = func(injector di.Injector) error { return nil } - freshArtifactBuilder.AddFileFunc = func(path string, content []byte) error { return nil } + freshArtifactBuilder.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { return nil } freshArtifactBuilder.CreateFunc = func(outputPath string, tag string) (string, error) { receivedTag = tag return "test-result.tar.gz", nil diff --git a/cmd/push_test.go b/cmd/push_test.go index 9af2b2fd9..3b05f4199 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "testing" "github.com/windsorcli/cli/pkg/bundler" @@ -40,7 +41,7 @@ contexts: // Create mock artifact builder artifactBuilder := bundler.NewMockArtifact() artifactBuilder.InitializeFunc = func(injector di.Injector) error { return nil } - artifactBuilder.AddFileFunc = func(path string, content []byte) error { return nil } + artifactBuilder.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { return nil } artifactBuilder.PushFunc = func(registryBase string, repoName string, tag string) error { return nil } // Create mock template bundler diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 0e3ec7fe3..8ad48248e 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -685,7 +685,7 @@ func (b *BaseBlueprintHandler) processBlueprintTemplate(outputPath, content, con // Private Methods // ============================================================================= -// resolveComponentSources processes each Terraform component's source field, expanding it into a full +// resolveComponentSources transforms component source names into fully qualified // URL with path prefix and reference information based on the associated source configuration. func (b *BaseBlueprintHandler) resolveComponentSources(blueprint *blueprintv1alpha1.Blueprint) { resolvedComponents := make([]blueprintv1alpha1.TerraformComponent, len(blueprint.TerraformComponents)) @@ -694,6 +694,11 @@ func (b *BaseBlueprintHandler) resolveComponentSources(blueprint *blueprintv1alp for i, component := range resolvedComponents { for _, source := range blueprint.Sources { if component.Source == source.Name { + // Skip URL resolution for OCI sources - let terraform generator handle them + if strings.HasPrefix(source.Url, "oci://") { + break + } + pathPrefix := source.PathPrefix if pathPrefix == "" { pathPrefix = "terraform" @@ -731,7 +736,8 @@ func (b *BaseBlueprintHandler) resolveComponentPaths(blueprint *blueprintv1alpha for i, component := range resolvedComponents { componentCopy := component - if b.isValidTerraformRemoteSource(componentCopy.Source) { + // Check if this is a remote source (Git URL) or OCI source + if b.isValidTerraformRemoteSource(componentCopy.Source) || b.isOCISource(componentCopy.Source) { componentCopy.FullPath = filepath.Join(projectRoot, ".windsor", ".tf_modules", componentCopy.Path) } else { componentCopy.FullPath = filepath.Join(projectRoot, "terraform", componentCopy.Path) diff --git a/pkg/bundler/artifact.go b/pkg/bundler/artifact.go index 2feed7c71..80cdc91bd 100644 --- a/pkg/bundler/artifact.go +++ b/pkg/bundler/artifact.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "fmt" + "os" "path/filepath" "strings" "time" @@ -73,7 +74,7 @@ type BlueprintMetadataInput struct { // Artifact defines the interface for artifact creation operations type Artifact interface { Initialize(injector di.Injector) error - AddFile(path string, content []byte) error + AddFile(path string, content []byte, mode os.FileMode) error Create(outputPath string, tag string) (string, error) Push(registryBase string, repoName string, tag string) error } @@ -82,9 +83,15 @@ type Artifact interface { // ArtifactBuilder Implementation // ============================================================================= +// FileInfo holds file content and permission information +type FileInfo struct { + Content []byte + Mode os.FileMode +} + // ArtifactBuilder implements the Artifact interface type ArtifactBuilder struct { - files map[string][]byte + files map[string]FileInfo shims *Shims shell shell.Shell tarballPath string @@ -101,7 +108,7 @@ type ArtifactBuilder struct { func NewArtifactBuilder() *ArtifactBuilder { return &ArtifactBuilder{ shims: NewShims(), - files: make(map[string][]byte), + files: make(map[string]FileInfo), } } @@ -128,8 +135,11 @@ func (a *ArtifactBuilder) Initialize(injector di.Injector) error { // Files are held in memory until Create() or Push() is called. The path becomes the relative // path within the generated tar.gz archive. Multiple calls with the same path will overwrite // the previous content. Special handling exists for "_templates/metadata.yaml" during packaging. -func (a *ArtifactBuilder) AddFile(path string, content []byte) error { - a.files[path] = content +func (a *ArtifactBuilder) AddFile(path string, content []byte, mode os.FileMode) error { + a.files[path] = FileInfo{ + Content: content, + Mode: mode, + } return nil } @@ -278,11 +288,11 @@ func (a *ArtifactBuilder) parseTagAndResolveMetadata(repoName, tag string) (stri } } - metadataData, hasMetadata := a.files["_templates/metadata.yaml"] + metadataFileInfo, hasMetadata := a.files["_templates/metadata.yaml"] var input BlueprintMetadataInput if hasMetadata { - if err := a.shims.YamlUnmarshal(metadataData, &input); err != nil { + if err := a.shims.YamlUnmarshal(metadataFileInfo.Content, &input); err != nil { return "", "", nil, fmt.Errorf("failed to parse metadata.yaml: %w", err) } } @@ -349,22 +359,22 @@ func (a *ArtifactBuilder) createTarballInMemory(metadata []byte) ([]byte, error) return nil, fmt.Errorf("failed to write metadata: %w", err) } - for path, content := range a.files { + for path, fileInfo := range a.files { if path == "_templates/metadata.yaml" { continue } header := &tar.Header{ Name: path, - Mode: 0644, - Size: int64(len(content)), + Mode: int64(fileInfo.Mode), + Size: int64(len(fileInfo.Content)), } if err := tarWriter.WriteHeader(header); err != nil { return nil, fmt.Errorf("failed to write header for %s: %w", path, err) } - if _, err := tarWriter.Write(content); err != nil { + if _, err := tarWriter.Write(fileInfo.Content); err != nil { return nil, fmt.Errorf("failed to write content for %s: %w", path, err) } } @@ -412,22 +422,22 @@ func (a *ArtifactBuilder) createTarballToDisk(outputPath string, metadata []byte return fmt.Errorf("failed to write metadata: %w", err) } - for path, content := range a.files { + for path, fileInfo := range a.files { if path == "_templates/metadata.yaml" { continue } header := &tar.Header{ Name: path, - Mode: 0644, - Size: int64(len(content)), + Mode: int64(fileInfo.Mode), + Size: int64(len(fileInfo.Content)), } if err := tarWriter.WriteHeader(header); err != nil { return fmt.Errorf("failed to write header for %s: %w", path, err) } - if _, err := tarWriter.Write(content); err != nil { + if _, err := tarWriter.Write(fileInfo.Content); err != nil { return fmt.Errorf("failed to write content for %s: %w", path, err) } } diff --git a/pkg/bundler/artifact_test.go b/pkg/bundler/artifact_test.go index b40f5a552..c1dbad81a 100644 --- a/pkg/bundler/artifact_test.go +++ b/pkg/bundler/artifact_test.go @@ -356,7 +356,7 @@ func TestArtifactBuilder_AddFile(t *testing.T) { // When adding a file testPath := "test/file.txt" testContent := []byte("test content") - err := builder.AddFile(testPath, testContent) + err := builder.AddFile(testPath, testContent, 0644) // Then no error should be returned if err != nil { @@ -367,10 +367,10 @@ func TestArtifactBuilder_AddFile(t *testing.T) { if len(builder.files) != 1 { t.Errorf("Expected 1 file, got %d", len(builder.files)) } - if content, exists := builder.files[testPath]; !exists { + if fileInfo, exists := builder.files[testPath]; !exists { t.Error("Expected file to be stored") - } else if string(content) != string(testContent) { - t.Errorf("Expected content %s, got %s", testContent, content) + } else if string(fileInfo.Content) != string(testContent) { + t.Errorf("Expected content %s, got %s", testContent, fileInfo.Content) } }) @@ -386,7 +386,7 @@ func TestArtifactBuilder_AddFile(t *testing.T) { } for path, content := range files { - err := builder.AddFile(path, content) + err := builder.AddFile(path, content, 0644) if err != nil { t.Errorf("Unexpected error adding file %s: %v", path, err) } @@ -398,10 +398,10 @@ func TestArtifactBuilder_AddFile(t *testing.T) { } for path, expectedContent := range files { - if actualContent, exists := builder.files[path]; !exists { + if actualFileInfo, exists := builder.files[path]; !exists { t.Errorf("Expected file %s to be stored", path) - } else if string(actualContent) != string(expectedContent) { - t.Errorf("Expected content %s for file %s, got %s", expectedContent, path, actualContent) + } else if string(actualFileInfo.Content) != string(expectedContent) { + t.Errorf("Expected content %s for file %s, got %s", expectedContent, path, actualFileInfo.Content) } } }) @@ -448,7 +448,7 @@ name: myproject version: v2.0.0 description: A test project `) - builder.AddFile("_templates/metadata.yaml", metadataContent) + builder.AddFile("_templates/metadata.yaml", metadataContent, 0644) // Override YamlUnmarshal to parse the metadata builder.shims.YamlUnmarshal = func(data []byte, v any) error { @@ -481,7 +481,7 @@ description: A test project builder, _ := setup(t) // Add metadata file with different values - builder.AddFile("_templates/metadata.yaml", []byte("metadata")) + builder.AddFile("_templates/metadata.yaml", []byte("metadata"), 0644) builder.shims.YamlUnmarshal = func(data []byte, v any) error { if metadata, ok := v.(*BlueprintMetadataInput); ok { metadata.Name = "frommetadata" @@ -549,7 +549,7 @@ description: A test project // Given a builder with metadata containing only name builder, _ := setup(t) - builder.AddFile("_templates/metadata.yaml", []byte("metadata")) + builder.AddFile("_templates/metadata.yaml", []byte("metadata"), 0644) builder.shims.YamlUnmarshal = func(data []byte, v any) error { if metadata, ok := v.(*BlueprintMetadataInput); ok { metadata.Name = "testproject" @@ -574,7 +574,7 @@ description: A test project // Given a builder with invalid metadata builder, _ := setup(t) - builder.AddFile("_templates/metadata.yaml", []byte("invalid yaml")) + builder.AddFile("_templates/metadata.yaml", []byte("invalid yaml"), 0644) builder.shims.YamlUnmarshal = func(data []byte, v any) error { return fmt.Errorf("yaml parse error") } @@ -711,7 +711,7 @@ description: A test project t.Run("ErrorWhenFileHeaderWriteFails", func(t *testing.T) { // Given a builder with files and failing file header write builder, _ := setup(t) - builder.AddFile("test.txt", []byte("content")) + builder.AddFile("test.txt", []byte("content"), 0644) mockTarWriter := &mockTarWriter{ writeHeaderFunc: func(hdr *tar.Header) error { @@ -744,7 +744,7 @@ description: A test project t.Run("ErrorWhenFileContentWriteFails", func(t *testing.T) { // Given a builder with files and failing file content write builder, _ := setup(t) - builder.AddFile("test.txt", []byte("content")) + builder.AddFile("test.txt", []byte("content"), 0644) writeCount := 0 mockTarWriter := &mockTarWriter{ @@ -780,8 +780,8 @@ description: A test project t.Run("SkipsMetadataFileInFileLoop", func(t *testing.T) { // Given a builder with metadata file and other files builder, _ := setup(t) - builder.AddFile("_templates/metadata.yaml", []byte("metadata content")) - builder.AddFile("other.txt", []byte("other content")) + builder.AddFile("_templates/metadata.yaml", []byte("metadata content"), 0644) + builder.AddFile("other.txt", []byte("other content"), 0644) filesWritten := make(map[string]bool) mockTarWriter := &mockTarWriter{ @@ -844,7 +844,7 @@ func TestArtifactBuilder_Push(t *testing.T) { input.Version = "1.0.0" return nil } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) _, err := builder.Create("test.tar.gz", "") if err != nil { t.Fatalf("Failed to create artifact: %v", err) @@ -874,7 +874,7 @@ func TestArtifactBuilder_Push(t *testing.T) { // Return empty metadata (no name or version) return nil } - builder.AddFile("_templates/metadata.yaml", []byte("")) + builder.AddFile("_templates/metadata.yaml", []byte(""), 0644) // When pushing with empty repoName (simulates Create method scenario) err := builder.Push("registry.example.com", "", "") @@ -894,7 +894,7 @@ func TestArtifactBuilder_Push(t *testing.T) { // No version set return nil } - builder.AddFile("_templates/metadata.yaml", []byte("name: test")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test"), 0644) // When pushing without providing tag err := builder.Push("registry.example.com", "test", "") @@ -912,7 +912,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return nil, fmt.Errorf("mock implementation error") } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing err := builder.Push("registry.example.com", "myapp", "2.0.0") @@ -942,8 +942,8 @@ func TestArtifactBuilder_Push(t *testing.T) { return nil, fmt.Errorf("expected test termination") } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) - builder.AddFile("test.txt", []byte("test content")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) + builder.AddFile("test.txt", []byte("test content"), 0644) // When pushing (this should work entirely in-memory) err := builder.Push("registry.example.com", "test", "1.0.0") @@ -972,7 +972,7 @@ func TestArtifactBuilder_Push(t *testing.T) { } } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing err := builder.Push("registry.example.com", "test", "1.0.0") @@ -993,7 +993,7 @@ func TestArtifactBuilder_Push(t *testing.T) { input.Version = "1.0.0" return nil } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing with invalid registry format (contains invalid characters) err := builder.Push("invalid registry format with spaces", "test", "1.0.0") @@ -1023,7 +1023,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return nil, fmt.Errorf("config file mutation failed") } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1047,7 +1047,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return nil, fmt.Errorf("expected test termination") } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing with empty tag (should use version from metadata) err := builder.Push("registry.example.com", "test", "") @@ -1088,7 +1088,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return mockImg } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1142,7 +1142,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return mockImg } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1188,7 +1188,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return mockImg } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1240,7 +1240,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return mockImg } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing (assuming remote.Get will fail for config, triggering upload path) err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1261,7 +1261,7 @@ func TestArtifactBuilder_Push(t *testing.T) { // Return empty input so tag parsing is tested return nil } - builder.AddFile("_templates/metadata.yaml", []byte("")) + builder.AddFile("_templates/metadata.yaml", []byte(""), 0644) // When creating with tag containing multiple colons (should fail in Create method) _, err := builder.Create("test.tar.gz", "name:version:extra") @@ -1280,7 +1280,7 @@ func TestArtifactBuilder_Push(t *testing.T) { mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { return nil } - builder.AddFile("_templates/metadata.yaml", []byte("")) + builder.AddFile("_templates/metadata.yaml", []byte(""), 0644) // When creating with tag having empty parts (should fail in Create method) invalidTags := []string{":version", "name:", ":"} @@ -1303,7 +1303,7 @@ func TestArtifactBuilder_Push(t *testing.T) { input.Version = "1.0.0" return nil } - builder.AddFile("_templates/metadata.yaml", []byte("version: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("version: 1.0.0"), 0644) // Mock to terminate early after metadata resolution mocks.Shims.AppendLayers = func(base v1.Image, layers ...v1.Layer) (v1.Image, error) { @@ -1349,7 +1349,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return mockImg } - builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0")) + builder.AddFile("_templates/metadata.yaml", []byte("name: test\nversion: 1.0.0"), 0644) // When pushing with empty tag (should construct URL without tag) err := builder.Push("registry.example.com", "test", "") @@ -1369,7 +1369,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return fmt.Errorf("layer upload failed") } - builder.AddFile("file.txt", []byte("content")) + builder.AddFile("file.txt", []byte("content"), 0644) // When calling Push err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1392,7 +1392,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return fmt.Errorf("manifest upload failed") } - builder.AddFile("file.txt", []byte("content")) + builder.AddFile("file.txt", []byte("content"), 0644) // When calling Push err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1415,7 +1415,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return &remote.Descriptor{}, nil } - builder.AddFile("file.txt", []byte("content")) + builder.AddFile("file.txt", []byte("content"), 0644) // When calling Push err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1439,7 +1439,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return nil } - builder.AddFile("file.txt", []byte("content")) + builder.AddFile("file.txt", []byte("content"), 0644) // When calling Push err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1469,7 +1469,7 @@ func TestArtifactBuilder_Push(t *testing.T) { return fmt.Errorf("config upload failed") } - builder.AddFile("file.txt", []byte("content")) + builder.AddFile("file.txt", []byte("content"), 0644) // When calling Push err := builder.Push("registry.example.com", "test", "1.0.0") @@ -1581,7 +1581,7 @@ func TestArtifactBuilder_createTarballInMemory(t *testing.T) { t.Run("ErrorWhenTarWriterWriteHeaderFails", func(t *testing.T) { // Given a builder with files builder, mocks := setup(t) - builder.AddFile("test.txt", []byte("content")) + builder.AddFile("test.txt", []byte("content"), 0644) // Mock tar writer to fail on WriteHeader mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { @@ -1604,7 +1604,7 @@ func TestArtifactBuilder_createTarballInMemory(t *testing.T) { t.Run("ErrorWhenTarWriterWriteFails", func(t *testing.T) { // Given a builder with files builder, mocks := setup(t) - builder.AddFile("test.txt", []byte("content")) + builder.AddFile("test.txt", []byte("content"), 0644) // Mock tar writer to fail on Write mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { @@ -1627,7 +1627,7 @@ func TestArtifactBuilder_createTarballInMemory(t *testing.T) { t.Run("ErrorWhenFileHeaderWriteFails", func(t *testing.T) { // Given a builder with files builder, mocks := setup(t) - builder.AddFile("test.txt", []byte("content")) + builder.AddFile("test.txt", []byte("content"), 0644) headerCount := 0 // Mock tar writer to fail on second WriteHeader (for file) @@ -1658,7 +1658,7 @@ func TestArtifactBuilder_createTarballInMemory(t *testing.T) { t.Run("ErrorWhenFileContentWriteFails", func(t *testing.T) { // Given a builder with files builder, mocks := setup(t) - builder.AddFile("test.txt", []byte("content")) + builder.AddFile("test.txt", []byte("content"), 0644) writeCount := 0 // Mock tar writer to fail on second Write (for file content) @@ -1686,7 +1686,7 @@ func TestArtifactBuilder_createTarballInMemory(t *testing.T) { t.Run("ErrorWhenTarWriterCloseFails", func(t *testing.T) { // Given a builder with files builder, mocks := setup(t) - builder.AddFile("test.txt", []byte("content")) + builder.AddFile("test.txt", []byte("content"), 0644) // Mock tar writer to fail on Close mocks.Shims.NewTarWriter = func(w io.Writer) TarWriter { diff --git a/pkg/bundler/kustomize_bundler.go b/pkg/bundler/kustomize_bundler.go index 9ad4a826e..77ad9d9db 100644 --- a/pkg/bundler/kustomize_bundler.go +++ b/pkg/bundler/kustomize_bundler.go @@ -67,7 +67,7 @@ func (k *KustomizeBundler) Bundle(artifact Artifact) error { } artifactPath := "kustomize/" + filepath.ToSlash(relPath) - return artifact.AddFile(artifactPath, data) + return artifact.AddFile(artifactPath, data, info.Mode()) }) } diff --git a/pkg/bundler/kustomize_bundler_test.go b/pkg/bundler/kustomize_bundler_test.go index a5cc9e873..af601ed4f 100644 --- a/pkg/bundler/kustomize_bundler_test.go +++ b/pkg/bundler/kustomize_bundler_test.go @@ -56,7 +56,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { // Set up mocks to simulate finding kustomize files filesAdded := make(map[string][]byte) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded[path] = content return nil } @@ -145,7 +145,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { } filesAdded := make([]string, 0) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded = append(filesAdded, path) return nil } @@ -264,7 +264,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { bundler.shims.ReadFile = func(filename string) ([]byte, error) { return []byte("content"), nil } - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { return fmt.Errorf("artifact storage full") } @@ -285,7 +285,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { bundler, mocks := setup(t) filesAdded := make([]string, 0) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded = append(filesAdded, path) return nil } @@ -335,7 +335,7 @@ func TestKustomizeBundler_Bundle(t *testing.T) { bundler, mocks := setup(t) filesAdded := make([]string, 0) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded = append(filesAdded, path) return nil } diff --git a/pkg/bundler/mock_artifact.go b/pkg/bundler/mock_artifact.go index a870f60b7..95fdb8d3f 100644 --- a/pkg/bundler/mock_artifact.go +++ b/pkg/bundler/mock_artifact.go @@ -1,6 +1,8 @@ package bundler import ( + "os" + "github.com/windsorcli/cli/pkg/di" ) @@ -16,7 +18,7 @@ import ( // MockArtifact is a mock implementation of the Artifact interface type MockArtifact struct { InitializeFunc func(injector di.Injector) error - AddFileFunc func(path string, content []byte) error + AddFileFunc func(path string, content []byte, mode os.FileMode) error CreateFunc func(outputPath string, tag string) (string, error) PushFunc func(registryBase string, repoName string, tag string) error } @@ -43,9 +45,9 @@ func (m *MockArtifact) Initialize(injector di.Injector) error { } // AddFile calls the mock AddFileFunc if set, otherwise returns nil -func (m *MockArtifact) AddFile(path string, content []byte) error { +func (m *MockArtifact) AddFile(path string, content []byte, mode os.FileMode) error { if m.AddFileFunc != nil { - return m.AddFileFunc(path, content) + return m.AddFileFunc(path, content, mode) } return nil } diff --git a/pkg/bundler/mock_artifact_test.go b/pkg/bundler/mock_artifact_test.go index b9a05efaa..07aa321f5 100644 --- a/pkg/bundler/mock_artifact_test.go +++ b/pkg/bundler/mock_artifact_test.go @@ -1,6 +1,7 @@ package bundler import ( + "os" "testing" "github.com/windsorcli/cli/pkg/di" @@ -64,13 +65,13 @@ func TestMockArtifact_AddFile(t *testing.T) { // Given a mock with a custom add file function mock := NewMockArtifact() called := false - mock.AddFileFunc = func(path string, content []byte) error { + mock.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { called = true return nil } // When calling AddFile - err := mock.AddFile("test/path", []byte("content")) + err := mock.AddFile("test/path", []byte("content"), 0644) // Then the mock function should be called if !called { @@ -86,7 +87,7 @@ func TestMockArtifact_AddFile(t *testing.T) { mock := NewMockArtifact() // When calling AddFile - err := mock.AddFile("test/path", []byte("content")) + err := mock.AddFile("test/path", []byte("content"), 0644) // Then no error should be returned if err != nil { diff --git a/pkg/bundler/template_bundler.go b/pkg/bundler/template_bundler.go index 63de967d3..4a11916bb 100644 --- a/pkg/bundler/template_bundler.go +++ b/pkg/bundler/template_bundler.go @@ -66,7 +66,7 @@ func (t *TemplateBundler) Bundle(artifact Artifact) error { } artifactPath := "_template/" + filepath.ToSlash(relPath) - return artifact.AddFile(artifactPath, data) + return artifact.AddFile(artifactPath, data, info.Mode()) }) } diff --git a/pkg/bundler/template_bundler_test.go b/pkg/bundler/template_bundler_test.go index af665053e..e3f167496 100644 --- a/pkg/bundler/template_bundler_test.go +++ b/pkg/bundler/template_bundler_test.go @@ -56,7 +56,7 @@ func TestTemplateBundler_Bundle(t *testing.T) { // Set up mocks to simulate finding template files filesAdded := make(map[string][]byte) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded[path] = content return nil } @@ -257,7 +257,7 @@ func TestTemplateBundler_Bundle(t *testing.T) { bundler.shims.ReadFile = func(filename string) ([]byte, error) { return []byte("content"), nil } - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { return fmt.Errorf("artifact storage full") } @@ -278,7 +278,7 @@ func TestTemplateBundler_Bundle(t *testing.T) { bundler, mocks := setup(t) filesAdded := make([]string, 0) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded = append(filesAdded, path) return nil } diff --git a/pkg/bundler/terraform_bundler.go b/pkg/bundler/terraform_bundler.go index ba3f3b508..0d52062d5 100644 --- a/pkg/bundler/terraform_bundler.go +++ b/pkg/bundler/terraform_bundler.go @@ -82,7 +82,7 @@ func (t *TerraformBundler) Bundle(artifact Artifact) error { } artifactPath := "terraform/" + filepath.ToSlash(relPath) - return artifact.AddFile(artifactPath, data) + return artifact.AddFile(artifactPath, data, info.Mode()) }) } diff --git a/pkg/bundler/terraform_bundler_test.go b/pkg/bundler/terraform_bundler_test.go index 66f2c5d0e..84b9bd3c6 100644 --- a/pkg/bundler/terraform_bundler_test.go +++ b/pkg/bundler/terraform_bundler_test.go @@ -54,7 +54,7 @@ func TestTerraformBundler_Bundle(t *testing.T) { // Given a terraform bundler with valid terraform files bundler, mocks := setup(t) filesAdded := make(map[string][]byte) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded[path] = content return nil } @@ -137,7 +137,7 @@ func TestTerraformBundler_Bundle(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 { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded[path] = content return nil } @@ -245,7 +245,7 @@ func TestTerraformBundler_Bundle(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 { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded[path] = content return nil } @@ -329,7 +329,7 @@ func TestTerraformBundler_Bundle(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 { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded[path] = content return nil } @@ -487,7 +487,7 @@ func TestTerraformBundler_Bundle(t *testing.T) { } filesAdded := make([]string, 0) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded = append(filesAdded, path) return nil } @@ -606,7 +606,7 @@ func TestTerraformBundler_Bundle(t *testing.T) { bundler.shims.ReadFile = func(filename string) ([]byte, error) { return []byte("content"), nil } - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { return fmt.Errorf("artifact storage full") } @@ -627,7 +627,7 @@ func TestTerraformBundler_Bundle(t *testing.T) { bundler, mocks := setup(t) filesAdded := make([]string, 0) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded = append(filesAdded, path) return nil } @@ -677,7 +677,7 @@ func TestTerraformBundler_Bundle(t *testing.T) { bundler, mocks := setup(t) filesAdded := make([]string, 0) - mocks.Artifact.AddFileFunc = func(path string, content []byte) error { + mocks.Artifact.AddFileFunc = func(path string, content []byte, mode os.FileMode) error { filesAdded = append(filesAdded, path) return nil } diff --git a/pkg/generators/generator_test.go b/pkg/generators/generator_test.go index 16a7b9487..2e6109276 100644 --- a/pkg/generators/generator_test.go +++ b/pkg/generators/generator_test.go @@ -23,7 +23,7 @@ import ( type Mocks struct { Injector di.Injector ConfigHandler config.ConfigHandler - BlueprintHandler blueprint.MockBlueprintHandler + BlueprintHandler *blueprint.MockBlueprintHandler Shell *shell.MockShell Shims *Shims } @@ -144,9 +144,6 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { shims.MkdirAll = func(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) } - shims.TempDir = func(_, _ string) (string, error) { - return t.TempDir(), nil - } shims.RemoveAll = func(path string) error { return os.RemoveAll(path) } @@ -210,7 +207,7 @@ output "local_output1" { mocks := &Mocks{ Injector: injector, ConfigHandler: configHandler, - BlueprintHandler: *mockBlueprintHandler, + BlueprintHandler: mockBlueprintHandler, Shell: mockShell, Shims: shims, } diff --git a/pkg/generators/shims.go b/pkg/generators/shims.go index bb77fa698..e02c7f896 100644 --- a/pkg/generators/shims.go +++ b/pkg/generators/shims.go @@ -1,11 +1,17 @@ package generators import ( + "archive/tar" + "bytes" "encoding/json" + "io" "os" "path/filepath" "github.com/goccy/go-yaml" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" ) // The shims package is a system call abstraction layer for the generators package @@ -19,20 +25,31 @@ import ( // Shims provides mockable wrappers around system and runtime functions type Shims struct { - WriteFile func(name string, data []byte, perm os.FileMode) error - ReadFile func(name string) ([]byte, error) - MkdirAll func(path string, perm os.FileMode) error - Stat func(name string) (os.FileInfo, error) - MarshalYAML func(v any) ([]byte, error) - TempDir func(dir, pattern string) (string, error) - RemoveAll func(path string) error - Chdir func(dir string) error - ReadDir func(name string) ([]os.DirEntry, error) - Setenv func(key, value string) error - YamlUnmarshal func(data []byte, v any) error - JsonMarshal func(v any) ([]byte, error) - JsonUnmarshal func(data []byte, v any) error - FilepathRel func(basepath, targpath string) (string, error) + WriteFile func(name string, data []byte, perm os.FileMode) error + ReadFile func(name string) ([]byte, error) + MkdirAll func(path string, perm os.FileMode) error + Stat func(name string) (os.FileInfo, error) + MarshalYAML func(v any) ([]byte, error) + RemoveAll func(path string) error + Chdir func(dir string) error + ReadDir func(name string) ([]os.DirEntry, error) + Setenv func(key, value string) error + YamlUnmarshal func(data []byte, v any) error + JsonMarshal func(v any) ([]byte, error) + JsonUnmarshal func(data []byte, v any) error + FilepathRel func(basepath, targpath string) (string, error) + NewTarReader func(r io.Reader) *tar.Reader + NewBytesReader func(data []byte) io.Reader + ReadAll func(reader io.Reader) ([]byte, error) + ParseReference func(ref string, opts ...name.Option) (name.Reference, error) + RemoteImage func(ref name.Reference, options ...remote.Option) (v1.Image, error) + ImageLayers func(img v1.Image) ([]v1.Layer, error) + LayerUncompressed func(layer v1.Layer) (io.ReadCloser, error) + Create func(path string) (*os.File, error) + Copy func(dst io.Writer, src io.Reader) (int64, error) + Chmod func(name string, mode os.FileMode) error + EOFError func() error + TypeDir func() byte } // ============================================================================= @@ -42,19 +59,30 @@ type Shims struct { // NewShims creates a new Shims instance with default implementations func NewShims() *Shims { return &Shims{ - WriteFile: os.WriteFile, - ReadFile: os.ReadFile, - MkdirAll: os.MkdirAll, - Stat: os.Stat, - MarshalYAML: yaml.Marshal, - TempDir: os.MkdirTemp, - RemoveAll: os.RemoveAll, - Chdir: os.Chdir, - ReadDir: os.ReadDir, - Setenv: os.Setenv, - YamlUnmarshal: yaml.Unmarshal, - JsonMarshal: json.Marshal, - JsonUnmarshal: json.Unmarshal, - FilepathRel: filepath.Rel, + WriteFile: os.WriteFile, + ReadFile: os.ReadFile, + MkdirAll: os.MkdirAll, + Stat: os.Stat, + MarshalYAML: yaml.Marshal, + RemoveAll: os.RemoveAll, + Chdir: os.Chdir, + ReadDir: os.ReadDir, + Setenv: os.Setenv, + YamlUnmarshal: yaml.Unmarshal, + JsonMarshal: json.Marshal, + JsonUnmarshal: json.Unmarshal, + FilepathRel: filepath.Rel, + NewTarReader: tar.NewReader, + NewBytesReader: func(data []byte) io.Reader { return bytes.NewReader(data) }, + ReadAll: io.ReadAll, + ParseReference: func(ref string, opts ...name.Option) (name.Reference, error) { return name.ParseReference(ref) }, + RemoteImage: func(ref name.Reference, options ...remote.Option) (v1.Image, error) { return remote.Image(ref) }, + ImageLayers: func(img v1.Image) ([]v1.Layer, error) { return img.Layers() }, + LayerUncompressed: func(layer v1.Layer) (io.ReadCloser, error) { return layer.Uncompressed() }, + Create: os.Create, + Copy: io.Copy, + Chmod: os.Chmod, + EOFError: func() error { return io.EOF }, + TypeDir: func() byte { return tar.TypeDir }, } } diff --git a/pkg/generators/terraform_generator.go b/pkg/generators/terraform_generator.go index fa7735c4d..502effcc0 100644 --- a/pkg/generators/terraform_generator.go +++ b/pkg/generators/terraform_generator.go @@ -7,7 +7,9 @@ import ( "path/filepath" "sort" "strings" + "time" + "github.com/briandowns/spinner" "github.com/google/go-jsonnet" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" @@ -76,6 +78,12 @@ func (g *TerraformGenerator) Write(overwrite ...bool) error { } g.reset = shouldOverwrite + // Preload all OCI artifacts before processing components + ociArtifacts, err := g.preloadOCIArtifacts() + if err != nil { + return fmt.Errorf("failed to preload OCI artifacts: %w", err) + } + components := g.blueprintHandler.GetTerraformComponents() templateValues, err := g.processTemplates(shouldOverwrite) @@ -119,7 +127,7 @@ func (g *TerraformGenerator) Write(overwrite ...bool) error { for _, component := range components { if component.Source != "" { - if err := g.generateModuleShim(component); err != nil { + if err := g.generateModuleShim(component, ociArtifacts); err != nil { return fmt.Errorf("failed to generate module shim: %w", err) } } @@ -257,28 +265,52 @@ func (g *TerraformGenerator) processJsonnetTemplate(templateFile, contextName st // generateModuleShim creates a local reference to a remote Terraform module. // It provides a shim layer that maintains module configuration while allowing Windsor to manage it. -// The function orchestrates the creation of main.tf, variables.tf, and outputs.tf files. -// It ensures proper module initialization and state management. -func (g *TerraformGenerator) generateModuleShim(component blueprintv1alpha1.TerraformComponent) error { +// The function orchestrates the creation of main.tf, variables.tf, and outputs.tf files for module initialization, +// handling both OCI and standard source types with proper path resolution and state management. +func (g *TerraformGenerator) generateModuleShim(component blueprintv1alpha1.TerraformComponent, ociArtifacts map[string][]byte) error { moduleDir := component.FullPath if err := g.shims.MkdirAll(moduleDir, 0755); err != nil { return fmt.Errorf("failed to create module directory: %w", err) } - if err := g.writeShimMainTf(moduleDir, component.Source); err != nil { - return err - } + var resolvedSource string + var modulePath string + var err error - if err := g.shims.Chdir(moduleDir); err != nil { - return fmt.Errorf("failed to change to module directory: %w", err) + if g.isOCISource(component.Source) { + extractedPath, err := g.extractOCIModule(component.Source, component.Path, ociArtifacts) + if err != nil { + return fmt.Errorf("failed to extract OCI module: %w", err) + } + + relPath, err := g.shims.FilepathRel(moduleDir, extractedPath) + if err != nil { + return fmt.Errorf("failed to calculate relative path: %w", err) + } + + resolvedSource = relPath + modulePath = extractedPath + } else { + resolvedSource = component.Source + modulePath = moduleDir } - modulePath, err := g.initializeTerraformModule(component) - if err != nil { + if err := g.writeShimMainTf(moduleDir, resolvedSource); err != nil { return err } - if err := g.writeShimVariablesTf(moduleDir, modulePath, component.Source); err != nil { + if !g.isOCISource(component.Source) { + if err := g.shims.Chdir(moduleDir); err != nil { + return fmt.Errorf("failed to change to module directory: %w", err) + } + + modulePath, err = g.initializeTerraformModule(component) + if err != nil { + return err + } + } + + if err := g.writeShimVariablesTf(moduleDir, modulePath, resolvedSource); err != nil { return err } @@ -289,6 +321,202 @@ func (g *TerraformGenerator) generateModuleShim(component blueprintv1alpha1.Terr return nil } +// isOCISource determines if a source reference points to an OCI artifact by checking for OCI URL patterns +// and resolving source names through the blueprint handler. It handles both direct OCI URLs and named +// source references that map to OCI repositories, while excluding already-resolved extraction paths. +func (g *TerraformGenerator) isOCISource(source string) bool { + if strings.Contains(source, ".oci_extracted") { + return false + } + + if strings.HasPrefix(source, "oci://") { + return true + } + + sources := g.blueprintHandler.GetSources() + for _, src := range sources { + if src.Name == source && strings.HasPrefix(src.Url, "oci://") { + return true + } + } + + return false +} + +// extractOCIModule extracts a specific terraform module from an OCI artifact. +// It handles OCI URL resolution, artifact caching, and module path extraction. +// The function manages the complete lifecycle from source lookup to module deployment, +// ensuring efficient caching and proper directory structure for terraform modules. +func (g *TerraformGenerator) extractOCIModule(source, path string, ociArtifacts map[string][]byte) (string, error) { + message := fmt.Sprintf("📥 Loading component %s", path) + + spin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) + spin.Suffix = " " + message + spin.Start() + + defer func() { + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[32m✔\033[0m %s - \033[32mDone\033[0m\n", message) + }() + + sources := g.blueprintHandler.GetSources() + var ociURL string + for _, src := range sources { + if src.Name == source { + ociURL = src.Url + break + } + } + + if ociURL == "" { + return "", fmt.Errorf("source %s not found", source) + } + + registry, repository, tag, err := g.parseOCIRef(ociURL) + if err != nil { + return "", fmt.Errorf("failed to parse OCI reference: %w", err) + } + + cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag) + + projectRoot, err := g.shell.GetProjectRoot() + if err != nil { + return "", fmt.Errorf("failed to get project root: %w", err) + } + + extractionKey := fmt.Sprintf("%s-%s-%s", registry, repository, tag) + fullModulePath := filepath.Join(projectRoot, ".windsor", ".oci_extracted", extractionKey, "terraform", path) + if _, err := g.shims.Stat(fullModulePath); err == nil { + return fullModulePath, nil + } + + var artifactData []byte + if cachedData, exists := ociArtifacts[cacheKey]; exists { + artifactData = cachedData + } else { + artifactData, err = g.downloadOCIArtifact(registry, repository, tag) + if err != nil { + return "", fmt.Errorf("failed to download OCI artifact: %w", err) + } + ociArtifacts[cacheKey] = artifactData + } + + if err := g.extractModuleFromArtifact(artifactData, path, extractionKey); err != nil { + return "", fmt.Errorf("failed to extract module from artifact: %w", err) + } + + return fullModulePath, nil +} + +// downloadOCIArtifact downloads an OCI artifact and returns the tar.gz data. +// It provides OCI registry communication, image retrieval, and layer extraction. +// The function handles OCI reference parsing, remote image access, and data streaming. +// It ensures proper resource cleanup and memory-efficient artifact data retrieval. +func (g *TerraformGenerator) downloadOCIArtifact(registry, repository, tag string) ([]byte, error) { + ref := fmt.Sprintf("%s/%s:%s", registry, repository, tag) + + parsedRef, err := g.shims.ParseReference(ref) + if err != nil { + return nil, fmt.Errorf("failed to parse reference %s: %w", ref, err) + } + + img, err := g.shims.RemoteImage(parsedRef) + if err != nil { + return nil, fmt.Errorf("failed to get image: %w", err) + } + + layers, err := g.shims.ImageLayers(img) + if err != nil { + return nil, fmt.Errorf("failed to get image layers: %w", err) + } + + if len(layers) == 0 { + return nil, fmt.Errorf("no layers found in image") + } + + layer := layers[0] + reader, err := g.shims.LayerUncompressed(layer) + if err != nil { + return nil, fmt.Errorf("failed to get layer reader: %w", err) + } + defer reader.Close() + + data, err := g.shims.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read layer data: %w", err) + } + + return data, nil +} + +// extractModuleFromArtifact extracts a specific terraform module from cached artifact data directly to .oci_extracted. +// It provides selective tar stream processing, directory structure creation, and file permission management. +// The function handles OCI artifact data extraction, module file deployment, and executable script permissions. +// It ensures proper file system operations with error handling and maintains original tar header permissions. +func (g *TerraformGenerator) extractModuleFromArtifact(artifactData []byte, modulePath, extractionKey string) error { + projectRoot, err := g.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("failed to get project root: %w", err) + } + + reader := g.shims.NewBytesReader(artifactData) + tarReader := g.shims.NewTarReader(reader) + targetPrefix := "terraform/" + modulePath + + extractionDir := filepath.Join(projectRoot, ".windsor", ".oci_extracted", extractionKey) + + for { + header, err := tarReader.Next() + if err == g.shims.EOFError() { + break + } + if err != nil { + return fmt.Errorf("failed to read tar header: %w", err) + } + + if !strings.HasPrefix(header.Name, targetPrefix) { + continue + } + + relativePath := strings.TrimPrefix(header.Name, "terraform/") + destPath := filepath.Join(extractionDir, "terraform", relativePath) + + if header.Typeflag == g.shims.TypeDir() { + if err := g.shims.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", destPath, err) + } + continue + } + + if err := g.shims.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory for %s: %w", destPath, err) + } + + file, err := g.shims.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + _, err = g.shims.Copy(file, tarReader) + file.Close() + if err != nil { + return fmt.Errorf("failed to write file %s: %w", destPath, err) + } + + fileMode := os.FileMode(header.Mode) + + if strings.HasSuffix(destPath, ".sh") { + fileMode |= 0111 + } + + if err := g.shims.Chmod(destPath, fileMode); err != nil { + return fmt.Errorf("failed to set file permissions for %s: %w", destPath, err) + } + } + + return nil +} + // writeShimMainTf creates the main.tf file for the shim module. // It provides the initial module configuration with source reference. // The function ensures proper HCL syntax and maintains consistent module structure. @@ -448,10 +676,10 @@ func (g *TerraformGenerator) writeShimVariablesTf(moduleDir, modulePath, source return nil } -// writeShimOutputsTf creates the outputs.tf file for the shim module. -// It provides output definition extraction and shim generation. -// The function creates references to module.main outputs while preserving descriptions. -// It handles file reading, parsing, and writing with proper error handling. +// writeShimOutputsTf creates the outputs.tf file for the shim module by extracting output definitions from the source module. +// It provides output definition extraction and shim generation that preserves descriptions while creating references to module.main outputs. +// The function ensures proper HCL syntax and maintains consistent output structure for terraform modules. +// It handles file reading, parsing, and writing with comprehensive error handling for module compatibility. func (g *TerraformGenerator) writeShimOutputsTf(moduleDir, modulePath string) error { outputsPath := filepath.Join(modulePath, "outputs.tf") if _, err := g.shims.Stat(outputsPath); err == nil { @@ -476,12 +704,10 @@ func (g *TerraformGenerator) writeShimOutputsTf(moduleDir, modulePath string) er shimBlock := shimBody.AppendNewBlock("output", []string{outputName}) shimBlockBody := shimBlock.Body() - // Copy description if present if attr := block.Body().GetAttribute("description"); attr != nil { shimBlockBody.SetAttributeRaw("description", attr.Expr().BuildTokens(nil)) } - // Set value to reference module.main output shimBlockBody.SetAttributeTraversal("value", hcl.Traversal{ hcl.TraverseRoot{Name: "module"}, hcl.TraverseAttr{Name: "main"}, @@ -749,7 +975,7 @@ func writeComponentValues(body *hclwrite.Body, values map[string]any, protectedV continue } } - // If no default, just comment null + body.AppendUnstructuredTokens(hclwrite.Tokens{ {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# %s = null", info.Name))}, }) @@ -781,7 +1007,6 @@ func writeHeredoc(body *hclwrite.Body, name string, content string) { // Handles descriptions, sensitive flags, multi-line strings, and object/map formatting. // Ensures correct HCL syntax for all supported value types. func writeVariable(body *hclwrite.Body, name string, value any, variables []VariableInfo) { - // Find variable info var info *VariableInfo for _, v := range variables { if v.Name == name { @@ -790,7 +1015,6 @@ func writeVariable(body *hclwrite.Body, name string, value any, variables []Vari } } - // Write description if available if info != nil && info.Description != "" { body.AppendUnstructuredTokens(hclwrite.Tokens{ {Type: hclsyntax.TokenComment, Bytes: []byte("# " + info.Description)}, @@ -798,7 +1022,6 @@ func writeVariable(body *hclwrite.Body, name string, value any, variables []Vari body.AppendNewline() } - // Handle sensitive variables if info != nil && info.Sensitive { body.AppendUnstructuredTokens(hclwrite.Tokens{ {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# %s = \"(sensitive)\"", name))}, @@ -814,7 +1037,6 @@ func writeVariable(body *hclwrite.Body, name string, value any, variables []Vari return } case map[string]any: - // Render as HCL object assignment, not heredoc rendered := formatValue(v) assignment := fmt.Sprintf("%s = %s", name, rendered) body.AppendUnstructuredTokens(hclwrite.Tokens{ @@ -824,7 +1046,6 @@ func writeVariable(body *hclwrite.Body, name string, value any, variables []Vari return } - // Write normal variable body.SetAttributeValue(name, convertToCtyValue(value)) } @@ -948,6 +1169,88 @@ func convertFromCtyValue(val cty.Value) any { } } +// parseOCIRef parses an OCI reference into registry, repository, and tag components. +// It validates the OCI reference format and extracts the individual components for artifact resolution. +func (g *TerraformGenerator) parseOCIRef(ociRef string) (registry, repository, tag string, err error) { + if !strings.HasPrefix(ociRef, "oci://") { + return "", "", "", fmt.Errorf("invalid OCI reference format: %s", ociRef) + } + + ref := strings.TrimPrefix(ociRef, "oci://") + + parts := strings.Split(ref, ":") + if len(parts) != 2 { + return "", "", "", fmt.Errorf("invalid OCI reference format, expected registry/repository:tag: %s", ociRef) + } + + repoWithRegistry := parts[0] + tag = parts[1] + + repoParts := strings.SplitN(repoWithRegistry, "/", 2) + if len(repoParts) != 2 { + return "", "", "", fmt.Errorf("invalid OCI reference format, expected registry/repository:tag: %s", ociRef) + } + + registry = repoParts[0] + repository = repoParts[1] + + return registry, repository, tag, nil +} + +// preloadOCIArtifacts analyzes all blueprint sources and downloads unique OCI artifacts upfront. +// It returns a map of cached artifacts keyed by their registry/repository:tag identifier. +func (g *TerraformGenerator) preloadOCIArtifacts() (map[string][]byte, error) { + sources := g.blueprintHandler.GetSources() + ociArtifacts := make(map[string][]byte) + + uniqueOCISources := make(map[string]bool) + for _, source := range sources { + if strings.HasPrefix(source.Url, "oci://") { + uniqueOCISources[source.Url] = true + } + } + + if len(uniqueOCISources) == 0 { + return ociArtifacts, nil + } + + message := "📦 Loading OCI sources" + spin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) + spin.Suffix = " " + message + spin.Start() + + defer func() { + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[32m✔\033[0m %s - \033[32mDone\033[0m\n", message) + }() + + for _, source := range sources { + if !strings.HasPrefix(source.Url, "oci://") { + continue + } + + registry, repository, tag, err := g.parseOCIRef(source.Url) + if err != nil { + return nil, fmt.Errorf("failed to parse OCI reference for source %s: %w", source.Name, err) + } + + cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag) + + if _, exists := ociArtifacts[cacheKey]; exists { + continue + } + + artifactData, err := g.downloadOCIArtifact(registry, repository, tag) + if err != nil { + return nil, fmt.Errorf("failed to download OCI artifact for source %s: %w", source.Name, err) + } + + ociArtifacts[cacheKey] = artifactData + } + + return ociArtifacts, nil +} + // ============================================================================= // Interface Compliance // ============================================================================= diff --git a/pkg/generators/terraform_generator_test.go b/pkg/generators/terraform_generator_test.go index d793e1106..9dc41dee2 100644 --- a/pkg/generators/terraform_generator_test.go +++ b/pkg/generators/terraform_generator_test.go @@ -1,745 +1,1663 @@ package generators import ( + "archive/tar" + "bytes" "fmt" + "io" "io/fs" + "math/big" "os" "path/filepath" "reflect" "strings" "testing" + "time" "github.com/goccy/go-yaml" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/config" "github.com/zclconf/go-cty/cty" ) -func TestConvertToCtyValue(t *testing.T) { - tests := []struct { - name string - input any - expected cty.Value - }{ - { - name: "String", - input: "test", - expected: cty.StringVal("test"), - }, - { - name: "Int", - input: 42, - expected: cty.NumberIntVal(42), - }, - { - name: "Float64", - input: 42.5, - expected: cty.NumberFloatVal(42.5), - }, - { - name: "Bool", - input: true, - expected: cty.BoolVal(true), - }, - { - name: "EmptyList", - input: []any{}, - expected: cty.ListValEmpty(cty.DynamicPseudoType), - }, - { - name: "List", - input: []any{"item1", "item2"}, - expected: cty.ListVal([]cty.Value{cty.StringVal("item1"), cty.StringVal("item2")}), - }, - { - name: "Map", - input: map[string]any{"key": "value"}, - expected: cty.ObjectVal(map[string]cty.Value{"key": cty.StringVal("value")}), - }, - { - name: "Unsupported", - input: struct{}{}, - expected: cty.NilVal, - }, - { - name: "Nil", - input: nil, - expected: cty.NilVal, - }, - } +// ============================================================================= +// Test Setup +// ============================================================================= - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := convertToCtyValue(tt.input) - if !result.RawEquals(tt.expected) { - t.Errorf("expected %#v, got %#v", tt.expected, result) - } - }) - } +type simpleDirEntry struct { + name string + isDir bool } -func TestConvertFromCtyValue(t *testing.T) { - tests := []struct { - name string - input cty.Value - expected any - }{ - { - name: "String", - input: cty.StringVal("test"), - expected: "test", - }, - { - name: "Int", - input: cty.NumberIntVal(42), - expected: int64(42), - }, - { - name: "Float", - input: cty.NumberFloatVal(42.5), - expected: float64(42.5), - }, - { - name: "Bool", - input: cty.BoolVal(true), - expected: true, - }, - { - name: "List", - input: cty.ListVal([]cty.Value{cty.StringVal("item1"), cty.StringVal("item2")}), - expected: []any{"item1", "item2"}, - }, - { - name: "EmptyList", - input: cty.ListValEmpty(cty.String), - expected: []any(nil), - }, - { - name: "Map", - input: cty.MapVal(map[string]cty.Value{"key": cty.StringVal("value")}), - expected: map[string]any{"key": "value"}, - }, - { - name: "EmptyMap", - input: cty.MapValEmpty(cty.String), - expected: map[string]any{}, - }, - { - name: "Object", - input: cty.ObjectVal(map[string]cty.Value{"key": cty.StringVal("value")}), - expected: map[string]any{"key": "value"}, - }, - { - name: "Null", - input: cty.NullVal(cty.String), - expected: nil, - }, - { - name: "Unknown", - input: cty.UnknownVal(cty.String), - expected: nil, - }, - { - name: "Set", - input: cty.SetVal([]cty.Value{cty.StringVal("item1"), cty.StringVal("item2")}), - expected: []any{"item1", "item2"}, - }, - { - name: "Tuple", - input: cty.TupleVal([]cty.Value{cty.StringVal("item1"), cty.NumberIntVal(42)}), - expected: []any{"item1", int64(42)}, - }, - } +func (s *simpleDirEntry) Name() string { + return s.name +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := convertFromCtyValue(tt.input) - if !reflect.DeepEqual(result, tt.expected) { - t.Errorf("expected %#v (%T), got %#v (%T)", tt.expected, tt.expected, result, result) - } - }) - } +func (s *simpleDirEntry) IsDir() bool { + return s.isDir } -func TestWriteVariableSensitive(t *testing.T) { - // Given a body and variables with a sensitive variable - file := hclwrite.NewEmptyFile() - body := file.Body() - variables := []VariableInfo{ - { - Name: "test_var", - Description: "Test variable", - Sensitive: true, - }, +func (s *simpleDirEntry) Type() fs.FileMode { + if s.isDir { + return fs.ModeDir } + return 0 +} - // When writeVariable is called - writeVariable(body, "test_var", "value", variables) +func (s *simpleDirEntry) Info() (fs.FileInfo, error) { + return nil, fmt.Errorf("not implemented") +} - // Then the variable should be commented out with (sensitive) - expected := `# Test variable -# test_var = "(sensitive)" -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) - } +// Mock types for OCI testing +type mockReference struct{} + +func (m *mockReference) Context() name.Repository { + return name.Repository{} } -func TestWriteVariableNonSensitive(t *testing.T) { - // Given a body and variables with a non-sensitive variable - file := hclwrite.NewEmptyFile() - body := file.Body() - variables := []VariableInfo{ - { - Name: "test_var", - Description: "Test variable", - Sensitive: false, - }, - } +func (m *mockReference) Identifier() string { + return "v1.0.0" +} - // When writeVariable is called - writeVariable(body, "test_var", "value", variables) +func (m *mockReference) Name() string { + return "registry.example.com/my-repo:v1.0.0" +} - // Then the variable should be written with its value - expected := `# Test variable -test_var = "value" -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) - } +func (m *mockReference) String() string { + return "registry.example.com/my-repo:v1.0.0" } -func TestWriteVariableWithComment(t *testing.T) { - // Given a body and variables with a variable with comment - file := hclwrite.NewEmptyFile() - body := file.Body() - variables := []VariableInfo{ - { - Name: "test_var", - Description: "Test variable description", - }, - } +func (m *mockReference) Scope(action string) string { + return "" +} - // When writeVariable is called - writeVariable(body, "test_var", "value", variables) +type mockImage struct{} - // Then the variable should be written with its comment - expected := `# Test variable description -test_var = "value" -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) - } +func (m *mockImage) Layers() ([]v1.Layer, error) { + return []v1.Layer{&mockLayer{}}, nil } -func TestWriteComponentValues(t *testing.T) { - // Given a body and variables with component values - file := hclwrite.NewEmptyFile() - body := file.Body() - variables := []VariableInfo{ - { - Name: "var1", - Description: "Variable 1", - Sensitive: true, - Default: "default1", - }, - { - Name: "var2", - Description: "Variable 2", - Sensitive: false, - Default: "default2", - }, - { - Name: "var3", - Description: "Variable 3", - Default: "default3", - }, - } - values := map[string]any{ - "var2": "pinned_value", - } - protectedValues := map[string]bool{} +func (m *mockImage) MediaType() (types.MediaType, error) { + return "", nil +} - // When writeComponentValues is called - writeComponentValues(body, values, protectedValues, variables) +func (m *mockImage) Size() (int64, error) { + return 0, nil +} - // Then the variables should be written in order with proper handling of sensitive values - expected := ` -# Variable 1 -# var1 = "(sensitive)" +func (m *mockImage) ConfigName() (v1.Hash, error) { + return v1.Hash{}, nil +} -# Variable 2 -var2 = "pinned_value" +func (m *mockImage) ConfigFile() (*v1.ConfigFile, error) { + return nil, nil +} -# Variable 3 -# var3 = "default3" -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) - } +func (m *mockImage) RawConfigFile() ([]byte, error) { + return nil, nil } -func TestWriteDefaultValues(t *testing.T) { - // Given a body and variables with default values - file := hclwrite.NewEmptyFile() - body := file.Body() - variables := []VariableInfo{ - { - Name: "var1", - Description: "Variable 1", - Sensitive: true, - Default: "default1", - }, - { - Name: "var2", - Description: "Variable 2", - Sensitive: false, - Default: "default2", - }, - { - Name: "var3", - Description: "Variable 3", - Default: "default3", - }, +func (m *mockImage) Digest() (v1.Hash, error) { + return v1.Hash{}, nil +} + +func (m *mockImage) Manifest() (*v1.Manifest, error) { + return nil, nil +} + +func (m *mockImage) RawManifest() ([]byte, error) { + return nil, nil +} + +func (m *mockImage) LayerByDigest(hash v1.Hash) (v1.Layer, error) { + return &mockLayer{}, nil +} + +func (m *mockImage) LayerByDiffID(hash v1.Hash) (v1.Layer, error) { + return &mockLayer{}, nil +} + +type mockLayer struct{} + +func (m *mockLayer) Digest() (v1.Hash, error) { + return v1.Hash{}, nil +} + +func (m *mockLayer) DiffID() (v1.Hash, error) { + return v1.Hash{}, nil +} + +func (m *mockLayer) Compressed() (io.ReadCloser, error) { + return nil, nil +} + +func (m *mockLayer) Uncompressed() (io.ReadCloser, error) { + return nil, nil +} + +func (m *mockLayer) Size() (int64, error) { + return 0, nil +} + +func (m *mockLayer) MediaType() (types.MediaType, error) { + return "", nil +} + +// mockFileInfo implements os.FileInfo for testing +type mockFileInfo struct { + name string + isDir bool + mode os.FileMode +} + +func (m *mockFileInfo) Name() string { return m.name } +func (m *mockFileInfo) Size() int64 { return 0 } +func (m *mockFileInfo) Mode() os.FileMode { return m.mode } +func (m *mockFileInfo) ModTime() time.Time { return time.Time{} } +func (m *mockFileInfo) IsDir() bool { return m.isDir } +func (m *mockFileInfo) Sys() any { return nil } + +// mockDirEntry implements os.DirEntry for testing +type mockDirEntry struct { + name string + isDir bool +} + +func (m *mockDirEntry) Name() string { return m.name } +func (m *mockDirEntry) IsDir() bool { return m.isDir } +func (m *mockDirEntry) Type() os.FileMode { return 0 } +func (m *mockDirEntry) Info() (os.FileInfo, error) { + return &mockFileInfo{name: m.name, isDir: m.isDir}, nil +} + +// setupTerraformGeneratorMocks extends base mocks with terraform generator specific mocking +func setupTerraformGeneratorMocks(mocks *Mocks) { + // OCI-related mocks + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return &mockReference{}, nil + } + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return &mockImage{}, nil + } + mocks.Shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { + return []v1.Layer{&mockLayer{}}, nil + } + mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + testData := []byte("test artifact data") + return io.NopCloser(bytes.NewReader(testData)), nil + } + mocks.Shims.ReadAll = func(r io.Reader) ([]byte, error) { + return io.ReadAll(r) + } + + // Tar extraction mocks + mocks.Shims.NewBytesReader = func(data []byte) io.Reader { + return bytes.NewReader(data) + } + mocks.Shims.NewTarReader = func(r io.Reader) *tar.Reader { + return tar.NewReader(r) + } + mocks.Shims.EOFError = func() error { + return io.EOF + } + mocks.Shims.TypeDir = func() byte { + return tar.TypeDir + } + mocks.Shims.Create = func(path string) (*os.File, error) { + return os.Create(path) + } + mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { + return io.Copy(dst, src) + } + mocks.Shims.Chmod = func(name string, mode os.FileMode) error { + return nil // Default to successful chmod + } +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestTerraformGenerator_Write(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And a component with source + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", + } + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{component} + } + + // And ExecSilent is mocked to return output with module path + mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { + return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"} +{"@level":"info","@message":"- main in /path/to/module","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + } + + // And ReadFile is mocked to return content for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // When Write is called + err := generator.Write() + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And GetProjectRoot is mocked to return an error + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("error getting project root") + } + + // When Write is called + err := generator.Write() + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "failed to process terraform templates: failed to get project root: error getting project root" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) + } + }) + + t.Run("ErrorMkdirAll", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And MkdirAll is mocked to return an error + mocks.Shims.MkdirAll = func(_ string, _ fs.FileMode) error { + return fmt.Errorf("mock error creating directory") + } + + // When Write is called + err := generator.Write() + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "failed to create terraform directory: mock error creating directory" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) + } + }) + + t.Run("ErrorGetConfigRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And GetConfigRoot is mocked to return an error + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("mock error getting config root") + } + + // When Write is called + err := generator.Write() + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "failed to get config root: mock error getting config root" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) + } + }) + + t.Run("DeletesTerraformDirOnReset", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And .terraform directory exists + var removedPath string + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + if strings.HasSuffix(path, ".terraform") { + return nil, nil // exists + } + return nil, os.ErrNotExist + } + mocks.Shims.RemoveAll = func(path string) error { + removedPath = path + return nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/mock/context", nil + } + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project", nil + } + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{} + } + mocks.Shims.MkdirAll = func(_ string, _ fs.FileMode) error { return nil } + mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { return nil } + mocks.Shims.ReadFile = func(_ string) ([]byte, error) { return []byte{}, nil } + mocks.Shims.Chdir = func(_ string) error { return nil } + + // When Write is called with reset=true + err := generator.Write(true) + + // Then no error should occur + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // And the .terraform directory should be removed + want := filepath.Join("/mock/context", ".terraform") + if removedPath != want { + t.Errorf("expected RemoveAll called with %q, got %q", want, removedPath) + } + }) + + t.Run("ErrorRemovingTerraformDir", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And .terraform directory exists but RemoveAll fails + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + if strings.HasSuffix(path, ".terraform") { + return nil, nil // exists + } + return nil, os.ErrNotExist + } + mocks.Shims.RemoveAll = func(path string) error { + return fmt.Errorf("mock error removing directory") + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/mock/context", nil + } + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project", nil + } + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{} + } + + // When Write is called with reset=true + err := generator.Write(true) + + // Then an error should be returned + if err == nil { + t.Fatal("expected error, got nil") + } + + // And the error should match the expected message + expectedError := "failed to remove .terraform directory: mock error removing directory" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorFromGenerateModuleShim", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And a component with source that will cause generateModuleShim to fail + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "test-component", + FullPath: "/tmp/terraform/test-component", + } + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{component} + } + + // Mock processTemplates to succeed + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/tmp/context", nil + } + + mocks.Shims.MkdirAll = func(path string, _ fs.FileMode) error { + if strings.Contains(path, "terraform") && !strings.Contains(path, "test-component") { + return nil // Allow terraform folder creation + } + return fmt.Errorf("mock error creating module directory") + } + + // When Write is called + err := generator.Write() + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to generate module shim") { + t.Errorf("expected error to contain 'failed to generate module shim', got %v", err) + } + }) + +} + +// ============================================================================= +// Test Private Methods +// ============================================================================= + +func TestTerraformGenerator_processTemplates(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("TemplateDirectoryNotExists", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return os.ErrNotExist for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, os.ErrNotExist + } + return nil, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur and result should be nil + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + }) + + t.Run("ErrorCheckingTemplateDirectory", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return an error for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, fmt.Errorf("permission denied") + } + return nil, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to check template directory") { + t.Errorf("expected error to contain 'failed to check template directory', got %v", err) + } + }) + + t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Shell.GetProjectRoot is mocked to return an error + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to get project root") { + t.Errorf("expected error to contain 'failed to get project root', got %v", err) + } + }) + + t.Run("SuccessWithEmptyDirectory", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And ReadDir is mocked to return empty directory + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And result should be an empty map + if result == nil { + t.Errorf("expected non-nil result, got nil") + } + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + }) + + t.Run("ProcessesJsonnetFiles", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // Mock all dependencies for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + test_var: "test_value" +}`), nil + } + + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{"test_var": "test_value"} + } + return nil + } + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/tmp/contexts/_template/terraform", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should contain the processed template + if len(templateValues) != 1 { + t.Errorf("expected 1 template value, got %d", len(templateValues)) + } + }) + + t.Run("ErrorProcessingJsonnetTemplate", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // And ReadFile is mocked to return an error for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("file read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("expected error to contain 'error reading template file', got %v", err) + } + }) + + t.Run("RecursivelyProcessesDirectories", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, empty on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return []fs.DirEntry{}, nil // Empty subdirectory + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And ReadDir should have been called twice (once for root, once for subdir) + if callCount != 2 { + t.Errorf("expected ReadDir to be called 2 times, got %d", callCount) + } + }) + + t.Run("ErrorInRecursiveDirectoryCall", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, error on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return nil, fmt.Errorf("subdirectory read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + } + }) + + t.Run("ErrorGettingConfigRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And GetConfigRoot is mocked to return an error + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to get config root") { + t.Errorf("expected error to contain 'failed to get config root', got %v", err) + } + }) + + t.Run("UsesEnvironmentVariableForContextName", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success for template directory + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And ReadDir is mocked to return empty directory + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + } + + // And GetString is mocked to return empty string (no context configured) + mocks.ConfigHandler.(*config.MockConfigHandler).GetStringFunc = func(key string, defaultValue ...string) string { + if key == "context" { + return "" // No context configured + } + return "" + } + + // Mock environment variable + originalEnv := os.Getenv("WINDSOR_CONTEXT") + os.Setenv("WINDSOR_CONTEXT", "env-context") + defer os.Setenv("WINDSOR_CONTEXT", originalEnv) + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And result should be an empty map + if result == nil { + t.Errorf("expected non-nil result, got nil") + } + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + + // Note: The environment variable usage is tested by the fact that + // the function completes successfully and calls walkTemplateDirectory + // with the environment context name + }) +} + +func TestTerraformGenerator_walkTemplateDirectory(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("ErrorReadingDirectory", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadDir is mocked to return an error + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return nil, fmt.Errorf("permission denied") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + } + }) + + t.Run("IgnoresNonJsonnetFiles", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry + mockEntry := &simpleDirEntry{name: "test.txt", isDir: false} + + // And ReadDir is mocked to return a non-jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should be empty + if len(templateValues) != 0 { + t.Errorf("expected 0 template values, got %d", len(templateValues)) + } + }) + + t.Run("ProcessesJsonnetFiles", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // Mock all dependencies for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`{ + test_var: "test_value" +}`), nil + } + + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } + + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return []byte(`{"test": "config", "name": "test-context"}`), nil + } + + mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { + if values, ok := v.(*map[string]any); ok { + *values = map[string]any{"test_var": "test_value"} + } + return nil + } + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp", nil + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/tmp/contexts/_template/terraform", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And template values should contain the processed template + if len(templateValues) != 1 { + t.Errorf("expected 1 template value, got %d", len(templateValues)) + } + }) + + t.Run("ErrorProcessingJsonnetTemplate", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // Create a simple mock for fs.DirEntry with jsonnet file + mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + + // And ReadDir is mocked to return a jsonnet file + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{mockEntry}, nil + } + + // And ReadFile is mocked to return an error for processJsonnetTemplate + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("file read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("expected error to contain 'error reading template file', got %v", err) + } + }) + + t.Run("RecursivelyProcessesDirectories", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, empty on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return []fs.DirEntry{}, nil // Empty subdirectory + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And ReadDir should have been called twice (once for root, once for subdir) + if callCount != 2 { + t.Errorf("expected ReadDir to be called 2 times, got %d", callCount) + } + }) + + t.Run("ErrorInRecursiveDirectoryCall", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + callCount := 0 + // Create mock directory entry + mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + + // And ReadDir is mocked to return a directory on first call, error on second + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + callCount++ + if callCount == 1 { + return []fs.DirEntry{mockDirEntry}, nil + } + return nil, fmt.Errorf("subdirectory read error") + } + + templateValues := make(map[string]map[string]any) + + // When walkTemplateDirectory is called + err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read template directory") { + t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + } + }) + + t.Run("ErrorGettingConfigRoot", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And template directory exists + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And GetConfigRoot is mocked to return an error + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "", fmt.Errorf("config root error") + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to get config root") { + t.Errorf("expected error to contain 'failed to get config root', got %v", err) + } + }) + + t.Run("ContextNameFromEnvironment", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And template directory exists + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.Contains(path, "_template") { + return nil, nil + } + return nil, nil + } + + // And GetString returns empty string (no context configured) + mocks.ConfigHandler.(*config.MockConfigHandler).GetStringFunc = func(key string, defaultValue ...string) string { + if key == "context" { + return "" + } + return "" + } + + // And environment variable is set + originalEnv := os.Getenv("WINDSOR_CONTEXT") + defer func() { + if originalEnv == "" { + os.Unsetenv("WINDSOR_CONTEXT") + } else { + os.Setenv("WINDSOR_CONTEXT", originalEnv) + } + }() + os.Setenv("WINDSOR_CONTEXT", "env-context") + + // And ReadDir is mocked to return empty directory + mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { + return []fs.DirEntry{}, nil + } + + // When processTemplates is called + result, err := generator.processTemplates(false) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And result should be an empty map + if result == nil { + t.Errorf("expected non-nil result, got nil") + } + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } + }) +} + +func TestTerraformGenerator_processJsonnetTemplate(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks } - // When writeDefaultValues is called - writeDefaultValues(body, variables, nil) + t.Run("ErrorReadingTemplateFile", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return an error + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("file not found") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error reading template file") { + t.Errorf("expected error to contain 'error reading template file', got %v", err) + } + }) + + t.Run("ErrorMarshallingContextToYAML", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`local context = std.extVar("context"); +{ + test_var: context.test +}`), nil + } + + // And YamlMarshalWithDefinedPaths is mocked to return an error + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return nil, fmt.Errorf("yaml marshal error") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error marshalling context to YAML") { + t.Errorf("expected error to contain 'error marshalling context to YAML', got %v", err) + } + }) + + t.Run("ErrorUnmarshallingContextYAML", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`local context = std.extVar("context"); +{ + test_var: context.test +}`), nil + } + + // And YamlMarshalWithDefinedPaths is mocked + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } + + // And YamlUnmarshal is mocked to return an error + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + return fmt.Errorf("yaml unmarshal error") + } + + templateValues := make(map[string]map[string]any) + + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error unmarshalling context YAML") { + t.Errorf("expected error to contain 'error unmarshalling context YAML', got %v", err) + } + }) + + t.Run("ErrorMarshallingContextToJSON", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return jsonnet content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte(`local context = std.extVar("context"); +{ + test_var: context.test +}`), nil + } + + // And YamlMarshalWithDefinedPaths is mocked + mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { + return []byte("test: config"), nil + } - // Then the variables should be written in order with proper handling of sensitive values - expected := ` -# Variable 1 -# var1 = "(sensitive)" + // And YamlUnmarshal is mocked + mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { + if configMap, ok := v.(*map[string]any); ok { + *configMap = map[string]any{"test": "config"} + } + return nil + } -# Variable 2 -# var2 = "default2" + // And JsonMarshal is mocked to return an error + mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("json marshal error") + } -# Variable 3 -# var3 = "default3" -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) - } -} + templateValues := make(map[string]map[string]any) -func TestWriteVariableYAML(t *testing.T) { - // Given a body and variables with a YAML multiline value - file := hclwrite.NewEmptyFile() - body := file.Body() - variables := []VariableInfo{ - { - Name: "worker_config_patches", - Description: "Worker configuration patches", - }, - } + // When processJsonnetTemplate is called + err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) - // When writeVariable is called with a YAML multiline string - yamlValue := `machine: - kubelet: - extraMounts: - - destination: /var/local - options: - - rbind - - rw - source: /var/local - type: bind` - writeVariable(body, "worker_config_patches", yamlValue, variables) + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "error marshalling context map to JSON") { + t.Errorf("expected error to contain 'error marshalling context map to JSON', got %v", err) + } + }) - // Then the variable should be written as a heredoc with valid YAML - actual := string(file.Bytes()) + t.Run("ErrorEvaluatingJsonnet", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) - // Extract the YAML content from the heredoc - lines := strings.Split(actual, "\n") - var yamlContent strings.Builder - inYAML := false - for _, line := range lines { - if strings.Contains(line, "< 0 && args[0] == "init" { + return "", fmt.Errorf("terraform init failed") + } + return "", nil } // When generateModuleShim is called - err := generator.generateModuleShim(component) + err := generator.generateModuleShim(component, make(map[string][]byte)) // Then an error should be returned if err == nil { t.Fatalf("expected an error, got nil") } - // And the error should match the expected error - expectedError := "failed to change to module directory: mock error changing directory" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + // And the error should indicate terraform init failure + expectedError := "failed to initialize terraform" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("expected error containing %q, got %q", expectedError, err.Error()) } }) - t.Run("ErrorSetenv", func(t *testing.T) { + t.Run("NoValidModulePath", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) @@ -1122,362 +2242,268 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", - FullPath: "original/full/path", + FullPath: filepath.Join(t.TempDir(), "module/path1"), } - // And Setenv is mocked to return an error - mocks.Shims.Setenv = func(_ string, _ string) error { - return fmt.Errorf("mock error setting environment variable") + // Mock WriteFile to succeed + originalWriteFile := generator.shims.WriteFile + generator.shims.WriteFile = func(path string, data []byte, perm fs.FileMode) error { + return nil + } + defer func() { + generator.shims.WriteFile = originalWriteFile + }() + + // Mock ExecProgress to return output without a valid module path + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + if cmd == "terraform" && len(args) > 0 && args[0] == "init" { + return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z"} +{"@level":"info","@message":"No modules to initialize","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + } + return "", nil + } + + // And mock Stat to return not exist for all paths + originalStat := generator.shims.Stat + generator.shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist } + defer func() { + generator.shims.Stat = originalStat + }() // When generateModuleShim is called - err := generator.generateModuleShim(component) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } + err := generator.generateModuleShim(component, make(map[string][]byte)) - // And the error should match the expected error - expectedError := "failed to set TF_DATA_DIR: mock error setting environment variable" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + // Then no error should be returned because the function handles missing files + if err != nil { + t.Errorf("expected no error, got %v", err) } }) - t.Run("ErrorExecSilent", func(t *testing.T) { - // Given a TerraformGenerator with mocks + t.Run("BlankOutputLine", func(t *testing.T) { generator, mocks := setup(t) - - // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - - // And ExecProgress is mocked to return an error mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - return "", fmt.Errorf("mock error running terraform init") + return "\n", nil } - - // When generateModuleShim is called - err := generator.generateModuleShim(component) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist } - - // And the error should match the expected error - expectedError := "failed to initialize terraform: mock error running terraform init" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component, make(map[string][]byte)) + if err != nil { + t.Errorf("expected no error, got %v", err) } }) - t.Run("ErrorGetConfigRoot", func(t *testing.T) { - // Given a TerraformGenerator with mocks + t.Run("MalformedJSONOutput", func(t *testing.T) { generator, mocks := setup(t) - - // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - - // And GetConfigRoot is mocked to return an error - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("mock error getting config root") + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return "not-json-line", nil } - - // When generateModuleShim is called - err := generator.generateModuleShim(component) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist } - - // And the error should match the expected error - expectedError := "failed to get config root: mock error getting config root" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component, make(map[string][]byte)) + if err != nil { + t.Errorf("expected no error, got %v", err) } }) - t.Run("ErrorReadingVariables", func(t *testing.T) { - // Given a TerraformGenerator with mocks + t.Run("LogLineWithoutMainIn", func(t *testing.T) { generator, mocks := setup(t) - - // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - - // And ExecSilent is mocked to return output with module path - mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"} -{"@level":"info","@message":"- main in /path/to/module","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"No main here","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil } - - // And Stat is mocked to return success for the module path mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if path == "/path/to/module" { - return nil, nil - } return nil, os.ErrNotExist } - - // And ReadFile is mocked to return an error for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return nil, fmt.Errorf("mock error reading variables.tf") + return []byte(`variable "test" { type = "string" }`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - - // When generateModuleShim is called - err := generator.generateModuleShim(component) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should match the expected error - expectedError := "failed to read variables.tf: mock error reading variables.tf" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + err := generator.generateModuleShim(component, make(map[string][]byte)) + if err != nil { + t.Errorf("expected no error, got %v", err) } }) - t.Run("ErrorWritingMainTf", func(t *testing.T) { - // Given a TerraformGenerator with mocks + t.Run("MainInAtEndOfString", func(t *testing.T) { generator, mocks := setup(t) - - // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - - // And ExecSilent is mocked to return output with module path - mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"} -{"@level":"info","@message":"- main in /path/to/module","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"- main in","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil } - - // And Stat is mocked to return success for the module path mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if path == "/path/to/module" { - return nil, nil - } return nil, os.ErrNotExist } - - // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil + return []byte(`variable "test" { type = "string" }`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - - // And WriteFile is mocked to return an error for main.tf - mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { - if strings.HasSuffix(path, "main.tf") { - return fmt.Errorf("mock error writing main.tf") - } - return nil - } - - // When generateModuleShim is called - err := generator.generateModuleShim(component) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should match the expected error - expectedError := "failed to write main.tf: mock error writing main.tf" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + err := generator.generateModuleShim(component, make(map[string][]byte)) + if err != nil { + t.Errorf("expected no error, got %v", err) } }) - t.Run("ErrorWritingVariablesTf", func(t *testing.T) { - // Given a TerraformGenerator with mocks + t.Run("EmptyPathAfterMainIn", func(t *testing.T) { generator, mocks := setup(t) - - // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - - // And ExecSilent is mocked to return output with module path - mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"} -{"@level":"info","@message":"- main in /path/to/module","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"- main in ","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil } - - // And Stat is mocked to return success for the module path mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if path == "/path/to/module" { - return nil, nil - } return nil, os.ErrNotExist } - - // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil + return []byte(`variable "test" { type = "string" }`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - - // And WriteFile is mocked to return an error for variables.tf - mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { - if strings.HasSuffix(path, "variables.tf") { - return fmt.Errorf("mock error writing variables.tf") - } - return nil - } - - // When generateModuleShim is called - err := generator.generateModuleShim(component) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should match the expected error - expectedError := "failed to write shim variables.tf: mock error writing variables.tf" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + err := generator.generateModuleShim(component, make(map[string][]byte)) + if err != nil { + t.Errorf("expected no error, got %v", err) } }) - t.Run("ErrorWritingOutputsTf", func(t *testing.T) { - // Given a TerraformGenerator with mocks + t.Run("StatFailsForDetectedPath", func(t *testing.T) { generator, mocks := setup(t) - - // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - - // And ExecSilent is mocked to return output with module path - mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"} -{"@level":"info","@message":"- main in /path/to/module","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"- main in /not/a/real/path","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil } - - // And Stat is mocked to return success for the module path and outputs.tf mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if path == "/path/to/module" || strings.HasSuffix(path, "outputs.tf") { - return nil, nil - } return nil, os.ErrNotExist } - - // And ReadFile is mocked to return content for variables.tf and outputs.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil - } - if strings.HasSuffix(path, "outputs.tf") { - return []byte(`output "test" { - value = "test" -}`), nil + return []byte(`variable "test" { type = "string" }`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - - // And WriteFile is mocked to return an error for outputs.tf - mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { - if strings.HasSuffix(path, "outputs.tf") { - return fmt.Errorf("mock error writing outputs.tf") - } - return nil - } - - // When generateModuleShim is called - err := generator.generateModuleShim(component) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should match the expected error - expectedError := "failed to write shim outputs.tf: mock error writing outputs.tf" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + err := generator.generateModuleShim(component, make(map[string][]byte)) + if err != nil { + t.Errorf("expected no error, got %v", err) } }) - t.Run("ModulePathResolution", func(t *testing.T) { + t.Run("SuccessWithOCISource", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with source + // And a component with OCI source component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", + Source: "core-oci", + Path: "cluster/talos", + FullPath: "/project/terraform/cluster/talos", } - // And ExecSilent is mocked to return output without module path - mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z"}`, nil + // And blueprint handler returns OCI sources + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + { + Name: "core-oci", + Url: "oci://ghcr.io/windsorcli/core:v0.0.1", + }, + } } - // And Stat is mocked to return success for .tf_modules/variables.tf + // And shell GetProjectRoot returns project root + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/project", nil + } + + // And FilepathRel calculates relative path + mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { + return "../../../.windsor/.oci_extracted/ghcr.io-windsorcli_core-v0.0.1/terraform/cluster/talos", nil + } + + // And the extracted module path exists mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - // Convert path to forward slashes for consistent comparison - path = filepath.ToSlash(path) - if strings.HasSuffix(path, ".tf_modules/variables.tf") { - return nil, nil + if strings.Contains(path, ".oci_extracted") { + return &mockFileInfo{name: "talos", isDir: true}, nil } return nil, os.ErrNotExist } - // And ReadFile is mocked to return content for variables.tf + // And ReadFile returns content from extracted module mocks.Shims.ReadFile = func(path string) ([]byte, error) { - // Convert path to forward slashes for consistent comparison - path = filepath.ToSlash(path) if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" + return []byte(`variable "cluster_name" { + description = "Name of the Talos cluster" type = string +}`), nil + } + if strings.HasSuffix(path, "outputs.tf") { + return []byte(`output "cluster_endpoint" { + description = "Cluster endpoint" + value = "https://cluster.example.com" }`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - // When generateModuleShim is called - err := generator.generateModuleShim(component) + // And MkdirAll is mocked to avoid filesystem operations + mocks.Shims.MkdirAll = func(path string, perm fs.FileMode) error { + return nil + } + + // And WriteFile is mocked to avoid filesystem operations + mocks.Shims.WriteFile = func(path string, data []byte, perm fs.FileMode) error { + return nil + } + + // And ociArtifacts contains pre-extracted data + ociArtifacts := map[string][]byte{ + "ghcr.io/windsorcli/core:v0.0.1": []byte("mock-artifact-data"), + } + + // When generateModuleShim is called with OCI source + err := generator.generateModuleShim(component, ociArtifacts) // Then no error should occur if err != nil { @@ -1485,7 +2511,20 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { } }) - t.Run("ModulePathResolutionFailure", func(t *testing.T) { +} + +func TestTerraformGenerator_writeModuleFile(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("Success", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) @@ -1496,19 +2535,6 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { FullPath: "original/full/path", } - // And ExecSilent is mocked to return output without module path - mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z"}`, nil - } - - // And Stat is mocked to return success for the standard path - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.HasSuffix(path, "variables.tf") { - return nil, nil - } - return nil, os.ErrNotExist - } - // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { @@ -1520,8 +2546,8 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { return nil, fmt.Errorf("unexpected file read: %s", path) } - // When generateModuleShim is called - err := generator.generateModuleShim(component) + // When writeModuleFile is called + err := generator.writeModuleFile("test_dir", component) // Then no error should occur if err != nil { @@ -1529,7 +2555,7 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { } }) - t.Run("ErrorInTerraformInit", func(t *testing.T) { + t.Run("ErrorReadFile", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) @@ -1537,43 +2563,30 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", - FullPath: filepath.Join(t.TempDir(), "module/path1"), - } - - // Mock the WriteFile method to succeed - originalWriteFile := generator.shims.WriteFile - generator.shims.WriteFile = func(path string, data []byte, perm fs.FileMode) error { - return nil + FullPath: "original/full/path", } - // Restore original WriteFile after test - defer func() { - generator.shims.WriteFile = originalWriteFile - }() - // And ExecProgress is mocked to return an error - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - if cmd == "terraform" && len(args) > 0 && args[0] == "init" { - return "", fmt.Errorf("terraform init failed") - } - return "", nil + // And ReadFile is mocked to return an error + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return nil, fmt.Errorf("mock error reading file") } - // When generateModuleShim is called - err := generator.generateModuleShim(component) + // When writeModuleFile is called + err := generator.writeModuleFile("test_dir", component) // Then an error should be returned if err == nil { t.Fatalf("expected an error, got nil") } - // And the error should indicate terraform init failure - expectedError := "failed to initialize terraform" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("expected error containing %q, got %q", expectedError, err.Error()) + // And the error should match the expected error + expectedError := "failed to read variables.tf: mock error reading file" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) - t.Run("NoValidModulePath", func(t *testing.T) { + t.Run("ErrorWriteFile", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) @@ -1581,197 +2594,331 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", - FullPath: filepath.Join(t.TempDir(), "module/path1"), - } - - // Mock WriteFile to succeed - originalWriteFile := generator.shims.WriteFile - generator.shims.WriteFile = func(path string, data []byte, perm fs.FileMode) error { - return nil + FullPath: "original/full/path", } - defer func() { - generator.shims.WriteFile = originalWriteFile - }() - // Mock ExecProgress to return output without a valid module path - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - if cmd == "terraform" && len(args) > 0 && args[0] == "init" { - return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z"} -{"@level":"info","@message":"No modules to initialize","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + // And ReadFile is mocked to return content for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil } - return "", nil + return nil, fmt.Errorf("unexpected file read: %s", path) } - // And mock Stat to return not exist for all paths - originalStat := generator.shims.Stat - generator.shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist + // And WriteFile is mocked to return an error + mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { + return fmt.Errorf("mock error writing file") } - defer func() { - generator.shims.Stat = originalStat - }() - // When generateModuleShim is called - err := generator.generateModuleShim(component) + // When writeModuleFile is called + err := generator.writeModuleFile("test_dir", component) - // Then no error should be returned because the function handles missing files - if err != nil { - t.Errorf("expected no error, got %v", err) + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "mock error writing file" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) +} - t.Run("BlankOutputLine", func(t *testing.T) { +func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) + + // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - return "\n", nil - } - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist - } + + // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { type = "string" }`), nil + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - err := generator.generateModuleShim(component) + + // When writeTfvarsFile is called + err := generator.writeTfvarsFile("test_dir", component) + + // Then no error should occur if err != nil { t.Errorf("expected no error, got %v", err) } }) - t.Run("MalformedJSONOutput", func(t *testing.T) { + t.Run("ErrorMkdirAll", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) + + // And a component with source component := blueprintv1alpha1.TerraformComponent{ Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", } - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - return "not-json-line", nil + + // And MkdirAll is mocked to return an error + mocks.Shims.MkdirAll = func(_ string, _ fs.FileMode) error { + return fmt.Errorf("mock error creating directory") } - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist + + // When writeTfvarsFile is called + err := generator.writeTfvarsFile("test_dir", component) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "failed to create directory: mock error creating directory" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) + } + }) + + t.Run("ErrorWriteFile", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And a component with source + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", } + + // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { type = "string" }`), nil + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - err := generator.generateModuleShim(component) - if err != nil { - t.Errorf("expected no error, got %v", err) + + // And WriteFile is mocked to return an error + mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { + return fmt.Errorf("mock error writing file") + } + + // When writeTfvarsFile is called + err := generator.writeTfvarsFile("test_dir", component) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "error writing tfvars file: mock error writing file" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) - t.Run("LogLineWithoutMainIn", func(t *testing.T) { + t.Run("ErrorCheckExistingTfvarsFile", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) + + // And a component with values component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", - } - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - return `{"@level":"info","@message":"No main here","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + Path: "module/path1", + Values: map[string]interface{}{ + "test": "value", + }, } + + // And Stat is mocked to return an error mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist + return nil, fmt.Errorf("mock error checking existing tfvars file") } - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { type = "string" }`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) + + // When writeTfvarsFile is called + err := generator.writeTfvarsFile("test.tfvars", component) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") } - err := generator.generateModuleShim(component) - if err != nil { - t.Errorf("expected no error, got %v", err) + + // And the error should match the expected error + expectedError := "error checking tfvars file: mock error checking existing tfvars file" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) - t.Run("MainInAtEndOfString", func(t *testing.T) { + t.Run("ErrorReadFile", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) + + // And a component with values component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", - } - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - return `{"@level":"info","@message":"- main in","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + Path: "module/path1", + Values: map[string]interface{}{ + "test": "value", + }, } + + // And Stat is mocked to return success mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist + return nil, nil } + + // And ReadFile is mocked to return an error mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { type = "string" }`), nil + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil } - return nil, fmt.Errorf("unexpected file read: %s", path) + return nil, fmt.Errorf("mock error reading existing tfvars file") } - err := generator.generateModuleShim(component) - if err != nil { - t.Errorf("expected no error, got %v", err) + + // When writeTfvarsFile is called + err := generator.writeTfvarsFile("test.tfvars", component) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "failed to read existing tfvars file: mock error reading existing tfvars file" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) - t.Run("EmptyPathAfterMainIn", func(t *testing.T) { + t.Run("ErrorWriteComponentValues", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) + + // And a component with values component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", + Values: map[string]interface{}{ + "test": "value", + }, } - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - return `{"@level":"info","@message":"- main in ","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil - } + + // And Stat is mocked to return not exist mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { return nil, os.ErrNotExist } + + // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { type = "string" }`), nil + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - err := generator.generateModuleShim(component) - if err != nil { - t.Errorf("expected no error, got %v", err) + + // And WriteFile is mocked to return an error for component values + mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { + return fmt.Errorf("mock error writing component values") + } + + // When writeTfvarsFile is called + err := generator.writeTfvarsFile("test.tfvars", component) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "error writing tfvars file: mock error writing component values" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) - t.Run("StatFailsForDetectedPath", func(t *testing.T) { + t.Run("ErrorWriteFileAfterValues", func(t *testing.T) { + // Given a TerraformGenerator with mocks generator, mocks := setup(t) + + // And a component with values component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", Path: "module/path1", FullPath: "original/full/path", + Values: map[string]interface{}{ + "test": "value", + }, } - mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { - return `{"@level":"info","@message":"- main in /not/a/real/path","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil - } + + // And Stat is mocked to return not exist mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { return nil, os.ErrNotExist } + + // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { type = "string" }`), nil + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - err := generator.generateModuleShim(component) - if err != nil { - t.Errorf("expected no error, got %v", err) + + // And WriteFile is mocked to return an error + mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { + return fmt.Errorf("mock error writing final tfvars file") + } + + // When writeTfvarsFile is called + err := generator.writeTfvarsFile("test.tfvars", component) + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "error writing tfvars file: mock error writing final tfvars file" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) } -func TestTerraformGenerator_writeModuleFile(t *testing.T) { +func TestTerraformGenerator_checkExistingTfvarsFile(t *testing.T) { setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { mocks := setupMocks(t) generator := NewTerraformGenerator(mocks.Injector) @@ -1782,30 +2929,17 @@ func TestTerraformGenerator_writeModuleFile(t *testing.T) { return generator, mocks } - t.Run("Success", func(t *testing.T) { + t.Run("FileDoesNotExist", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with source - component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", - } - - // And ReadFile is mocked to return content for variables.tf - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) + // And Stat is mocked to return not found + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist } - // When writeModuleFile is called - err := generator.writeModuleFile("test_dir", component) + // When checkExistingTfvarsFile is called + err := generator.checkExistingTfvarsFile("test.tfvars") // Then no error should occur if err != nil { @@ -1813,15 +2947,36 @@ func TestTerraformGenerator_writeModuleFile(t *testing.T) { } }) - t.Run("ErrorReadFile", func(t *testing.T) { + t.Run("FileExists", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with source - component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", + // And Stat is mocked to return success + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, nil + } + + // And ReadFile is mocked to return content + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + return []byte("test content"), nil + } + + // When checkExistingTfvarsFile is called + err := generator.checkExistingTfvarsFile("test.tfvars") + + // Then os.ErrExist should be returned + if err != os.ErrExist { + t.Errorf("expected os.ErrExist, got %v", err) + } + }) + + t.Run("ErrorReadingFile", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, nil } // And ReadFile is mocked to return an error @@ -1829,8 +2984,8 @@ func TestTerraformGenerator_writeModuleFile(t *testing.T) { return nil, fmt.Errorf("mock error reading file") } - // When writeModuleFile is called - err := generator.writeModuleFile("test_dir", component) + // When checkExistingTfvarsFile is called + err := generator.checkExistingTfvarsFile("test.tfvars") // Then an error should be returned if err == nil { @@ -1838,56 +2993,50 @@ func TestTerraformGenerator_writeModuleFile(t *testing.T) { } // And the error should match the expected error - expectedError := "failed to read variables.tf: mock error reading file" + expectedError := "failed to read existing tfvars file: mock error reading file" if err.Error() != expectedError { t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) +} - t.Run("ErrorWriteFile", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And a component with source - component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", - } - - // And ReadFile is mocked to return content for variables.tf - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) - } +func TestTerraformGenerator_addTfvarsHeader(t *testing.T) { + t.Run("WithSource", func(t *testing.T) { + // Given a body and source + file := hclwrite.NewEmptyFile() + body := file.Body() + source := "fake-source" - // And WriteFile is mocked to return an error - mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { - return fmt.Errorf("mock error writing file") + // When addTfvarsHeader is called + addTfvarsHeader(body, source) + + // Then the header should be written with source + expected := `# Managed by Windsor CLI: This file is partially managed by the windsor CLI. Your changes will not be overwritten. +# Module source: fake-source +` + if string(file.Bytes()) != expected { + t.Errorf("expected %q, got %q", expected, string(file.Bytes())) } + }) - // When writeModuleFile is called - err := generator.writeModuleFile("test_dir", component) + t.Run("WithoutSource", func(t *testing.T) { + // Given a body without source + file := hclwrite.NewEmptyFile() + body := file.Body() - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } + // When addTfvarsHeader is called + addTfvarsHeader(body, "") - // And the error should match the expected error - expectedError := "mock error writing file" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + // Then the header should be written without source + expected := `# Managed by Windsor CLI: This file is partially managed by the windsor CLI. Your changes will not be overwritten. +` + if string(file.Bytes()) != expected { + t.Errorf("expected %q, got %q", expected, string(file.Bytes())) } }) } -func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { +func TestTerraformGenerator_writeShimVariablesTf(t *testing.T) { setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { mocks := setupMocks(t) generator := NewTerraformGenerator(mocks.Injector) @@ -1902,13 +3051,6 @@ func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with source - component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", - } - // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { @@ -1920,8 +3062,8 @@ func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { return nil, fmt.Errorf("unexpected file read: %s", path) } - // When writeTfvarsFile is called - err := generator.writeTfvarsFile("test_dir", component) + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") // Then no error should occur if err != nil { @@ -1929,24 +3071,28 @@ func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { } }) - t.Run("ErrorMkdirAll", func(t *testing.T) { + t.Run("ErrorWriteFile", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with source - component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", + // And ReadFile is mocked to return content for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) } - // And MkdirAll is mocked to return an error - mocks.Shims.MkdirAll = func(_ string, _ fs.FileMode) error { - return fmt.Errorf("mock error creating directory") + // And WriteFile is mocked to return an error + mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { + return fmt.Errorf("mock error writing file") } - // When writeTfvarsFile is called - err := generator.writeTfvarsFile("test_dir", component) + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") // Then an error should be returned if err == nil { @@ -1954,147 +3100,119 @@ func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { } // And the error should match the expected error - expectedError := "failed to create directory: mock error creating directory" + expectedError := "failed to write shim variables.tf: mock error writing file" if err.Error() != expectedError { t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) - t.Run("ErrorWriteFile", func(t *testing.T) { + t.Run("CopiesSensitiveAttribute", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with source - component := blueprintv1alpha1.TerraformComponent{ - Source: "fake-source", - Path: "module/path1", - FullPath: "original/full/path", - } - - // And ReadFile is mocked to return content for variables.tf + // And ReadFile is mocked to return variables.tf with sensitive attribute mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" + return []byte(`variable "sensitive_var" { + description = "Sensitive variable" type = string + sensitive = true }`), nil } return nil, fmt.Errorf("unexpected file read: %s", path) } - // And WriteFile is mocked to return an error - mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { - return fmt.Errorf("mock error writing file") + // And WriteFile is mocked to capture the content + var writtenContent []byte + mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { + if strings.HasSuffix(path, "variables.tf") { + writtenContent = content + } + return nil } - // When writeTfvarsFile is called - err := generator.writeTfvarsFile("test_dir", component) + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - // And the error should match the expected error - expectedError := "error writing tfvars file: mock error writing file" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + // And the sensitive attribute should be copied + file, diags := hclwrite.ParseConfig(writtenContent, "variables.tf", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse HCL: %v", diags) + } + body := file.Body() + block := body.Blocks()[0] + attr := block.Body().GetAttribute("sensitive") + if attr == nil { + t.Error("expected sensitive attribute to be present") + } else { + tokens := attr.Expr().BuildTokens(nil) + if len(tokens) < 1 || tokens[0].Type != hclsyntax.TokenIdent || string(tokens[0].Bytes) != "true" { + t.Error("expected sensitive attribute to be true") + } } }) - t.Run("ErrorCheckExistingTfvarsFile", func(t *testing.T) { + t.Run("ErrorReadingVariables", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with values - component := blueprintv1alpha1.TerraformComponent{ - Path: "module/path1", - Values: map[string]interface{}{ - "test": "value", - }, - } - - // And Stat is mocked to return an error - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, fmt.Errorf("mock error checking existing tfvars file") + // And ReadFile is mocked to return an error for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return nil, fmt.Errorf("mock error reading variables.tf") + } + return nil, fmt.Errorf("unexpected file read: %s", path) } - // When writeTfvarsFile is called - err := generator.writeTfvarsFile("test.tfvars", component) + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") // Then an error should be returned if err == nil { t.Fatalf("expected an error, got nil") } - // And the error should match the expected error - expectedError := "error checking tfvars file: mock error checking existing tfvars file" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read variables.tf") { + t.Errorf("expected error to contain 'failed to read variables.tf', got %v", err) } }) - t.Run("ErrorReadFile", func(t *testing.T) { + t.Run("ErrorParsingVariables", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with values - component := blueprintv1alpha1.TerraformComponent{ - Path: "module/path1", - Values: map[string]interface{}{ - "test": "value", - }, - } - - // And Stat is mocked to return success - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, nil - } - - // And ReadFile is mocked to return an error + // And ReadFile is mocked to return invalid HCL content mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil + return []byte(`invalid hcl syntax {`), nil } - return nil, fmt.Errorf("mock error reading existing tfvars file") + return nil, fmt.Errorf("unexpected file read: %s", path) } - // When writeTfvarsFile is called - err := generator.writeTfvarsFile("test.tfvars", component) + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") // Then an error should be returned if err == nil { t.Fatalf("expected an error, got nil") } - // And the error should match the expected error - expectedError := "failed to read existing tfvars file: mock error reading existing tfvars file" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to parse variables.tf") { + t.Errorf("expected error to contain 'failed to parse variables.tf', got %v", err) } }) - t.Run("ErrorWriteComponentValues", func(t *testing.T) { + t.Run("ErrorWritingMainTf", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with values - component := blueprintv1alpha1.TerraformComponent{ - Path: "module/path1", - FullPath: "original/full/path", - Values: map[string]interface{}{ - "test": "value", - }, - } - - // And Stat is mocked to return not exist - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist - } - // And ReadFile is mocked to return content for variables.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { if strings.HasSuffix(path, "variables.tf") { @@ -2106,62 +3224,62 @@ func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { return nil, fmt.Errorf("unexpected file read: %s", path) } - // And WriteFile is mocked to return an error for component values - mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { - return fmt.Errorf("mock error writing component values") + // And WriteFile is mocked to fail only for main.tf + mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { + if strings.HasSuffix(path, "main.tf") { + return fmt.Errorf("mock error writing main.tf") + } + return nil // Success for variables.tf } - // When writeTfvarsFile is called - err := generator.writeTfvarsFile("test.tfvars", component) + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") // Then an error should be returned if err == nil { t.Fatalf("expected an error, got nil") } - // And the error should match the expected error - expectedError := "error writing tfvars file: mock error writing component values" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to write shim main.tf") { + t.Errorf("expected error to contain 'failed to write shim main.tf', got %v", err) } }) +} - t.Run("ErrorWriteFileAfterValues", func(t *testing.T) { +func TestTerraformGenerator_writeShimOutputsTf(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("ErrorReadingOutputs", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And a component with values - component := blueprintv1alpha1.TerraformComponent{ - Path: "module/path1", - FullPath: "original/full/path", - Values: map[string]interface{}{ - "test": "value", - }, - } - - // And Stat is mocked to return not exist + // And Stat is mocked to return success for outputs.tf mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.HasSuffix(path, "outputs.tf") { + return nil, nil + } return nil, os.ErrNotExist } - // And ReadFile is mocked to return content for variables.tf + // And ReadFile is mocked to return an error for outputs.tf mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil + if strings.HasSuffix(path, "outputs.tf") { + return nil, fmt.Errorf("mock error reading outputs.tf") } return nil, fmt.Errorf("unexpected file read: %s", path) } - // And WriteFile is mocked to return an error - mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { - return fmt.Errorf("mock error writing final tfvars file") - } - - // When writeTfvarsFile is called - err := generator.writeTfvarsFile("test.tfvars", component) + // When writeShimOutputsTf is called + err := generator.writeShimOutputsTf("test_dir", "module_path") // Then an error should be returned if err == nil { @@ -2169,14 +3287,14 @@ func TestTerraformGenerator_writeTfvarsFile(t *testing.T) { } // And the error should match the expected error - expectedError := "error writing tfvars file: mock error writing final tfvars file" + expectedError := "failed to read outputs.tf: mock error reading outputs.tf" if err.Error() != expectedError { t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) } -func TestTerraformGenerator_checkExistingTfvarsFile(t *testing.T) { +func TestTerraformGenerator_isOCISource(t *testing.T) { setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { mocks := setupMocks(t) generator := NewTerraformGenerator(mocks.Injector) @@ -2187,307 +3305,299 @@ func TestTerraformGenerator_checkExistingTfvarsFile(t *testing.T) { return generator, mocks } - t.Run("FileDoesNotExist", func(t *testing.T) { + t.Run("DirectOCIURL", func(t *testing.T) { // Given a TerraformGenerator with mocks - generator, mocks := setup(t) + generator, _ := setup(t) - // And Stat is mocked to return not found - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist + // When isOCISource is called with a direct OCI URL + result := generator.isOCISource("oci://registry.example.com/repo:tag") + + // Then it should return true + if !result { + t.Error("expected isOCISource to return true for direct OCI URL") + } + }) + + t.Run("NamedOCISource", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + mockBH := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + mockBH.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://registry.example.com/terraform-modules:v1.0.0", + }, + } } - // When checkExistingTfvarsFile is called - err := generator.checkExistingTfvarsFile("test.tfvars") + // When isOCISource is called with a named OCI source + result := generator.isOCISource("oci-source") - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) + // Then it should return true + if !result { + t.Error("expected isOCISource to return true for named OCI source") } }) - t.Run("FileExists", func(t *testing.T) { + t.Run("NonOCISources", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - - // And Stat is mocked to return success - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, nil + mockBH := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + mockBH.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + { + Name: "git-source", + Url: "git::https://github.com/example/repo.git", + }, + } } - // And ReadFile is mocked to return content - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte("test content"), nil + testCases := []string{ + "/path/to/.oci_extracted/module", + "git::https://github.com/example/repo.git", + "./local/path", + "git-source", + "unknown-source", + "", } - // When checkExistingTfvarsFile is called - err := generator.checkExistingTfvarsFile("test.tfvars") + for _, source := range testCases { + // When isOCISource is called + result := generator.isOCISource(source) - // Then os.ErrExist should be returned - if err != os.ErrExist { - t.Errorf("expected os.ErrExist, got %v", err) + // Then it should return false + if result { + t.Errorf("expected isOCISource to return false for %s", source) + } } }) +} - t.Run("ErrorReadingFile", func(t *testing.T) { - // Given a TerraformGenerator with mocks +func TestTerraformGenerator_extractOCIModule(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("ModuleAlreadyExtracted", func(t *testing.T) { + // Given a TerraformGenerator with existing extracted module generator, mocks := setup(t) + mockBH := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + mockBH.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "test-source", Url: "oci://registry.example.com/modules:v1.0.0"}, + } + } - // And Stat is mocked to return success - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, nil + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/project", nil } - // And ReadFile is mocked to return an error - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return nil, fmt.Errorf("mock error reading file") + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + return nil, nil // Module already exists } - // When checkExistingTfvarsFile is called - err := generator.checkExistingTfvarsFile("test.tfvars") + // When extractOCIModule is called + result, err := generator.extractOCIModule("test-source", "test/module", make(map[string][]byte)) - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") + // Then it should return the existing path without error + if err != nil { + t.Errorf("expected no error, got %v", err) } - - // And the error should match the expected error - expectedError := "failed to read existing tfvars file: mock error reading file" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + expectedPath := "/tmp/project/.windsor/.oci_extracted/registry.example.com-modules-v1.0.0/terraform/test/module" + if result != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, result) } }) -} -func TestTerraformGenerator_addTfvarsHeader(t *testing.T) { - t.Run("WithSource", func(t *testing.T) { - // Given a body and source - file := hclwrite.NewEmptyFile() - body := file.Body() - source := "fake-source" + t.Run("SourceNotFound", func(t *testing.T) { + // Given a TerraformGenerator with no matching source + generator, mocks := setup(t) + mockBH := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + mockBH.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{} + } - // When addTfvarsHeader is called - addTfvarsHeader(body, source) + // When extractOCIModule is called with unknown source + _, err := generator.extractOCIModule("unknown-source", "test/module", make(map[string][]byte)) - // Then the header should be written with source - expected := `# Managed by Windsor CLI: This file is partially managed by the windsor CLI. Your changes will not be overwritten. -# Module source: fake-source -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) + // Then it should return an error + if err == nil { + t.Error("expected error for unknown source") + } + if !strings.Contains(err.Error(), "source unknown-source not found") { + t.Errorf("expected 'source not found' error, got %v", err) } }) - t.Run("WithoutSource", func(t *testing.T) { - // Given a body without source - file := hclwrite.NewEmptyFile() - body := file.Body() + t.Run("UsesCachedArtifact", func(t *testing.T) { + // Given a TerraformGenerator with cached OCI artifact + generator, mocks := setup(t) + mockBH := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + mockBH.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "test-source", Url: "oci://registry.example.com/modules:v1.0.0"}, + } + } - // When addTfvarsHeader is called - addTfvarsHeader(body, "") + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/tmp/project", nil + } - // Then the header should be written without source - expected := `# Managed by Windsor CLI: This file is partially managed by the windsor CLI. Your changes will not be overwritten. -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist // Module doesn't exist yet } - }) -} -func Test_formatValue(t *testing.T) { - t.Run("EmptyArray", func(t *testing.T) { - result := formatValue([]any{}) - if result != "[]" { - t.Errorf("expected [] got %q", result) + // Mock tar extraction - create a minimal valid tar stream + mocks.Shims.NewBytesReader = func(data []byte) io.Reader { + // Create a minimal tar file with just an EOF + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + tw.Close() // This creates a valid empty tar file + return bytes.NewReader(buf.Bytes()) } - }) - t.Run("EmptyMap", func(t *testing.T) { - result := formatValue(map[string]any{}) - if result != "{}" { - t.Errorf("expected {} got %q", result) + + mocks.Shims.NewTarReader = func(r io.Reader) *tar.Reader { + return tar.NewReader(r) } - }) - t.Run("NilValue", func(t *testing.T) { - result := formatValue(nil) - if result != "null" { - t.Errorf("expected null got %q", result) + + mocks.Shims.EOFError = func() error { return io.EOF } + + // And cached artifact data + cachedData := []byte("mock tar data") + ociArtifacts := map[string][]byte{ + "registry.example.com/modules:v1.0.0": cachedData, } - }) - t.Run("ComplexNestedObject", func(t *testing.T) { - input := map[string]any{ - "node_groups": map[string]any{ - "default": map[string]any{ - "instance_types": []any{"t3.medium"}, - "min_size": 1, - "max_size": 3, - "desired_size": 2, - }, - }, + + // When extractOCIModule is called + result, err := generator.extractOCIModule("test-source", "test/module", ociArtifacts) + + // Then it should succeed and return correct path (even with empty tar) + if err != nil { + t.Errorf("expected no error, got %v", err) } - expected := `{ - node_groups = { - default = { - desired_size = 2 - instance_types = ["t3.medium"] - max_size = 3 - min_size = 1 - } - } -}` - result := formatValue(input) - if result != expected { - t.Errorf("expected %q, got %q", expected, result) + expectedPath := "/tmp/project/.windsor/.oci_extracted/registry.example.com-modules-v1.0.0/terraform/test/module" + if result != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, result) } }) - t.Run("EmptyAddons", func(t *testing.T) { - input := map[string]any{ - "addons": map[string]any{ - "vpc-cni": map[string]any{}, - "aws-efs-csi-driver": map[string]any{}, - "aws-ebs-csi-driver": map[string]any{}, - "eks-pod-identity-agent": map[string]any{}, - "coredns": map[string]any{}, - "external-dns": map[string]any{}, - }, +} + +func TestTerraformGenerator_downloadOCIArtifact(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) } - expected := `{ - addons = { - aws-ebs-csi-driver = {} - aws-efs-csi-driver = {} - coredns = {} - eks-pod-identity-agent = {} - external-dns = {} - vpc-cni = {} - } -}` - result := formatValue(input) - if result != expected { - t.Errorf("expected %q, got %q", expected, result) + return generator, mocks + } + + t.Run("ParseReferenceSuccess", func(t *testing.T) { + // Given a TerraformGenerator with successful parse but failing remote + generator, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, nil // Success } - }) -} -func Test_writeVariable(t *testing.T) { - t.Run("ComplexObject_node_groups", func(t *testing.T) { - file := hclwrite.NewEmptyFile() - writeVariable(file.Body(), "node_groups", map[string]any{ - "node_groups": map[string]any{ - "default": map[string]any{ - "instance_types": []any{"t3.medium"}, - "min_size": 1, - "max_size": 3, - "desired_size": 2, - }, - }, - }, nil) - expected := "node_groups = {\n node_groups = {\n default = {\n desired_size = 2\n instance_types = [\"t3.medium\"]\n max_size = 3\n min_size = 1\n }\n }\n}" - result := strings.TrimSpace(string(file.Bytes())) - if result != expected { - t.Errorf("expected\n%s\ngot\n%s", expected, result) + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, fmt.Errorf("remote image error") // Fail at next step } - }) - t.Run("ComplexObject_addons", func(t *testing.T) { - file := hclwrite.NewEmptyFile() - writeVariable(file.Body(), "addons", map[string]any{ - "addons": map[string]any{ - "vpc-cni": map[string]any{}, - "aws-efs-csi-driver": map[string]any{}, - "aws-ebs-csi-driver": map[string]any{}, - "eks-pod-identity-agent": map[string]any{}, - "coredns": map[string]any{}, - "external-dns": map[string]any{}, - }, - }, nil) - expected := "addons = {\n addons = {\n aws-ebs-csi-driver = {}\n aws-efs-csi-driver = {}\n coredns = {}\n eks-pod-identity-agent = {}\n external-dns = {}\n vpc-cni = {}\n }\n}" - result := strings.TrimSpace(string(file.Bytes())) - if result != expected { - t.Errorf("expected\n%s\ngot\n%s", expected, result) + + // When downloadOCIArtifact is called + _, err := generator.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should fail at remote image step + if err == nil { + t.Error("expected error for remote image failure") + } + if !strings.Contains(err.Error(), "failed to get image") { + t.Errorf("expected remote image error, got %v", err) } }) - t.Run("ObjectAssignment_no_heredoc", func(t *testing.T) { - file := hclwrite.NewEmptyFile() - value := map[string]any{ - "default": map[string]any{ - "desired_size": 2, - "instance_types": []any{"t3.medium"}, - "max_size": 3, - "min_size": 1, - }, + + t.Run("ParseReferenceError", func(t *testing.T) { + // Given a TerraformGenerator with parse reference error + generator, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, fmt.Errorf("parse error") } - writeVariable(file.Body(), "node_groups", value, nil) - expected := "node_groups = {\n default = {\n desired_size = 2\n instance_types = [\"t3.medium\"]\n max_size = 3\n min_size = 1\n }\n}" - result := strings.TrimSpace(string(file.Bytes())) - if result != expected { - t.Errorf("expected\n%s\ngot\n%s", expected, result) + + // When downloadOCIArtifact is called + _, err := generator.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should return parse reference error + if err == nil { + t.Error("expected error for parse reference failure") + } + if !strings.Contains(err.Error(), "failed to parse reference") { + t.Errorf("expected parse reference error, got %v", err) } }) -} -func Test_writeComponentValues(t *testing.T) { - t.Run("ComplexDefaults_NodeGroups", func(t *testing.T) { - file := hclwrite.NewEmptyFile() - variable := VariableInfo{ - Name: "node_groups", - Description: "Map of EKS managed node group definitions to create.", - Default: map[string]any{ - "default": map[string]any{ - "instance_types": []any{"t3.medium"}, - "min_size": 1, - "max_size": 3, - "desired_size": 2, - }, - }, + t.Run("RemoteImageError", func(t *testing.T) { + // Given a TerraformGenerator with remote image error + generator, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, nil } - writeComponentValues(file.Body(), map[string]any{}, map[string]bool{}, []VariableInfo{variable}) - expected := ` -# Map of EKS managed node group definitions to create. -# node_groups = { -# default = { -# desired_size = 2 -# instance_types = ["t3.medium"] -# max_size = 3 -# min_size = 1 -# } -# }` - result := string(file.Bytes()) - // Check that every line is commented - for _, line := range strings.Split(result, "\n") { - if strings.TrimSpace(line) == "" { - continue - } - if !strings.HasPrefix(line, "#") { - t.Errorf("uncommented line found: %q", line) - } + + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, fmt.Errorf("remote error") } - // Check that the output matches expected ignoring leading/trailing whitespace - if strings.TrimSpace(result) != strings.TrimSpace(expected) { - t.Errorf("expected\n%s\ngot\n%s", expected, result) + + // When downloadOCIArtifact is called + _, err := generator.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should return remote image error + if err == nil { + t.Error("expected error for remote image failure") + } + if !strings.Contains(err.Error(), "failed to get image") { + t.Errorf("expected remote image error, got %v", err) } }) - t.Run("ComplexDefaults_EmptyAddons", func(t *testing.T) { - file := hclwrite.NewEmptyFile() - variable := VariableInfo{ - Name: "addons", - Description: "Map of EKS add-ons", - Default: map[string]any{ - "vpc-cni": map[string]any{}, - "aws-efs-csi-driver": map[string]any{}, - "aws-ebs-csi-driver": map[string]any{}, - "eks-pod-identity-agent": map[string]any{}, - "coredns": map[string]any{}, - "external-dns": map[string]any{}, - }, + + t.Run("ImageLayersError", func(t *testing.T) { + // Given a TerraformGenerator with image layers error + generator, mocks := setup(t) + + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, nil } - writeComponentValues(file.Body(), map[string]any{}, map[string]bool{}, []VariableInfo{variable}) - expected := "\n# Map of EKS add-ons\n# addons = {\n# aws-ebs-csi-driver = {}\n# aws-efs-csi-driver = {}\n# coredns = {}\n# eks-pod-identity-agent = {}\n# external-dns = {}\n# vpc-cni = {}\n# }\n" - result := string(file.Bytes()) - if result != expected { - t.Errorf("expected %q, got %q", expected, result) + + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, nil + } + + mocks.Shims.ImageLayers = func(img v1.Image) ([]v1.Layer, error) { + return nil, fmt.Errorf("layers error") + } + + // When downloadOCIArtifact is called + _, err := generator.downloadOCIArtifact("registry.example.com", "modules", "v1.0.0") + + // Then it should return image layers error + if err == nil { + t.Error("expected error for image layers failure") + } + if !strings.Contains(err.Error(), "failed to get image layers") { + t.Errorf("expected image layers error, got %v", err) } }) } -func TestTerraformGenerator_writeShimVariablesTf(t *testing.T) { +func TestTerraformGenerator_parseVariablesFile(t *testing.T) { setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { mocks := setupMocks(t) generator := NewTerraformGenerator(mocks.Injector) @@ -2498,628 +3608,966 @@ func TestTerraformGenerator_writeShimVariablesTf(t *testing.T) { return generator, mocks } - t.Run("Success", func(t *testing.T) { + t.Run("AllVariableTypes", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And ReadFile is mocked to return content for variables.tf + // And ReadFile is mocked to return variables with all types and attributes mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" + return []byte(` +variable "string_var" { + description = "String variable" + type = string + default = "default_value" + sensitive = false +} + +variable "number_var" { + description = "Number variable" + type = number + default = 42 + sensitive = false +} + +variable "bool_var" { + description = "Boolean variable" + type = bool + default = true + sensitive = true +} + +variable "list_var" { + description = "List variable" + type = list(string) + default = ["item1", "item2"] +} + +variable "map_var" { + description = "Map variable" + type = map(string) + default = { key = "value" } +} + +variable "no_default" { + description = "Variable without default" type = string +} + +variable "no_description" { + type = string + default = "value" +} + +variable "invalid_default" { + description = "Variable with invalid default" + type = string + default = invalid +} + +variable "invalid_sensitive" { + description = "Variable with invalid sensitive" + type = string + sensitive = invalid }`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) } - // When writeShimVariablesTf is called - err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + // When parseVariablesFile is called + variables, err := generator.parseVariablesFile("test.tf", map[string]bool{}) // Then no error should occur if err != nil { t.Errorf("expected no error, got %v", err) } - }) - t.Run("ErrorWriteFile", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And ReadFile is mocked to return content for variables.tf - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) + // And all variables should be parsed correctly + expectedVars := map[string]VariableInfo{ + "string_var": { + Name: "string_var", + Description: "String variable", + Default: "default_value", + Sensitive: false, + }, + "number_var": { + Name: "number_var", + Description: "Number variable", + Default: int64(42), + Sensitive: false, + }, + "bool_var": { + Name: "bool_var", + Description: "Boolean variable", + Default: true, + Sensitive: true, + }, + "list_var": { + Name: "list_var", + Description: "List variable", + Default: []any{"item1", "item2"}, + }, + "map_var": { + Name: "map_var", + Description: "Map variable", + Default: map[string]any{"key": "value"}, + }, + "no_default": { + Name: "no_default", + Description: "Variable without default", + }, + "no_description": { + Name: "no_description", + Default: "value", + }, + "invalid_default": { + Name: "invalid_default", + Description: "Variable with invalid default", + }, + "invalid_sensitive": { + Name: "invalid_sensitive", + Description: "Variable with invalid sensitive", + }, } - // And WriteFile is mocked to return an error - mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { - return fmt.Errorf("mock error writing file") + // Verify each variable + if len(variables) != len(expectedVars) { + t.Errorf("expected %d variables, got %d", len(expectedVars), len(variables)) } - // When writeShimVariablesTf is called - err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } + for _, v := range variables { + expected, exists := expectedVars[v.Name] + if !exists { + t.Errorf("unexpected variable %s", v.Name) + continue + } - // And the error should match the expected error - expectedError := "failed to write shim variables.tf: mock error writing file" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + if v.Description != expected.Description { + t.Errorf("variable %s: expected description %q, got %q", v.Name, expected.Description, v.Description) + } + if !reflect.DeepEqual(v.Default, expected.Default) { + t.Errorf("variable %s: expected default %v (%T), got %v (%T)", v.Name, expected.Default, expected.Default, v.Default, v.Default) + } + if v.Sensitive != expected.Sensitive { + t.Errorf("variable %s: expected sensitive %v, got %v", v.Name, expected.Sensitive, v.Sensitive) + } } }) - t.Run("CopiesSensitiveAttribute", func(t *testing.T) { + t.Run("ProtectedVariables", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And ReadFile is mocked to return variables.tf with sensitive attribute + // And ReadFile is mocked to return variables mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "sensitive_var" { - description = "Sensitive variable" + return []byte(` +variable "protected_var" { + description = "Protected variable" + type = string +} + +variable "normal_var" { + description = "Normal variable" type = string - sensitive = true }`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) } - // And WriteFile is mocked to capture the content - var writtenContent []byte - mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { - if strings.HasSuffix(path, "variables.tf") { - writtenContent = content - } - return nil + // And protected values are set + protectedValues := map[string]bool{ + "protected_var": true, } - // When writeShimVariablesTf is called - err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + // When parseVariablesFile is called + variables, err := generator.parseVariablesFile("test.tf", protectedValues) // Then no error should occur if err != nil { t.Errorf("expected no error, got %v", err) } - // And the sensitive attribute should be copied - file, diags := hclwrite.ParseConfig(writtenContent, "variables.tf", hcl.Pos{Line: 1, Column: 1}) - if diags.HasErrors() { - t.Fatalf("failed to parse HCL: %v", diags) + // And only non-protected variables should be included + if len(variables) != 1 { + t.Errorf("expected 1 variable, got %d", len(variables)) } - body := file.Body() - block := body.Blocks()[0] - attr := block.Body().GetAttribute("sensitive") - if attr == nil { - t.Error("expected sensitive attribute to be present") - } else { - tokens := attr.Expr().BuildTokens(nil) - if len(tokens) < 1 || tokens[0].Type != hclsyntax.TokenIdent || string(tokens[0].Bytes) != "true" { - t.Error("expected sensitive attribute to be true") - } + if variables[0].Name != "normal_var" { + t.Errorf("expected variable normal_var, got %s", variables[0].Name) } }) - t.Run("ErrorReadingVariables", func(t *testing.T) { + t.Run("InvalidHCL", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And ReadFile is mocked to return an error for variables.tf + // And ReadFile is mocked to return invalid HCL mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return nil, fmt.Errorf("mock error reading variables.tf") - } - return nil, fmt.Errorf("unexpected file read: %s", path) + return []byte(`invalid hcl content`), nil } - // When writeShimVariablesTf is called - err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + // When parseVariablesFile is called + _, err := generator.parseVariablesFile("test.tf", map[string]bool{}) - // Then an error should be returned + // Then an error should occur if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to read variables.tf") { - t.Errorf("expected error to contain 'failed to read variables.tf', got %v", err) + t.Error("expected error, got nil") } }) - t.Run("ErrorParsingVariables", func(t *testing.T) { + t.Run("ReadFileError", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And ReadFile is mocked to return invalid HCL content + // And ReadFile is mocked to return an error mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`invalid hcl syntax {`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) + return nil, fmt.Errorf("read error") } - // When writeShimVariablesTf is called - err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + // When parseVariablesFile is called + _, err := generator.parseVariablesFile("test.tf", map[string]bool{}) - // Then an error should be returned + // Then an error should occur if err == nil { - t.Fatalf("expected an error, got nil") + t.Error("expected error, got nil") } - - // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to parse variables.tf") { - t.Errorf("expected error to contain 'failed to parse variables.tf', got %v", err) + expectedError := "failed to read variables.tf: read error" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) } }) +} - t.Run("ErrorWritingMainTf", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) +// ============================================================================= +// Test Helpers +// ============================================================================= - // And ReadFile is mocked to return content for variables.tf - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "test" { - description = "Test variable" - type = string -}`), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) - } +func TestConvertToCtyValue(t *testing.T) { + tests := []struct { + name string + input any + expected cty.Value + }{ + { + name: "String", + input: "test", + expected: cty.StringVal("test"), + }, + { + name: "Int", + input: 42, + expected: cty.NumberIntVal(42), + }, + { + name: "Float64", + input: 42.5, + expected: cty.NumberFloatVal(42.5), + }, + { + name: "Bool", + input: true, + expected: cty.BoolVal(true), + }, + { + name: "EmptyList", + input: []any{}, + expected: cty.ListValEmpty(cty.DynamicPseudoType), + }, + { + name: "List", + input: []any{"item1", "item2"}, + expected: cty.ListVal([]cty.Value{cty.StringVal("item1"), cty.StringVal("item2")}), + }, + { + name: "Map", + input: map[string]any{"key": "value"}, + expected: cty.ObjectVal(map[string]cty.Value{"key": cty.StringVal("value")}), + }, + { + name: "Unsupported", + input: struct{}{}, + expected: cty.NilVal, + }, + { + name: "Nil", + input: nil, + expected: cty.NilVal, + }, + } - // And WriteFile is mocked to fail only for main.tf - mocks.Shims.WriteFile = func(path string, _ []byte, _ fs.FileMode) error { - if strings.HasSuffix(path, "main.tf") { - return fmt.Errorf("mock error writing main.tf") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertToCtyValue(tt.input) + if !result.RawEquals(tt.expected) { + t.Errorf("expected %#v, got %#v", tt.expected, result) } - return nil // Success for variables.tf - } - - // When writeShimVariablesTf is called - err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - - // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to write shim main.tf") { - t.Errorf("expected error to contain 'failed to write shim main.tf', got %v", err) - } - }) + }) + } } -func TestTerraformGenerator_writeShimOutputsTf(t *testing.T) { - setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { - mocks := setupMocks(t) - generator := NewTerraformGenerator(mocks.Injector) - generator.shims = mocks.Shims - if err := generator.Initialize(); err != nil { - t.Fatalf("failed to initialize TerraformGenerator: %v", err) - } - return generator, mocks +func TestConvertFromCtyValue(t *testing.T) { + tests := []struct { + name string + input cty.Value + expected any + }{ + { + name: "String", + input: cty.StringVal("test"), + expected: "test", + }, + { + name: "Int", + input: cty.NumberIntVal(42), + expected: int64(42), + }, + { + name: "Float", + input: cty.NumberFloatVal(42.5), + expected: float64(42.5), + }, + { + name: "NumberBigFloat", + input: cty.NumberVal(big.NewFloat(42.5)), + expected: float64(42.5), + }, + { + name: "Bool", + input: cty.BoolVal(true), + expected: true, + }, + { + name: "List", + input: cty.ListVal([]cty.Value{cty.StringVal("item1"), cty.StringVal("item2")}), + expected: []any{"item1", "item2"}, + }, + { + name: "EmptyList", + input: cty.ListValEmpty(cty.String), + expected: []any(nil), + }, + { + name: "Map", + input: cty.MapVal(map[string]cty.Value{"key": cty.StringVal("value")}), + expected: map[string]any{"key": "value"}, + }, + { + name: "EmptyMap", + input: cty.MapValEmpty(cty.String), + expected: map[string]any{}, + }, + { + name: "Object", + input: cty.ObjectVal(map[string]cty.Value{"key": cty.StringVal("value")}), + expected: map[string]any{"key": "value"}, + }, + { + name: "Null", + input: cty.NullVal(cty.String), + expected: nil, + }, + { + name: "Unknown", + input: cty.UnknownVal(cty.String), + expected: nil, + }, + { + name: "Set", + input: cty.SetVal([]cty.Value{cty.StringVal("item1"), cty.StringVal("item2")}), + expected: []any{"item1", "item2"}, + }, + { + name: "Tuple", + input: cty.TupleVal([]cty.Value{cty.StringVal("item1"), cty.NumberIntVal(42)}), + expected: []any{"item1", int64(42)}, + }, + { + name: "UnsupportedType", + input: cty.DynamicVal, + expected: nil, + }, } - t.Run("ErrorReadingOutputs", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And Stat is mocked to return success for outputs.tf - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.HasSuffix(path, "outputs.tf") { - return nil, nil + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertFromCtyValue(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("expected %#v (%T), got %#v (%T)", tt.expected, tt.expected, result, result) } - return nil, os.ErrNotExist - } + }) + } +} - // And ReadFile is mocked to return an error for outputs.tf - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - if strings.HasSuffix(path, "outputs.tf") { - return nil, fmt.Errorf("mock error reading outputs.tf") - } - return nil, fmt.Errorf("unexpected file read: %s", path) +func TestFormatValue(t *testing.T) { + t.Run("EmptyArray", func(t *testing.T) { + result := formatValue([]any{}) + if result != "[]" { + t.Errorf("expected [] got %q", result) } + }) - // When writeShimOutputsTf is called - err := generator.writeShimOutputsTf("test_dir", "module_path") - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") + t.Run("EmptyMap", func(t *testing.T) { + result := formatValue(map[string]any{}) + if result != "{}" { + t.Errorf("expected {} got %q", result) } + }) - // And the error should match the expected error - expectedError := "failed to read outputs.tf: mock error reading outputs.tf" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) + t.Run("NilValue", func(t *testing.T) { + result := formatValue(nil) + if result != "null" { + t.Errorf("expected null got %q", result) } }) -} - -// ============================================================================= -// Test Template Processing Methods -// ============================================================================= -func TestTerraformGenerator_processTemplates(t *testing.T) { - setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { - mocks := setupMocks(t) - generator := NewTerraformGenerator(mocks.Injector) - generator.shims = mocks.Shims - if err := generator.Initialize(); err != nil { - t.Fatalf("failed to initialize TerraformGenerator: %v", err) + t.Run("ComplexNestedObject", func(t *testing.T) { + input := map[string]any{ + "node_groups": map[string]any{ + "default": map[string]any{ + "instance_types": []any{"t3.medium"}, + "min_size": 1, + "max_size": 3, + "desired_size": 2, + }, + }, } - return generator, mocks - } - - t.Run("TemplateDirectoryNotExists", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And Stat is mocked to return os.ErrNotExist for template directory - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.Contains(path, "_template") { - return nil, os.ErrNotExist - } - return nil, nil + expected := `{ + node_groups = { + default = { + desired_size = 2 + instance_types = ["t3.medium"] + max_size = 3 + min_size = 1 + } + } +}` + result := formatValue(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) } + }) - // When processTemplates is called - result, err := generator.processTemplates(false) - - // Then no error should occur and result should be nil - if err != nil { - t.Errorf("expected no error, got %v", err) + t.Run("EmptyAddons", func(t *testing.T) { + input := map[string]any{ + "addons": map[string]any{ + "vpc-cni": map[string]any{}, + "aws-efs-csi-driver": map[string]any{}, + "aws-ebs-csi-driver": map[string]any{}, + "eks-pod-identity-agent": map[string]any{}, + "coredns": map[string]any{}, + "external-dns": map[string]any{}, + }, } - if result != nil { - t.Errorf("expected nil result, got %v", result) + expected := `{ + addons = { + aws-ebs-csi-driver = {} + aws-efs-csi-driver = {} + coredns = {} + eks-pod-identity-agent = {} + external-dns = {} + vpc-cni = {} + } +}` + result := formatValue(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) } }) +} - t.Run("ErrorCheckingTemplateDirectory", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And Stat is mocked to return an error for template directory - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.Contains(path, "_template") { - return nil, fmt.Errorf("permission denied") - } - return nil, nil - } - - // When processTemplates is called - result, err := generator.processTemplates(false) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - if result != nil { - t.Errorf("expected nil result, got %v", result) +func TestWriteComponentValues(t *testing.T) { + t.Run("BasicComponentValues", func(t *testing.T) { + // Given a body and variables with component values + file := hclwrite.NewEmptyFile() + body := file.Body() + variables := []VariableInfo{ + { + Name: "var1", + Description: "Variable 1", + Sensitive: true, + Default: "default1", + }, + { + Name: "var2", + Description: "Variable 2", + Sensitive: false, + Default: "default2", + }, + { + Name: "var3", + Description: "Variable 3", + Default: "default3", + }, } - - // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to check template directory") { - t.Errorf("expected error to contain 'failed to check template directory', got %v", err) + values := map[string]any{ + "var2": "pinned_value", } - }) - - t.Run("ErrorGettingProjectRoot", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) + protectedValues := map[string]bool{} - // And Shell.GetProjectRoot is mocked to return an error - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("project root error") - } + // When writeComponentValues is called + writeComponentValues(body, values, protectedValues, variables) - // When processTemplates is called - result, err := generator.processTemplates(false) + // Then the variables should be written in order with proper handling of sensitive values + expected := ` +# Variable 1 +# var1 = "(sensitive)" - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } - if result != nil { - t.Errorf("expected nil result, got %v", result) - } +# Variable 2 +var2 = "pinned_value" - // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to get project root") { - t.Errorf("expected error to contain 'failed to get project root', got %v", err) +# Variable 3 +# var3 = "default3" +` + if string(file.Bytes()) != expected { + t.Errorf("expected %q, got %q", expected, string(file.Bytes())) } }) - t.Run("SuccessWithEmptyDirectory", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And Stat is mocked to return success for template directory - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.Contains(path, "_template") { - return nil, nil - } - return nil, nil - } - - // And ReadDir is mocked to return empty directory - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return []fs.DirEntry{}, nil + t.Run("ComplexDefaultsNodeGroups", func(t *testing.T) { + // Given a body and variables with complex nested default for node groups + file := hclwrite.NewEmptyFile() + variable := VariableInfo{ + Name: "node_groups", + Description: "Map of EKS managed node group definitions to create.", + Default: map[string]any{ + "default": map[string]any{ + "instance_types": []any{"t3.medium"}, + "min_size": 1, + "max_size": 3, + "desired_size": 2, + }, + }, } - // When processTemplates is called - result, err := generator.processTemplates(false) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } + // When writeComponentValues is called + writeComponentValues(file.Body(), map[string]any{}, map[string]bool{}, []VariableInfo{variable}) - // And result should be an empty map - if result == nil { - t.Errorf("expected non-nil result, got nil") + // Then the complex default should be commented out correctly + expected := ` +# Map of EKS managed node group definitions to create. +# node_groups = { +# default = { +# desired_size = 2 +# instance_types = ["t3.medium"] +# max_size = 3 +# min_size = 1 +# } +# }` + result := string(file.Bytes()) + // Check that every line is commented + for _, line := range strings.Split(result, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if !strings.HasPrefix(line, "#") { + t.Errorf("uncommented line found: %q", line) + } } - if len(result) != 0 { - t.Errorf("expected empty result, got %v", result) + // Check that the output matches expected ignoring leading/trailing whitespace + if strings.TrimSpace(result) != strings.TrimSpace(expected) { + t.Errorf("expected\n%s\ngot\n%s", expected, result) } }) - t.Run("ProcessesJsonnetFiles", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // Create a simple mock for fs.DirEntry with jsonnet file - mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} - - // And ReadDir is mocked to return a jsonnet file - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return []fs.DirEntry{mockEntry}, nil - } - - // Mock all dependencies for processJsonnetTemplate - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(`{ - test_var: "test_value" -}`), nil + t.Run("ComplexDefaultsEmptyAddons", func(t *testing.T) { + // Given a body and variables with complex empty map defaults for addons + file := hclwrite.NewEmptyFile() + variable := VariableInfo{ + Name: "addons", + Description: "Map of EKS add-ons", + Default: map[string]any{ + "vpc-cni": map[string]any{}, + "aws-efs-csi-driver": map[string]any{}, + "aws-ebs-csi-driver": map[string]any{}, + "eks-pod-identity-agent": map[string]any{}, + "coredns": map[string]any{}, + "external-dns": map[string]any{}, + }, } - mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { - return []byte("test: config"), nil - } + // When writeComponentValues is called + writeComponentValues(file.Body(), map[string]any{}, map[string]bool{}, []VariableInfo{variable}) - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - if configMap, ok := v.(*map[string]any); ok { - *configMap = map[string]any{"test": "config"} - } - return nil + // Then the complex empty map defaults should be commented correctly + expected := "\n# Map of EKS add-ons\n# addons = {\n# aws-ebs-csi-driver = {}\n# aws-efs-csi-driver = {}\n# coredns = {}\n# eks-pod-identity-agent = {}\n# external-dns = {}\n# vpc-cni = {}\n# }\n" + result := string(file.Bytes()) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) } + }) +} - mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { - return []byte(`{"test": "config", "name": "test-context"}`), nil - } +func TestWriteDefaultValues(t *testing.T) { + // Given a body and variables with default values + file := hclwrite.NewEmptyFile() + body := file.Body() + variables := []VariableInfo{ + { + Name: "var1", + Description: "Variable 1", + Sensitive: true, + Default: "default1", + }, + { + Name: "var2", + Description: "Variable 2", + Sensitive: false, + Default: "default2", + }, + { + Name: "var3", + Description: "Variable 3", + Default: "default3", + }, + } - mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { - if values, ok := v.(*map[string]any); ok { - *values = map[string]any{"test_var": "test_value"} - } - return nil - } + // When writeDefaultValues is called + writeDefaultValues(body, variables, nil) - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/tmp", nil - } + // Then the variables should be written in order with proper handling of sensitive values + expected := ` +# Variable 1 +# var1 = "(sensitive)" - templateValues := make(map[string]map[string]any) +# Variable 2 +# var2 = "default2" - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/tmp/contexts/_template/terraform", "/context/path", "test-context", false, templateValues) +# Variable 3 +# var3 = "default3" +` + if string(file.Bytes()) != expected { + t.Errorf("expected %q, got %q", expected, string(file.Bytes())) + } +} - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) +func TestWriteVariable(t *testing.T) { + t.Run("SensitiveVariable", func(t *testing.T) { + // Given a body and variables with a sensitive variable + file := hclwrite.NewEmptyFile() + body := file.Body() + variables := []VariableInfo{ + { + Name: "test_var", + Description: "Test variable", + Sensitive: true, + }, } - // And template values should contain the processed template - if len(templateValues) != 1 { - t.Errorf("expected 1 template value, got %d", len(templateValues)) + // When writeVariable is called + writeVariable(body, "test_var", "value", variables) + + // Then the variable should be commented out with (sensitive) + expected := `# Test variable +# test_var = "(sensitive)" +` + if string(file.Bytes()) != expected { + t.Errorf("expected %q, got %q", expected, string(file.Bytes())) } }) - t.Run("ErrorProcessingJsonnetTemplate", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) + t.Run("NonSensitiveVariable", func(t *testing.T) { + // Given a body and variables with a non-sensitive variable + file := hclwrite.NewEmptyFile() + body := file.Body() + variables := []VariableInfo{ + { + Name: "test_var", + Description: "Test variable", + Sensitive: false, + }, + } - // Create a simple mock for fs.DirEntry with jsonnet file - mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} + // When writeVariable is called + writeVariable(body, "test_var", "value", variables) - // And ReadDir is mocked to return a jsonnet file - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return []fs.DirEntry{mockEntry}, nil + // Then the variable should be written with its value + expected := `# Test variable +test_var = "value" +` + if string(file.Bytes()) != expected { + t.Errorf("expected %q, got %q", expected, string(file.Bytes())) } + }) - // And ReadFile is mocked to return an error for processJsonnetTemplate - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return nil, fmt.Errorf("file read error") + t.Run("VariableWithComment", func(t *testing.T) { + // Given a body and variables with a variable with comment + file := hclwrite.NewEmptyFile() + body := file.Body() + variables := []VariableInfo{ + { + Name: "test_var", + Description: "Test variable description", + }, } - templateValues := make(map[string]map[string]any) - - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) - - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") - } + // When writeVariable is called + writeVariable(body, "test_var", "value", variables) - // And the error should contain the expected message - if !strings.Contains(err.Error(), "error reading template file") { - t.Errorf("expected error to contain 'error reading template file', got %v", err) + // Then the variable should be written with its comment + expected := `# Test variable description +test_var = "value" +` + if string(file.Bytes()) != expected { + t.Errorf("expected %q, got %q", expected, string(file.Bytes())) } }) - t.Run("RecursivelyProcessesDirectories", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - callCount := 0 - // Create mock directory entry - mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} + t.Run("YAMLMultilineValue", func(t *testing.T) { + // Given a body and variables with a YAML multiline value + file := hclwrite.NewEmptyFile() + body := file.Body() + variables := []VariableInfo{ + { + Name: "worker_config_patches", + Description: "Worker configuration patches", + }, + } - // And ReadDir is mocked to return a directory on first call, empty on second - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - callCount++ - if callCount == 1 { - return []fs.DirEntry{mockDirEntry}, nil + // When writeVariable is called with a YAML multiline string + yamlValue := `machine: + kubelet: + extraMounts: + - destination: /var/local + options: + - rbind + - rw + source: /var/local + type: bind` + writeVariable(body, "worker_config_patches", yamlValue, variables) + + // Then the variable should be written as a heredoc with valid YAML + actual := string(file.Bytes()) + + // Extract the YAML content from the heredoc + lines := strings.Split(actual, "\n") + var yamlContent strings.Builder + inYAML := false + for _, line := range lines { + if strings.Contains(line, "< 0 && extractedFiles[0] != "/project/.windsor/.oci_extracted/test-extraction-key/terraform/test/module/main.tf" { + t.Errorf("expected only test/module/main.tf to be extracted, got %v", extractedFiles) } + }) - templateValues := make(map[string]map[string]any) + t.Run("DirectoryCreationError", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) - // When processJsonnetTemplate is called - err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + // And a tar archive with directory entries + testDirs := []string{"terraform/test/module"} + tarData := createTestTarData(map[string]string{}, testDirs) + + mocks.Shims.MkdirAll = func(path string, perm fs.FileMode) error { + return fmt.Errorf("permission denied") + } + mocks.Shims.Chmod = func(name string, mode os.FileMode) error { + return nil // Won't be called due to directory creation error + } + + // When extractModuleFromArtifact is called + err := generator.extractModuleFromArtifact(tarData, "test/module", "test-extraction-key") // Then an error should be returned if err == nil { @@ -3356,54 +4879,33 @@ func TestTerraformGenerator_processJsonnetTemplate(t *testing.T) { } // And the error should contain the expected message - if !strings.Contains(err.Error(), "jsonnet template must output valid JSON") { - t.Errorf("expected error to contain 'jsonnet template must output valid JSON', got %v", err) + if !strings.Contains(err.Error(), "failed to create directory") { + t.Errorf("expected error to contain 'failed to create directory', got %v", err) } }) - t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + t.Run("FileCreationError", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And Shell.GetProjectRoot is mocked to return an error - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("project root error") - } - - // And ReadFile is mocked to return valid jsonnet content - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(`{ - test_var: "test_value" -}`), nil - } - - // Mock the required dependencies - mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { - return []byte("test: config"), nil + // And a tar archive with file entries + testFiles := map[string]string{ + "terraform/test/module/main.tf": "module content", } + tarData := createTestTarData(testFiles, []string{}) - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - if configMap, ok := v.(*map[string]any); ok { - *configMap = map[string]any{"test": "config"} - } + mocks.Shims.MkdirAll = func(path string, perm fs.FileMode) error { return nil } - - mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { - return []byte(`{"test": "config", "name": "test-context"}`), nil + mocks.Shims.Create = func(path string) (*os.File, error) { + return nil, fmt.Errorf("permission denied") } - - mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { - if values, ok := v.(*map[string]any); ok { - *values = map[string]any{"test_var": "test_value"} - } - return nil + mocks.Shims.Chmod = func(name string, mode os.FileMode) error { + return nil // Won't be called due to file creation error } - templateValues := make(map[string]map[string]any) - - // When processJsonnetTemplate is called - err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + // When extractModuleFromArtifact is called + err := generator.extractModuleFromArtifact(tarData, "test/module", "test-extraction-key") // Then an error should be returned if err == nil { @@ -3411,59 +4913,36 @@ func TestTerraformGenerator_processJsonnetTemplate(t *testing.T) { } // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to get project root") { - t.Errorf("expected error to contain 'failed to get project root', got %v", err) + if !strings.Contains(err.Error(), "failed to create file") { + t.Errorf("expected error to contain 'failed to create file', got %v", err) } }) - t.Run("ErrorCalculatingRelativePath", func(t *testing.T) { + t.Run("FileCopyError", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And Shell.GetProjectRoot is mocked to return expected path - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/tmp", nil - } - - // And ReadFile is mocked to return valid jsonnet content - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(`{ - test_var: "test_value" -}`), nil - } - - // Mock the required dependencies - mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { - return []byte("test: config"), nil + // And a tar archive with file entries + testFiles := map[string]string{ + "terraform/test/module/main.tf": "module content", } + tarData := createTestTarData(testFiles, []string{}) - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - if configMap, ok := v.(*map[string]any); ok { - *configMap = map[string]any{"test": "config"} - } + mocks.Shims.MkdirAll = func(path string, perm fs.FileMode) error { return nil } - - mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { - return []byte(`{"test": "config", "name": "test-context"}`), nil + mocks.Shims.Create = func(path string) (*os.File, error) { + return &os.File{}, nil } - - mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { - if values, ok := v.(*map[string]any); ok { - *values = map[string]any{"test_var": "test_value"} - } - return nil + mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { + return 0, fmt.Errorf("write error") } - - // And FilepathRel is mocked to return an error - mocks.Shims.FilepathRel = func(basepath, targpath string) (string, error) { - return "", fmt.Errorf("relative path calculation error") + mocks.Shims.Chmod = func(name string, mode os.FileMode) error { + return nil // Won't be called due to copy error } - templateValues := make(map[string]map[string]any) - - // When processJsonnetTemplate is called - err := generator.processJsonnetTemplate("/template/test.jsonnet", "test-context", templateValues) + // When extractModuleFromArtifact is called + err := generator.extractModuleFromArtifact(tarData, "test/module", "test-extraction-key") // Then an error should be returned if err == nil { @@ -3471,172 +4950,151 @@ func TestTerraformGenerator_processJsonnetTemplate(t *testing.T) { } // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to calculate relative path") { - t.Errorf("expected error to contain 'failed to calculate relative path', got %v", err) + if !strings.Contains(err.Error(), "failed to write file") { + t.Errorf("expected error to contain 'failed to write file', got %v", err) } }) - t.Run("SuccessProcessingTemplate", func(t *testing.T) { + t.Run("EmptyTarArchive", func(t *testing.T) { // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And Shell.GetProjectRoot is mocked to return expected path - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/tmp", nil - } - - // And ReadFile is mocked to return valid jsonnet content - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(`local context = std.extVar("context"); -{ - test_var: context.test, - context_name: context.name -}`), nil - } - - // Mock the required dependencies - mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { - return []byte("test: config"), nil - } + generator, _ := setup(t) - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - if configMap, ok := v.(*map[string]any); ok { - *configMap = map[string]any{"test": "config"} - } - return nil - } + // And an empty tar archive + tarData := createTestTarData(map[string]string{}, []string{}) - mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { - return []byte(`{"test": "config", "name": "test-context"}`), nil - } + // When extractModuleFromArtifact is called + err := generator.extractModuleFromArtifact(tarData, "test/module", "test-extraction-key") - mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { - if values, ok := v.(*map[string]any); ok { - *values = map[string]any{ - "test_var": "config", - "context_name": "test-context", - } - } - return nil + // Then no error should occur (empty extraction is valid) + if err != nil { + t.Errorf("expected no error, got %v", err) } + }) - templateValues := make(map[string]map[string]any) + t.Run("InvalidTarData", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, _ := setup(t) - // When processJsonnetTemplate is called - err := generator.processJsonnetTemplate("/tmp/contexts/_template/terraform/test.jsonnet", "test-context", templateValues) + // And invalid tar data + invalidTarData := []byte("invalid tar data") - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } + // When extractModuleFromArtifact is called + err := generator.extractModuleFromArtifact(invalidTarData, "test/module", "/project") - // And template values should contain the processed template - if len(templateValues) != 1 { - t.Errorf("expected 1 template value, got %d", len(templateValues)) - } - if _, exists := templateValues["test"]; !exists { - t.Errorf("expected template values to contain 'test' key") + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") } - // And the values should match expected content - values := templateValues["test"] - if values["test_var"] != "config" { - t.Errorf("expected test_var to be 'config', got %v", values["test_var"]) - } - if values["context_name"] != "test-context" { - t.Errorf("expected context_name to be 'test-context', got %v", values["context_name"]) + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to read tar header") { + t.Errorf("expected error to contain 'failed to read tar header', got %v", err) } }) - t.Run("SuccessWithNestedPath", func(t *testing.T) { + t.Run("FilePermissionsPreserved", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And Shell.GetProjectRoot is mocked to return expected path - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/tmp", nil - } - - // And ReadFile is mocked to return valid jsonnet content - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(`{ - nested_var: "nested_value" -}`), nil - } - - // Mock the required dependencies - mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { - return []byte("test: config"), nil + // And a tar archive with executable files + testFiles := map[string]string{ + "terraform/test/module/main.tf": "module content", + "terraform/test/module/scripts/run.sh": "#!/bin/bash\necho 'hello'", } + tarData := createTestTarData(testFiles, []string{}) - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - if configMap, ok := v.(*map[string]any); ok { - *configMap = map[string]any{"test": "config"} - } + // And mocks for file operations + mocks.Shims.MkdirAll = func(path string, perm fs.FileMode) error { return nil } - - mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { - return []byte(`{"test": "config", "name": "test-context"}`), nil + mocks.Shims.Create = func(path string) (*os.File, error) { + return &os.File{}, nil + } + mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { + return 100, nil } - mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { - if values, ok := v.(*map[string]any); ok { - *values = map[string]any{"nested_var": "nested_value"} - } + // And track chmod calls + chmodCalls := make(map[string]os.FileMode) + mocks.Shims.Chmod = func(name string, mode os.FileMode) error { + chmodCalls[name] = mode return nil } - templateValues := make(map[string]map[string]any) - - // When processJsonnetTemplate is called with nested path - err := generator.processJsonnetTemplate("/tmp/contexts/_template/terraform/nested/path/component.jsonnet", "test-context", templateValues) + // When extractModuleFromArtifact is called + err := generator.extractModuleFromArtifact(tarData, "test/module", "test-extraction-key") // Then no error should occur if err != nil { t.Errorf("expected no error, got %v", err) } - // And template values should contain the processed template with nested key - if len(templateValues) != 1 { - t.Errorf("expected 1 template value, got %d", len(templateValues)) + // And chmod should have been called for all files + if len(chmodCalls) == 0 { + t.Errorf("expected chmod to be called for extracted files") } - if _, exists := templateValues["nested/path/component"]; !exists { - t.Errorf("expected template values to contain 'nested/path/component' key") + + // And permissions should be preserved (the test tar data creates files with 0644) + for path, mode := range chmodCalls { + if !strings.Contains(path, "/project/.windsor/.oci_extracted/test-extraction-key/terraform/test/module/") { + t.Errorf("unexpected file path in chmod call: %s", path) + } + // createTestTarData creates regular files with 0644 mode + // but .sh files get execute permissions added (0755) + if strings.HasSuffix(path, ".sh") { + if mode != 0755 { + t.Errorf("expected file mode 0755 for .sh file, got %o for file %s", mode, path) + } + } else { + if mode != 0644 { + t.Errorf("expected file mode 0644, got %o for file %s", mode, path) + } + } } }) -} -// ============================================================================= -// Test Helpers -// ============================================================================= + t.Run("ChmodError", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) -// simpleDirEntry implements fs.DirEntry for testing -type simpleDirEntry struct { - name string - isDir bool -} + // And a tar archive with files + testFiles := map[string]string{ + "terraform/test/module/main.tf": "module content", + } + tarData := createTestTarData(testFiles, []string{}) -func (s *simpleDirEntry) Name() string { - return s.name -} + // And mocks for file operations + mocks.Shims.MkdirAll = func(path string, perm fs.FileMode) error { + return nil + } + mocks.Shims.Create = func(path string) (*os.File, error) { + return &os.File{}, nil + } + mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { + return 100, nil + } -func (s *simpleDirEntry) IsDir() bool { - return s.isDir -} + // And chmod returns an error + mocks.Shims.Chmod = func(name string, mode os.FileMode) error { + return fmt.Errorf("permission denied") + } -func (s *simpleDirEntry) Type() fs.FileMode { - if s.isDir { - return fs.ModeDir - } - return 0 -} + // When extractModuleFromArtifact is called + err := generator.extractModuleFromArtifact(tarData, "test/module", "test-extraction-key") -func (s *simpleDirEntry) Info() (fs.FileInfo, error) { - return nil, nil + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to set file permissions") { + t.Errorf("expected error to contain 'failed to set file permissions', got %v", err) + } + }) } -func TestTerraformGenerator_walkTemplateDirectory(t *testing.T) { +func TestTerraformGenerator_preloadOCIArtifacts(t *testing.T) { setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { mocks := setupMocks(t) generator := NewTerraformGenerator(mocks.Injector) @@ -3647,207 +5105,236 @@ func TestTerraformGenerator_walkTemplateDirectory(t *testing.T) { return generator, mocks } - t.Run("ErrorReadingDirectory", func(t *testing.T) { + t.Run("NoOCISources", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And ReadDir is mocked to return an error - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return nil, fmt.Errorf("permission denied") + // And GetSources returns no OCI sources + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "git-source", Url: "https://github.com/example/repo.git"}, + {Name: "local-source", Url: "file:///local/path"}, + } } - templateValues := make(map[string]map[string]any) - - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to read template directory") { - t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + // And an empty map should be returned + if len(artifacts) != 0 { + t.Errorf("expected empty artifacts map, got %d items", len(artifacts)) } }) - t.Run("IgnoresNonJsonnetFiles", func(t *testing.T) { + t.Run("EmptySourcesList", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // Create a simple mock for fs.DirEntry - mockEntry := &simpleDirEntry{name: "test.txt", isDir: false} - - // And ReadDir is mocked to return a non-jsonnet file - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return []fs.DirEntry{mockEntry}, nil + // And GetSources returns empty list + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{} } - templateValues := make(map[string]map[string]any) - - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() // Then no error should occur if err != nil { t.Errorf("expected no error, got %v", err) } - // And template values should be empty - if len(templateValues) != 0 { - t.Errorf("expected 0 template values, got %d", len(templateValues)) + // And an empty map should be returned + if len(artifacts) != 0 { + t.Errorf("expected empty artifacts map, got %d items", len(artifacts)) } }) - t.Run("ProcessesJsonnetFiles", func(t *testing.T) { + t.Run("SingleOCISourceSuccess", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // Create a simple mock for fs.DirEntry with jsonnet file - mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} - - // And ReadDir is mocked to return a jsonnet file - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return []fs.DirEntry{mockEntry}, nil + // And GetSources returns one OCI source + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "oci-source", Url: "oci://registry.example.com/my-repo:v1.0.0"}, + } } - // Mock all dependencies for processJsonnetTemplate - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(`{ - test_var: "test_value" -}`), nil - } + // And terraform generator specific mocks are set up + setupTerraformGeneratorMocks(mocks) - mocks.ConfigHandler.(*config.MockConfigHandler).YamlMarshalWithDefinedPathsFunc = func(config any) ([]byte, error) { - return []byte("test: config"), nil - } + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() - mocks.Shims.YamlUnmarshal = func(data []byte, v any) error { - if configMap, ok := v.(*map[string]any); ok { - *configMap = map[string]any{"test": "config"} - } - return nil + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - mocks.Shims.JsonMarshal = func(v any) ([]byte, error) { - return []byte(`{"test": "config", "name": "test-context"}`), nil + // And the artifacts map should contain the cached data + expectedKey := "registry.example.com/my-repo:v1.0.0" + if len(artifacts) != 1 { + t.Errorf("expected 1 artifact, got %d", len(artifacts)) } + expectedData := []byte("test artifact data") + if !bytes.Equal(artifacts[expectedKey], expectedData) { + t.Errorf("expected artifact data to match test data") + } + }) - mocks.Shims.JsonUnmarshal = func(data []byte, v any) error { - if values, ok := v.(*map[string]any); ok { - *values = map[string]any{"test_var": "test_value"} + t.Run("MultipleOCISourcesDifferentArtifacts", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And GetSources returns multiple different OCI sources + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "oci-source1", Url: "oci://registry.example.com/repo1:v1.0.0"}, + {Name: "oci-source2", Url: "oci://registry.example.com/repo2:v2.0.0"}, } - return nil } - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "/tmp", nil + // And terraform generator specific mocks are set up with counter + downloadCallCount := 0 + setupTerraformGeneratorMocks(mocks) + // Override LayerUncompressed for counter behavior + mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + downloadCallCount++ + data := []byte(fmt.Sprintf("test artifact data %d", downloadCallCount)) + return io.NopCloser(bytes.NewReader(data)), nil } - templateValues := make(map[string]map[string]any) - - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/tmp/contexts/_template/terraform", "/context/path", "test-context", false, templateValues) + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() // Then no error should occur if err != nil { t.Errorf("expected no error, got %v", err) } - // And template values should contain the processed template - if len(templateValues) != 1 { - t.Errorf("expected 1 template value, got %d", len(templateValues)) + // And the artifacts map should contain both entries + if len(artifacts) != 2 { + t.Errorf("expected 2 artifacts, got %d", len(artifacts)) + } + + // And both downloads should have happened + if downloadCallCount != 2 { + t.Errorf("expected 2 download calls, got %d", downloadCallCount) + } + + // And both artifacts should be present + key1 := "registry.example.com/repo1:v1.0.0" + key2 := "registry.example.com/repo2:v2.0.0" + if _, exists := artifacts[key1]; !exists { + t.Errorf("expected artifact %s to exist", key1) + } + if _, exists := artifacts[key2]; !exists { + t.Errorf("expected artifact %s to exist", key2) } }) - t.Run("ErrorProcessingJsonnetTemplate", func(t *testing.T) { + t.Run("MultipleOCISourcesSameArtifact", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // Create a simple mock for fs.DirEntry with jsonnet file - mockEntry := &simpleDirEntry{name: "test.jsonnet", isDir: false} - - // And ReadDir is mocked to return a jsonnet file - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return []fs.DirEntry{mockEntry}, nil + // And GetSources returns multiple sources pointing to same OCI artifact + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "oci-source1", Url: "oci://registry.example.com/my-repo:v1.0.0"}, + {Name: "oci-source2", Url: "oci://registry.example.com/my-repo:v1.0.0"}, + {Name: "git-source", Url: "https://github.com/example/repo.git"}, + } } - // And ReadFile is mocked to return an error for processJsonnetTemplate - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return nil, fmt.Errorf("file read error") + // And terraform generator specific mocks are set up with counter + testData := []byte("test artifact data") + downloadCallCount := 0 + setupTerraformGeneratorMocks(mocks) + // Override LayerUncompressed for counter behavior + mocks.Shims.LayerUncompressed = func(layer v1.Layer) (io.ReadCloser, error) { + downloadCallCount++ + return io.NopCloser(bytes.NewReader(testData)), nil } - templateValues := make(map[string]map[string]any) + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } - // Then an error should be returned - if err == nil { - t.Fatalf("expected an error, got nil") + // And the artifacts map should contain only one entry (deduplicated) + if len(artifacts) != 1 { + t.Errorf("expected 1 artifact, got %d", len(artifacts)) } - // And the error should contain the expected message - if !strings.Contains(err.Error(), "error reading template file") { - t.Errorf("expected error to contain 'error reading template file', got %v", err) + // And the download should only happen once (caching works) + if downloadCallCount != 1 { + t.Errorf("expected 1 download call, got %d", downloadCallCount) + } + + expectedKey := "registry.example.com/my-repo:v1.0.0" + if !bytes.Equal(artifacts[expectedKey], testData) { + t.Errorf("expected artifact data to match test data") } }) - t.Run("RecursivelyProcessesDirectories", func(t *testing.T) { + t.Run("ErrorParsingOCIReference", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - callCount := 0 - // Create mock directory entry - mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} - - // And ReadDir is mocked to return a directory on first call, empty on second - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - callCount++ - if callCount == 1 { - return []fs.DirEntry{mockDirEntry}, nil + // And GetSources returns an invalid OCI source (missing repository part) + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "invalid-oci", Url: "oci://registry.example.com:v1.0.0"}, } - return []fs.DirEntry{}, nil // Empty subdirectory } - templateValues := make(map[string]map[string]any) + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) + // And the error should contain the expected message + if !strings.Contains(err.Error(), "failed to parse OCI reference for source invalid-oci") { + t.Errorf("expected error to contain 'failed to parse OCI reference for source invalid-oci', got %v", err) } - // And ReadDir should have been called twice (once for root, once for subdir) - if callCount != 2 { - t.Errorf("expected ReadDir to be called 2 times, got %d", callCount) + // And artifacts should be nil + if artifacts != nil { + t.Errorf("expected nil artifacts, got %v", artifacts) } }) - t.Run("ErrorInRecursiveDirectoryCall", func(t *testing.T) { + t.Run("ErrorDownloadingOCIArtifact", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - callCount := 0 - // Create mock directory entry - mockDirEntry := &simpleDirEntry{name: "subdir", isDir: true} - - // And ReadDir is mocked to return a directory on first call, error on second - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - callCount++ - if callCount == 1 { - return []fs.DirEntry{mockDirEntry}, nil + // And GetSources returns a valid OCI source + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "oci-source", Url: "oci://registry.example.com/my-repo:v1.0.0"}, } - return nil, fmt.Errorf("subdirectory read error") } - templateValues := make(map[string]map[string]any) + // And terraform generator specific mocks are set up with error + setupTerraformGeneratorMocks(mocks) + // Override ParseReference to return an error + mocks.Shims.ParseReference = func(ref string, opts ...name.Option) (name.Reference, error) { + return nil, fmt.Errorf("mock download error") + } - // When walkTemplateDirectory is called - err := generator.walkTemplateDirectory("/template/dir", "/context/path", "test-context", false, templateValues) + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() // Then an error should be returned if err == nil { @@ -3855,95 +5342,113 @@ func TestTerraformGenerator_walkTemplateDirectory(t *testing.T) { } // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to read template directory") { - t.Errorf("expected error to contain 'failed to read template directory', got %v", err) + if !strings.Contains(err.Error(), "failed to download OCI artifact for source oci-source") { + t.Errorf("expected error to contain 'failed to download OCI artifact for source oci-source', got %v", err) + } + + // And artifacts should be nil + if artifacts != nil { + t.Errorf("expected nil artifacts, got %v", artifacts) } }) - t.Run("ErrorGettingConfigRoot", func(t *testing.T) { + t.Run("ErrorFromRemoteImage", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And template directory exists - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.Contains(path, "_template") { - return nil, nil + // And GetSources returns a valid OCI source + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "oci-source", Url: "oci://registry.example.com/my-repo:v1.0.0"}, } - return nil, nil } - // And GetConfigRoot is mocked to return an error - mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return "", fmt.Errorf("config root error") + // And terraform generator specific mocks are set up with RemoteImage error + setupTerraformGeneratorMocks(mocks) + // Override RemoteImage to return an error + mocks.Shims.RemoteImage = func(ref name.Reference, options ...remote.Option) (v1.Image, error) { + return nil, fmt.Errorf("remote image error") } - // When processTemplates is called - result, err := generator.processTemplates(false) + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() // Then an error should be returned if err == nil { t.Fatalf("expected an error, got nil") } - if result != nil { - t.Errorf("expected nil result, got %v", result) - } // And the error should contain the expected message - if !strings.Contains(err.Error(), "failed to get config root") { - t.Errorf("expected error to contain 'failed to get config root', got %v", err) + if !strings.Contains(err.Error(), "failed to download OCI artifact for source oci-source") { + t.Errorf("expected error to contain 'failed to download OCI artifact for source oci-source', got %v", err) + } + + // And artifacts should be nil + if artifacts != nil { + t.Errorf("expected nil artifacts, got %v", artifacts) } }) - t.Run("ContextNameFromEnvironment", func(t *testing.T) { + t.Run("MixedSourceTypesWithOCI", func(t *testing.T) { // Given a TerraformGenerator with mocks generator, mocks := setup(t) - // And template directory exists - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.Contains(path, "_template") { - return nil, nil + // And GetSources returns mixed source types including one OCI source + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "git-source", Url: "https://github.com/example/repo.git"}, + {Name: "oci-source", Url: "oci://registry.example.com/my-repo:v1.0.0"}, + {Name: "local-source", Url: "file:///local/path"}, } - return nil, nil } - // And GetString returns empty string (no context configured) - mocks.ConfigHandler.(*config.MockConfigHandler).GetStringFunc = func(key string, defaultValue ...string) string { - if key == "context" { - return "" - } - return "" + // And terraform generator specific mocks are set up + setupTerraformGeneratorMocks(mocks) + + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) } - // And environment variable is set - originalEnv := os.Getenv("WINDSOR_CONTEXT") - defer func() { - if originalEnv == "" { - os.Unsetenv("WINDSOR_CONTEXT") - } else { - os.Setenv("WINDSOR_CONTEXT", originalEnv) - } - }() - os.Setenv("WINDSOR_CONTEXT", "env-context") + // And only the OCI artifact should be cached (non-OCI sources ignored) + if len(artifacts) != 1 { + t.Errorf("expected 1 artifact, got %d", len(artifacts)) + } - // And ReadDir is mocked to return empty directory - mocks.Shims.ReadDir = func(path string) ([]fs.DirEntry, error) { - return []fs.DirEntry{}, nil + expectedKey := "registry.example.com/my-repo:v1.0.0" + expectedData := []byte("test artifact data") + if !bytes.Equal(artifacts[expectedKey], expectedData) { + t.Errorf("expected artifact data to match test data") } + }) - // When processTemplates is called - result, err := generator.processTemplates(false) + t.Run("MixedSourceTypesNoOCI", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) - // Then no error should occur + // And GetSources returns mixed source types with no OCI sources + mocks.BlueprintHandler.GetSourcesFunc = func() []blueprintv1alpha1.Source { + return []blueprintv1alpha1.Source{ + {Name: "git-source", Url: "https://github.com/example/repo.git"}, + {Name: "local-source", Url: "file:///local/path"}, + {Name: "http-source", Url: "https://releases.example.com/module.tar.gz"}, + } + } + + // When preloadOCIArtifacts is called + artifacts, err := generator.preloadOCIArtifacts() + + // Then no error should occur (non-OCI sources should be ignored) if err != nil { t.Errorf("expected no error, got %v", err) } - // And result should be an empty map - if result == nil { - t.Errorf("expected non-nil result, got nil") - } - if len(result) != 0 { - t.Errorf("expected empty result, got %v", result) + // And artifacts map should be empty (no OCI sources to process) + if len(artifacts) != 0 { + t.Errorf("expected 0 artifacts, got %d", len(artifacts)) } }) } From 87798c1f94ca23dec58401204a00e151a5868284 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 3 Jul 2025 16:05:51 -0400 Subject: [PATCH 2/2] Fix windows and gosec --- pkg/generators/terraform_generator.go | 10 ++++-- pkg/generators/terraform_generator_test.go | 37 +++++++++++++--------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/pkg/generators/terraform_generator.go b/pkg/generators/terraform_generator.go index 502effcc0..305886d87 100644 --- a/pkg/generators/terraform_generator.go +++ b/pkg/generators/terraform_generator.go @@ -498,12 +498,18 @@ func (g *TerraformGenerator) extractModuleFromArtifact(artifactData []byte, modu } _, err = g.shims.Copy(file, tarReader) - file.Close() + if closeErr := file.Close(); closeErr != nil { + return fmt.Errorf("failed to close file %s: %w", destPath, closeErr) + } if err != nil { return fmt.Errorf("failed to write file %s: %w", destPath, err) } - fileMode := os.FileMode(header.Mode) + modeValue := header.Mode & 0777 + if modeValue < 0 || modeValue > 0777 { + return fmt.Errorf("invalid file mode %o for %s", header.Mode, destPath) + } + fileMode := os.FileMode(uint32(modeValue)) if strings.HasSuffix(destPath, ".sh") { fileMode |= 0111 diff --git a/pkg/generators/terraform_generator_test.go b/pkg/generators/terraform_generator_test.go index 9dc41dee2..8e7260003 100644 --- a/pkg/generators/terraform_generator_test.go +++ b/pkg/generators/terraform_generator_test.go @@ -178,6 +178,11 @@ func (m *mockDirEntry) Info() (os.FileInfo, error) { return &mockFileInfo{name: m.name, isDir: m.isDir}, nil } +// createMockFile creates a temporary file that can be used in tests +func createMockFile() (*os.File, error) { + return os.CreateTemp("", "mock-test-file-*") +} + // setupTerraformGeneratorMocks extends base mocks with terraform generator specific mocking func setupTerraformGeneratorMocks(mocks *Mocks) { // OCI-related mocks @@ -3411,8 +3416,8 @@ func TestTerraformGenerator_extractOCIModule(t *testing.T) { t.Errorf("expected no error, got %v", err) } expectedPath := "/tmp/project/.windsor/.oci_extracted/registry.example.com-modules-v1.0.0/terraform/test/module" - if result != expectedPath { - t.Errorf("expected path %s, got %s", expectedPath, result) + if filepath.ToSlash(result) != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, filepath.ToSlash(result)) } }) @@ -3483,8 +3488,8 @@ func TestTerraformGenerator_extractOCIModule(t *testing.T) { t.Errorf("expected no error, got %v", err) } expectedPath := "/tmp/project/.windsor/.oci_extracted/registry.example.com-modules-v1.0.0/terraform/test/module" - if result != expectedPath { - t.Errorf("expected path %s, got %s", expectedPath, result) + if filepath.ToSlash(result) != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, filepath.ToSlash(result)) } }) } @@ -4764,7 +4769,7 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { mocks.Shims.Create = func(path string) (*os.File, error) { extractedFiles = append(extractedFiles, path) // Return a mock file that doesn't actually write to disk - return &os.File{}, nil + return createMockFile() } mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { return 100, nil // Simulate successful copy @@ -4791,8 +4796,9 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { t.Errorf("expected %d files extracted, got %d", len(expectedFiles), len(extractedFiles)) } for _, extractedFile := range extractedFiles { - if !expectedFiles[extractedFile] { - t.Errorf("unexpected file extracted: %q", extractedFile) + normalizedPath := filepath.ToSlash(extractedFile) + if !expectedFiles[normalizedPath] { + t.Errorf("unexpected file extracted: %q", normalizedPath) } } @@ -4800,7 +4806,7 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { expectedDir := "/project/.windsor/.oci_extracted/test-extraction-key/terraform/test/module" found := false for _, dir := range createdDirs { - if dir == expectedDir { + if filepath.ToSlash(dir) == expectedDir { found = true break } @@ -4829,7 +4835,7 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { } mocks.Shims.Create = func(path string) (*os.File, error) { extractedFiles = append(extractedFiles, path) - return &os.File{}, nil + return createMockFile() } mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { return 100, nil @@ -4850,7 +4856,7 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { if len(extractedFiles) != 1 { t.Errorf("expected 1 file extracted, got %d", len(extractedFiles)) } - if len(extractedFiles) > 0 && extractedFiles[0] != "/project/.windsor/.oci_extracted/test-extraction-key/terraform/test/module/main.tf" { + if len(extractedFiles) > 0 && filepath.ToSlash(extractedFiles[0]) != "/project/.windsor/.oci_extracted/test-extraction-key/terraform/test/module/main.tf" { t.Errorf("expected only test/module/main.tf to be extracted, got %v", extractedFiles) } }) @@ -4932,7 +4938,7 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { return nil } mocks.Shims.Create = func(path string) (*os.File, error) { - return &os.File{}, nil + return createMockFile() } mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { return 0, fmt.Errorf("write error") @@ -5008,7 +5014,7 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { return nil } mocks.Shims.Create = func(path string) (*os.File, error) { - return &os.File{}, nil + return createMockFile() } mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { return 100, nil @@ -5036,8 +5042,9 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { // And permissions should be preserved (the test tar data creates files with 0644) for path, mode := range chmodCalls { - if !strings.Contains(path, "/project/.windsor/.oci_extracted/test-extraction-key/terraform/test/module/") { - t.Errorf("unexpected file path in chmod call: %s", path) + normalizedPath := filepath.ToSlash(path) + if !strings.Contains(normalizedPath, "/project/.windsor/.oci_extracted/test-extraction-key/terraform/test/module/") { + t.Errorf("unexpected file path in chmod call: %s", normalizedPath) } // createTestTarData creates regular files with 0644 mode // but .sh files get execute permissions added (0755) @@ -5068,7 +5075,7 @@ func TestTerraformGenerator_extractModuleFromArtifact(t *testing.T) { return nil } mocks.Shims.Create = func(path string) (*os.File, error) { - return &os.File{}, nil + return createMockFile() } mocks.Shims.Copy = func(dst io.Writer, src io.Reader) (int64, error) { return 100, nil