diff --git a/pkg/generators/generator_test.go b/pkg/generators/generator_test.go index 86d1a6f37..aec6e90f3 100644 --- a/pkg/generators/generator_test.go +++ b/pkg/generators/generator_test.go @@ -83,9 +83,10 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { mockShell.GetProjectRootFunc = func() (string, error) { return tmpDir, nil } - mockShell.ExecSilentFunc = func(cmd string, args ...string) (string, error) { + mockShell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { if cmd == "terraform" && len(args) > 0 && args[0] == "init" { - return "Initializing modules...\n- main in /path/to/module", nil + 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 } return "", nil } diff --git a/pkg/generators/terraform_generator.go b/pkg/generators/terraform_generator.go index 38d47b204..109fa948b 100644 --- a/pkg/generators/terraform_generator.go +++ b/pkg/generators/terraform_generator.go @@ -1,6 +1,7 @@ package generators import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -37,6 +38,15 @@ type VariableInfo struct { Sensitive bool } +// TerraformInitOutput represents the JSON output from terraform init +type TerraformInitOutput struct { + Level string `json:"@level"` + Message string `json:"@message"` + Module string `json:"@module"` + Timestamp string `json:"@timestamp"` + Type string `json:"type"` +} + // ============================================================================= // Constructor // ============================================================================= @@ -98,14 +108,16 @@ func (g *TerraformGenerator) Write() error { // generateModuleShim creates a local reference to a remote Terraform module. // Algorithm: -// 1. Create module directory in .tf_modules// +// 1. Create module directory in the component's path (component.FullPath) // 2. Generate main.tf with module reference to original source -// 3. Run 'terraform init' to download the module -// 4. Locate the downloaded module in .terraform directory -// 5. Extract variable definitions from the original module -// 6. Create variables.tf with all variables from original module -// 7. Extract and map outputs from the original module to outputs.tf -// 8. This creates a local reference that maintains all variable definitions +// 3. Set TF_DATA_DIR to store Terraform state in context-specific location +// 4. Run 'terraform init' to download the module +// 5. Parse the init output to locate the downloaded module +// 6. Extract variable definitions from the original module +// 7. Create variables.tf with all variables from original module +// 8. Generate outputs.tf to expose all outputs from the original module +// 9. Create a local tfvars template with default values and comments +// 10. This creates a local reference that maintains all variable definitions // while allowing Windsor to manage the module configuration func (g *TerraformGenerator) generateModuleShim(component blueprintv1alpha1.TerraformComponent) error { moduleDir := component.FullPath @@ -135,67 +147,89 @@ func (g *TerraformGenerator) generateModuleShim(component blueprintv1alpha1.Terr return fmt.Errorf("failed to set TF_DATA_DIR: %w", err) } - output, err := g.shell.ExecSilent("terraform", "init", "-migrate-state", "-upgrade") + output, err := g.shell.ExecProgress(fmt.Sprintf("📥 Loading component %s", component.Path), "terraform", "init", "--backend=false", "-input=false", "-json") if err != nil { return fmt.Errorf("failed to initialize terraform: %w", err) } - modulePath := "" + detectedPath := "" lines := strings.Split(output, "\n") for _, line := range lines { - if strings.Contains(line, "- main in") { - parts := strings.Split(line, "- main in ") - if len(parts) == 2 { - modulePath = strings.TrimSpace(parts[1]) + if line == "" { + continue + } + var initOutput TerraformInitOutput + if err := json.Unmarshal([]byte(line), &initOutput); err != nil { + continue + } + if initOutput.Type == "log" { + msg := initOutput.Message + startIdx := strings.Index(msg, "- main in") + if startIdx == -1 { + continue + } + + pathStart := startIdx + len("- main in") + if pathStart >= len(msg) { + continue + } + + path := strings.TrimSpace(msg[pathStart:]) + + if path == "" { + continue + } + + if _, err := g.shims.Stat(path); err == nil { + detectedPath = path break } } } - if modulePath == "" { - tfModulesPath := filepath.Join(moduleDir, ".tf_modules") - variablesPath := filepath.Join(tfModulesPath, "variables.tf") - if _, err := g.shims.Stat(variablesPath); err == nil { - modulePath = tfModulesPath - } else { - return fmt.Errorf("failed to find module path in terraform init output") + // Use detected path if found, otherwise fall back to standard path + modulePath := filepath.Join(contextPath, ".terraform", component.Path, "modules", "main", "terraform", component.Path) + if detectedPath != "" { + if detectedPath != modulePath { + fmt.Printf("\033[33mWarning: Using detected module path %s instead of standard path %s\033[0m\n", detectedPath, modulePath) } + modulePath = detectedPath } variablesPath := filepath.Join(modulePath, "variables.tf") variablesContent, err := g.shims.ReadFile(variablesPath) - if err != nil { - return fmt.Errorf("failed to read variables.tf: %w", err) - } - - variablesFile, diags := hclwrite.ParseConfig(variablesContent, variablesPath, hcl.Pos{Line: 1, Column: 1}) - if diags.HasErrors() { - return fmt.Errorf("failed to parse variables.tf: %w", diags) - } - - shimMainContent := hclwrite.NewEmptyFile() - shimBlock := shimMainContent.Body().AppendNewBlock("module", []string{"main"}) - shimBody := shimBlock.Body() - shimBody.SetAttributeValue("source", cty.StringVal(component.Source)) + if err == nil { + variablesFile, diags := hclwrite.ParseConfig(variablesContent, variablesPath, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return fmt.Errorf("failed to parse variables.tf: %w", diags) + } - for _, block := range variablesFile.Body().Blocks() { - if block.Type() == "variable" { - labels := block.Labels() - if len(labels) > 0 { - shimBody.SetAttributeTraversal(labels[0], hcl.Traversal{ - hcl.TraverseRoot{Name: "var"}, - hcl.TraverseAttr{Name: labels[0]}, - }) + shimMainContent := hclwrite.NewEmptyFile() + shimBlock := shimMainContent.Body().AppendNewBlock("module", []string{"main"}) + shimBody := shimBlock.Body() + shimBody.SetAttributeValue("source", cty.StringVal(component.Source)) + + for _, block := range variablesFile.Body().Blocks() { + if block.Type() == "variable" { + labels := block.Labels() + if len(labels) > 0 { + shimBody.SetAttributeTraversal(labels[0], hcl.Traversal{ + hcl.TraverseRoot{Name: "var"}, + hcl.TraverseAttr{Name: labels[0]}, + }) + } } } - } - if err := g.shims.WriteFile(filepath.Join(moduleDir, "main.tf"), shimMainContent.Bytes(), 0644); err != nil { - return fmt.Errorf("failed to write shim main.tf: %w", err) - } + if err := g.shims.WriteFile(filepath.Join(moduleDir, "main.tf"), shimMainContent.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write shim main.tf: %w", err) + } - if err := g.shims.WriteFile(filepath.Join(moduleDir, "variables.tf"), variablesContent, 0644); err != nil { - return fmt.Errorf("failed to write shim variables.tf: %w", err) + if err := g.shims.WriteFile(filepath.Join(moduleDir, "variables.tf"), variablesContent, 0644); err != nil { + return fmt.Errorf("failed to write shim variables.tf: %w", err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read variables.tf: %w", err) } outputsPath := filepath.Join(modulePath, "outputs.tf") diff --git a/pkg/generators/terraform_generator_test.go b/pkg/generators/terraform_generator_test.go index 091033148..fb2ed492c 100644 --- a/pkg/generators/terraform_generator_test.go +++ b/pkg/generators/terraform_generator_test.go @@ -796,7 +796,8 @@ func TestTerraformGenerator_Write(t *testing.T) { // And ExecSilent is mocked to return output with module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...\n- main in /path/to/module", nil + 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 @@ -916,7 +917,16 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output with module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...\n- main in /path/to/module", nil + 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 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 @@ -1043,8 +1053,8 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { FullPath: "original/full/path", } - // And ExecSilent is mocked to return an error - mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { + // 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") } @@ -1107,7 +1117,16 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output with module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...\n- main in /path/to/module", nil + 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 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 @@ -1146,7 +1165,16 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output with module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...\n- main in /path/to/module", nil + 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 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 @@ -1196,7 +1224,16 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output with module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...\n- main in /path/to/module", nil + 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 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 @@ -1246,7 +1283,16 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output with module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...\n- main in /path/to/module", nil + 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 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 @@ -1265,14 +1311,6 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { return nil, fmt.Errorf("unexpected file read: %s", path) } - // 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 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") { @@ -1309,7 +1347,7 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output without module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...", nil + return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"}`, nil } // And Stat is mocked to return success for .tf_modules/variables.tf @@ -1357,17 +1395,66 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { // And ExecSilent is mocked to return output without module path mocks.Shell.ExecSilentFunc = func(_ string, _ ...string) (string, error) { - return "Initializing modules...", nil + return `{"@level":"info","@message":"Initializing modules...","@module":"terraform.ui","@timestamp":"2025-05-09T16:25:03Z","message_code":"initializing_modules_message","type":"init_output"}`, nil } - // And Stat is mocked to return error for .tf_modules/variables.tf + // And Stat is mocked to return success for the standard path mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - if strings.HasSuffix(path, ".tf_modules/variables.tf") { - return nil, os.ErrNotExist + 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") { + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // When generateModuleShim is called + err := generator.generateModuleShim(component) + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("ErrorInTerraformInit", 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: 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 + } + // 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 + } + // When generateModuleShim is called err := generator.generateModuleShim(component) @@ -1376,10 +1463,57 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { t.Fatalf("expected an error, got nil") } - // And the error should match the expected error - expectedError := "failed to find module path in terraform init output" - 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("NoValidModulePath", 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: 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 + } + 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 no error should be returned because the function handles missing files + if err != nil { + t.Errorf("expected no error, got %v", err) } }) }