diff --git a/pkg/blueprint/blueprint_handler_helper_test.go b/pkg/blueprint/blueprint_handler_helper_test.go index 90017aa2c..576e053a4 100644 --- a/pkg/blueprint/blueprint_handler_helper_test.go +++ b/pkg/blueprint/blueprint_handler_helper_test.go @@ -37,6 +37,7 @@ func (m *mockConfigHandler) SetContext(context string) error func (m *mockConfigHandler) GetConfigRoot() (string, error) { return "/tmp", nil } func (m *mockConfigHandler) Clean() error { return nil } func (m *mockConfigHandler) IsLoaded() bool { return true } +func (m *mockConfigHandler) IsContextConfigLoaded() bool { return true } func (m *mockConfigHandler) SetSecretsProvider(provider secrets.SecretsProvider) {} func (m *mockConfigHandler) GenerateContextID() error { return nil } func (m *mockConfigHandler) YamlMarshalWithDefinedPaths(v any) ([]byte, error) { return nil, nil } diff --git a/pkg/config/config_handler.go b/pkg/config/config_handler.go index 5abd50ed2..f4545a2eb 100644 --- a/pkg/config/config_handler.go +++ b/pkg/config/config_handler.go @@ -38,6 +38,7 @@ type ConfigHandler interface { GetConfigRoot() (string, error) Clean() error IsLoaded() bool + IsContextConfigLoaded() bool SetSecretsProvider(provider secrets.SecretsProvider) GenerateContextID() error YamlMarshalWithDefinedPaths(v any) ([]byte, error) diff --git a/pkg/config/config_handler_test.go b/pkg/config/config_handler_test.go index 40e8ce7f4..79819024a 100644 --- a/pkg/config/config_handler_test.go +++ b/pkg/config/config_handler_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/api/v1alpha1/cluster" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/secrets" "github.com/windsorcli/cli/pkg/shell" @@ -190,6 +191,219 @@ func TestConfigHandler_IsLoaded(t *testing.T) { }) } +// TestConfigHandler_IsContextConfigLoaded tests the IsContextConfigLoaded method of the ConfigHandler +func TestConfigHandler_IsContextConfigLoaded(t *testing.T) { + setup := func(t *testing.T) (*YamlConfigHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewYamlConfigHandler(mocks.Injector) + handler.shims = mocks.Shims + return handler, mocks + } + + t.Run("ReturnsFalseWhenBaseConfigNotLoaded", func(t *testing.T) { + // Given a YamlConfigHandler with base config not loaded + handler, _ := setup(t) + handler.BaseConfigHandler.loaded = false + + // When IsContextConfigLoaded is called + isLoaded := handler.IsContextConfigLoaded() + + // Then it should return false + if isLoaded { + t.Errorf("expected IsContextConfigLoaded to return false when base config not loaded, got true") + } + }) + + t.Run("ReturnsFalseWhenContextNotSet", func(t *testing.T) { + // Given a YamlConfigHandler with base config loaded but no context set + handler, mocks := setup(t) + handler.BaseConfigHandler.loaded = true + + // Mock GetContext to return empty string + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte(""), nil // Empty context + } + mocks.Shims.Getenv = func(key string) string { + return "" // No environment variable + } + handler.Initialize() + + // When IsContextConfigLoaded is called + isLoaded := handler.IsContextConfigLoaded() + + // Then it should return false + if isLoaded { + t.Errorf("expected IsContextConfigLoaded to return false when context not set, got true") + } + }) + + t.Run("ReturnsFalseWhenContextsMapIsNil", func(t *testing.T) { + // Given a YamlConfigHandler with base config loaded and context set, but no contexts map + handler, mocks := setup(t) + handler.BaseConfigHandler.loaded = true + handler.config = v1alpha1.Config{ + Contexts: nil, // No contexts map + } + + // Mock GetContext to return a context name + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("test-context"), nil + } + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.Initialize() + + // When IsContextConfigLoaded is called + isLoaded := handler.IsContextConfigLoaded() + + // Then it should return false + if isLoaded { + t.Errorf("expected IsContextConfigLoaded to return false when contexts map is nil, got true") + } + }) + + t.Run("ReturnsFalseWhenContextDoesNotExist", func(t *testing.T) { + // Given a YamlConfigHandler with base config loaded, context set, but context doesn't exist in map + handler, mocks := setup(t) + handler.BaseConfigHandler.loaded = true + handler.config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "other-context": { + // Empty context but valid + }, + }, + } + + // Mock GetContext to return a context name that doesn't exist + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("test-context"), nil + } + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.Initialize() + + // When IsContextConfigLoaded is called + isLoaded := handler.IsContextConfigLoaded() + + // Then it should return false + if isLoaded { + t.Errorf("expected IsContextConfigLoaded to return false when context doesn't exist, got true") + } + }) + + t.Run("ReturnsFalseWhenContextExistsButIsNil", func(t *testing.T) { + // Given a YamlConfigHandler with base config loaded, context set, but context value is nil + handler, mocks := setup(t) + handler.BaseConfigHandler.loaded = true + handler.config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "test-context": nil, // Context exists but is nil + }, + } + + // Mock GetContext to return the context name + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("test-context"), nil + } + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.Initialize() + + // When IsContextConfigLoaded is called + isLoaded := handler.IsContextConfigLoaded() + + // Then it should return false + if isLoaded { + t.Errorf("expected IsContextConfigLoaded to return false when context exists but is nil, got true") + } + }) + + t.Run("ReturnsTrueWhenContextExistsAndIsValid", func(t *testing.T) { + // Given a YamlConfigHandler with base config loaded, context set, and valid context config + handler, mocks := setup(t) + handler.BaseConfigHandler.loaded = true + handler.config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "test-context": { + Cluster: &cluster.ClusterConfig{ + Workers: cluster.NodeGroupConfig{ + Volumes: []string{"/var/blah"}, + }, + }, + }, + }, + } + + // Mock GetContext to return the context name + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("test-context"), nil + } + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.Initialize() + + // When IsContextConfigLoaded is called + isLoaded := handler.IsContextConfigLoaded() + + // Then it should return true + if !isLoaded { + t.Errorf("expected IsContextConfigLoaded to return true when context exists and is valid, got false") + } + }) + + t.Run("ReturnsTrueWhenContextExistsAndHasEmptyConfig", func(t *testing.T) { + // Given a YamlConfigHandler with base config loaded, context set, and context config exists but is empty + handler, mocks := setup(t) + handler.BaseConfigHandler.loaded = true + handler.config = v1alpha1.Config{ + Contexts: map[string]*v1alpha1.Context{ + "test-context": { + // Empty config but still valid + }, + }, + } + + // Mock GetContext to return the context name + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "/mock/project/root", nil + } + mocks.Shims.ReadFile = func(filename string) ([]byte, error) { + return []byte("test-context"), nil + } + mocks.Shims.Getenv = func(key string) string { + return "" + } + handler.Initialize() + + // When IsContextConfigLoaded is called + isLoaded := handler.IsContextConfigLoaded() + + // Then it should return true (empty config is still valid) + if !isLoaded { + t.Errorf("expected IsContextConfigLoaded to return true when context exists with empty config, got false") + } + }) +} + // TestBaseConfigHandler_GetContext tests the GetContext method of the BaseConfigHandler func TestBaseConfigHandler_GetContext(t *testing.T) { setup := func(t *testing.T) (*BaseConfigHandler, *Mocks) { diff --git a/pkg/config/mock_config_handler.go b/pkg/config/mock_config_handler.go index 93834a005..6155cfd90 100644 --- a/pkg/config/mock_config_handler.go +++ b/pkg/config/mock_config_handler.go @@ -15,6 +15,7 @@ type MockConfigHandler struct { GetStringFunc func(key string, defaultValue ...string) string GetIntFunc func(key string, defaultValue ...int) int GetBoolFunc func(key string, defaultValue ...bool) bool + IsContextConfigLoadedFunc func() bool GetStringSliceFunc func(key string, defaultValue ...[]string) []string GetStringMapFunc func(key string, defaultValue ...map[string]string) map[string]string SetFunc func(key string, value any) error @@ -85,6 +86,14 @@ func (m *MockConfigHandler) IsLoaded() bool { return false } +// IsContextConfigLoaded calls the mock IsContextConfigLoadedFunc if set, otherwise returns false +func (m *MockConfigHandler) IsContextConfigLoaded() bool { + if m.IsContextConfigLoadedFunc != nil { + return m.IsContextConfigLoadedFunc() + } + return false +} + // GetString calls the mock GetStringFunc if set, otherwise returns a reasonable default string func (m *MockConfigHandler) GetString(key string, defaultValue ...string) string { if m.GetStringFunc != nil { diff --git a/pkg/config/mock_config_handler_test.go b/pkg/config/mock_config_handler_test.go index bea2aeb32..611c128d0 100644 --- a/pkg/config/mock_config_handler_test.go +++ b/pkg/config/mock_config_handler_test.go @@ -642,6 +642,35 @@ func TestMockConfigHandler_IsLoaded(t *testing.T) { }) } +func TestMockConfigHandler_IsContextConfigLoaded(t *testing.T) { + t.Run("WithFuncSet", func(t *testing.T) { + // Given a new mock config handler with IsContextConfigLoadedFunc set + handler := NewMockConfigHandler() + handler.IsContextConfigLoadedFunc = func() bool { return true } + + // When IsContextConfigLoaded is called + loaded := handler.IsContextConfigLoaded() + + // Then the returned value should be true + if !loaded { + t.Errorf("Expected IsContextConfigLoaded to return true, got %v", loaded) + } + }) + + t.Run("WithNoFuncSet", func(t *testing.T) { + // Given a new mock config handler without IsContextConfigLoadedFunc set + handler := NewMockConfigHandler() + + // When IsContextConfigLoaded is called + loaded := handler.IsContextConfigLoaded() + + // Then the returned value should be false + if loaded { + t.Errorf("Expected IsContextConfigLoaded to return false, got %v", loaded) + } + }) +} + func TestMockConfigHandler_LoadConfigString(t *testing.T) { t.Run("WithFuncSet", func(t *testing.T) { // Given a mock config handler with LoadConfigStringFunc set diff --git a/pkg/config/yaml_config_handler.go b/pkg/config/yaml_config_handler.go index c07f8adae..7cbdb0ad2 100644 --- a/pkg/config/yaml_config_handler.go +++ b/pkg/config/yaml_config_handler.go @@ -151,6 +151,27 @@ func (y *YamlConfigHandler) LoadContextConfig() error { return nil } +// IsContextConfigLoaded determines whether context-specific configuration has been loaded for the current context. +// It returns true if the base configuration is loaded, the current context name is set, and a non-nil context +// configuration exists for the current context in the configuration map. Returns false otherwise. +func (y *YamlConfigHandler) IsContextConfigLoaded() bool { + if !y.BaseConfigHandler.loaded { + return false + } + + contextName := y.GetContext() + if contextName == "" { + return false + } + + if y.config.Contexts == nil { + return false + } + + context, exists := y.config.Contexts[contextName] + return exists && context != nil +} + // SaveConfig writes configuration to root windsor.yaml and the current context's windsor.yaml. // Root windsor.yaml contains only the version field. The context file contains the context config // without the contexts wrapper. Files are created only if absent: root windsor.yaml is created if diff --git a/pkg/pipelines/check_test.go b/pkg/pipelines/check_test.go index 8a735941c..810f68817 100644 --- a/pkg/pipelines/check_test.go +++ b/pkg/pipelines/check_test.go @@ -281,7 +281,7 @@ func TestCheckPipeline_Initialize(t *testing.T) { if err == nil { t.Fatal("Expected error, got nil") } - if err.Error() != "failed to load config: error loading config file: config loading failed" { + if err.Error() != "failed to load base config: error loading config file: config loading failed" { t.Errorf("Expected config loading error, got: %v", err) } }) diff --git a/pkg/pipelines/env_test.go b/pkg/pipelines/env_test.go index 352f7e3f2..7fe0d1294 100644 --- a/pkg/pipelines/env_test.go +++ b/pkg/pipelines/env_test.go @@ -188,7 +188,7 @@ func TestEnvPipeline_Initialize(t *testing.T) { if err == nil { t.Fatal("Expected error, got nil") } - if err.Error() != "failed to load config: error retrieving project root: project root error" { + if err.Error() != "failed to load base config: error retrieving project root: project root error" { t.Errorf("Expected load config error, got: %v", err) } }) diff --git a/pkg/pipelines/init.go b/pkg/pipelines/init.go index 9c3b75cce..8e66b773d 100644 --- a/pkg/pipelines/init.go +++ b/pkg/pipelines/init.go @@ -84,8 +84,10 @@ func (p *InitPipeline) Initialize(injector di.Injector, ctx context.Context) err return fmt.Errorf("Error setting context value: %w", err) } - if err := p.setDefaultConfiguration(ctx, contextName); err != nil { - return err + if !p.configHandler.IsContextConfigLoaded() { + if err := p.setDefaultConfiguration(ctx, contextName); err != nil { + return err + } } if err := p.processPlatformConfiguration(ctx); err != nil { @@ -478,8 +480,6 @@ func (p *InitPipeline) prepareTemplateData(ctx context.Context) (map[string][]by return make(map[string][]byte), nil } - - // processTemplateData renders and processes template data for the InitPipeline. // Renders all templates using the template renderer, and loads blueprint data from the rendered output if present. // Returns the rendered template data map or an error if rendering or blueprint loading fails. diff --git a/pkg/pipelines/pipeline.go b/pkg/pipelines/pipeline.go index 904c43b9b..e753c9450 100644 --- a/pkg/pipelines/pipeline.go +++ b/pkg/pipelines/pipeline.go @@ -143,16 +143,9 @@ func (p *BasePipeline) Initialize(injector di.Injector, ctx context.Context) err return fmt.Errorf("failed to initialize config handler: %w", err) } - // Only load existing config if reset flag is not set - reset := false - if resetValue := ctx.Value("reset"); resetValue != nil { - reset = resetValue.(bool) - } - - if !reset { - if err := p.loadConfig(); err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + // Load base config first + if err := p.loadBaseConfig(); err != nil { + return fmt.Errorf("failed to load base config: %w", err) } // Set Windsor context if specified in execution context @@ -162,6 +155,11 @@ func (p *BasePipeline) Initialize(injector di.Injector, ctx context.Context) err } } + // Load context config after context is set + if err := p.configHandler.LoadContextConfig(); err != nil { + return fmt.Errorf("failed to load context config: %w", err) + } + return nil } @@ -524,6 +522,42 @@ func (p *BasePipeline) loadConfig() error { return nil } +// loadBaseConfig loads only the base configuration file (windsor.yaml) without loading context config +func (p *BasePipeline) loadBaseConfig() error { + if p.shell == nil { + return fmt.Errorf("shell not initialized") + } + if p.configHandler == nil { + return fmt.Errorf("config handler not initialized") + } + if p.shims == nil { + return fmt.Errorf("shims not initialized") + } + + projectRoot, err := p.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error retrieving project root: %w", err) + } + + yamlPath := filepath.Join(projectRoot, "windsor.yaml") + ymlPath := filepath.Join(projectRoot, "windsor.yml") + + var cliConfigPath string + if _, err := p.shims.Stat(yamlPath); err == nil { + cliConfigPath = yamlPath + } else if _, err := p.shims.Stat(ymlPath); err == nil { + cliConfigPath = ymlPath + } + + if cliConfigPath != "" { + if err := p.configHandler.LoadConfig(cliConfigPath); err != nil { + return fmt.Errorf("error loading config file: %w", err) + } + } + + return nil +} + // withEnvPrinters creates environment printers based on configuration func (p *BasePipeline) withEnvPrinters() ([]envpkg.EnvPrinter, error) { if p.configHandler == nil { diff --git a/pkg/template/jsonnet_template.go b/pkg/template/jsonnet_template.go index 563bb1ebc..7df971667 100644 --- a/pkg/template/jsonnet_template.go +++ b/pkg/template/jsonnet_template.go @@ -115,6 +115,7 @@ func (t *JsonnetTemplate) Process(templateData map[string][]byte, renderedData m // Returns the resulting map or an error if any step fails. func (t *JsonnetTemplate) processJsonnetTemplate(templateContent string) (map[string]any, error) { config := t.configHandler.GetConfig() + contextYAML, err := t.configHandler.YamlMarshalWithDefinedPaths(config) if err != nil { return nil, fmt.Errorf("failed to marshal context to YAML: %w", err)