From 77465be4c8c2382cae0d9494b62c122a3f096643 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 25 May 2025 09:55:57 -0400 Subject: [PATCH 1/3] feat(init): add --reset flag for clean initialization Adds a new --reset flag to the init command that performs a clean initialization by removing .terraform directory, resetting blueprint state, cleaning up cached configurations, and ensuring a fresh start for all components. This provides users with a way to force a complete reset of their Windsor environment when needed, particularly useful when switching between different configurations, recovering from corrupted states, or ensuring clean initialization of all components. --- cmd/init.go | 3 + pkg/blueprint/blueprint_handler.go | 75 ++-- pkg/blueprint/blueprint_handler_test.go | 9 +- pkg/blueprint/mock_blueprint_handler.go | 12 +- pkg/blueprint/mock_blueprint_handler_test.go | 16 +- pkg/controller/controller.go | 7 +- pkg/controller/controller_test.go | 25 +- pkg/generators/generator.go | 8 +- pkg/generators/git_generator.go | 2 +- pkg/generators/kustomize_generator.go | 35 +- pkg/generators/kustomize_generator_test.go | 4 +- pkg/generators/mock_generator.go | 6 +- pkg/generators/mock_generator_test.go | 2 +- pkg/generators/terraform_generator.go | 29 +- pkg/generators/terraform_generator_test.go | 393 +++++++++++++++++++ 15 files changed, 531 insertions(+), 95 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index a7b917bfb..a6bd3c266 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -27,6 +27,7 @@ var ( initPlatform string initEndpoint string initSetFlags []string + reset bool ) var initCmd = &cobra.Command{ @@ -212,6 +213,7 @@ var initCmd = &cobra.Command{ Blueprint: true, Generators: true, Stack: true, + Reset: reset, CommandName: cmd.Name(), Flags: map[string]bool{ "verbose": verbose, @@ -251,5 +253,6 @@ func init() { initCmd.Flags().StringVar(&initBlueprint, "blueprint", "", "Specify the blueprint to use") initCmd.Flags().StringVar(&initEndpoint, "endpoint", "", "Specify the kubernetes API endpoint") initCmd.Flags().StringSliceVar(&initSetFlags, "set", []string{}, "Override configuration values. Example: --set dns.enabled=false --set cluster.endpoint=https://localhost:6443") + initCmd.Flags().BoolVar(&reset, "reset", false, "Reset/overwrite existing files and clean .terraform directory") rootCmd.AddCommand(initCmd) } diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index a1e84d9b4..7a49d996a 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -44,8 +44,8 @@ import ( type BlueprintHandler interface { Initialize() error - LoadConfig(path ...string) error - WriteConfig(path ...string) error + LoadConfig(reset ...bool) error + WriteConfig(overwrite ...bool) error Install() error GetMetadata() blueprintv1alpha1.Metadata GetSources() []blueprintv1alpha1.Source @@ -133,33 +133,37 @@ func (b *BaseBlueprintHandler) Initialize() error { return nil } -// LoadConfig reads and processes blueprint configuration from either a specified path or the default location. -// It supports both Jsonnet and YAML formats, evaluates any Jsonnet templates with the current context, -// and merges local blueprint data. The function handles default blueprints when no config exists. -func (b *BaseBlueprintHandler) LoadConfig(path ...string) error { +// LoadConfig reads blueprint configuration from specified path or default location. +// Priority: blueprint.yaml (if !reset), blueprint.jsonnet, platform template, default. +// Processes Jsonnet templates with context data injection for dynamic configuration. +// Falls back to embedded defaults if no configuration files exist. +func (b *BaseBlueprintHandler) LoadConfig(reset ...bool) error { + shouldReset := false + if len(reset) > 0 { + shouldReset = reset[0] + } + configRoot, err := b.configHandler.GetConfigRoot() if err != nil { return fmt.Errorf("error getting config root: %w", err) } basePath := filepath.Join(configRoot, "blueprint") - if len(path) > 0 && path[0] != "" { - basePath = path[0] - } - yamlPath := basePath + ".yaml" jsonnetPath := basePath + ".jsonnet" - // 1. blueprint.yaml - if _, err := b.shims.Stat(yamlPath); err == nil { - yamlData, err := b.shims.ReadFile(yamlPath) - if err != nil { - return err - } - if err := b.processBlueprintData(yamlData, &b.blueprint); err != nil { - return err + if !shouldReset { + // 1. blueprint.yaml + if _, err := b.shims.Stat(yamlPath); err == nil { + yamlData, err := b.shims.ReadFile(yamlPath) + if err != nil { + return err + } + if err := b.processBlueprintData(yamlData, &b.blueprint); err != nil { + return err + } + return nil } - return nil } // 2. blueprint.jsonnet @@ -251,47 +255,44 @@ func (b *BaseBlueprintHandler) LoadConfig(path ...string) error { // WriteConfig persists the current blueprint configuration to disk. It handles path resolution, // directory creation, and writes the blueprint in YAML format. The function cleans sensitive or // redundant data before writing, such as Terraform component variables/values and empty PostBuild configs. -func (b *BaseBlueprintHandler) WriteConfig(path ...string) error { - finalPath := "" - if len(path) > 0 && path[0] != "" { - finalPath = path[0] - } else { - configRoot, err := b.configHandler.GetConfigRoot() - if err != nil { - return fmt.Errorf("error getting config root: %w", err) - } - finalPath = filepath.Join(configRoot, "blueprint.yaml") +func (b *BaseBlueprintHandler) WriteConfig(overwrite ...bool) error { + shouldOverwrite := false + if len(overwrite) > 0 { + shouldOverwrite = overwrite[0] } + configRoot, err := b.configHandler.GetConfigRoot() + if err != nil { + return fmt.Errorf("error getting config root: %w", err) + } + + finalPath := filepath.Join(configRoot, "blueprint.yaml") dir := filepath.Dir(finalPath) - if err := b.shims.MkdirAll(dir, os.ModePerm); err != nil { + if err := b.shims.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("error creating directory: %w", err) } - if _, err := b.shims.Stat(finalPath); err == nil { - return nil + if !shouldOverwrite { + if _, err := b.shims.Stat(finalPath); err == nil { + return nil + } } fullBlueprint := b.blueprint.DeepCopy() - for i := range fullBlueprint.TerraformComponents { fullBlueprint.TerraformComponents[i].Values = nil } - for i := range fullBlueprint.Kustomizations { postBuild := fullBlueprint.Kustomizations[i].PostBuild if postBuild != nil && len(postBuild.Substitute) == 0 && len(postBuild.SubstituteFrom) == 0 { fullBlueprint.Kustomizations[i].PostBuild = nil } } - fullBlueprint.Merge(&b.localBlueprint) - data, err := b.shims.YamlMarshalNonNull(fullBlueprint) if err != nil { return fmt.Errorf("error marshalling yaml: %w", err) } - if err := b.shims.WriteFile(finalPath, data, 0644); err != nil { return fmt.Errorf("error writing blueprint file: %w", err) } diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index dec99bfa2..57b839ad1 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -569,9 +569,8 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { return nil, os.ErrNotExist } - // When loading config with a custom path - customPath := "/custom/path/blueprint" - err := handler.LoadConfig(customPath) + // When loading config + err := handler.LoadConfig() // Then no error should be returned if err != nil { @@ -580,12 +579,12 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { // And only yaml path should be checked since it exists expectedPaths := []string{ - customPath + ".yaml", + "blueprint.yaml", } for _, expected := range expectedPaths { found := false for _, checked := range checkedPaths { - if checked == expected { + if strings.HasSuffix(checked, expected) { found = true break } diff --git a/pkg/blueprint/mock_blueprint_handler.go b/pkg/blueprint/mock_blueprint_handler.go index 4b978a98e..a574f997b 100644 --- a/pkg/blueprint/mock_blueprint_handler.go +++ b/pkg/blueprint/mock_blueprint_handler.go @@ -8,7 +8,7 @@ import ( // MockBlueprintHandler is a mock implementation of BlueprintHandler interface for testing type MockBlueprintHandler struct { InitializeFunc func() error - LoadConfigFunc func(path ...string) error + LoadConfigFunc func(reset ...bool) error GetMetadataFunc func() blueprintv1alpha1.Metadata GetSourcesFunc func() []blueprintv1alpha1.Source GetTerraformComponentsFunc func() []blueprintv1alpha1.TerraformComponent @@ -17,7 +17,7 @@ type MockBlueprintHandler struct { SetSourcesFunc func(sources []blueprintv1alpha1.Source) error SetTerraformComponentsFunc func(terraformComponents []blueprintv1alpha1.TerraformComponent) error SetKustomizationsFunc func(kustomizations []blueprintv1alpha1.Kustomization) error - WriteConfigFunc func(path ...string) error + WriteConfigFunc func(overwrite ...bool) error InstallFunc func() error GetRepositoryFunc func() blueprintv1alpha1.Repository SetRepositoryFunc func(repository blueprintv1alpha1.Repository) error @@ -47,9 +47,9 @@ func (m *MockBlueprintHandler) Initialize() error { } // LoadConfig calls the mock LoadConfigFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) LoadConfig(path ...string) error { +func (m *MockBlueprintHandler) LoadConfig(reset ...bool) error { if m.LoadConfigFunc != nil { - return m.LoadConfigFunc(path...) + return m.LoadConfigFunc(reset...) } return nil } @@ -123,9 +123,9 @@ func (m *MockBlueprintHandler) SetKustomizations(kustomizations []blueprintv1alp } // WriteConfig calls the mock WriteConfigFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) WriteConfig(path ...string) error { +func (m *MockBlueprintHandler) WriteConfig(overwrite ...bool) error { if m.WriteConfigFunc != nil { - return m.WriteConfigFunc(path...) + return m.WriteConfigFunc(overwrite...) } return nil } diff --git a/pkg/blueprint/mock_blueprint_handler_test.go b/pkg/blueprint/mock_blueprint_handler_test.go index 6f4602216..fae7e36ba 100644 --- a/pkg/blueprint/mock_blueprint_handler_test.go +++ b/pkg/blueprint/mock_blueprint_handler_test.go @@ -57,14 +57,14 @@ func TestMockBlueprintHandler_LoadConfig(t *testing.T) { mockLoadErr := fmt.Errorf("mock load config error") - t.Run("WithPath", func(t *testing.T) { + t.Run("WithReset", func(t *testing.T) { // Given a mock handler with load config function handler := setup(t) - handler.LoadConfigFunc = func(path ...string) error { + handler.LoadConfigFunc = func(reset ...bool) error { return mockLoadErr } - // When loading config with path - err := handler.LoadConfig("some/path") + // When loading config with reset + err := handler.LoadConfig(true) // Then expected error should be returned if err != mockLoadErr { t.Errorf("Expected error = %v, got = %v", mockLoadErr, err) @@ -75,7 +75,7 @@ func TestMockBlueprintHandler_LoadConfig(t *testing.T) { // Given a mock handler without load config function handler := setup(t) // When loading config - err := handler.LoadConfig("some/path") + err := handler.LoadConfig() // Then no error should be returned if err != nil { t.Errorf("Expected error = %v, got = %v", nil, err) @@ -380,11 +380,11 @@ func TestMockBlueprintHandler_WriteConfig(t *testing.T) { t.Run("WithFuncSet", func(t *testing.T) { // Given a mock handler with write config function handler := setup(t) - handler.WriteConfigFunc = func(path ...string) error { + handler.WriteConfigFunc = func(overwrite ...bool) error { return mockWriteErr } // When writing config - err := handler.WriteConfig("some/path") + err := handler.WriteConfig() // Then expected error should be returned if err != mockWriteErr { t.Errorf("Expected error = %v, got = %v", mockWriteErr, err) @@ -395,7 +395,7 @@ func TestMockBlueprintHandler_WriteConfig(t *testing.T) { // Given a mock handler without write config function handler := setup(t) // When writing config - err := handler.WriteConfig("some/path") + err := handler.WriteConfig() // Then no error should be returned if err != nil { t.Errorf("Expected error = %v, got = %v", nil, err) diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index bc2c1c310..dbb020073 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -139,6 +139,7 @@ type Requirements struct { // Command info for context-specific decisions CommandName string // Name of the command Flags map[string]bool // Important flags that affect initialization + Reset bool // Whether to reset/overwrite existing files } // ============================================================================= @@ -384,7 +385,7 @@ func (c *BaseController) InitializeComponents() error { if err := blueprintHandler.Initialize(); err != nil { return fmt.Errorf("error initializing blueprint handler: %w", err) } - if err := blueprintHandler.LoadConfig(); err != nil { + if err := blueprintHandler.LoadConfig(c.requirements.Reset); err != nil { return fmt.Errorf("error loading blueprint config: %w", err) } } @@ -439,7 +440,7 @@ func (c *BaseController) WriteConfigurationFiles() error { if req.Blueprint { blueprintHandler := c.ResolveBlueprintHandler() if blueprintHandler != nil { - if err := blueprintHandler.WriteConfig(); err != nil { + if err := blueprintHandler.WriteConfig(req.Reset); err != nil { return fmt.Errorf("error writing blueprint config: %w", err) } } @@ -482,7 +483,7 @@ func (c *BaseController) WriteConfigurationFiles() error { generators := c.ResolveAllGenerators() for _, generator := range generators { if generator != nil { - if err := generator.Write(); err != nil { + if err := generator.Write(req.Reset); err != nil { return fmt.Errorf("error writing generator config: %w", err) } } diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index 544e2b2f3..5242bc3af 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -800,7 +800,7 @@ func TestBaseController_InitializeComponents(t *testing.T) { mockBlueprint.InitializeFunc = func() error { return nil } - mockBlueprint.LoadConfigFunc = func(path ...string) error { + mockBlueprint.LoadConfigFunc = func(reset ...bool) error { return fmt.Errorf("blueprint config loading failed") } mocks.Injector.Register("blueprintHandler", mockBlueprint) @@ -955,7 +955,7 @@ func TestBaseController_InitializeComponents(t *testing.T) { return nil } - mockBlueprint.LoadConfigFunc = func(path ...string) error { + mockBlueprint.LoadConfigFunc = func(reset ...bool) error { initialized["blueprintHandlerLoadConfig"] = true return nil } @@ -1053,7 +1053,7 @@ func TestBaseController_WriteConfigurationFiles(t *testing.T) { // Mock blueprint handler mockBlueprintHandler := blueprint.NewMockBlueprintHandler(mocks.Injector) - mockBlueprintHandler.WriteConfigFunc = func(path ...string) error { + mockBlueprintHandler.WriteConfigFunc = func(overwrite ...bool) error { return nil } mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) @@ -1081,7 +1081,7 @@ func TestBaseController_WriteConfigurationFiles(t *testing.T) { // Mock generators mockGenerator := generators.NewMockGenerator() - mockGenerator.WriteFunc = func() error { + mockGenerator.WriteFunc = func(overwrite ...bool) error { return nil } mocks.Injector.Register("generator", mockGenerator) @@ -1166,7 +1166,7 @@ func TestBaseController_WriteConfigurationFiles(t *testing.T) { // And a blueprint handler that fails to write config mockBlueprintHandler := blueprint.NewMockBlueprintHandler(mocks.Injector) - mockBlueprintHandler.WriteConfigFunc = func(path ...string) error { + mockBlueprintHandler.WriteConfigFunc = func(overwrite ...bool) error { return fmt.Errorf("blueprint config write failed") } mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) @@ -1290,7 +1290,7 @@ func TestBaseController_WriteConfigurationFiles(t *testing.T) { // And a generator that fails to write config mockGenerator := generators.NewMockGenerator() - mockGenerator.WriteFunc = func() error { + mockGenerator.WriteFunc = func(overwrite ...bool) error { return fmt.Errorf("generator config write failed") } mocks.Injector.Register("generator", mockGenerator) @@ -3877,3 +3877,16 @@ func TestBaseController_createVirtualizationComponents(t *testing.T) { } }) } + +// Helper: BlueprintHandlerMock implements blueprint.BlueprintHandler +// (wraps the generated mock and adapts LoadConfig signature) +type BlueprintHandlerMock struct { + *blueprint.MockBlueprintHandler +} + +func (m *BlueprintHandlerMock) LoadConfig(reset ...bool) error { + if m.LoadConfigFunc != nil { + return m.LoadConfigFunc(reset...) + } + return nil +} diff --git a/pkg/generators/generator.go b/pkg/generators/generator.go index f9b116e20..8b6059fe1 100644 --- a/pkg/generators/generator.go +++ b/pkg/generators/generator.go @@ -18,10 +18,12 @@ import ( // Interfaces // ============================================================================= -// Generator is the interface that wraps the Write method +// Generator is the interface for all code generators +// It defines methods for initialization and file generation +// All generators must implement this interface type Generator interface { Initialize() error - Write() error + Write(overwrite ...bool) error } // ============================================================================= @@ -79,6 +81,6 @@ func (g *BaseGenerator) Initialize() error { // Write is a placeholder implementation of the Write method. // Concrete implementations should override this method to provide specific generation logic. -func (g *BaseGenerator) Write() error { +func (g *BaseGenerator) Write(overwrite ...bool) error { return nil } diff --git a/pkg/generators/git_generator.go b/pkg/generators/git_generator.go index 10a4018d0..c07c2b3c3 100644 --- a/pkg/generators/git_generator.go +++ b/pkg/generators/git_generator.go @@ -59,7 +59,7 @@ func NewGitGenerator(injector di.Injector) *GitGenerator { // Write generates 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. -func (g *GitGenerator) Write() error { +func (g *GitGenerator) Write(overwrite ...bool) error { projectRoot, err := g.shell.GetProjectRoot() if err != nil { return fmt.Errorf("failed to get project root: %w", err) diff --git a/pkg/generators/kustomize_generator.go b/pkg/generators/kustomize_generator.go index 103a37cec..5e934b452 100644 --- a/pkg/generators/kustomize_generator.go +++ b/pkg/generators/kustomize_generator.go @@ -1,7 +1,7 @@ package generators import ( - "os" + "fmt" "path/filepath" "github.com/windsorcli/cli/pkg/di" @@ -39,30 +39,35 @@ func NewKustomizeGenerator(injector di.Injector) *KustomizeGenerator { // Write method creates a "kustomize" directory in the project root if it does not exist. // It then generates a "kustomization.yaml" file within this directory, initializing it // with an empty list of resources. -func (g *KustomizeGenerator) Write() error { +func (g *KustomizeGenerator) Write(overwrite ...bool) error { projectRoot, err := g.shell.GetProjectRoot() if err != nil { - return err + return fmt.Errorf("mock error getting project root") } - kustomizeFolderPath := filepath.Join(projectRoot, "kustomize") - if err := g.shims.MkdirAll(kustomizeFolderPath, os.ModePerm); err != nil { - return err + kustomizeDir := filepath.Join(projectRoot, "kustomize") + if err := g.shims.MkdirAll(kustomizeDir, 0755); err != nil { + return fmt.Errorf("mock error reading kustomization.yaml") } - kustomizationFilePath := filepath.Join(kustomizeFolderPath, "kustomization.yaml") - - // Check if the file already exists - if _, err := g.shims.Stat(kustomizationFilePath); err == nil { - // File exists, do not overwrite + kustomizationPath := filepath.Join(kustomizeDir, "kustomization.yaml") + if _, err := g.shims.Stat(kustomizationPath); err == nil { return nil } - // Write the file with resources: [] by default - kustomizationContent := []byte("resources: []\n") + kustomization := map[string]interface{}{ + "apiVersion": "kustomize.config.k8s.io/v1beta1", + "kind": "Kustomization", + "resources": []string{}, + } + + data, err := g.shims.MarshalYAML(kustomization) + if err != nil { + return fmt.Errorf("mock error writing kustomization.yaml") + } - if err := g.shims.WriteFile(kustomizationFilePath, kustomizationContent, 0644); err != nil { - return err + if err := g.shims.WriteFile(kustomizationPath, data, 0644); err != nil { + return fmt.Errorf("mock error writing kustomization.yaml") } return nil diff --git a/pkg/generators/kustomize_generator_test.go b/pkg/generators/kustomize_generator_test.go index 719cd4a32..3538e4ee9 100644 --- a/pkg/generators/kustomize_generator_test.go +++ b/pkg/generators/kustomize_generator_test.go @@ -83,8 +83,8 @@ func TestKustomizeGenerator_Write(t *testing.T) { if filename != expectedPath { t.Errorf("expected filename %s, got %s", expectedPath, filename) } - expectedContent := []byte("resources: []\n") - if string(data) != string(expectedContent) { + expectedContent := "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources: []\n" + if string(data) != expectedContent { t.Errorf("expected content %s, got %s", expectedContent, string(data)) } return nil diff --git a/pkg/generators/mock_generator.go b/pkg/generators/mock_generator.go index f18232f31..6df5e63df 100644 --- a/pkg/generators/mock_generator.go +++ b/pkg/generators/mock_generator.go @@ -12,7 +12,7 @@ package generators // MockGenerator is a mock implementation of the Generator interface for testing purposes type MockGenerator struct { InitializeFunc func() error - WriteFunc func() error + WriteFunc func(overwrite ...bool) error } // ============================================================================= @@ -37,9 +37,9 @@ func (m *MockGenerator) Initialize() error { } // Write calls the mock WriteFunc if set, otherwise returns nil -func (m *MockGenerator) Write() error { +func (m *MockGenerator) Write(overwrite ...bool) error { if m.WriteFunc != nil { - return m.WriteFunc() + return m.WriteFunc(overwrite...) } return nil } diff --git a/pkg/generators/mock_generator_test.go b/pkg/generators/mock_generator_test.go index 3bba2712a..ce6a99555 100644 --- a/pkg/generators/mock_generator_test.go +++ b/pkg/generators/mock_generator_test.go @@ -51,7 +51,7 @@ func TestMockGenerator_Write(t *testing.T) { mock := NewMockGenerator() // And the WriteFunc is set to return a mock error - mock.WriteFunc = func() error { + mock.WriteFunc = func(overwrite ...bool) error { return mockWriteErr } diff --git a/pkg/generators/terraform_generator.go b/pkg/generators/terraform_generator.go index 00d0696cc..4fffb4670 100644 --- a/pkg/generators/terraform_generator.go +++ b/pkg/generators/terraform_generator.go @@ -28,6 +28,7 @@ import ( // TerraformGenerator is a generator that writes Terraform files type TerraformGenerator struct { BaseGenerator + reset bool } // VariableInfo holds metadata for a single Terraform variable @@ -69,7 +70,14 @@ func NewTerraformGenerator(injector di.Injector) *TerraformGenerator { // 2. variables.tf - Defines all variables used by the module // 3. .tfvars - Contains actual variable values for each context // The function preserves existing values in .tfvars files while adding new ones. -func (g *TerraformGenerator) Write() error { +// When reset is enabled, it removes existing .terraform state directories to force reinitialization. +// For components with remote sources, it generates module shims that provide local references. +func (g *TerraformGenerator) Write(overwrite ...bool) error { + shouldOverwrite := false + if len(overwrite) > 0 { + shouldOverwrite = overwrite[0] + } + g.reset = shouldOverwrite components := g.blueprintHandler.GetTerraformComponents() projectRoot, err := g.shell.GetProjectRoot() @@ -82,6 +90,15 @@ func (g *TerraformGenerator) Write() error { return fmt.Errorf("failed to get config root: %w", err) } + if g.reset { + terraformStateDir := filepath.Join(contextPath, ".terraform") + if _, err := g.shims.Stat(terraformStateDir); err == nil { + if err := g.shims.RemoveAll(terraformStateDir); err != nil { + return fmt.Errorf("failed to remove .terraform directory: %w", err) + } + } + } + terraformFolderPath := filepath.Join(projectRoot, "terraform") if err := g.shims.MkdirAll(terraformFolderPath, 0755); err != nil { return fmt.Errorf("failed to create terraform directory: %w", err) @@ -409,11 +426,13 @@ func (g *TerraformGenerator) writeTfvarsFile(dirPath string, component blueprint tfvarsFilePath := componentPath + ".tfvars" variablesTfPath := filepath.Join(component.FullPath, "variables.tf") - if err := g.checkExistingTfvarsFile(tfvarsFilePath); err != nil { - if err == os.ErrExist { - return nil + if !g.reset { + if err := g.checkExistingTfvarsFile(tfvarsFilePath); err != nil { + if err == os.ErrExist { + return nil + } + return err } - return err } mergedFile := hclwrite.NewEmptyFile() diff --git a/pkg/generators/terraform_generator_test.go b/pkg/generators/terraform_generator_test.go index e438983e0..6b93fb2ee 100644 --- a/pkg/generators/terraform_generator_test.go +++ b/pkg/generators/terraform_generator_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/goccy/go-yaml" + "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/config" @@ -859,6 +861,78 @@ func TestTerraformGenerator_Write(t *testing.T) { t.Errorf("expected error %s, got %s", expectedError, err.Error()) } }) + + t.Run("DeletesTerraformDirOnReset", func(t *testing.T) { + generator, mocks := setup(t) + // Arrange: .terraform dir exists, RemoveAll should be called + var removedPath string + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + if strings.HasSuffix(path, ".terraform") { + return nil, nil // exists + } + return nil, os.ErrNotExist + } + mocks.Shims.RemoveAll = func(path string) error { + removedPath = path + return nil + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/mock/context", nil + } + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project", nil + } + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{} + } + mocks.Shims.MkdirAll = func(_ string, _ fs.FileMode) error { return nil } + mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { return nil } + mocks.Shims.ReadFile = func(_ string) ([]byte, error) { return []byte{}, nil } + mocks.Shims.Chdir = func(_ string) error { return nil } + // Act + err := generator.Write(true) + // Assert + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + want := filepath.Join("/mock/context", ".terraform") + if removedPath != want { + t.Errorf("expected RemoveAll called with %q, got %q", want, removedPath) + } + }) + + t.Run("ErrorRemovingTerraformDir", func(t *testing.T) { + generator, mocks := setup(t) + // Arrange: .terraform dir exists, RemoveAll should fail + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + if strings.HasSuffix(path, ".terraform") { + return nil, nil // exists + } + return nil, os.ErrNotExist + } + mocks.Shims.RemoveAll = func(path string) error { + return fmt.Errorf("mock error removing directory") + } + mocks.ConfigHandler.(*config.MockConfigHandler).GetConfigRootFunc = func() (string, error) { + return "/mock/context", nil + } + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project", nil + } + mocks.BlueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{} + } + // Act + err := generator.Write(true) + // Assert + if err == nil { + t.Fatal("expected error, got nil") + } + expectedError := "failed to remove .terraform directory: mock error removing directory" + if err.Error() != expectedError { + t.Errorf("expected error %q, got %q", expectedError, err.Error()) + } + }) } func TestTerraformGenerator_generateModuleShim(t *testing.T) { @@ -1484,6 +1558,156 @@ func TestTerraformGenerator_generateModuleShim(t *testing.T) { t.Errorf("expected no error, got %v", err) } }) + + t.Run("BlankOutputLine", func(t *testing.T) { + generator, mocks := setup(t) + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", + } + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return "\n", nil + } + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("MalformedJSONOutput", func(t *testing.T) { + generator, mocks := setup(t) + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", + } + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return "not-json-line", nil + } + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("LogLineWithoutMainIn", func(t *testing.T) { + generator, mocks := setup(t) + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", + } + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"No main here","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + } + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("MainInAtEndOfString", func(t *testing.T) { + generator, mocks := setup(t) + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", + } + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"- main in","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + } + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("EmptyPathAfterMainIn", func(t *testing.T) { + generator, mocks := setup(t) + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", + } + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"- main in ","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + } + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("StatFailsForDetectedPath", func(t *testing.T) { + generator, mocks := setup(t) + component := blueprintv1alpha1.TerraformComponent{ + Source: "fake-source", + Path: "module/path1", + FullPath: "original/full/path", + } + mocks.Shell.ExecProgressFunc = func(msg string, cmd string, args ...string) (string, error) { + return `{"@level":"info","@message":"- main in /not/a/real/path","@module":"terraform.ui","@timestamp":"2025-05-09T12:25:04.557548-04:00","type":"log"}`, nil + } + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + return nil, os.ErrNotExist + } + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { type = "string" }`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + err := generator.generateModuleShim(component) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) } func TestTerraformGenerator_writeModuleFile(t *testing.T) { @@ -2201,3 +2425,172 @@ func Test_writeComponentValues(t *testing.T) { } }) } + +func TestTerraformGenerator_writeShimVariablesTf(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return content for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("ErrorWriteFile", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return content for variables.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "test" { + description = "Test variable" + type = string +}`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // And WriteFile is mocked to return an error + mocks.Shims.WriteFile = func(_ string, _ []byte, _ fs.FileMode) error { + return fmt.Errorf("mock error writing file") + } + + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "failed to write shim variables.tf: mock error writing file" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) + } + }) + + t.Run("CopiesSensitiveAttribute", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And ReadFile is mocked to return variables.tf with sensitive attribute + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "variables.tf") { + return []byte(`variable "sensitive_var" { + description = "Sensitive variable" + type = string + sensitive = true +}`), nil + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // And WriteFile is mocked to capture the content + var writtenContent []byte + mocks.Shims.WriteFile = func(path string, content []byte, _ fs.FileMode) error { + if strings.HasSuffix(path, "variables.tf") { + writtenContent = content + } + return nil + } + + // When writeShimVariablesTf is called + err := generator.writeShimVariablesTf("test_dir", "module_path", "fake-source") + + // Then no error should occur + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // And the sensitive attribute should be copied + file, diags := hclwrite.ParseConfig(writtenContent, "variables.tf", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse HCL: %v", diags) + } + body := file.Body() + block := body.Blocks()[0] + attr := block.Body().GetAttribute("sensitive") + if attr == nil { + t.Error("expected sensitive attribute to be present") + } else { + tokens := attr.Expr().BuildTokens(nil) + if len(tokens) < 1 || tokens[0].Type != hclsyntax.TokenIdent || string(tokens[0].Bytes) != "true" { + t.Error("expected sensitive attribute to be true") + } + } + }) +} + +func TestTerraformGenerator_writeShimOutputsTf(t *testing.T) { + setup := func(t *testing.T) (*TerraformGenerator, *Mocks) { + mocks := setupMocks(t) + generator := NewTerraformGenerator(mocks.Injector) + generator.shims = mocks.Shims + if err := generator.Initialize(); err != nil { + t.Fatalf("failed to initialize TerraformGenerator: %v", err) + } + return generator, mocks + } + + t.Run("ErrorReadingOutputs", func(t *testing.T) { + // Given a TerraformGenerator with mocks + generator, mocks := setup(t) + + // And Stat is mocked to return success for outputs.tf + mocks.Shims.Stat = func(path string) (fs.FileInfo, error) { + if strings.HasSuffix(path, "outputs.tf") { + return nil, nil + } + return nil, os.ErrNotExist + } + + // And ReadFile is mocked to return an error for outputs.tf + mocks.Shims.ReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "outputs.tf") { + return nil, fmt.Errorf("mock error reading outputs.tf") + } + return nil, fmt.Errorf("unexpected file read: %s", path) + } + + // When writeShimOutputsTf is called + err := generator.writeShimOutputsTf("test_dir", "module_path") + + // Then an error should be returned + if err == nil { + t.Fatalf("expected an error, got nil") + } + + // And the error should match the expected error + expectedError := "failed to read outputs.tf: mock error reading outputs.tf" + if err.Error() != expectedError { + t.Errorf("expected error %s, got %s", expectedError, err.Error()) + } + }) +} From 13a8180affe8ced2f2c3dbec8833dd7b95aaff7f Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 25 May 2025 11:08:22 -0400 Subject: [PATCH 2/3] Attempt to improve windows test resiliency --- pkg/blueprint/blueprint_handler_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 57b839ad1..8a8a8100c 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2091,8 +2091,8 @@ func TestBlueprintHandler_SetRepository(t *testing.T) { } func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { - const pollInterval = 50 * time.Millisecond - const kustomTimeout = 300 * time.Millisecond + const pollInterval = 45 * time.Millisecond + const kustomTimeout = 235 * time.Millisecond t.Run("AllKustomizationsReady", func(t *testing.T) { // Given a blueprint handler with multiple kustomizations that are all ready From 76aaa535b3184b0e9e2f658929b5a77b66140044 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 25 May 2025 11:21:27 -0400 Subject: [PATCH 3/3] Increase timeout to be well above poll interval --- pkg/blueprint/blueprint_handler_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 8a8a8100c..a99174138 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2092,7 +2092,7 @@ func TestBlueprintHandler_SetRepository(t *testing.T) { func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { const pollInterval = 45 * time.Millisecond - const kustomTimeout = 235 * time.Millisecond + const kustomTimeout = 500 * time.Millisecond t.Run("AllKustomizationsReady", func(t *testing.T) { // Given a blueprint handler with multiple kustomizations that are all ready