diff --git a/pkg/config/config_handler.go b/pkg/config/config_handler.go index 5f796824f..6cd63f705 100644 --- a/pkg/config/config_handler.go +++ b/pkg/config/config_handler.go @@ -21,6 +21,7 @@ type ConfigHandler interface { Initialize() error LoadConfig(path string) error LoadConfigString(content string) error + LoadContextConfig() error GetString(key string, defaultValue ...string) string GetInt(key string, defaultValue ...int) int GetBool(key string, defaultValue ...bool) bool @@ -176,6 +177,11 @@ func (c *BaseConfigHandler) IsLoaded() bool { return c.loaded } +// LoadContextConfig provides a base implementation that should be overridden by concrete implementations +func (c *BaseConfigHandler) LoadContextConfig() error { + return fmt.Errorf("LoadContextConfig not implemented in base config handler") +} + // SetSecretsProvider sets the secrets provider for the config handler func (c *BaseConfigHandler) SetSecretsProvider(provider secrets.SecretsProvider) { c.secretsProviders = append(c.secretsProviders, provider) diff --git a/pkg/config/config_handler_test.go b/pkg/config/config_handler_test.go index 2aaaff439..40e8ce7f4 100644 --- a/pkg/config/config_handler_test.go +++ b/pkg/config/config_handler_test.go @@ -684,3 +684,34 @@ func TestBaseConfigHandler_SetSecretsProvider(t *testing.T) { } }) } + +// ============================================================================= +// LoadContextConfig Tests +// ============================================================================= + +func TestBaseConfigHandler_LoadContextConfig(t *testing.T) { + setup := func(t *testing.T) *BaseConfigHandler { + t.Helper() + injector := di.NewInjector() + handler := NewBaseConfigHandler(injector) + return handler + } + + t.Run("ReturnsNotImplementedError", func(t *testing.T) { + // Given a BaseConfigHandler + handler := setup(t) + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then it should return the expected error + if err == nil { + t.Fatal("LoadContextConfig() expected error, got nil") + } + + expectedError := "LoadContextConfig not implemented in base config handler" + if err.Error() != expectedError { + t.Errorf("LoadContextConfig() error = %v, expected '%s'", err, expectedError) + } + }) +} diff --git a/pkg/config/mock_config_handler.go b/pkg/config/mock_config_handler.go index ad8fec983..6e8278c3a 100644 --- a/pkg/config/mock_config_handler.go +++ b/pkg/config/mock_config_handler.go @@ -10,6 +10,7 @@ type MockConfigHandler struct { InitializeFunc func() error LoadConfigFunc func(path string) error LoadConfigStringFunc func(content string) error + LoadContextConfigFunc func() error IsLoadedFunc func() bool GetStringFunc func(key string, defaultValue ...string) string GetIntFunc func(key string, defaultValue ...int) int @@ -68,6 +69,14 @@ func (m *MockConfigHandler) LoadConfigString(content string) error { return nil } +// LoadContextConfig calls the mock LoadContextConfigFunc if set, otherwise returns nil +func (m *MockConfigHandler) LoadContextConfig() error { + if m.LoadContextConfigFunc != nil { + return m.LoadContextConfigFunc() + } + return nil +} + // IsLoaded calls the mock IsLoadedFunc if set, otherwise returns false func (m *MockConfigHandler) IsLoaded() bool { if m.IsLoadedFunc != nil { diff --git a/pkg/config/mock_config_handler_test.go b/pkg/config/mock_config_handler_test.go index 0872c8864..38a413a3f 100644 --- a/pkg/config/mock_config_handler_test.go +++ b/pkg/config/mock_config_handler_test.go @@ -735,3 +735,75 @@ func TestMockConfigHandler_GenerateContextID(t *testing.T) { } }) } + +func TestMockConfigHandler_LoadContextConfig(t *testing.T) { + t.Run("WithFuncSet", func(t *testing.T) { + // Given a MockConfigHandler with LoadContextConfigFunc set + mockHandler := NewMockConfigHandler() + expectedError := fmt.Errorf("mocked load context config error") + mockHandler.LoadContextConfigFunc = func() error { + return expectedError + } + + // When LoadContextConfig is called + err := mockHandler.LoadContextConfig() + + // Then it should return the mocked error + if err != expectedError { + t.Errorf("LoadContextConfig() error = %v, expected %v", err, expectedError) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a MockConfigHandler with no LoadContextConfigFunc set + mockHandler := NewMockConfigHandler() + + // When LoadContextConfig is called + err := mockHandler.LoadContextConfig() + + // Then it should return nil + if err != nil { + t.Errorf("LoadContextConfig() error = %v, expected nil", err) + } + }) +} + +func TestMockConfigHandler_YamlMarshalWithDefinedPaths(t *testing.T) { + t.Run("WithFuncSet", func(t *testing.T) { + // Given a MockConfigHandler with YamlMarshalWithDefinedPathsFunc set + mockHandler := NewMockConfigHandler() + expectedResult := []byte("mocked: yaml") + expectedError := fmt.Errorf("mocked marshal error") + mockHandler.YamlMarshalWithDefinedPathsFunc = func(v any) ([]byte, error) { + return expectedResult, expectedError + } + + // When YamlMarshalWithDefinedPaths is called + result, err := mockHandler.YamlMarshalWithDefinedPaths("test") + + // Then it should return the mocked result and error + if string(result) != string(expectedResult) { + t.Errorf("YamlMarshalWithDefinedPaths() result = %v, expected %v", result, expectedResult) + } + if err != expectedError { + t.Errorf("YamlMarshalWithDefinedPaths() error = %v, expected %v", err, expectedError) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a MockConfigHandler with no YamlMarshalWithDefinedPathsFunc set + mockHandler := NewMockConfigHandler() + + // When YamlMarshalWithDefinedPaths is called + result, err := mockHandler.YamlMarshalWithDefinedPaths("test") + + // Then it should return the default YAML content and no error + expectedDefault := []byte("contexts:\n mock-context:\n dns:\n domain: mock.domain.com") + if string(result) != string(expectedDefault) { + t.Errorf("YamlMarshalWithDefinedPaths() result = %v, expected %v", result, expectedDefault) + } + if err != nil { + t.Errorf("YamlMarshalWithDefinedPaths() error = %v, expected nil", err) + } + }) +} diff --git a/pkg/config/yaml_config_handler.go b/pkg/config/yaml_config_handler.go index e632341eb..766f64720 100644 --- a/pkg/config/yaml_config_handler.go +++ b/pkg/config/yaml_config_handler.go @@ -77,6 +77,60 @@ func (y *YamlConfigHandler) LoadConfig(path string) error { return y.LoadConfigString(string(data)) } +// LoadContextConfig loads context-specific windsor.yaml configuration and merges it with the existing configuration. +// It looks for windsor.yaml files in the current context's directory (contexts//windsor.yaml). +// The context-specific configuration is expected to contain only the context configuration values without +// the top-level "contexts" key. These values are merged into the current context configuration. +func (y *YamlConfigHandler) LoadContextConfig() error { + if y.shell == nil { + return fmt.Errorf("shell not initialized") + } + + projectRoot, err := y.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error retrieving project root: %w", err) + } + + contextName := y.GetContext() + contextConfigDir := filepath.Join(projectRoot, "contexts", contextName) + + yamlPath := filepath.Join(contextConfigDir, "windsor.yaml") + ymlPath := filepath.Join(contextConfigDir, "windsor.yml") + + var contextConfigPath string + if _, err := y.shims.Stat(yamlPath); err == nil { + contextConfigPath = yamlPath + } else if _, err := y.shims.Stat(ymlPath); err == nil { + contextConfigPath = ymlPath + } + + if contextConfigPath == "" { + return nil + } + + data, err := y.shims.ReadFile(contextConfigPath) + if err != nil { + return fmt.Errorf("error reading context config file: %w", err) + } + + var contextConfig v1alpha1.Context + if err := y.shims.YamlUnmarshal(data, &contextConfig); err != nil { + return fmt.Errorf("error unmarshalling context yaml: %w", err) + } + + if y.config.Contexts == nil { + y.config.Contexts = make(map[string]*v1alpha1.Context) + } + + if y.config.Contexts[contextName] == nil { + y.config.Contexts[contextName] = &v1alpha1.Context{} + } + + y.config.Contexts[contextName].Merge(&contextConfig) + + return nil +} + // SaveConfig saves the current configuration to the specified path. If the path is empty, it uses the previously loaded path. // If overwrite is false and the file exists, it will not overwrite the file func (y *YamlConfigHandler) SaveConfig(path string, overwrite ...bool) error { diff --git a/pkg/config/yaml_config_handler_test.go b/pkg/config/yaml_config_handler_test.go index 57fc4ccd8..154921242 100644 --- a/pkg/config/yaml_config_handler_test.go +++ b/pkg/config/yaml_config_handler_test.go @@ -3358,3 +3358,307 @@ contexts: } }) } + +// ============================================================================= +// LoadContextConfig Tests +// ============================================================================= + +func TestYamlConfigHandler_LoadContextConfig(t *testing.T) { + setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewYamlConfigHandler(mocks.Injector) + handler.shims = mocks.Shims + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + return handler, mocks + } + + t.Run("SuccessWithContextConfig", func(t *testing.T) { + // Given a YamlConfigHandler with existing config + handler, mocks := setup(t) + + // Load base configuration + baseConfig := `version: v1alpha1 +contexts: + production: + provider: aws + environment: + BASE_VAR: base_value` + if err := handler.LoadConfigString(baseConfig); err != nil { + t.Fatalf("Failed to load base config: %v", err) + } + + // Set current context to production + if err := handler.SetContext("production"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + // Override the shim to return the correct context + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "production" + } + return "" + } + + // Create context-specific config file + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + contextDir := filepath.Join(projectRoot, "contexts", "production") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + contextConfigPath := filepath.Join(contextDir, "windsor.yaml") + contextConfig := `provider: local +environment: + CONTEXT_VAR: context_value + BASE_VAR: overridden_value +aws: + enabled: true` + + if err := os.WriteFile(contextConfigPath, []byte(contextConfig), 0644); err != nil { + t.Fatalf("Failed to write context config: %v", err) + } + + // Override shims to allow reading the actual context file + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return os.Stat(name) + } + handler.shims.ReadFile = func(filename string) ([]byte, error) { + return os.ReadFile(filename) + } + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadContextConfig() unexpected error: %v", err) + } + + // And the context configuration should be merged + if handler.GetString("provider") != "local" { + t.Errorf("Expected provider to be overridden to 'local', got '%s'", handler.GetString("provider")) + } + if handler.GetString("environment.CONTEXT_VAR") != "context_value" { + t.Errorf("Expected CONTEXT_VAR to be 'context_value', got '%s'", handler.GetString("environment.CONTEXT_VAR")) + } + if handler.GetString("environment.BASE_VAR") != "overridden_value" { + t.Errorf("Expected BASE_VAR to be overridden to 'overridden_value', got '%s'", handler.GetString("environment.BASE_VAR")) + } + if !handler.GetBool("aws.enabled") { + t.Error("Expected aws.enabled to be true") + } + }) + + t.Run("SuccessWithYmlExtension", func(t *testing.T) { + // Given a YamlConfigHandler + handler, mocks := setup(t) + if err := handler.SetContext("local"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + // Override the shim to return the correct context + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "local" + } + return "" + } + + // Create context-specific config file with .yml extension + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + contextDir := filepath.Join(projectRoot, "contexts", "local") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + contextConfigPath := filepath.Join(contextDir, "windsor.yml") + contextConfig := `provider: local +environment: + TEST_VAR: test_value` + + if err := os.WriteFile(contextConfigPath, []byte(contextConfig), 0644); err != nil { + t.Fatalf("Failed to write context config: %v", err) + } + + // Override shims to allow reading the actual context file + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return os.Stat(name) + } + handler.shims.ReadFile = func(filename string) ([]byte, error) { + return os.ReadFile(filename) + } + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadContextConfig() unexpected error: %v", err) + } + + // And the context configuration should be loaded + if handler.GetString("provider") != "local" { + t.Errorf("Expected provider to be 'local', got '%s'", handler.GetString("provider")) + } + if handler.GetString("environment.TEST_VAR") != "test_value" { + t.Errorf("Expected TEST_VAR to be 'test_value', got '%s'", handler.GetString("environment.TEST_VAR")) + } + }) + + t.Run("SuccessWithoutContextConfig", func(t *testing.T) { + // Given a YamlConfigHandler without context-specific config + handler, _ := setup(t) + if err := handler.SetContext("nonexistent"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadContextConfig() unexpected error: %v", err) + } + }) + + t.Run("ErrorReadingContextConfigFile", func(t *testing.T) { + // Given a YamlConfigHandler + handler, mocks := setup(t) + if err := handler.SetContext("test"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + // And a context config file that exists but cannot be read + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + contextDir := filepath.Join(projectRoot, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + contextConfigPath := filepath.Join(contextDir, "windsor.yaml") + if err := os.WriteFile(contextConfigPath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to write context config: %v", err) + } + + // Mock ReadFile to return an error + handler.shims.ReadFile = func(filename string) ([]byte, error) { + // Normalize path separators for cross-platform compatibility + normalizedPath := filepath.ToSlash(filename) + if strings.Contains(normalizedPath, "contexts/test/windsor.yaml") { + return nil, fmt.Errorf("mocked read error") + } + return os.ReadFile(filename) + } + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("LoadContextConfig() expected error, got nil") + } + + // And the error message should contain the expected text + expectedError := "error reading context config file: mocked read error" + if err.Error() != expectedError { + t.Errorf("LoadContextConfig() error = %v, expected '%s'", err, expectedError) + } + }) + + t.Run("ErrorUnmarshallingContextConfig", func(t *testing.T) { + // Given a YamlConfigHandler + handler, mocks := setup(t) + if err := handler.SetContext("test"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + // Override the shim to return the correct context + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + + // And a context config file with invalid YAML + projectRoot, _ := mocks.Shell.GetProjectRootFunc() + contextDir := filepath.Join(projectRoot, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + contextConfigPath := filepath.Join(contextDir, "windsor.yaml") + invalidYaml := `provider: aws +invalid yaml: [ +` + if err := os.WriteFile(contextConfigPath, []byte(invalidYaml), 0644); err != nil { + t.Fatalf("Failed to write context config: %v", err) + } + + // Override shims to allow reading the actual context file + handler.shims.Stat = func(name string) (os.FileInfo, error) { + return os.Stat(name) + } + handler.shims.ReadFile = func(filename string) ([]byte, error) { + return os.ReadFile(filename) + } + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("LoadContextConfig() expected error, got nil") + } + + // And the error message should contain the expected text + if !strings.Contains(err.Error(), "error unmarshalling context yaml") { + t.Errorf("LoadContextConfig() error = %v, expected to contain 'error unmarshalling context yaml'", err) + } + }) + + t.Run("ErrorShellNotInitialized", func(t *testing.T) { + // Given a YamlConfigHandler without shell + handler, _ := setup(t) + handler.shell = nil + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("LoadContextConfig() expected error, got nil") + } + + // And the error message should be as expected + expectedError := "shell not initialized" + if err.Error() != expectedError { + t.Errorf("LoadContextConfig() error = %v, expected '%s'", err, expectedError) + } + }) + + t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + // Given a YamlConfigHandler with shell that returns error + handler, mocks := setup(t) + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("mocked project root error") + } + + // When LoadContextConfig is called + err := handler.LoadContextConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("LoadContextConfig() expected error, got nil") + } + + // And the error message should be as expected + expectedError := "error retrieving project root: mocked project root error" + if err.Error() != expectedError { + t.Errorf("LoadContextConfig() error = %v, expected '%s'", err, expectedError) + } + }) +} diff --git a/pkg/pipelines/pipeline.go b/pkg/pipelines/pipeline.go index 7d145a487..edb07b1eb 100644 --- a/pkg/pipelines/pipeline.go +++ b/pkg/pipelines/pipeline.go @@ -507,6 +507,10 @@ func (p *BasePipeline) loadConfig() error { } } + if err := p.configHandler.LoadContextConfig(); err != nil { + return fmt.Errorf("error loading context config: %w", err) + } + return nil }