From fa75cbc6773074e932d565958fac4f10f9c8c9af Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:15:03 -0500 Subject: [PATCH] refactor(generator): Remove the generator package The generator package provided limited functionality used by two very specific modules. The generator is no longer relevant in our current architecture, so we've moved its functionality inline to the methods that need it. Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- pkg/composer/composer.go | 99 +- pkg/composer/terraform/module_resolver.go | 538 +++++- pkg/generators/generator.go | 96 - pkg/generators/generator_test.go | 471 ----- pkg/generators/git_generator.go | 155 -- pkg/generators/git_generator_test.go | 388 ---- pkg/generators/mock_generator.go | 54 - pkg/generators/mock_generator_test.go | 79 - pkg/generators/shims.go | 75 - pkg/generators/terraform_generator.go | 593 ------ pkg/generators/terraform_generator_test.go | 1936 -------------------- 11 files changed, 625 insertions(+), 3859 deletions(-) delete mode 100644 pkg/generators/generator.go delete mode 100644 pkg/generators/generator_test.go delete mode 100644 pkg/generators/git_generator.go delete mode 100644 pkg/generators/git_generator_test.go delete mode 100644 pkg/generators/mock_generator.go delete mode 100644 pkg/generators/mock_generator_test.go delete mode 100644 pkg/generators/shims.go delete mode 100644 pkg/generators/terraform_generator.go delete mode 100644 pkg/generators/terraform_generator_test.go 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, "<