diff --git a/pkg/composer/composer.go b/pkg/composer/composer.go index a34c5539b..809d02200 100644 --- a/pkg/composer/composer.go +++ b/pkg/composer/composer.go @@ -2,12 +2,14 @@ package composer import ( "fmt" + "os" + "path/filepath" + "strings" "github.com/windsorcli/cli/pkg/composer/artifact" "github.com/windsorcli/cli/pkg/composer/blueprint" "github.com/windsorcli/cli/pkg/composer/terraform" "github.com/windsorcli/cli/pkg/context" - "github.com/windsorcli/cli/pkg/generators" ) // The Composer package provides high-level resource management functionality @@ -162,11 +164,96 @@ func (r *Composer) Generate(overwrite ...bool) error { // ============================================================================= // generateGitignore creates or updates the .gitignore file with Windsor-specific entries. -// It delegates to the GitGenerator to maintain consistency with the existing generator logic. +// It ensures Windsor-specific paths are excluded from version control while preserving user-defined entries. func (r *Composer) generateGitignore() error { - gitGenerator := generators.NewGitGenerator(r.Injector) - if err := gitGenerator.Initialize(); err != nil { - return fmt.Errorf("failed to initialize git generator: %w", err) + gitIgnoreLines := []string{ + "# managed by windsor cli", + ".windsor/", + ".volumes/", + "terraform/**/backend_override.tf", + "contexts/**/.terraform/", + "contexts/**/.tfstate/", + "contexts/**/.kube/", + "contexts/**/.talos/", + "contexts/**/.omni/", + "contexts/**/.aws/", + "contexts/**/.azure/", } - return gitGenerator.Generate(nil) + + projectRoot, err := r.Shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("failed to get project root: %w", err) + } + + gitignorePath := filepath.Join(projectRoot, ".gitignore") + + content, err := os.ReadFile(gitignorePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read .gitignore: %w", err) + } + + if os.IsNotExist(err) { + content = []byte{} + } + + existingLines := make(map[string]struct{}) + commentedNormalized := make(map[string]struct{}) + var unmanagedLines []string + lines := strings.Split(string(content), "\n") + for i, line := range lines { + existingLines[line] = struct{}{} + + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "#") { + norm := normalizeGitignoreComment(trimmed) + if norm != "" { + commentedNormalized[norm] = struct{}{} + } + } + + if i == len(lines)-1 && line == "" { + continue + } + unmanagedLines = append(unmanagedLines, line) + } + + for _, line := range gitIgnoreLines { + if line == "# managed by windsor cli" { + if _, exists := existingLines[line]; !exists { + unmanagedLines = append(unmanagedLines, "") + unmanagedLines = append(unmanagedLines, line) + } + continue + } + + if _, exists := existingLines[line]; !exists { + if _, commentedExists := commentedNormalized[line]; !commentedExists { + unmanagedLines = append(unmanagedLines, line) + } + } + } + + finalContent := strings.Join(unmanagedLines, "\n") + + if !strings.HasSuffix(finalContent, "\n") { + finalContent += "\n" + } + + if err := os.WriteFile(gitignorePath, []byte(finalContent), 0644); err != nil { + return fmt.Errorf("failed to write to .gitignore: %w", err) + } + + return nil +} + +// normalizeGitignoreComment normalizes a commented .gitignore line to its uncommented form. +// It removes all leading #, whitespace, and trailing whitespace. +func normalizeGitignoreComment(line string) string { + trimmed := strings.TrimLeft(line, " \t") + if !strings.HasPrefix(trimmed, "#") { + return "" + } + noHash := strings.TrimLeft(trimmed, "#") + noHash = strings.TrimLeft(noHash, " \t") + return strings.TrimSpace(noHash) } diff --git a/pkg/composer/terraform/module_resolver.go b/pkg/composer/terraform/module_resolver.go index d5a8ae3ba..2fb3ab6f2 100644 --- a/pkg/composer/terraform/module_resolver.go +++ b/pkg/composer/terraform/module_resolver.go @@ -2,15 +2,19 @@ package terraform import ( "fmt" + "os" "path/filepath" + "sort" + "strings" "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/composer/blueprint" "github.com/windsorcli/cli/pkg/context/config" "github.com/windsorcli/cli/pkg/context/shell" "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/generators" "github.com/zclconf/go-cty/cty" ) @@ -41,6 +45,7 @@ type BaseModuleResolver struct { shell shell.Shell configHandler config.ConfigHandler blueprintHandler blueprint.BlueprintHandler + reset bool } // ============================================================================= @@ -88,19 +93,540 @@ func (h *BaseModuleResolver) Initialize() error { } // GenerateTfvars creates Terraform configuration files, including tfvars files, for all blueprint components. -// It delegates to the TerraformGenerator to maintain consistency with the existing generator logic. +// It processes template data keyed by "terraform/", generating tfvars files at +// contexts//terraform/.tfvars. For each entry in the input data, it skips keys +// not prefixed with "terraform/" and skips components not present in the blueprint. For all components +// in the blueprint, it ensures a tfvars file is generated if not already handled by the input data. +// The method uses the blueprint handler to retrieve TerraformComponents and determines the variables.tf +// location based on component source (remote or local). Module resolution is handled by pkg/terraform. func (h *BaseModuleResolver) GenerateTfvars(overwrite bool) error { - terraformGenerator := generators.NewTerraformGenerator(h.injector) - if err := terraformGenerator.Initialize(); err != nil { - return fmt.Errorf("failed to initialize terraform generator: %w", err) + h.reset = overwrite + + contextPath, err := h.configHandler.GetConfigRoot() + if err != nil { + return fmt.Errorf("failed to get config root: %w", err) + } + + projectRoot, err := h.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("failed to get project root: %w", err) + } + + components := h.blueprintHandler.GetTerraformComponents() + + for _, component := range components { + componentValues := component.Inputs + if componentValues == nil { + componentValues = make(map[string]any) + } + + if err := h.generateComponentTfvars(projectRoot, contextPath, component, componentValues); err != nil { + return fmt.Errorf("failed to generate tfvars for component %s: %w", component.Path, err) + } } - return terraformGenerator.Generate(nil, overwrite) + + return nil } // ============================================================================= // Private Methods // ============================================================================= +// VariableInfo holds metadata for a single Terraform variable +type VariableInfo struct { + Name string + Description string + Default any + Sensitive bool +} + +// checkExistingTfvarsFile checks if a tfvars file exists and is readable. +// Returns os.ErrExist if the file exists and is readable, or an error if the file exists but is not readable. +func (h *BaseModuleResolver) checkExistingTfvarsFile(tfvarsFilePath string) error { + _, err := h.shims.Stat(tfvarsFilePath) + if err == nil { + _, err := h.shims.ReadFile(tfvarsFilePath) + if err != nil { + return fmt.Errorf("failed to read existing tfvars file: %w", err) + } + return os.ErrExist + } else if !os.IsNotExist(err) { + return fmt.Errorf("error checking tfvars file: %w", err) + } + return nil +} + +// parseVariablesFile parses variables.tf and returns metadata about the variables. +// It extracts variable names, descriptions, default values, and sensitivity flags. +// Protected values are excluded from the returned metadata. +func (h *BaseModuleResolver) parseVariablesFile(variablesTfPath string, protectedValues map[string]bool) ([]VariableInfo, error) { + variablesContent, err := h.shims.ReadFile(variablesTfPath) + if err != nil { + return nil, fmt.Errorf("failed to read variables.tf: %w", err) + } + + variablesFile, diags := hclwrite.ParseConfig(variablesContent, variablesTfPath, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return nil, fmt.Errorf("failed to parse variables.tf: %w", diags) + } + + var variables []VariableInfo + for _, block := range variablesFile.Body().Blocks() { + if block.Type() == "variable" && len(block.Labels()) > 0 { + variableName := block.Labels()[0] + + if protectedValues[variableName] { + continue + } + + info := VariableInfo{ + Name: variableName, + } + + if attr := block.Body().GetAttribute("description"); attr != nil { + exprBytes := attr.Expr().BuildTokens(nil).Bytes() + parsedExpr, diags := hclsyntax.ParseExpression(exprBytes, "description", hcl.Pos{Line: 1, Column: 1}) + if !diags.HasErrors() { + val, diags := parsedExpr.Value(nil) + if !diags.HasErrors() && val.Type() == cty.String { + info.Description = val.AsString() + } + } + } + + if attr := block.Body().GetAttribute("sensitive"); attr != nil { + exprBytes := attr.Expr().BuildTokens(nil).Bytes() + parsedExpr, diags := hclsyntax.ParseExpression(exprBytes, "sensitive", hcl.Pos{Line: 1, Column: 1}) + if !diags.HasErrors() { + val, diags := parsedExpr.Value(nil) + if !diags.HasErrors() && val.Type() == cty.Bool { + info.Sensitive = val.True() + } + } + } + + if attr := block.Body().GetAttribute("default"); attr != nil { + exprBytes := attr.Expr().BuildTokens(nil).Bytes() + parsedExpr, diags := hclsyntax.ParseExpression(exprBytes, "default", hcl.Pos{Line: 1, Column: 1}) + if !diags.HasErrors() { + val, diags := parsedExpr.Value(nil) + if !diags.HasErrors() { + info.Default = convertFromCtyValue(val) + } + } + } + + variables = append(variables, info) + } + } + + return variables, nil +} + +// generateComponentTfvars generates tfvars files for a single Terraform component. +// For components with a non-empty Source, only the module tfvars file is generated at .windsor/.tf_modules//terraform.tfvars. +// For components with an empty Source, only the context tfvars file is generated at /terraform/.tfvars. +// Returns an error if variables.tf cannot be found or if tfvars file generation fails. +func (h *BaseModuleResolver) generateComponentTfvars(projectRoot, contextPath string, component blueprintv1alpha1.TerraformComponent, componentValues map[string]any) error { + variablesTfPath, err := h.findVariablesTfFileForComponent(projectRoot, component) + if err != nil { + return fmt.Errorf("failed to find variables.tf for component %s: %w", component.Path, err) + } + + if component.Source != "" { + moduleTfvarsPath := filepath.Join(projectRoot, ".windsor", ".tf_modules", component.Path, "terraform.tfvars") + if err := h.removeTfvarsFiles(filepath.Dir(moduleTfvarsPath)); err != nil { + return fmt.Errorf("failed cleaning existing .tfvars in module dir %s: %w", filepath.Dir(moduleTfvarsPath), err) + } + if err := h.generateTfvarsFile(moduleTfvarsPath, variablesTfPath, componentValues, component.Source); err != nil { + return fmt.Errorf("failed to generate module tfvars file: %w", err) + } + } else { + terraformKey := "terraform/" + component.Path + tfvarsFilePath := filepath.Join(contextPath, terraformKey+".tfvars") + if err := h.generateTfvarsFile(tfvarsFilePath, variablesTfPath, componentValues, component.Source); err != nil { + return fmt.Errorf("failed to generate context tfvars file: %w", err) + } + } + + return nil +} + +// findVariablesTfFileForComponent returns the path to the variables.tf file for the specified Terraform component. +// If the component has a non-empty Source, the path is .windsor/.tf_modules//variables.tf under the project root. +// If the component has an empty Source, the path is terraform//variables.tf under the project root. +// Returns the variables.tf file path if it exists, or an error if not found. +func (h *BaseModuleResolver) findVariablesTfFileForComponent(projectRoot string, component blueprintv1alpha1.TerraformComponent) (string, error) { + var variablesTfPath string + + if component.Source != "" { + variablesTfPath = filepath.Join(projectRoot, ".windsor", ".tf_modules", component.Path, "variables.tf") + } else { + variablesTfPath = filepath.Join(projectRoot, "terraform", component.Path, "variables.tf") + } + + if _, err := h.shims.Stat(variablesTfPath); err != nil { + return "", fmt.Errorf("variables.tf not found for component %s at %s", component.Path, variablesTfPath) + } + + return variablesTfPath, nil +} + +// generateTfvarsFile generates a tfvars file at the specified path using the provided variables.tf file and component values. +// It parses the variables.tf file to extract variable definitions, merges them with the given component values (excluding protected values), +// and writes a formatted tfvars file. If the file already exists and reset mode is not enabled, the function skips writing. +// The function ensures the parent directory exists and returns an error if any file or directory operation fails. +func (h *BaseModuleResolver) generateTfvarsFile(tfvarsFilePath, variablesTfPath string, componentValues map[string]any, source string) error { + protectedValues := map[string]bool{ + "context_path": true, + "os_type": true, + "context_id": true, + } + + if !h.reset { + if err := h.checkExistingTfvarsFile(tfvarsFilePath); err != nil { + if err == os.ErrExist { + return nil + } + return err + } + } + + variables, err := h.parseVariablesFile(variablesTfPath, protectedValues) + if err != nil { + return fmt.Errorf("failed to parse variables.tf: %w", err) + } + + mergedFile := hclwrite.NewEmptyFile() + body := mergedFile.Body() + + addTfvarsHeader(body, source) + + if len(componentValues) > 0 { + writeComponentValues(body, componentValues, protectedValues, variables) + } else { + writeComponentValues(body, componentValues, map[string]bool{}, variables) + } + + parentDir := filepath.Dir(tfvarsFilePath) + if err := h.shims.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + if err := h.shims.WriteFile(tfvarsFilePath, mergedFile.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write tfvars file: %w", err) + } + + return nil +} + +// removeTfvarsFiles removes any .tfvars files directly under the specified directory. +// This is used to ensure module directories do not retain stale tfvars prior to regeneration. +func (h *BaseModuleResolver) removeTfvarsFiles(dir string) error { + if _, err := h.shims.Stat(dir); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + entries, err := h.shims.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(strings.ToLower(name), ".tfvars") { + fullPath := filepath.Join(dir, name) + if err := h.shims.RemoveAll(fullPath); err != nil { + return err + } + } + } + + return nil +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +// addTfvarsHeader adds a Windsor CLI management header to the tfvars file body. +// It includes a module source comment if provided, ensuring users are aware of CLI management and module provenance. +func addTfvarsHeader(body *hclwrite.Body, source string) { + windsorHeaderToken := "Managed by Windsor CLI:" + headerComment := fmt.Sprintf("# %s This file is partially managed by the windsor CLI. Your changes will not be overwritten.", windsorHeaderToken) + body.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte(headerComment + "\n")}, + }) + if source != "" { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# Module source: %s\n", source))}, + }) + } +} + +// writeComponentValues writes all component-provided or default variable values to the tfvars file body. +// It comments out default values and descriptions for unset variables, and writes explicit values for set variables. +// Handles sensitive variables and preserves variable order from variables.tf. +func writeComponentValues(body *hclwrite.Body, values map[string]any, protectedValues map[string]bool, variables []VariableInfo) { + for _, info := range variables { + if protectedValues[info.Name] { + continue + } + + body.AppendNewline() + + if info.Description != "" { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte("# " + info.Description)}, + }) + body.AppendNewline() + } + + if val, exists := values[info.Name]; exists { + writeVariable(body, info.Name, val, []VariableInfo{}) + continue + } + + if info.Sensitive { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# %s = \"(sensitive)\"", info.Name))}, + }) + body.AppendNewline() + continue + } + + if info.Default != nil { + defaultVal := convertToCtyValue(info.Default) + if !defaultVal.IsNull() { + var rendered string + if defaultVal.Type().IsObjectType() || defaultVal.Type().IsMapType() { + var mapStr strings.Builder + mapStr.WriteString(fmt.Sprintf("%s = %s", info.Name, formatValue(convertFromCtyValue(defaultVal)))) + rendered = mapStr.String() + } else { + rendered = fmt.Sprintf("%s = %s", info.Name, string(hclwrite.TokensForValue(defaultVal).Bytes())) + } + for _, line := range strings.Split(rendered, "\n") { + body.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte("# " + line)}, + }) + body.AppendNewline() + } + continue + } + } + + body.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# %s = null", info.Name))}, + }) + body.AppendNewline() + } +} + +// writeHeredoc writes a multi-line string value as a heredoc assignment in the tfvars file body. +// Used for YAML or other multi-line string values to preserve formatting. +func writeHeredoc(body *hclwrite.Body, name string, content string) { + tokens := hclwrite.Tokens{ + {Type: hclsyntax.TokenOHeredoc, Bytes: []byte("< 0 && opts[0] != nil { - options = opts[0] - } - - // Create a new injector - var injector di.Injector - if options.Injector == nil { - injector = di.NewMockInjector() - } else { - injector = options.Injector - } - - // Create a new config handler - var configHandler config.ConfigHandler - if options.ConfigHandler == nil { - configHandler = config.NewMockConfigHandler() - configHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { - return filepath.Join(tmpDir, "contexts", "default"), nil - } - } else { - configHandler = options.ConfigHandler - } - injector.Register("configHandler", configHandler) - - // Create a new mock shell - mockShell := shell.NewMockShell() - mockShell.GetProjectRootFunc = func() (string, error) { - return tmpDir, nil - } - mockShell.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","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 - } - injector.Register("shell", mockShell) - - // Create a new mock blueprint handler - mockBlueprintHandler := blueprint.NewMockBlueprintHandler(injector) - injector.Register("blueprintHandler", mockBlueprintHandler) - - // Create a new mock artifact builder - mockArtifactBuilder := bundler.NewMockArtifact() - // Set up default Pull behavior to return empty map - mockArtifactBuilder.PullFunc = func(ociRefs []string) (map[string][]byte, error) { - return make(map[string][]byte), nil - } - if err := mockArtifactBuilder.Initialize(injector); err != nil { - t.Fatalf("failed to initialize artifact builder: %v", err) - } - injector.Register("artifactBuilder", mockArtifactBuilder) - - // Mock the GetTerraformComponents method - mockBlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - // Common components setup - remoteComponent := blueprintv1alpha1.TerraformComponent{ - Source: "git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git//terraform/remote/path@v1.0.0", - Path: "remote/path", - FullPath: filepath.Join(tmpDir, ".windsor", ".tf_modules", "remote/path"), - Inputs: map[string]any{ - "remote_variable1": "default_value", - }, - } - - localComponent := blueprintv1alpha1.TerraformComponent{ - Source: "local/path", - Path: "local/path", - FullPath: filepath.Join(tmpDir, ".windsor", ".tf_modules", "local/path"), - Inputs: map[string]any{ - "local_variable1": "default_value", - }, - } - - return []blueprintv1alpha1.TerraformComponent{remoteComponent, localComponent} - } - - // Set project root environment variable - os.Setenv("WINDSOR_PROJECT_ROOT", tmpDir) - - // Register cleanup to restore original state - t.Cleanup(func() { - os.Unsetenv("WINDSOR_PROJECT_ROOT") - if err := os.Chdir(origDir); err != nil { - t.Logf("Warning: Failed to change back to original directory: %v", err) - } - }) - - // Create shims with mock implementations - shims := NewShims() - shims.WriteFile = func(path string, data []byte, perm fs.FileMode) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - return os.WriteFile(path, data, perm) - } - shims.MkdirAll = func(path string, perm fs.FileMode) error { - return os.MkdirAll(path, perm) - } - shims.RemoveAll = func(path string) error { - return os.RemoveAll(path) - } - shims.Chdir = func(path string) error { - return os.Chdir(path) - } - shims.Stat = func(path string) (fs.FileInfo, error) { - return os.Stat(path) - } - shims.Setenv = func(key, value string) error { - return os.Setenv(key, value) - } - shims.ReadFile = func(path string) ([]byte, error) { - // Handle variables.tf - if strings.HasSuffix(path, "variables.tf") { - return []byte(`variable "remote_variable1" { - description = "Remote variable 1" - type = string - default = "default_value" -} - -variable "local_variable1" { - description = "Local variable 1" - type = string - default = "default_value" -}`), nil - } - - // Handle tfvars files - if strings.HasSuffix(path, ".tfvars") { - return []byte(`# Managed by Windsor CLI -remote_variable1 = "default_value" -local_variable1 = "default_value"`), nil - } - - // Handle outputs.tf - if strings.HasSuffix(path, "outputs.tf") { - return []byte(`output "remote_output1" { - value = "remote_value1" - description = "Remote output 1" -} - -output "local_output1" { - value = "local_value1" - description = "Local output 1" -}`), nil - } - - return []byte{}, nil - } - shims.JsonUnmarshal = func(data []byte, v any) error { - return json.Unmarshal(data, v) - } - shims.FilepathRel = func(basepath, targpath string) (string, error) { - return filepath.Rel(basepath, targpath) - } - - configHandler.Initialize() - - // Create base mocks - mocks := &Mocks{ - Injector: injector, - ConfigHandler: configHandler, - BlueprintHandler: mockBlueprintHandler, - Shell: mockShell, - Shims: shims, - } - - return mocks -} - -// ============================================================================= -// Test Constructor -// ============================================================================= - -func TestGenerator_NewGenerator(t *testing.T) { - mocks := setupMocks(t) - generator := NewGenerator(mocks.Injector) - - if generator == nil { - t.Errorf("Expected generator to be non-nil") - } -} - -// ============================================================================= -// Test Public Methods -// ============================================================================= - -func TestGenerator_Initialize(t *testing.T) { - setup := func(t *testing.T) (*BaseGenerator, *Mocks) { - mocks := setupMocks(t) - generator := NewGenerator(mocks.Injector) - generator.shims = mocks.Shims - - return generator, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a set of safe mocks - generator, _ := setup(t) - - // And the BaseGenerator is initialized - err := generator.Initialize() - - // Then the initialization should succeed - if err != nil { - t.Errorf("Expected Initialize to succeed, but got error: %v", err) - } - }) - - t.Run("ErrorResolvingBlueprintHandler", func(t *testing.T) { - // Given a set of safe mocks - generator, mocks := setup(t) - - // And a mock injector with a nil blueprint handler - mocks.Injector.Register("blueprintHandler", nil) - - // And the BaseGenerator is initialized - err := generator.Initialize() - - // Then the initialization should fail - if err == nil { - t.Errorf("Expected Initialize to fail, but it succeeded") - } - }) - - t.Run("ErrorResolvingShell", func(t *testing.T) { - // Given a set of safe mocks - generator, mocks := setup(t) - - // And a mock injector with a nil shell - mocks.Injector.Register("shell", nil) - - // When the BaseGenerator is initialized - err := generator.Initialize() - - // Then the initialization should fail - if err == nil { - t.Errorf("Expected Initialize to fail, but it succeeded") - } - }) - - t.Run("ErrorResolvingConfigHandler", func(t *testing.T) { - // Given a set of mocks - generator, mocks := setup(t) - - // And a mock injector with a nil config handler - mocks.Injector.Register("configHandler", nil) - - // When the BaseGenerator is initialized - err := generator.Initialize() - - // Then the initialization should fail - if err == nil { - t.Errorf("Expected Initialize to fail, but it succeeded") - } - - // And the error should match the expected error - expectedError := "failed to resolve config handler" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) - } - }) -} - -func TestGenerator_Generate(t *testing.T) { - setup := func(t *testing.T) (*BaseGenerator, *Mocks) { - mocks := setupMocks(t) - generator := NewGenerator(mocks.Injector) - generator.shims = mocks.Shims - generator.Initialize() - - return generator, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a BaseGenerator - generator, _ := setup(t) - - // When the Generate method is called - err := generator.Generate(map[string]any{"test": "data"}) - - // Then the Generate method should succeed (placeholder implementation) - if err != nil { - t.Errorf("Expected Generate to succeed, but got error: %v", err) - } - }) - - t.Run("SuccessWithOverwrite", func(t *testing.T) { - // Given a BaseGenerator - generator, _ := setup(t) - - // When the Generate method is called with overwrite parameter - err := generator.Generate(map[string]any{"test": "data"}, true) - - // Then the Generate method should succeed (placeholder implementation) - if err != nil { - t.Errorf("Expected Generate to succeed, but got error: %v", err) - } - }) - - t.Run("SuccessWithNilData", func(t *testing.T) { - // Given a BaseGenerator - generator, _ := setup(t) - - // When the Generate method is called with nil data - err := generator.Generate(nil) - - // Then the Generate method should succeed (placeholder implementation) - if err != nil { - t.Errorf("Expected Generate to succeed, but got error: %v", err) - } - }) -} - -func TestNewShims(t *testing.T) { - t.Run("CreatesShimsWithDefaultImplementations", func(t *testing.T) { - // When NewShims is called - shims := NewShims() - - // Then it should return a non-nil Shims instance - if shims == nil { - t.Errorf("Expected NewShims to return non-nil Shims") - } - - // And all function fields should be set - if shims.WriteFile == nil { - t.Errorf("Expected WriteFile to be set") - } - if shims.ReadFile == nil { - t.Errorf("Expected ReadFile to be set") - } - if shims.MkdirAll == nil { - t.Errorf("Expected MkdirAll to be set") - } - if shims.Stat == nil { - t.Errorf("Expected Stat to be set") - } - if shims.MarshalYAML == nil { - t.Errorf("Expected MarshalYAML to be set") - } - if shims.RemoveAll == nil { - t.Errorf("Expected RemoveAll to be set") - } - if shims.Chdir == nil { - t.Errorf("Expected Chdir to be set") - } - if shims.ReadDir == nil { - t.Errorf("Expected ReadDir to be set") - } - if shims.Setenv == nil { - t.Errorf("Expected Setenv to be set") - } - if shims.YamlUnmarshal == nil { - t.Errorf("Expected YamlUnmarshal to be set") - } - if shims.JsonMarshal == nil { - t.Errorf("Expected JsonMarshal to be set") - } - if shims.JsonUnmarshal == nil { - t.Errorf("Expected JsonUnmarshal to be set") - } - if shims.FilepathRel == nil { - t.Errorf("Expected FilepathRel to be set") - } - if shims.NewTarReader == nil { - t.Errorf("Expected NewTarReader to be set") - } - if shims.NewBytesReader == nil { - t.Errorf("Expected NewBytesReader to be set") - } - if shims.Create == nil { - t.Errorf("Expected Create to be set") - } - if shims.Copy == nil { - t.Errorf("Expected Copy to be set") - } - if shims.Chmod == nil { - t.Errorf("Expected Chmod to be set") - } - if shims.EOFError == nil { - t.Errorf("Expected EOFError to be set") - } - if shims.TypeDir == nil { - t.Errorf("Expected TypeDir to be set") - } - }) - - t.Run("DefaultImplementationsWork", func(t *testing.T) { - // Given a new Shims instance - shims := NewShims() - - // When testing some of the default implementations - // Test EOFError - err := shims.EOFError() - if err == nil { - t.Errorf("Expected EOFError to return an error") - } - - // Test TypeDir - typeDir := shims.TypeDir() - if typeDir == 0 { - t.Errorf("Expected TypeDir to return non-zero value") - } - - // Test NewBytesReader - data := []byte("test data") - reader := shims.NewBytesReader(data) - if reader == nil { - t.Errorf("Expected NewBytesReader to return non-nil reader") - } - }) -} diff --git a/pkg/generators/git_generator.go b/pkg/generators/git_generator.go deleted file mode 100644 index 98db05bda..000000000 --- a/pkg/generators/git_generator.go +++ /dev/null @@ -1,155 +0,0 @@ -package generators - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/windsorcli/cli/pkg/di" -) - -// The GitGenerator is a specialized component that manages Git configuration files. -// It provides functionality to create and update .gitignore files with Windsor-specific entries. -// The GitGenerator ensures proper Git configuration for Windsor projects, -// maintaining consistent version control settings across all contexts. - -// ============================================================================= -// Constants -// ============================================================================= - -// Define the item to add to the .gitignore -var gitIgnoreLines = []string{ - "# managed by windsor cli", - ".windsor/", - ".volumes/", - "terraform/**/backend_override.tf", - "contexts/**/.terraform/", - "contexts/**/.tfstate/", - "contexts/**/.kube/", - "contexts/**/.talos/", - "contexts/**/.omni/", - "contexts/**/.aws/", - "contexts/**/.azure/", -} - -// ============================================================================= -// Types -// ============================================================================= - -// GitGenerator is a generator that writes Git configuration files -type GitGenerator struct { - BaseGenerator -} - -// ============================================================================= -// Constructor -// ============================================================================= - -// NewGitGenerator creates a new GitGenerator -func NewGitGenerator(injector di.Injector) *GitGenerator { - return &GitGenerator{ - BaseGenerator: *NewGenerator(injector), - } -} - -// ============================================================================= -// Public Methods -// ============================================================================= - -// Generate creates the Git configuration files by creating or updating the .gitignore file. -// It ensures that Windsor-specific entries are added while preserving any existing user-defined entries. -// For GitGenerator, the data parameter is not used since it always generates the .gitignore file -// in the project root based on predefined rules. The overwrite parameter is not used since -// the GitGenerator always merges with existing content rather than overwriting. -func (g *GitGenerator) Generate(data map[string]any, overwrite ...bool) error { - projectRoot, err := g.shell.GetProjectRoot() - if err != nil { - return fmt.Errorf("failed to get project root: %w", err) - } - - gitignorePath := filepath.Join(projectRoot, ".gitignore") - - content, err := g.shims.ReadFile(gitignorePath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read .gitignore: %w", err) - } - - if os.IsNotExist(err) { - content = []byte{} - } - - existingLines := make(map[string]struct{}) - commentedNormalized := make(map[string]struct{}) - var unmanagedLines []string - lines := strings.Split(string(content), "\n") - for i, line := range lines { - existingLines[line] = struct{}{} - - // Track normalized commented versions of Windsor entries - trimmed := strings.TrimLeft(line, " \t") - if strings.HasPrefix(trimmed, "#") { - norm := normalizeGitignoreComment(trimmed) - if norm != "" { - commentedNormalized[norm] = struct{}{} - } - } - - if i == len(lines)-1 && line == "" { - continue - } - unmanagedLines = append(unmanagedLines, line) - } - - for _, line := range gitIgnoreLines { - if line == "# managed by windsor cli" { - if _, exists := existingLines[line]; !exists { - unmanagedLines = append(unmanagedLines, "") - unmanagedLines = append(unmanagedLines, line) - } - continue - } - - if _, exists := existingLines[line]; !exists { - if _, commentedExists := commentedNormalized[line]; !commentedExists { - unmanagedLines = append(unmanagedLines, line) - } - } - } - - finalContent := strings.Join(unmanagedLines, "\n") - - if !strings.HasSuffix(finalContent, "\n") { - finalContent += "\n" - } - - if err := g.shims.WriteFile(gitignorePath, []byte(finalContent), 0644); err != nil { - return fmt.Errorf("failed to write to .gitignore: %w", err) - } - - return nil -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -// normalizeGitignoreComment normalizes a commented .gitignore line to its uncommented form. -// It removes all leading #, whitespace, and trailing whitespace. -func normalizeGitignoreComment(line string) string { - trimmed := strings.TrimLeft(line, " \t") - if !strings.HasPrefix(trimmed, "#") { - return "" - } - // Remove all leading # and whitespace after # - noHash := strings.TrimLeft(trimmed, "#") - noHash = strings.TrimLeft(noHash, " \t") - return strings.TrimSpace(noHash) -} - -// ============================================================================= -// Interface Compliance -// ============================================================================= - -// Ensure GitGenerator implements Generator -var _ Generator = (*GitGenerator)(nil) diff --git a/pkg/generators/git_generator_test.go b/pkg/generators/git_generator_test.go deleted file mode 100644 index 1732dbd64..000000000 --- a/pkg/generators/git_generator_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package generators - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" -) - -// ============================================================================= -// Test Setup -// ============================================================================= - -const ( - gitGenTestExistingContent = "existing content\n" - gitGenTestExpectedContent = `existing content - -# managed by windsor cli -.windsor/ -.volumes/ -terraform/**/backend_override.tf -contexts/**/.terraform/ -contexts/**/.tfstate/ -contexts/**/.kube/ -contexts/**/.talos/ -contexts/**/.omni/ -contexts/**/.aws/ -contexts/**/.azure/` -) - -// ============================================================================= -// Test Constructor -// ============================================================================= - -func TestGitGenerator_NewGitGenerator(t *testing.T) { - t.Run("NewGitGenerator", func(t *testing.T) { - // Given a set of mocks - mocks := setupMocks(t) - - // When a new GitGenerator is created - generator := NewGitGenerator(mocks.Injector) - generator.shims = mocks.Shims - if err := generator.Initialize(); err != nil { - t.Fatalf("failed to initialize GitGenerator: %v", err) - } - - // Then the GitGenerator should be created correctly - if generator == nil { - t.Fatalf("expected GitGenerator to be created, got nil") - } - - // And the GitGenerator should have the correct injector - if generator.injector != mocks.Injector { - t.Errorf("expected GitGenerator to have the correct injector") - } - }) -} - -// ============================================================================= -// Test Public Methods -// ============================================================================= - -func TestGitGenerator_Write(t *testing.T) { - setup := func(t *testing.T) (*GitGenerator, *Mocks) { - mocks := setupMocks(t) - generator := NewGitGenerator(mocks.Injector) - generator.shims = mocks.Shims - if err := generator.Initialize(); err != nil { - t.Fatalf("failed to initialize GitGenerator: %v", err) - } - return generator, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a GitGenerator with mocks - generator, mocks := setup(t) - - // And GetProjectRoot is mocked to return a specific path - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return filepath.Join("mock", "project", "root"), nil - } - - // And ReadFile is mocked to return existing content - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - expectedPath := filepath.Join("mock", "project", "root", ".gitignore") - if path == expectedPath { - return []byte(gitGenTestExistingContent), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) - } - - // And WriteFile is mocked to verify the content - var writtenPath string - var writtenContent []byte - mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { - writtenPath = path - writtenContent = content - return nil - } - - // When Generate is called - err := generator.Generate(nil) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the file should be written to the correct path - expectedPath := filepath.Join("mock", "project", "root", ".gitignore") - if writtenPath != expectedPath { - t.Errorf("expected filename %s, got %s", expectedPath, writtenPath) - } - - // And the content should be correct - expectedContent := gitGenTestExpectedContent - actualContent := string(writtenContent) - if actualContent != expectedContent { - // Trim trailing whitespace and newlines for robust comparison - trimmedExpected := expectedContent - trimmedActual := actualContent - for len(trimmedExpected) > 0 && (trimmedExpected[len(trimmedExpected)-1] == '\n' || trimmedExpected[len(trimmedExpected)-1] == '\r') { - trimmedExpected = trimmedExpected[:len(trimmedExpected)-1] - } - for len(trimmedActual) > 0 && (trimmedActual[len(trimmedActual)-1] == '\n' || trimmedActual[len(trimmedActual)-1] == '\r') { - trimmedActual = trimmedActual[:len(trimmedActual)-1] - } - if trimmedActual != trimmedExpected { - t.Errorf("expected content %q, got %q", trimmedExpected, trimmedActual) - } - } - }) - - t.Run("ErrorGettingProjectRoot", func(t *testing.T) { - // Given a set of mocks - mocks := setupMocks(t) - - // And GetProjectRoot is mocked to return an error - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return "", fmt.Errorf("mock error getting project root") - } - - // And a new GitGenerator is created - generator := NewGitGenerator(mocks.Injector) - generator.shims = mocks.Shims - if err := generator.Initialize(); err != nil { - t.Fatalf("failed to initialize GitGenerator: %v", err) - } - - // When Generate is called - err := generator.Generate(nil) - - // 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 project root: mock error getting project root" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) - } - }) - - t.Run("ErrorReadingGitignore", func(t *testing.T) { - // Given a GitGenerator with mocks - generator, mocks := setup(t) - - // And ReadFile is mocked to return an error - mocks.Shims.ReadFile = func(_ string) ([]byte, error) { - return nil, fmt.Errorf("mock error reading .gitignore") - } - - // When Generate is called - err := generator.Generate(nil) - - // 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 .gitignore: mock error reading .gitignore" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) - } - }) - - t.Run("GitignoreDoesNotExist", func(t *testing.T) { - // Given a GitGenerator with mocks - generator, mocks := setup(t) - - // And ReadFile is mocked to simulate .gitignore does not exist - mocks.Shims.ReadFile = func(_ string) ([]byte, error) { - return nil, os.ErrNotExist - } - - // And WriteFile is mocked to simulate successful file creation - mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { - return nil - } - - // When Write is called - err := generator.Generate(nil) - - // Then no error should be returned - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - }) - - t.Run("ErrorWritingGitignore", func(t *testing.T) { - // Given a GitGenerator with mocks - generator, mocks := setup(t) - - // And WriteFile is mocked to simulate an error during file writing - mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { - return fmt.Errorf("mock error writing .gitignore") - } - - // When Write is called - err := generator.Generate(nil) - - // 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 to .gitignore: mock error writing .gitignore" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) - } - }) - - t.Run("HandlesCommentedOutLines", func(t *testing.T) { - // Given a GitGenerator with mocks - generator, mocks := setup(t) - - // And GetProjectRoot is mocked to return a specific path - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return filepath.Join("mock", "project", "root"), nil - } - - // And ReadFile is mocked to return content with various commented out Windsor entries - commentedContent := "existing content\n# .aws/\n # .aws/\n# .aws/\n## .aws/\n#\t.aws/\n# .aws/ \n#contexts/**/.terraform/\n# contexts/**/.terraform/ " - commentedContent = strings.ReplaceAll(commentedContent, "#\t.aws/", "#\t.aws/") - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - expectedPath := filepath.Join("mock", "project", "root", ".gitignore") - if path == expectedPath { - return []byte(commentedContent), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) - } - - // And WriteFile is mocked to verify the content - var writtenContent []byte - mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { - writtenContent = content - return nil - } - - // When Write is called - err := generator.Generate(nil) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the content should preserve all commented lines and not add uncommented duplicates - actualContent := string(writtenContent) - commentVariants := []string{ - "# .aws/", - " # .aws/", - "# .aws/", - "## .aws/", - "#\t.aws/", - "# .aws/ ", - "#contexts/**/.terraform/", - "# contexts/**/.terraform/ ", - } - commentVariants[4] = "#\t.aws/" - for i, variant := range commentVariants { - if i == 4 { - variant = "#\t.aws/" - } - if !strings.Contains(actualContent, variant) { - t.Errorf("expected content to preserve commented variant: %q", variant) - } - } - - // Check that uncommented versions are NOT added when any commented version exists - lines := strings.Split(actualContent, "\n") - hasUncommentedAws := false - hasUncommentedTerraform := false - for _, line := range lines { - if strings.TrimSpace(line) == ".aws/" { - hasUncommentedAws = true - } - if strings.TrimSpace(line) == "contexts/**/.terraform/" { - hasUncommentedTerraform = true - } - } - if hasUncommentedAws { - t.Errorf("expected content to not add uncommented .aws/ when any commented version exists") - } - if hasUncommentedTerraform { - t.Errorf("expected content to not add uncommented contexts/**/.terraform/ when any commented version exists") - } - }) -} - -func TestGitGenerator_Generate(t *testing.T) { - setup := func(t *testing.T) (*GitGenerator, *Mocks) { - mocks := setupMocks(t) - generator := NewGitGenerator(mocks.Injector) - generator.shims = mocks.Shims - if err := generator.Initialize(); err != nil { - t.Fatalf("failed to initialize GitGenerator: %v", err) - } - return generator, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a GitGenerator with mocks - generator, mocks := setup(t) - - // And GetProjectRoot is mocked to return a specific path - mocks.Shell.GetProjectRootFunc = func() (string, error) { - return filepath.Join("mock", "project", "root"), nil - } - - // And ReadFile is mocked to return existing content - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - expectedPath := filepath.Join("mock", "project", "root", ".gitignore") - if path == expectedPath { - return []byte(gitGenTestExistingContent), nil - } - return nil, fmt.Errorf("unexpected file read: %s", path) - } - - // And WriteFile is mocked to verify the content - var writtenPath string - var writtenContent []byte - mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { - writtenPath = path - writtenContent = content - return nil - } - - // When Generate is called with any data (should be ignored) - err := generator.Generate(map[string]any{ - "terraform/some/module": map[string]any{"ignored": "data"}, - }) - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - - // And the file should be written to the correct path - expectedPath := filepath.Join("mock", "project", "root", ".gitignore") - if writtenPath != expectedPath { - t.Errorf("expected filename %s, got %s", expectedPath, writtenPath) - } - - // And the content should be correct - expectedContent := gitGenTestExpectedContent - actualContent := string(writtenContent) - if actualContent != expectedContent { - // Trim trailing whitespace and newlines for robust comparison - trimmedExpected := expectedContent - trimmedActual := actualContent - for len(trimmedExpected) > 0 && (trimmedExpected[len(trimmedExpected)-1] == '\n' || trimmedExpected[len(trimmedExpected)-1] == '\r') { - trimmedExpected = trimmedExpected[:len(trimmedExpected)-1] - } - for len(trimmedActual) > 0 && (trimmedActual[len(trimmedActual)-1] == '\n' || trimmedActual[len(trimmedActual)-1] == '\r') { - trimmedActual = trimmedActual[:len(trimmedActual)-1] - } - if trimmedActual != trimmedExpected { - t.Errorf("expected content %q, got %q", trimmedExpected, trimmedActual) - } - } - }) -} diff --git a/pkg/generators/mock_generator.go b/pkg/generators/mock_generator.go deleted file mode 100644 index 72bc9d443..000000000 --- a/pkg/generators/mock_generator.go +++ /dev/null @@ -1,54 +0,0 @@ -package generators - -// The MockGenerator is a testing component that provides a mock implementation of the Generator interface. -// It provides customizable function fields for testing different Generator behaviors. -// The MockGenerator enables isolated testing of components that depend on the Generator interface, -// allowing for controlled simulation of Generator operations in test scenarios. - -// ============================================================================= -// Types -// ============================================================================= - -// MockGenerator is a mock implementation of the Generator interface for testing purposes -type MockGenerator struct { - InitializeFunc func() error - GenerateFunc func(data map[string]any, overwrite ...bool) error -} - -// ============================================================================= -// Constructor -// ============================================================================= - -// NewMockGenerator creates a new instance of MockGenerator -func NewMockGenerator() *MockGenerator { - return &MockGenerator{} -} - -// ============================================================================= -// Public Methods -// ============================================================================= - -// Initialize calls the mock InitializeFunc if set, otherwise returns nil -func (m *MockGenerator) Initialize() error { - if m.InitializeFunc != nil { - return m.InitializeFunc() - } - return nil -} - - - -// Generate calls the mock GenerateFunc if set, otherwise returns nil -func (m *MockGenerator) Generate(data map[string]any, overwrite ...bool) error { - if m.GenerateFunc != nil { - return m.GenerateFunc(data, overwrite...) - } - return nil -} - -// ============================================================================= -// Interface Compliance -// ============================================================================= - -// Ensure MockGenerator implements Generator -var _ Generator = (*MockGenerator)(nil) diff --git a/pkg/generators/mock_generator_test.go b/pkg/generators/mock_generator_test.go deleted file mode 100644 index 59421bcce..000000000 --- a/pkg/generators/mock_generator_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package generators - -import ( - "fmt" - "testing" -) - -// ============================================================================= -// Test Public Methods -// ============================================================================= - -func TestMockGenerator_Initialize(t *testing.T) { - t.Run("Initialize", func(t *testing.T) { - // Given a new MockGenerator - mock := NewMockGenerator() - - // And the InitializeFunc is set to return nil - mock.InitializeFunc = func() error { - return nil - } - - // When Initialize is called - err := mock.Initialize() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) - - t.Run("NoInitializeFunc", func(t *testing.T) { - // Given a new MockGenerator - mock := NewMockGenerator() - - // When Initialize is called without setting InitializeFunc - err := mock.Initialize() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) -} - -func TestMockGenerator_Generate(t *testing.T) { - // Given a mock generate error - mockGenerateErr := fmt.Errorf("mock generate error") - - t.Run("WithFuncSet", func(t *testing.T) { - // Given a new MockGenerator - mock := NewMockGenerator() - - // And the GenerateFunc is set to return a mock error - mock.GenerateFunc = func(data map[string]any, overwrite ...bool) error { - return mockGenerateErr - } - - // When Generate is called - err := mock.Generate(map[string]any{"test": "data"}) - - // Then the mock error should be returned - if err != mockGenerateErr { - t.Errorf("Expected error = %v, got = %v", mockGenerateErr, err) - } - }) - - t.Run("WithNoFuncSet", func(t *testing.T) { - // Given a new MockGenerator - mock := NewMockGenerator() - - // When Generate is called without setting GenerateFunc - err := mock.Generate(map[string]any{"test": "data"}) - - // Then no error should be returned - if err != nil { - t.Errorf("Expected error = %v, got = %v", nil, err) - } - }) -} diff --git a/pkg/generators/shims.go b/pkg/generators/shims.go deleted file mode 100644 index 4e30b2ba0..000000000 --- a/pkg/generators/shims.go +++ /dev/null @@ -1,75 +0,0 @@ -package generators - -import ( - "archive/tar" - "bytes" - "encoding/json" - "io" - "os" - "path/filepath" - - "github.com/goccy/go-yaml" -) - -// The shims package is a system call abstraction layer for the generators package -// It provides mockable wrappers around system and runtime functions -// It serves as a testing aid by allowing system calls to be intercepted -// It enables dependency injection and test isolation for system-level operations - -// ============================================================================= -// Types -// ============================================================================= - -// 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) - 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 - 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 -} - -// ============================================================================= -// Helpers -// ============================================================================= - -// 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, - 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) }, - 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 deleted file mode 100644 index aab2b66a0..000000000 --- a/pkg/generators/terraform_generator.go +++ /dev/null @@ -1,593 +0,0 @@ -package generators - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "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/di" - "github.com/zclconf/go-cty/cty" -) - -// The TerraformGenerator is a specialized component that manages Terraform configuration files. -// It provides functionality to create and update Terraform modules, variables, and tfvars files. -// The TerraformGenerator ensures proper infrastructure-as-code configuration for Windsor projects, -// maintaining consistent Terraform structure across all contexts. - -// ============================================================================= -// Types -// ============================================================================= - -// TerraformGenerator is a generator that writes Terraform files -type TerraformGenerator struct { - BaseGenerator - reset bool -} - -// VariableInfo holds metadata for a single Terraform variable -type VariableInfo struct { - Name string - Description string - Default any - Sensitive bool -} - -// ============================================================================= -// Constructor -// ============================================================================= - -// NewTerraformGenerator creates a new TerraformGenerator with the provided dependency injector. -// It initializes the base generator and prepares it for Terraform file generation. -func NewTerraformGenerator(injector di.Injector) *TerraformGenerator { - return &TerraformGenerator{ - BaseGenerator: *NewGenerator(injector), - } -} - -// ============================================================================= -// Public Methods -// ============================================================================= - -// Generate creates Terraform configuration files, including tfvars files, for all blueprint components. -// It processes template data keyed by "terraform/", generating tfvars files at -// contexts//terraform/.tfvars. For each entry in the input data, it skips keys -// not prefixed with "terraform/" and skips components not present in the blueprint. For all components -// in the blueprint, it ensures a tfvars file is generated if not already handled by the input data. -// The method uses the blueprint handler to retrieve TerraformComponents and determines the variables.tf -// location based on component source (remote or local). Module resolution is handled by pkg/terraform. -func (g *TerraformGenerator) Generate(data map[string]any, overwrite ...bool) error { - shouldOverwrite := false - if len(overwrite) > 0 { - shouldOverwrite = overwrite[0] - } - g.reset = shouldOverwrite - - contextPath, err := g.configHandler.GetConfigRoot() - if err != nil { - return fmt.Errorf("failed to get config root: %w", err) - } - - projectRoot, err := g.shell.GetProjectRoot() - if err != nil { - return fmt.Errorf("failed to get project root: %w", err) - } - - components := g.blueprintHandler.GetTerraformComponents() - - for _, component := range components { - componentValues := component.Inputs - if componentValues == nil { - componentValues = make(map[string]any) - } - - if err := g.generateComponentTfvars(projectRoot, contextPath, component, componentValues); err != nil { - return fmt.Errorf("failed to generate tfvars for component %s: %w", component.Path, err) - } - } - - return nil -} - -// ============================================================================= -// Private Methods -// ============================================================================= - -// checkExistingTfvarsFile checks if a tfvars file exists and is readable. -// Returns os.ErrExist if the file exists and is readable, or an error if the file exists but is not readable. -func (g *TerraformGenerator) checkExistingTfvarsFile(tfvarsFilePath string) error { - _, err := g.shims.Stat(tfvarsFilePath) - if err == nil { - _, err := g.shims.ReadFile(tfvarsFilePath) - if err != nil { - return fmt.Errorf("failed to read existing tfvars file: %w", err) - } - return os.ErrExist - } else if !os.IsNotExist(err) { - return fmt.Errorf("error checking tfvars file: %w", err) - } - return nil -} - -// parseVariablesFile parses variables.tf and returns metadata about the variables. -// It extracts variable names, descriptions, default values, and sensitivity flags. -// Protected values are excluded from the returned metadata. -func (g *TerraformGenerator) parseVariablesFile(variablesTfPath string, protectedValues map[string]bool) ([]VariableInfo, error) { - variablesContent, err := g.shims.ReadFile(variablesTfPath) - if err != nil { - return nil, fmt.Errorf("failed to read variables.tf: %w", err) - } - - variablesFile, diags := hclwrite.ParseConfig(variablesContent, variablesTfPath, hcl.Pos{Line: 1, Column: 1}) - if diags.HasErrors() { - return nil, fmt.Errorf("failed to parse variables.tf: %w", diags) - } - - var variables []VariableInfo - for _, block := range variablesFile.Body().Blocks() { - if block.Type() == "variable" && len(block.Labels()) > 0 { - variableName := block.Labels()[0] - - if protectedValues[variableName] { - continue - } - - info := VariableInfo{ - Name: variableName, - } - - if attr := block.Body().GetAttribute("description"); attr != nil { - exprBytes := attr.Expr().BuildTokens(nil).Bytes() - parsedExpr, diags := hclsyntax.ParseExpression(exprBytes, "description", hcl.Pos{Line: 1, Column: 1}) - if !diags.HasErrors() { - val, diags := parsedExpr.Value(nil) - if !diags.HasErrors() && val.Type() == cty.String { - info.Description = val.AsString() - } - } - } - - if attr := block.Body().GetAttribute("sensitive"); attr != nil { - exprBytes := attr.Expr().BuildTokens(nil).Bytes() - parsedExpr, diags := hclsyntax.ParseExpression(exprBytes, "sensitive", hcl.Pos{Line: 1, Column: 1}) - if !diags.HasErrors() { - val, diags := parsedExpr.Value(nil) - if !diags.HasErrors() && val.Type() == cty.Bool { - info.Sensitive = val.True() - } - } - } - - if attr := block.Body().GetAttribute("default"); attr != nil { - exprBytes := attr.Expr().BuildTokens(nil).Bytes() - parsedExpr, diags := hclsyntax.ParseExpression(exprBytes, "default", hcl.Pos{Line: 1, Column: 1}) - if !diags.HasErrors() { - val, diags := parsedExpr.Value(nil) - if !diags.HasErrors() { - info.Default = convertFromCtyValue(val) - } - } - } - - variables = append(variables, info) - } - } - - return variables, nil -} - -// generateComponentTfvars generates tfvars files for a single Terraform component. -// For components with a non-empty Source, only the module tfvars file is generated at .windsor/.tf_modules//terraform.tfvars. -// For components with an empty Source, only the context tfvars file is generated at /terraform/.tfvars. -// Returns an error if variables.tf cannot be found or if tfvars file generation fails. -func (g *TerraformGenerator) generateComponentTfvars(projectRoot, contextPath string, component blueprintv1alpha1.TerraformComponent, componentValues map[string]any) error { - variablesTfPath, err := g.findVariablesTfFileForComponent(projectRoot, component) - if err != nil { - return fmt.Errorf("failed to find variables.tf for component %s: %w", component.Path, err) - } - - if component.Source != "" { - moduleTfvarsPath := filepath.Join(projectRoot, ".windsor", ".tf_modules", component.Path, "terraform.tfvars") - if err := g.removeTfvarsFiles(filepath.Dir(moduleTfvarsPath)); err != nil { - return fmt.Errorf("failed cleaning existing .tfvars in module dir %s: %w", filepath.Dir(moduleTfvarsPath), err) - } - if err := g.generateTfvarsFile(moduleTfvarsPath, variablesTfPath, componentValues, component.Source); err != nil { - return fmt.Errorf("failed to generate module tfvars file: %w", err) - } - } else { - terraformKey := "terraform/" + component.Path - tfvarsFilePath := filepath.Join(contextPath, terraformKey+".tfvars") - if err := g.generateTfvarsFile(tfvarsFilePath, variablesTfPath, componentValues, component.Source); err != nil { - return fmt.Errorf("failed to generate context tfvars file: %w", err) - } - } - - return nil -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -// addTfvarsHeader adds a Windsor CLI management header to the tfvars file body. -// It includes a module source comment if provided, ensuring users are aware of CLI management and module provenance. -func addTfvarsHeader(body *hclwrite.Body, source string) { - windsorHeaderToken := "Managed by Windsor CLI:" - headerComment := fmt.Sprintf("# %s This file is partially managed by the windsor CLI. Your changes will not be overwritten.", windsorHeaderToken) - body.AppendUnstructuredTokens(hclwrite.Tokens{ - {Type: hclsyntax.TokenComment, Bytes: []byte(headerComment + "\n")}, - }) - if source != "" { - body.AppendUnstructuredTokens(hclwrite.Tokens{ - {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# Module source: %s\n", source))}, - }) - } -} - -// writeComponentValues writes all component-provided or default variable values to the tfvars file body. -// It comments out default values and descriptions for unset variables, and writes explicit values for set variables. -// Handles sensitive variables and preserves variable order from variables.tf. -func writeComponentValues(body *hclwrite.Body, values map[string]any, protectedValues map[string]bool, variables []VariableInfo) { - for _, info := range variables { - if protectedValues[info.Name] { - continue - } - - body.AppendNewline() - - if info.Description != "" { - body.AppendUnstructuredTokens(hclwrite.Tokens{ - {Type: hclsyntax.TokenComment, Bytes: []byte("# " + info.Description)}, - }) - body.AppendNewline() - } - - if val, exists := values[info.Name]; exists { - writeVariable(body, info.Name, val, []VariableInfo{}) - continue - } - - if info.Sensitive { - body.AppendUnstructuredTokens(hclwrite.Tokens{ - {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# %s = \"(sensitive)\"", info.Name))}, - }) - body.AppendNewline() - continue - } - - if info.Default != nil { - defaultVal := convertToCtyValue(info.Default) - if !defaultVal.IsNull() { - var rendered string - if defaultVal.Type().IsObjectType() || defaultVal.Type().IsMapType() { - var mapStr strings.Builder - mapStr.WriteString(fmt.Sprintf("%s = %s", info.Name, formatValue(convertFromCtyValue(defaultVal)))) - rendered = mapStr.String() - } else { - rendered = fmt.Sprintf("%s = %s", info.Name, string(hclwrite.TokensForValue(defaultVal).Bytes())) - } - for _, line := range strings.Split(rendered, "\n") { - body.AppendUnstructuredTokens(hclwrite.Tokens{ - {Type: hclsyntax.TokenComment, Bytes: []byte("# " + line)}, - }) - body.AppendNewline() - } - continue - } - } - - body.AppendUnstructuredTokens(hclwrite.Tokens{ - {Type: hclsyntax.TokenComment, Bytes: []byte(fmt.Sprintf("# %s = null", info.Name))}, - }) - body.AppendNewline() - } -} - -// writeHeredoc writes a multi-line string value as a heredoc assignment in the tfvars file body. -// Used for YAML or other multi-line string values to preserve formatting. -func writeHeredoc(body *hclwrite.Body, name string, content string) { - tokens := hclwrite.Tokens{ - {Type: hclsyntax.TokenOHeredoc, Bytes: []byte("</variables.tf under the project root. -// If the component has an empty Source, the path is terraform//variables.tf under the project root. -// Returns the variables.tf file path if it exists, or an error if not found. -func (g *TerraformGenerator) findVariablesTfFileForComponent(projectRoot string, component blueprintv1alpha1.TerraformComponent) (string, error) { - var variablesTfPath string - - if component.Source != "" { - variablesTfPath = filepath.Join(projectRoot, ".windsor", ".tf_modules", component.Path, "variables.tf") - } else { - variablesTfPath = filepath.Join(projectRoot, "terraform", component.Path, "variables.tf") - } - - if _, err := g.shims.Stat(variablesTfPath); err != nil { - return "", fmt.Errorf("variables.tf not found for component %s at %s", component.Path, variablesTfPath) - } - - return variablesTfPath, nil -} - -// generateTfvarsFile generates a tfvars file at the specified path using the provided variables.tf file and component values. -// It parses the variables.tf file to extract variable definitions, merges them with the given component values (excluding protected values), -// and writes a formatted tfvars file. If the file already exists and reset mode is not enabled, the function skips writing. -// The function ensures the parent directory exists and returns an error if any file or directory operation fails. -func (g *TerraformGenerator) generateTfvarsFile(tfvarsFilePath, variablesTfPath string, componentValues map[string]any, source string) error { - protectedValues := map[string]bool{ - "context_path": true, - "os_type": true, - "context_id": true, - } - - if !g.reset { - if err := g.checkExistingTfvarsFile(tfvarsFilePath); err != nil { - if err == os.ErrExist { - return nil - } - return err - } - } - - variables, err := g.parseVariablesFile(variablesTfPath, protectedValues) - if err != nil { - return fmt.Errorf("failed to parse variables.tf: %w", err) - } - - mergedFile := hclwrite.NewEmptyFile() - body := mergedFile.Body() - - addTfvarsHeader(body, source) - - if len(componentValues) > 0 { - writeComponentValues(body, componentValues, protectedValues, variables) - } else { - writeComponentValues(body, componentValues, map[string]bool{}, variables) - } - - parentDir := filepath.Dir(tfvarsFilePath) - if err := g.shims.MkdirAll(parentDir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - if err := g.shims.WriteFile(tfvarsFilePath, mergedFile.Bytes(), 0644); err != nil { - return fmt.Errorf("failed to write tfvars file: %w", err) - } - - return nil -} - -// removeTfvarsFiles removes any .tfvars files directly under the specified directory. -// This is used to ensure module directories do not retain stale tfvars prior to regeneration. -func (g *TerraformGenerator) removeTfvarsFiles(dir string) error { - if _, err := g.shims.Stat(dir); err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - entries, err := g.shims.ReadDir(dir) - if err != nil { - return err - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if strings.HasSuffix(strings.ToLower(name), ".tfvars") { - fullPath := filepath.Join(dir, name) - if err := g.shims.RemoveAll(fullPath); err != nil { - return err - } - } - } - - return nil -} - -// ============================================================================= -// Interface Compliance -// ============================================================================= - -// Ensure TerraformGenerator implements Generator -var _ Generator = (*TerraformGenerator)(nil) diff --git a/pkg/generators/terraform_generator_test.go b/pkg/generators/terraform_generator_test.go deleted file mode 100644 index e8a536f3c..000000000 --- a/pkg/generators/terraform_generator_test.go +++ /dev/null @@ -1,1936 +0,0 @@ -package generators - -import ( - "fmt" - "io/fs" - "math/big" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/goccy/go-yaml" - - "github.com/hashicorp/hcl/v2/hclwrite" - blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/context/config" - bundler "github.com/windsorcli/cli/pkg/composer/artifact" - "github.com/zclconf/go-cty/cty" -) - -// ============================================================================= -// Test Setup -// ============================================================================= - -type simpleDirEntry struct { - name string - isDir bool -} - -func (s *simpleDirEntry) Name() string { - return s.name -} - -func (s *simpleDirEntry) IsDir() bool { - return s.isDir -} - -func (s *simpleDirEntry) Type() fs.FileMode { - if s.isDir { - return fs.ModeDir - } - return 0 -} - -func (s *simpleDirEntry) Info() (fs.FileInfo, error) { - return nil, fmt.Errorf("not implemented") -} - -// 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 -} - -// ============================================================================= -// Test Private Methods -// ============================================================================= - -func TestTerraformGenerator_checkExistingTfvarsFile(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("FileDoesNotExist", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And Stat is mocked to return not found - mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { - return nil, os.ErrNotExist - } - - // When checkExistingTfvarsFile is called - err := generator.checkExistingTfvarsFile("test.tfvars") - - // Then no error should occur - if err != nil { - t.Errorf("expected no error, got %v", err) - } - }) - - t.Run("FileExists", 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 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 - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return nil, fmt.Errorf("mock error reading file") - } - - // When checkExistingTfvarsFile is called - err := generator.checkExistingTfvarsFile("test.tfvars") - - // 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 file" - if err.Error() != expectedError { - t.Errorf("expected error %s, got %s", expectedError, err.Error()) - } - }) -} - -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" - - // 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())) - } - }) - - t.Run("WithoutSource", func(t *testing.T) { - // Given a body without source - file := hclwrite.NewEmptyFile() - body := file.Body() - - // When addTfvarsHeader is called - addTfvarsHeader(body, "") - - // 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_parseVariablesFile(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("AllVariableTypes", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And ReadFile is mocked to return variables with all types and attributes - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - 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 - } - - // 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) - } - - // 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", - }, - } - - // Verify each variable - if len(variables) != len(expectedVars) { - t.Errorf("expected %d variables, got %d", len(expectedVars), len(variables)) - } - - for _, v := range variables { - expected, exists := expectedVars[v.Name] - if !exists { - t.Errorf("unexpected variable %s", v.Name) - continue - } - - 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("ProtectedVariables", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And ReadFile is mocked to return variables - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(` -variable "protected_var" { - description = "Protected variable" - type = string -} - -variable "normal_var" { - description = "Normal variable" - type = string -}`), nil - } - - // And protected values are set - protectedValues := map[string]bool{ - "protected_var": true, - } - - // 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 only non-protected variables should be included - if len(variables) != 1 { - t.Errorf("expected 1 variable, got %d", len(variables)) - } - if variables[0].Name != "normal_var" { - t.Errorf("expected variable normal_var, got %s", variables[0].Name) - } - }) - - t.Run("InvalidHCL", func(t *testing.T) { - // Given a TerraformGenerator with mocks - generator, mocks := setup(t) - - // And ReadFile is mocked to return invalid HCL - mocks.Shims.ReadFile = func(path string) ([]byte, error) { - return []byte(`invalid hcl content`), nil - } - - // When parseVariablesFile is called - _, err := generator.parseVariablesFile("test.tf", map[string]bool{}) - - // Then an error should occur - if err == nil { - t.Error("expected error, got nil") - } - }) - - t.Run("ReadFileError", 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("read error") - } - - // When parseVariablesFile is called - _, err := generator.parseVariablesFile("test.tf", map[string]bool{}) - - // Then an error should occur - if err == nil { - t.Error("expected error, got nil") - } - expectedError := "failed to read variables.tf: read error" - if err.Error() != expectedError { - t.Errorf("expected error %q, got %q", expectedError, err.Error()) - } - }) -} - -// ============================================================================= -// Test Helpers -// ============================================================================= - -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: "StringSliceEmpty", - input: []string{}, - expected: cty.ListValEmpty(cty.String), - }, - { - name: "StringSliceNonEmpty", - input: []string{"a", "b"}, - expected: cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), - }, - { - name: "Unsupported", - input: struct{}{}, - expected: cty.NilVal, - }, - { - name: "Nil", - input: nil, - expected: cty.NilVal, - }, - } - - 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) - } - }) - } -} - -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, - }, - } - - 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 TestFormatValue(t *testing.T) { - t.Run("EmptyArray", func(t *testing.T) { - result := formatValue([]any{}) - if result != "[]" { - t.Errorf("expected [] got %q", result) - } - }) - - t.Run("EmptyMap", func(t *testing.T) { - result := formatValue(map[string]any{}) - if result != "{}" { - t.Errorf("expected {} got %q", result) - } - }) - - t.Run("StringSliceEmpty", func(t *testing.T) { - result := formatValue([]string{}) - if result != "[]" { - t.Errorf("expected [] got %q", result) - } - }) - - t.Run("NilValue", func(t *testing.T) { - result := formatValue(nil) - if result != "null" { - t.Errorf("expected null got %q", result) - } - }) - - 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, - }, - }, - } - 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) - } - }) - - t.Run("StringSliceNonEmpty", func(t *testing.T) { - result := formatValue([]string{"a", "b"}) - if result != "[\"a\", \"b\"]" { - t.Errorf("expected [\"a\", \"b\"] got %q", 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{}, - }, - } - 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) - } - }) -} - -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", - }, - } - values := map[string]any{ - "var2": "pinned_value", - } - protectedValues := map[string]bool{} - - // When writeComponentValues is called - writeComponentValues(body, values, protectedValues, variables) - - // Then the variables should be written in order with proper handling of sensitive values - expected := ` -# Variable 1 -# var1 = "(sensitive)" - -# Variable 2 -var2 = "pinned_value" - -# Variable 3 -# var3 = "default3" -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) - } - }) - - 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 writeComponentValues is called - writeComponentValues(file.Body(), map[string]any{}, map[string]bool{}, []VariableInfo{variable}) - - // 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) - } - } - // 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("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{}, - }, - } - - // When writeComponentValues is called - writeComponentValues(file.Body(), map[string]any{}, map[string]bool{}, []VariableInfo{variable}) - - // 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) - } - }) -} - -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", - }, - } - - // When writeComponentValues is called with empty values - writeComponentValues(body, nil, map[string]bool{}, variables) - - // Then the variables should be written in order with proper handling of sensitive values - expected := ` -# Variable 1 -# var1 = "(sensitive)" - -# Variable 2 -# var2 = "default2" - -# Variable 3 -# var3 = "default3" -` - if string(file.Bytes()) != expected { - t.Errorf("expected %q, got %q", expected, string(file.Bytes())) - } -} - -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, - }, - } - - // 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("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, - }, - } - - // When writeVariable is called - writeVariable(body, "test_var", "value", variables) - - // 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())) - } - }) - - 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", - }, - } - - // When writeVariable is called - writeVariable(body, "test_var", "value", variables) - - // 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("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", - }, - } - - // 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, "<