From f2f3de9061f21c07fd5d4cf4a5d73605a16f4697 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sat, 11 Oct 2025 16:51:23 -0400 Subject: [PATCH] refactor(config): Consolidate values.yaml and windsor.yaml configs under config handler The `values.yaml` functionality has now been migrated in to the config handler. This is an intermediate PR. Subsequent PRs will leverage the config handler interface exclusively when acccessing configuration values. --- cmd/init.go | 12 + pkg/config/config_handler.go | 6 +- pkg/config/yaml_config_handler.go | 400 +++++-- pkg/config/yaml_config_handler_test.go | 1440 ++++++++++++++++++++++-- pkg/pipelines/init.go | 8 +- pkg/pipelines/pipeline_test.go | 20 + 6 files changed, 1740 insertions(+), 146 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index b5891b196..308116f9f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -76,6 +76,16 @@ var initCmd = &cobra.Command{ } configHandler := injector.Resolve("configHandler").(config.ConfigHandler) + + // Initialize the config handler to ensure schema validator is available + if err := configHandler.Initialize(); err != nil { + return fmt.Errorf("failed to initialize config handler: %w", err) + } + + // Load the schema to enable validation of --set flags + if err := configHandler.LoadContextConfig(); err != nil { + return fmt.Errorf("failed to load context config: %w", err) + } // Set provider in context if it's been set (either via --provider or --platform) if initProvider != "" { @@ -136,6 +146,7 @@ var initCmd = &cobra.Command{ } } + hasSetFlags := len(initSetFlags) > 0 for _, setFlag := range initSetFlags { parts := strings.SplitN(setFlag, "=", 2) if len(parts) == 2 { @@ -145,6 +156,7 @@ var initCmd = &cobra.Command{ } } + ctx = context.WithValue(ctx, "hasSetFlags", hasSetFlags) ctx = context.WithValue(ctx, "quiet", false) ctx = context.WithValue(ctx, "decrypt", false) initPipeline, err := pipelines.WithPipeline(injector, ctx, "initPipeline") diff --git a/pkg/config/config_handler.go b/pkg/config/config_handler.go index 8550cd7c3..a272d0dee 100644 --- a/pkg/config/config_handler.go +++ b/pkg/config/config_handler.go @@ -65,6 +65,7 @@ type BaseConfigHandler struct { loaded bool shims *Shims schemaValidator *SchemaValidator + contextValues map[string]any } // ============================================================================= @@ -74,8 +75,9 @@ type BaseConfigHandler struct { // NewBaseConfigHandler creates a new BaseConfigHandler instance func NewBaseConfigHandler(injector di.Injector) *BaseConfigHandler { return &BaseConfigHandler{ - injector: injector, - shims: NewShims(), + injector: injector, + shims: NewShims(), + contextValues: make(map[string]any), } } diff --git a/pkg/config/yaml_config_handler.go b/pkg/config/yaml_config_handler.go index cc0241691..585165a63 100644 --- a/pkg/config/yaml_config_handler.go +++ b/pkg/config/yaml_config_handler.go @@ -38,7 +38,6 @@ func NewYamlConfigHandler(injector di.Injector) *YamlConfigHandler { loadedContexts: make(map[string]bool), } - // Initialize the config version handler.config.Version = "v1alpha1" return handler @@ -78,7 +77,6 @@ func (y *YamlConfigHandler) LoadConfigString(content string) error { return fmt.Errorf("unsupported config version: %s", y.BaseConfigHandler.config.Version) } - y.BaseConfigHandler.loaded = true return nil } @@ -97,11 +95,14 @@ 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. +// LoadContextConfig loads the context-specific windsor.yaml file for the current context. +// It is intended for pipelines that only require static context configuration, not dynamic values. +// The values.yaml file is loaded lazily upon first access via Get() or SetContextValue(). +// Returns nil if already loaded, or if loading succeeds; otherwise returns an error for shell or file-related issues. func (y *YamlConfigHandler) LoadContextConfig() error { + if y.loaded { + return nil + } if y.shell == nil { return fmt.Errorf("shell not initialized") } @@ -120,35 +121,37 @@ func (y *YamlConfigHandler) LoadContextConfig() error { var contextConfigPath string if _, err := y.shims.Stat(yamlPath); err == nil { contextConfigPath = yamlPath - y.loadedContexts[contextName] = true } else if _, err := y.shims.Stat(ymlPath); err == nil { contextConfigPath = ymlPath - y.loadedContexts[contextName] = true } - if contextConfigPath == "" { - return nil - } + if contextConfigPath != "" { + data, err := y.shims.ReadFile(contextConfigPath) + if err != nil { + return fmt.Errorf("error reading context config file: %w", err) + } - 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) + } - 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 == 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) - if y.config.Contexts[contextName] == nil { - y.config.Contexts[contextName] = &v1alpha1.Context{} + y.loadedContexts[contextName] = true } - y.config.Contexts[contextName].Merge(&contextConfig) + if len(y.config.Contexts) > 0 { + y.loaded = true + } return nil } @@ -168,16 +171,21 @@ func (y *YamlConfigHandler) IsContextConfigLoaded() bool { return y.loadedContexts[contextName] } -// 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 -// missing; context windsor.yaml is created if missing and context is not in root config. Existing -// files are never overwritten. +// SaveConfig writes the current configuration state to disk. It writes the root windsor.yaml file with only the version field, +// and creates or updates the current context's windsor.yaml file and values.yaml containing dynamic schema-based values. +// The root windsor.yaml is created if missing; the context windsor.yaml is created if missing and not tracked in the root config; +// values.yaml is created if context values are present. If the overwrite parameter is true, existing files are updated with the +// current in-memory state. All operations are performed for the current context. func (y *YamlConfigHandler) SaveConfig(overwrite ...bool) error { if y.shell == nil { return fmt.Errorf("shell not initialized") } + shouldOverwrite := false + if len(overwrite) > 0 { + shouldOverwrite = overwrite[0] + } + projectRoot, err := y.shell.GetProjectRoot() if err != nil { return fmt.Errorf("error retrieving project root: %w", err) @@ -201,8 +209,10 @@ func (y *YamlConfigHandler) SaveConfig(overwrite ...bool) error { shouldCreateRootConfig := !rootExists shouldCreateContextConfig := !contextExists && !contextExistsInRoot + shouldUpdateRootConfig := shouldOverwrite && rootExists + shouldUpdateContextConfig := shouldOverwrite && contextExists - if shouldCreateRootConfig { + if shouldCreateRootConfig || shouldUpdateRootConfig { rootConfig := struct { Version string `yaml:"version"` }{ @@ -219,7 +229,7 @@ func (y *YamlConfigHandler) SaveConfig(overwrite ...bool) error { } } - if shouldCreateContextConfig { + if shouldCreateContextConfig || shouldUpdateContextConfig { var contextConfig v1alpha1.Context if y.config.Contexts != nil && y.config.Contexts[contextName] != nil { @@ -243,6 +253,12 @@ func (y *YamlConfigHandler) SaveConfig(overwrite ...bool) error { } } + if y.loaded && y.contextValues != nil && len(y.contextValues) > 0 { + if err := y.saveContextValues(); err != nil { + return fmt.Errorf("error saving values.yaml: %w", err) + } + } + return nil } @@ -253,42 +269,68 @@ func (y *YamlConfigHandler) SaveConfig(overwrite ...bool) error { func (y *YamlConfigHandler) SetDefault(context v1alpha1.Context) error { y.defaultContextConfig = context currentContext := y.GetContext() - contextKey := fmt.Sprintf("contexts.%s", currentContext) if y.Get(contextKey) == nil { return y.Set(contextKey, &context) - } else { - if y.config.Contexts == nil { - y.config.Contexts = make(map[string]*v1alpha1.Context) - } - - if y.config.Contexts[currentContext] == nil { - y.config.Contexts[currentContext] = &v1alpha1.Context{} - } + } - // Merge existing values INTO defaults (not the other way around) - // This ensures that existing explicit settings take precedence over defaults - defaultCopy := context.DeepCopy() - existingCopy := y.config.Contexts[currentContext].DeepCopy() - defaultCopy.Merge(existingCopy) // Merge existing INTO defaults - y.config.Contexts[currentContext] = defaultCopy + if y.config.Contexts == nil { + y.config.Contexts = make(map[string]*v1alpha1.Context) + } + if y.config.Contexts[currentContext] == nil { + y.config.Contexts[currentContext] = &v1alpha1.Context{} } + defaultCopy := context.DeepCopy() + existingCopy := y.config.Contexts[currentContext].DeepCopy() + defaultCopy.Merge(existingCopy) + y.config.Contexts[currentContext] = defaultCopy return nil } -// Get returns the value at the given configuration path, checking current and default context if needed. +// Get returns the value at the specified configuration path using the configured lookup precedence. +// Lookup order is: context configuration from windsor.yaml, then values.yaml, then schema defaults. +// Returns nil if the path is empty or if no value is found in any source. func (y *YamlConfigHandler) Get(path string) any { if path == "" { return nil } pathKeys := parsePath(path) + value := getValueByPath(y.config, pathKeys) - if value == nil && len(pathKeys) >= 2 && pathKeys[0] == "contexts" { + if value != nil { + return value + } + + if len(pathKeys) >= 2 && pathKeys[0] == "contexts" { + if len(pathKeys) >= 3 && y.loaded { + if err := y.ensureValuesYamlLoaded(); err != nil { + } + if y.contextValues != nil { + key := pathKeys[2] + if value, exists := y.contextValues[key]; exists { + return value + } + } + } + value = getValueByPath(y.defaultContextConfig, pathKeys[2:]) + if value != nil { + return value + } } - return value + + if len(pathKeys) == 1 && y.schemaValidator != nil && y.schemaValidator.Schema != nil { + defaults, err := y.schemaValidator.GetSchemaDefaults() + if err == nil { + if value, exists := defaults[pathKeys[0]]; exists { + return value + } + } + } + + return nil } // GetString retrieves a string value for the specified key from the configuration, with an optional default value. @@ -378,6 +420,8 @@ func (y *YamlConfigHandler) GetStringMap(key string, defaultValue ...map[string] } // Set updates the value at the specified path in the configuration using reflection. +// It parses the path, performs type conversion if necessary, and sets the value in the config structure. +// An error is returned if the path is invalid, conversion fails, or the update cannot be performed. func (y *YamlConfigHandler) Set(path string, value any) error { if path == "" { return nil @@ -388,7 +432,6 @@ func (y *YamlConfigHandler) Set(path string, value any) error { return fmt.Errorf("invalid path: %s", path) } - // If the value is a string, try to convert it based on the target type if strValue, ok := value.(string); ok { currentValue := y.Get(path) if currentValue != nil { @@ -405,26 +448,40 @@ func (y *YamlConfigHandler) Set(path string, value any) error { return setValueByPath(configValue, pathKeys, value, path) } -// SetContextValue sets a configuration value within the current context. +// SetContextValue sets a value at the given path for the current context, updating config or values.yaml in memory. +// The key must not be empty or have invalid dot notation. Values are schema-validated if available. +// Changes require SaveConfig to persist. Returns error if path or value is invalid, or conversion fails. func (y *YamlConfigHandler) SetContextValue(path string, value any) error { if path == "" { return fmt.Errorf("path cannot be empty") } - - // Initialize contexts map if it doesn't exist - if y.config.Contexts == nil { - y.config.Contexts = make(map[string]*v1alpha1.Context) + if strings.Contains(path, "..") || strings.HasPrefix(path, ".") || strings.HasSuffix(path, ".") { + return fmt.Errorf("invalid path format: %s", path) } - - // Get or create the current context - contextName := y.GetContext() - if y.config.Contexts[contextName] == nil { - y.config.Contexts[contextName] = &v1alpha1.Context{} + if y.isKeyInStaticSchema(path) { + if y.config.Contexts == nil { + y.config.Contexts = make(map[string]*v1alpha1.Context) + } + contextName := y.GetContext() + if y.config.Contexts[contextName] == nil { + y.config.Contexts[contextName] = &v1alpha1.Context{} + } + fullPath := fmt.Sprintf("contexts.%s.%s", contextName, path) + return y.Set(fullPath, value) + } + if err := y.ensureValuesYamlLoaded(); err != nil { + return fmt.Errorf("error loading values.yaml: %w", err) + } + convertedValue := y.convertStringValue(value) + y.contextValues[path] = convertedValue + if y.schemaValidator != nil && y.schemaValidator.Schema != nil { + if result, err := y.schemaValidator.Validate(y.contextValues); err != nil { + return fmt.Errorf("error validating context value: %w", err) + } else if !result.Valid { + return fmt.Errorf("context value validation failed: %v", result.Errors) + } } - - // Use the generic Set method with the full context path - fullPath := fmt.Sprintf("contexts.%s.%s", contextName, path) - return y.Set(fullPath, value) + return nil } // GetConfig returns the context config object for the current context, or the default if none is set. @@ -452,6 +509,56 @@ var _ ConfigHandler = (*YamlConfigHandler)(nil) // Private Methods // ============================================================================= +// ensureValuesYamlLoaded loads and validates the values.yaml file for the current context, and loads the schema if required. +// It initializes y.contextValues by reading values.yaml from the context directory, unless already loaded or not required. +// If values.yaml or the schema is missing, it initializes an empty map and performs schema validation if possible. +// Returns an error if any schema loading, reading, or unmarshaling fails. +func (y *YamlConfigHandler) ensureValuesYamlLoaded() error { + if y.contextValues != nil { + return nil + } + if y.shell == nil || !y.loaded { + y.contextValues = make(map[string]any) + return nil + } + 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) + valuesPath := filepath.Join(contextConfigDir, "values.yaml") + if y.schemaValidator != nil && y.schemaValidator.Schema == nil { + schemaPath := filepath.Join(projectRoot, "contexts", "_template", "schema.yaml") + if _, err := y.shims.Stat(schemaPath); err == nil { + if err := y.schemaValidator.LoadSchema(schemaPath); err != nil { + return fmt.Errorf("error loading schema: %w", err) + } + } + } + if _, err := y.shims.Stat(valuesPath); err == nil { + data, err := y.shims.ReadFile(valuesPath) + if err != nil { + return fmt.Errorf("error reading values.yaml: %w", err) + } + var values map[string]any + if err := y.shims.YamlUnmarshal(data, &values); err != nil { + return fmt.Errorf("error unmarshalling values.yaml: %w", err) + } + if y.schemaValidator != nil && y.schemaValidator.Schema != nil { + if result, err := y.schemaValidator.Validate(values); err != nil { + return fmt.Errorf("error validating values.yaml: %w", err) + } else if !result.Valid { + return fmt.Errorf("values.yaml validation failed: %v", result.Errors) + } + } + y.contextValues = values + } else { + y.contextValues = make(map[string]any) + } + return nil +} + // getValueByPath retrieves a value by navigating through a struct or map using YAML tags. func getValueByPath(current any, pathKeys []string) any { if len(pathKeys) == 0 { @@ -828,3 +935,166 @@ func (y *YamlConfigHandler) GenerateContextID() error { id := "w" + string(b) return y.SetContextValue("id", id) } + +// saveContextValues writes the current contextValues map to a values.yaml file in the context directory for the current context. +// This function ensures that the target context directory exists. If a schema validator is configured, it validates the contextValues +// against the schema before saving. The function marshals the values to YAML, writes them to values.yaml, and returns an error if +// validation, marshalling, or file writing fails. +func (y *YamlConfigHandler) saveContextValues() error { + if y.schemaValidator != nil && y.schemaValidator.Schema != nil { + if result, err := y.schemaValidator.Validate(y.contextValues); err != nil { + return fmt.Errorf("error validating values.yaml: %w", err) + } else if !result.Valid { + return fmt.Errorf("values.yaml validation failed: %v", result.Errors) + } + } + + configRoot, err := y.GetConfigRoot() + if err != nil { + return fmt.Errorf("error getting config root: %w", err) + } + + if err := y.shims.MkdirAll(configRoot, 0755); err != nil { + return fmt.Errorf("error creating context directory: %w", err) + } + + valuesPath := filepath.Join(configRoot, "values.yaml") + data, err := y.shims.YamlMarshal(y.contextValues) + if err != nil { + return fmt.Errorf("error marshalling values.yaml: %w", err) + } + + if err := y.shims.WriteFile(valuesPath, data, 0644); err != nil { + return fmt.Errorf("error writing values.yaml: %w", err) + } + + return nil +} + +// isKeyInStaticSchema determines whether the provided key exists as a top-level field +// in the static windsor.yaml schema, represented by v1alpha1.Context. It checks both +// direct keys and those nested with dot notation (e.g., "environment.TEST_VAR" -> "environment"). +// Returns true if the top-level key matches a YAML field tag in v1alpha1.Context, false otherwise. +func (y *YamlConfigHandler) isKeyInStaticSchema(key string) bool { + topLevelKey := key + if dotIndex := strings.Index(key, "."); dotIndex != -1 { + topLevelKey = key[:dotIndex] + } + contextType := reflect.TypeOf(v1alpha1.Context{}) + for i := 0; i < contextType.NumField(); i++ { + field := contextType.Field(i) + yamlTag := strings.Split(field.Tag.Get("yaml"), ",")[0] + if yamlTag == topLevelKey { + return true + } + } + return false +} + +// convertStringValue infers and converts a string value to the appropriate type based on schema type information. +// It is used to correctly coerce command-line --set flags (which arrive as strings) to their target types. +// The function uses the configured schema validator, if present, to determine the expected type for the value. +// If type information cannot be found in the schema, it applies pattern-based type conversion heuristics. +// The returned value is properly typed if conversion is possible; otherwise, the original value is returned. +func (y *YamlConfigHandler) convertStringValue(value any) any { + str, ok := value.(string) + if !ok { + return value + } + if y.schemaValidator != nil && y.schemaValidator.Schema != nil { + if expectedType := y.getExpectedTypeFromSchema(str); expectedType != "" { + if convertedValue := y.convertStringToType(str, expectedType); convertedValue != nil { + return convertedValue + } + } + } + return y.convertStringByPattern(str) +} + +// getExpectedTypeFromSchema attempts to find the expected type for a key in the schema +func (y *YamlConfigHandler) getExpectedTypeFromSchema(key string) string { + if y.schemaValidator == nil || y.schemaValidator.Schema == nil { + return "" + } + + properties, ok := y.schemaValidator.Schema["properties"] + if !ok { + return "" + } + + propertiesMap, ok := properties.(map[string]any) + if !ok { + return "" + } + + propSchema, exists := propertiesMap[key] + if !exists { + return "" + } + + propSchemaMap, ok := propSchema.(map[string]any) + if !ok { + return "" + } + + expectedType, ok := propSchemaMap["type"] + if !ok { + return "" + } + + expectedTypeStr, ok := expectedType.(string) + if !ok { + return "" + } + + return expectedTypeStr +} + +// convertStringToType converts a string value to the corresponding Go type based on the provided JSON schema type. +// It supports boolean, integer, number, and string schema types. Returns the converted value, or nil if conversion fails. +// The conversion follows JSON schema type expectations: booleans are case-insensitive, integers use strconv.Atoi, +// numbers use strconv.ParseFloat (64-bit), and unrecognized types or conversion failures return nil. +func (y *YamlConfigHandler) convertStringToType(str, expectedType string) any { + switch expectedType { + case "boolean": + switch strings.ToLower(str) { + case "true": + return true + case "false": + return false + } + case "integer": + if intVal, err := strconv.Atoi(str); err == nil { + return intVal + } + case "number": + if floatVal, err := strconv.ParseFloat(str, 64); err == nil { + return floatVal + } + case "string": + return str + } + return nil +} + +// convertStringByPattern attempts to infer and convert a string value to its most likely Go type. +// It recognizes "true"/"false" as booleans, parses numeric strings as integer or float as appropriate, +// and returns the original string if no conversion pattern matches. +func (y *YamlConfigHandler) convertStringByPattern(str string) any { + switch strings.ToLower(str) { + case "true": + return true + case "false": + return false + } + + if intVal, err := strconv.Atoi(str); err == nil { + return intVal + } + + if floatVal, err := strconv.ParseFloat(str, 64); err == nil { + return floatVal + } + + return str +} diff --git a/pkg/config/yaml_config_handler_test.go b/pkg/config/yaml_config_handler_test.go index 0b886dee0..3a8b4a5a3 100644 --- a/pkg/config/yaml_config_handler_test.go +++ b/pkg/config/yaml_config_handler_test.go @@ -12,6 +12,8 @@ import ( "github.com/windsorcli/cli/api/v1alpha1/aws" "github.com/windsorcli/cli/api/v1alpha1/cluster" "github.com/windsorcli/cli/api/v1alpha1/vm" + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/shell" ) // ============================================================================= @@ -238,6 +240,54 @@ func TestYamlConfigHandler_Get(t *testing.T) { t.Errorf("Expected nil for empty path, got %v", value) } }) + + t.Run("PrecedenceAndSchemaDefaults", func(t *testing.T) { + // Given a handler with schema validator and various data sources + handler, _ := setup(t) + handler.context = "test" + handler.loaded = true + + // Set up schema validator with defaults + handler.schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "SCHEMA_KEY": map[string]any{ + "default": "schema_default_value", + }, + }, + }, + } + + // Test schema defaults for single-key path + value := handler.Get("SCHEMA_KEY") + expected := "schema_default_value" + if value != expected { + t.Errorf("Expected schema default value '%s', got '%v'", expected, value) + } + + // Test that multi-key paths don't use schema defaults + value = handler.Get("contexts.test.SCHEMA_KEY") + if value != nil { + t.Errorf("Expected nil for multi-key path, got '%v'", value) + } + + // Test contextValues precedence + handler.contextValues = map[string]any{ + "TEST_VAR": "values_value", + } + value = handler.Get("contexts.test.TEST_VAR") + expected = "values_value" + if value != expected { + t.Errorf("Expected contextValues value '%s', got '%v'", expected, value) + } + + // Test that contextValues are not checked when not loaded + handler.loaded = false + value = handler.Get("contexts.test.TEST_VAR") + if value != nil { + t.Errorf("Expected nil when not loaded, got '%v'", value) + } + }) } func TestYamlConfigHandler_SaveConfig(t *testing.T) { @@ -1027,6 +1077,186 @@ contexts: t.Errorf("Root config appears to have been overwritten: %s", string(rootContent)) } }) + + t.Run("SavesContextValuesWhenLoaded", func(t *testing.T) { + // Given a YamlConfigHandler with loaded contextValues + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Use real filesystem operations for this test + handler.shims = NewShims() + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test-context" + } + return "" + } + + handler.context = "test-context" + handler.loaded = true + handler.contextValues = map[string]any{ + "test_key": "test_value", + "number": 42, + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should be created with the context values + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); os.IsNotExist(err) { + t.Fatalf("values.yaml was not created at %s", valuesPath) + } + + // And the content should match contextValues + content, err := os.ReadFile(valuesPath) + if err != nil { + t.Fatalf("Failed to read values.yaml: %v", err) + } + if !strings.Contains(string(content), "test_key") { + t.Errorf("values.yaml should contain 'test_key', got: %s", string(content)) + } + if !strings.Contains(string(content), "test_value") { + t.Errorf("values.yaml should contain 'test_value', got: %s", string(content)) + } + }) + + t.Run("SkipsSavingContextValuesWhenNotLoaded", func(t *testing.T) { + // Given a YamlConfigHandler with contextValues but not loaded + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.context = "test-context" + handler.loaded = false + handler.contextValues = map[string]any{ + "test_key": "test_value", + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should NOT be created + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { + t.Errorf("values.yaml should not have been created when not loaded") + } + }) + + t.Run("SkipsSavingContextValuesWhenNil", func(t *testing.T) { + // Given a YamlConfigHandler with nil contextValues + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.context = "test-context" + handler.loaded = true + handler.contextValues = nil + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should NOT be created + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { + t.Errorf("values.yaml should not have been created when contextValues is nil") + } + }) + + t.Run("SkipsSavingContextValuesWhenEmpty", func(t *testing.T) { + // Given a YamlConfigHandler with empty contextValues + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.context = "test-context" + handler.loaded = true + handler.contextValues = map[string]any{} + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And values.yaml should NOT be created + valuesPath := filepath.Join(tempDir, "contexts", "test-context", "values.yaml") + if _, err := os.Stat(valuesPath); !os.IsNotExist(err) { + t.Errorf("values.yaml should not have been created when contextValues is empty") + } + }) + + t.Run("SaveContextValuesError", func(t *testing.T) { + // Given a YamlConfigHandler with contextValues and a write error + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Use real filesystem operations but mock WriteFile to fail for values.yaml + handler.shims = NewShims() + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test-context" + } + return "" + } + handler.shims.WriteFile = func(filename string, data []byte, perm os.FileMode) error { + if strings.Contains(filename, "values.yaml") { + return fmt.Errorf("write error") + } + return os.WriteFile(filename, data, perm) + } + + handler.context = "test-context" + handler.loaded = true + handler.contextValues = map[string]any{ + "test_key": "test_value", + } + + // When SaveConfig is called + err := handler.SaveConfig() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error saving values.yaml") { + t.Errorf("Expected 'error saving values.yaml' in error, got %v", err) + } + }) } func TestYamlConfigHandler_GetString(t *testing.T) { @@ -2523,114 +2753,511 @@ func TestYamlConfigHandler_SetContextValue(t *testing.T) { t.Errorf("Expected dns.enabled to be true, got %v", config.DNS.Enabled) } }) -} - -func TestYamlConfigHandler_LoadConfigString(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("Success", func(t *testing.T) { - // Given a handler with a context set + t.Run("SchemaRoutingAndInitialization", func(t *testing.T) { handler, _ := setup(t) - handler.SetContext("test") + handler.context = "test" - // And a valid YAML configuration string - yamlContent := ` -version: v1alpha1 -contexts: - test: - environment: - TEST_VAR: test_value` + // Test invalid path formats + err := handler.SetContextValue("..invalid", "value") + if err == nil { + t.Error("Expected error for invalid path") + } - // When loading the configuration string - err := handler.LoadConfigString(yamlContent) + // Test static schema routing (goes to context config) + err = handler.SetContextValue("environment.STATIC_VAR", "static_value") + if err != nil { + t.Fatalf("Failed to set static schema value: %v", err) + } + if handler.config.Contexts["test"].Environment["STATIC_VAR"] != "static_value" { + t.Error("Static value should be in context config") + } - // Then no error should be returned + // Test dynamic schema routing (goes to contextValues) + err = handler.SetContextValue("dynamic_key", "dynamic_value") if err != nil { - t.Fatalf("LoadConfigString() unexpected error: %v", err) + t.Fatalf("Failed to set dynamic schema value: %v", err) + } + if handler.contextValues["dynamic_key"] != "dynamic_value" { + t.Error("Dynamic value should be in contextValues") } - // And the value should be correctly loaded - value := handler.GetString("environment.TEST_VAR") - if value != "test_value" { - t.Errorf("Expected TEST_VAR = 'test_value', got '%s'", value) + // Test initialization when not loaded + handler.loaded = false + handler.contextValues = nil + err = handler.SetContextValue("not_loaded_key", "not_loaded_value") + if err != nil { + t.Fatalf("Failed to set value when not loaded: %v", err) + } + if handler.contextValues["not_loaded_key"] != "not_loaded_value" { + t.Error("contextValues should be initialized even when not loaded") } }) - t.Run("EmptyContent", func(t *testing.T) { - // Given a handler with a context set + t.Run("SchemaAwareTypeConversion", func(t *testing.T) { handler, _ := setup(t) + handler.context = "test" + handler.loaded = true + + // Set up schema validator with type definitions + handler.schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "dev": map[string]any{ + "type": "boolean", + }, + "port": map[string]any{ + "type": "integer", + }, + "ratio": map[string]any{ + "type": "number", + }, + "name": map[string]any{ + "type": "string", + }, + }, + }, + } - // When loading an empty configuration string - err := handler.LoadConfigString("") + // Test boolean conversion + err := handler.SetContextValue("dev", "true") + if err != nil { + t.Fatalf("Failed to set boolean value: %v", err) + } + if handler.contextValues["dev"] != true { + t.Errorf("Expected boolean true, got %v (%T)", handler.contextValues["dev"], handler.contextValues["dev"]) + } - // Then no error should be returned + // Test integer conversion + err = handler.SetContextValue("port", "8080") if err != nil { - t.Fatalf("LoadConfigString() unexpected error: %v", err) + t.Fatalf("Failed to set integer value: %v", err) + } + if handler.contextValues["port"] != 8080 { + t.Errorf("Expected integer 8080, got %v (%T)", handler.contextValues["port"], handler.contextValues["port"]) + } + + // Test number conversion + err = handler.SetContextValue("ratio", "3.14") + if err != nil { + t.Fatalf("Failed to set number value: %v", err) + } + if handler.contextValues["ratio"] != 3.14 { + t.Errorf("Expected number 3.14, got %v (%T)", handler.contextValues["ratio"], handler.contextValues["ratio"]) + } + + // Test string conversion (should remain string) + err = handler.SetContextValue("name", "test") + if err != nil { + t.Fatalf("Failed to set string value: %v", err) + } + if handler.contextValues["name"] != "test" { + t.Errorf("Expected string 'test', got %v (%T)", handler.contextValues["name"], handler.contextValues["name"]) } }) - t.Run("InvalidYAML", func(t *testing.T) { - // Given a handler with a context set + t.Run("FallbackPatternConversion", func(t *testing.T) { handler, _ := setup(t) + handler.context = "test" + handler.loaded = true - // And an invalid YAML string - yamlContent := `invalid: yaml: content: [}` + // No schema validator - should use pattern matching - // When loading the invalid YAML - err := handler.LoadConfigString(yamlContent) + // Test boolean pattern matching + err := handler.SetContextValue("enabled", "true") + if err != nil { + t.Fatalf("Failed to set boolean value: %v", err) + } + if handler.contextValues["enabled"] != true { + t.Errorf("Expected boolean true, got %v (%T)", handler.contextValues["enabled"], handler.contextValues["enabled"]) + } - // Then an error should be returned - if err == nil { - t.Fatal("LoadConfigString() expected error for invalid YAML") + // Test integer pattern matching + err = handler.SetContextValue("count", "42") + if err != nil { + t.Fatalf("Failed to set integer value: %v", err) + } + if handler.contextValues["count"] != 42 { + t.Errorf("Expected integer 42, got %v (%T)", handler.contextValues["count"], handler.contextValues["count"]) } - // And the error message should indicate YAML unmarshalling failure - if !strings.Contains(err.Error(), "error unmarshalling yaml") { - t.Errorf("Expected error about invalid YAML, got: %v", err) + // Test float pattern matching + err = handler.SetContextValue("rate", "2.5") + if err != nil { + t.Fatalf("Failed to set float value: %v", err) + } + if handler.contextValues["rate"] != 2.5 { + t.Errorf("Expected float 2.5, got %v (%T)", handler.contextValues["rate"], handler.contextValues["rate"]) } }) - t.Run("UnsupportedVersion", func(t *testing.T) { - // Given a handler with a context set + t.Run("SchemaConversionFailure", func(t *testing.T) { handler, _ := setup(t) + handler.context = "test" + handler.loaded = true + + // Set up schema validator with boolean type and validation support + mockShell := handler.shell + mockValidator := NewSchemaValidator(mockShell) + mockValidator.Schema = map[string]any{ + "properties": map[string]any{ + "dev": map[string]any{ + "type": "boolean", + }, + }, + } + handler.schemaValidator = mockValidator - // And a YAML string with an unsupported version - yamlContent := ` -version: v2alpha1 -contexts: - test: {}` - - // When loading the YAML with unsupported version - err := handler.LoadConfigString(yamlContent) - - // Then an error should be returned + // Test invalid boolean value - should now fail validation + err := handler.SetContextValue("dev", "invalid") if err == nil { - t.Fatal("LoadConfigString() expected error for unsupported version") + t.Fatal("Expected validation error for invalid boolean value, got nil") } - - // And the error message should indicate unsupported version - if !strings.Contains(err.Error(), "unsupported config version") { - t.Errorf("Expected error about unsupported version, got: %v", err) + if !strings.Contains(err.Error(), "validation failed") && !strings.Contains(err.Error(), "type mismatch") { + t.Errorf("Expected validation error, got: %v", err) } }) } -func Test_makeAddressable(t *testing.T) { - t.Run("AlreadyAddressable", func(t *testing.T) { - // Given an addressable value - var x int = 42 - v := reflect.ValueOf(&x).Elem() - - // When making it addressable - result := makeAddressable(v) +func TestYamlConfigHandler_convertStringValue(t *testing.T) { + setup := func(t *testing.T) *YamlConfigHandler { + mocks := setupMocks(t) + handler := NewYamlConfigHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("NonStringValue", func(t *testing.T) { + handler := setup(t) + + // Non-string values should be returned as-is + result := handler.convertStringValue(42) + if result != 42 { + t.Errorf("Expected 42, got %v", result) + } + + result = handler.convertStringValue(true) + if result != true { + t.Errorf("Expected true, got %v", result) + } + }) + + t.Run("SchemaAwareConversion", func(t *testing.T) { + handler := setup(t) + + // Set up schema validator + handler.schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "enabled": map[string]any{ + "type": "boolean", + }, + "count": map[string]any{ + "type": "integer", + }, + "rate": map[string]any{ + "type": "number", + }, + }, + }, + } + + // Test boolean conversion + result := handler.convertStringValue("true") + if result != true { + t.Errorf("Expected boolean true, got %v (%T)", result, result) + } + + // Test integer conversion + result = handler.convertStringValue("42") + if result != 42 { + t.Errorf("Expected integer 42, got %v (%T)", result, result) + } + + // Test number conversion + result = handler.convertStringValue("3.14") + if result != 3.14 { + t.Errorf("Expected number 3.14, got %v (%T)", result, result) + } + }) + + t.Run("PatternMatchingFallback", func(t *testing.T) { + handler := setup(t) + // No schema validator - should use pattern matching + + // Test boolean pattern + result := handler.convertStringValue("true") + if result != true { + t.Errorf("Expected boolean true, got %v (%T)", result, result) + } + + result = handler.convertStringValue("false") + if result != false { + t.Errorf("Expected boolean false, got %v (%T)", result, result) + } + + // Test integer pattern + result = handler.convertStringValue("123") + if result != 123 { + t.Errorf("Expected integer 123, got %v (%T)", result, result) + } + + // Test float pattern + result = handler.convertStringValue("45.67") + if result != 45.67 { + t.Errorf("Expected float 45.67, got %v (%T)", result, result) + } + + // Test string (no conversion) + result = handler.convertStringValue("hello") + if result != "hello" { + t.Errorf("Expected string 'hello', got %v (%T)", result, result) + } + }) +} + +func TestYamlConfigHandler_getExpectedTypeFromSchema(t *testing.T) { + setup := func(t *testing.T) *YamlConfigHandler { + mocks := setupMocks(t) + handler := NewYamlConfigHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + t.Run("ValidSchema", func(t *testing.T) { + handler := setup(t) + + handler.schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": map[string]any{ + "enabled": map[string]any{ + "type": "boolean", + }, + "count": map[string]any{ + "type": "integer", + }, + }, + }, + } + + // Test existing property + result := handler.getExpectedTypeFromSchema("enabled") + if result != "boolean" { + t.Errorf("Expected 'boolean', got '%s'", result) + } + + result = handler.getExpectedTypeFromSchema("count") + if result != "integer" { + t.Errorf("Expected 'integer', got '%s'", result) + } + + // Test non-existing property + result = handler.getExpectedTypeFromSchema("nonexistent") + if result != "" { + t.Errorf("Expected empty string, got '%s'", result) + } + }) + + t.Run("NoSchemaValidator", func(t *testing.T) { + handler := setup(t) + // No schema validator + + result := handler.getExpectedTypeFromSchema("anykey") + if result != "" { + t.Errorf("Expected empty string, got '%s'", result) + } + }) + + t.Run("InvalidSchema", func(t *testing.T) { + handler := setup(t) + + handler.schemaValidator = &SchemaValidator{ + Schema: map[string]any{ + "properties": "invalid", // Should be map[string]any + }, + } + + result := handler.getExpectedTypeFromSchema("anykey") + if result != "" { + t.Errorf("Expected empty string, got '%s'", result) + } + }) +} + +func TestYamlConfigHandler_convertStringToType(t *testing.T) { + setup := func(t *testing.T) *YamlConfigHandler { + mocks := setupMocks(t) + handler := NewYamlConfigHandler(mocks.Injector) + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize handler: %v", err) + } + handler.shims = mocks.Shims + return handler + } + + handler := setup(t) + + t.Run("BooleanConversion", func(t *testing.T) { + result := handler.convertStringToType("true", "boolean") + if result != true { + t.Errorf("Expected true, got %v", result) + } + + result = handler.convertStringToType("false", "boolean") + if result != false { + t.Errorf("Expected false, got %v", result) + } + + result = handler.convertStringToType("invalid", "boolean") + if result != nil { + t.Errorf("Expected nil, got %v", result) + } + }) + + t.Run("IntegerConversion", func(t *testing.T) { + result := handler.convertStringToType("42", "integer") + if result != 42 { + t.Errorf("Expected 42, got %v", result) + } + + result = handler.convertStringToType("invalid", "integer") + if result != nil { + t.Errorf("Expected nil, got %v", result) + } + }) + + t.Run("NumberConversion", func(t *testing.T) { + result := handler.convertStringToType("3.14", "number") + if result != 3.14 { + t.Errorf("Expected 3.14, got %v", result) + } + + result = handler.convertStringToType("invalid", "number") + if result != nil { + t.Errorf("Expected nil, got %v", result) + } + }) + + t.Run("StringConversion", func(t *testing.T) { + result := handler.convertStringToType("hello", "string") + if result != "hello" { + t.Errorf("Expected 'hello', got %v", result) + } + }) +} + +func TestYamlConfigHandler_LoadConfigString(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("Success", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + handler.SetContext("test") + + // And a valid YAML configuration string + yamlContent := ` +version: v1alpha1 +contexts: + test: + environment: + TEST_VAR: test_value` + + // When loading the configuration string + err := handler.LoadConfigString(yamlContent) + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadConfigString() unexpected error: %v", err) + } + + // And the value should be correctly loaded + value := handler.GetString("environment.TEST_VAR") + if value != "test_value" { + t.Errorf("Expected TEST_VAR = 'test_value', got '%s'", value) + } + }) + + t.Run("EmptyContent", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + + // When loading an empty configuration string + err := handler.LoadConfigString("") + + // Then no error should be returned + if err != nil { + t.Fatalf("LoadConfigString() unexpected error: %v", err) + } + }) + + t.Run("InvalidYAML", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + + // And an invalid YAML string + yamlContent := `invalid: yaml: content: [}` + + // When loading the invalid YAML + err := handler.LoadConfigString(yamlContent) + + // Then an error should be returned + if err == nil { + t.Fatal("LoadConfigString() expected error for invalid YAML") + } + + // And the error message should indicate YAML unmarshalling failure + if !strings.Contains(err.Error(), "error unmarshalling yaml") { + t.Errorf("Expected error about invalid YAML, got: %v", err) + } + }) + + t.Run("UnsupportedVersion", func(t *testing.T) { + // Given a handler with a context set + handler, _ := setup(t) + + // And a YAML string with an unsupported version + yamlContent := ` +version: v2alpha1 +contexts: + test: {}` + + // When loading the YAML with unsupported version + err := handler.LoadConfigString(yamlContent) + + // Then an error should be returned + if err == nil { + t.Fatal("LoadConfigString() expected error for unsupported version") + } + + // And the error message should indicate unsupported version + if !strings.Contains(err.Error(), "unsupported config version") { + t.Errorf("Expected error about unsupported version, got: %v", err) + } + }) +} + +func Test_makeAddressable(t *testing.T) { + t.Run("AlreadyAddressable", func(t *testing.T) { + // Given an addressable value + var x int = 42 + v := reflect.ValueOf(&x).Elem() + + // When making it addressable + result := makeAddressable(v) // Then the same value should be returned if result.Interface() != v.Interface() { @@ -3494,10 +4121,10 @@ environment: 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) + // The error should be from reading the context config file + expectedError := "error reading context config file" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("LoadContextConfig() error = %v, expected to contain '%s'", err, expectedError) } }) @@ -3760,3 +4387,662 @@ invalid yaml: [ } }) } +func TestYamlConfigHandler_saveContextValues(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("Success", func(t *testing.T) { + // Given a YamlConfigHandler with context values + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + handler.context = "test" + handler.contextValues = map[string]any{ + "database_url": "postgres://localhost:5432/test", + "api_key": "secret123", + } + + // When saveContextValues is called + err := handler.saveContextValues() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And the values.yaml file should be created + valuesPath := filepath.Join(tempDir, "contexts", "test", "values.yaml") + if _, err := handler.shims.Stat(valuesPath); os.IsNotExist(err) { + t.Fatalf("values.yaml file was not created at %s", valuesPath) + } + }) + + t.Run("GetConfigRootError", func(t *testing.T) { + // Given a YamlConfigHandler with a shell that returns an error + handler, mocks := setup(t) + + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("failed to get project root") + } + + handler.context = "test" + handler.contextValues = map[string]any{"key": "value"} + + // When saveContextValues is called + err := handler.saveContextValues() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + expectedError := "error getting config root: failed to get project root" + if err.Error() != expectedError { + t.Errorf("Expected error: %s, got: %s", expectedError, err.Error()) + } + }) + + t.Run("MkdirAllError", func(t *testing.T) { + // Given a YamlConfigHandler with a shims that fails on MkdirAll + handler, mocks := setup(t) + + tempDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tempDir, nil + } + + // Mock MkdirAll to return an error + originalMkdirAll := mocks.Shims.MkdirAll + mocks.Shims.MkdirAll = func(path string, perm os.FileMode) error { + return fmt.Errorf("mkdir failed") + } + + handler.context = "test" + handler.contextValues = map[string]any{"key": "value"} + + // When saveContextValues is called + err := handler.saveContextValues() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + + expectedError := "error creating context directory: mkdir failed" + if err.Error() != expectedError { + t.Errorf("Expected error: %s, got: %s", expectedError, err.Error()) + } + + // Restore original function + mocks.Shims.MkdirAll = originalMkdirAll + }) +} + +func TestYamlConfigHandler_ensureValuesYamlLoaded(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("AlreadyLoaded", func(t *testing.T) { + // Given a handler with contextValues already loaded + handler, _ := setup(t) + handler.contextValues = map[string]any{"existing": "value"} + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should remain unchanged + if handler.contextValues["existing"] != "value" { + t.Error("contextValues should remain unchanged") + } + }) + + t.Run("ShellNotInitialized", func(t *testing.T) { + // Given a handler with no shell initialized + handler := &YamlConfigHandler{} + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should be initialized as empty + if handler.contextValues == nil { + t.Error("contextValues should be initialized") + } + if len(handler.contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + } + }) + + t.Run("ConfigNotLoaded", func(t *testing.T) { + // Given a handler with shell but not loaded + handler, _ := setup(t) + handler.loaded = false + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should be initialized as empty + if handler.contextValues == nil { + t.Error("contextValues should be initialized") + } + if len(handler.contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + } + }) + + t.Run("ErrorGettingProjectRoot", func(t *testing.T) { + // Given a handler with shell returning error on GetProjectRoot + handler, mocks := setup(t) + handler.loaded = true + handler.contextValues = nil // Ensure it's not already loaded + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return "", fmt.Errorf("project root error") + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error retrieving project root") { + t.Errorf("Expected 'error retrieving project root', got: %v", err) + } + }) + + t.Run("LoadsSchemaIfNotLoaded", func(t *testing.T) { + // Given a handler with schema validator but no schema loaded + handler, mocks := setup(t) + handler.loaded = true + handler.context = "test" + handler.contextValues = nil + handler.schemaValidator = NewSchemaValidator(mocks.Shell) + handler.schemaValidator.Shims = NewShims() // Use real filesystem for schema validator + + // Use real filesystem operations for this test + handler.shims = NewShims() + + // Create temp directory structure + tmpDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + + // Create schema file + schemaDir := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(schemaDir, 0755); err != nil { + t.Fatalf("Failed to create schema directory: %v", err) + } + schemaPath := filepath.Join(schemaDir, "schema.yaml") + schemaContent := ` +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + test_key: + type: string +` + if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + // Create context directory but no values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And schema should be loaded + if handler.schemaValidator.Schema == nil { + t.Error("Schema should be loaded") + } + + // And contextValues should be initialized as empty + if len(handler.contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + } + }) + + t.Run("ErrorLoadingSchema", func(t *testing.T) { + // Given a handler with schema validator and malformed schema file + handler, mocks := setup(t) + handler.loaded = true + handler.context = "test" + handler.contextValues = nil + handler.schemaValidator = NewSchemaValidator(mocks.Shell) + handler.schemaValidator.Shims = NewShims() // Use real filesystem for schema validator + + // Use real filesystem operations for this test + handler.shims = NewShims() + + // Create temp directory structure + tmpDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + + // Create malformed schema file + schemaDir := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(schemaDir, 0755); err != nil { + t.Fatalf("Failed to create schema directory: %v", err) + } + schemaPath := filepath.Join(schemaDir, "schema.yaml") + if err := os.WriteFile(schemaPath, []byte("invalid: yaml: content:"), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error for malformed schema, got nil") + } + if !strings.Contains(err.Error(), "error loading schema") { + t.Errorf("Expected 'error loading schema', got: %v", err) + } + }) + + t.Run("LoadsValuesYamlSuccessfully", func(t *testing.T) { + // Given a standalone handler with valid values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + handler := NewYamlConfigHandler(injector) + handler.shims = NewShims() + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + handler.loaded = true + handler.context = "test" + handler.contextValues = nil // Ensure values aren't already loaded + + // Create context directory and values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + valuesContent := `test_key: test_value +another_key: 123 +` + if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should contain the loaded values + if handler.contextValues == nil { + t.Fatal("contextValues is nil") + } + if len(handler.contextValues) == 0 { + t.Fatal("contextValues is empty") + } + if handler.contextValues["test_key"] != "test_value" { + t.Errorf("Expected test_key='test_value', got: %v", handler.contextValues["test_key"]) + } + anotherKey, ok := handler.contextValues["another_key"] + if !ok { + t.Error("Expected another_key to be present") + } else { + // YAML unmarshals numbers as different types depending on their value + // Check if it's 123 regardless of the specific integer type + switch v := anotherKey.(type) { + case int: + if v != 123 { + t.Errorf("Expected another_key=123, got: %v", v) + } + case int64: + if v != 123 { + t.Errorf("Expected another_key=123, got: %v", v) + } + case uint64: + if v != 123 { + t.Errorf("Expected another_key=123, got: %v", v) + } + default: + t.Errorf("Expected another_key to be numeric, got: %v (type: %T)", v, v) + } + } + }) + + t.Run("ErrorReadingValuesYaml", func(t *testing.T) { + // Given a standalone handler with values.yaml that cannot be read + tmpDir := t.TempDir() + injector := di.NewInjector() + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + handler := NewYamlConfigHandler(injector) + handler.shims = NewShims() + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + handler.loaded = true + handler.context = "test" + handler.contextValues = nil // Ensure values aren't already loaded + + // Create context directory and values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + if err := os.WriteFile(valuesPath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } + + // Mock ReadFile to return error for values.yaml + handler.shims.ReadFile = func(filename string) ([]byte, error) { + if strings.Contains(filename, "values.yaml") { + return nil, fmt.Errorf("read error") + } + return os.ReadFile(filename) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error reading values.yaml") { + t.Errorf("Expected 'error reading values.yaml', got: %v", err) + } + }) + + t.Run("ErrorUnmarshallingValuesYaml", func(t *testing.T) { + // Given a standalone handler with malformed values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + handler := NewYamlConfigHandler(injector) + handler.shims = NewShims() + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + handler.loaded = true + handler.context = "test" + handler.contextValues = nil // Ensure values aren't already loaded + + // Create context directory and malformed values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + if err := os.WriteFile(valuesPath, []byte("invalid: yaml: content:"), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "error unmarshalling values.yaml") { + t.Errorf("Expected 'error unmarshalling values.yaml', got: %v", err) + } + }) + + t.Run("ValidatesValuesYamlWithSchema", func(t *testing.T) { + // Given a standalone handler with schema and values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + handler := NewYamlConfigHandler(injector) + handler.shims = NewShims() + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + handler.loaded = true + handler.context = "test" + handler.contextValues = nil // Ensure values aren't already loaded + handler.schemaValidator.Shims = NewShims() + + // Create schema file + schemaDir := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(schemaDir, 0755); err != nil { + t.Fatalf("Failed to create schema directory: %v", err) + } + schemaPath := filepath.Join(schemaDir, "schema.yaml") + schemaContent := `$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + test_key: + type: string +additionalProperties: false +` + if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + // Create context directory and values.yaml with valid content + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + valuesContent := `test_key: test_value +` + if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should contain validated values + if handler.contextValues["test_key"] != "test_value" { + t.Errorf("Expected test_key='test_value', got: %v", handler.contextValues["test_key"]) + } + }) + + t.Run("ValidationFailsForInvalidValuesYaml", func(t *testing.T) { + // Given a standalone handler with schema and invalid values.yaml + tmpDir := t.TempDir() + injector := di.NewInjector() + + mockShell := shell.NewMockShell(injector) + mockShell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + injector.Register("shell", mockShell) + + handler := NewYamlConfigHandler(injector) + handler.shims = NewShims() + handler.shims.Getenv = func(key string) string { + if key == "WINDSOR_CONTEXT" { + return "test" + } + return "" + } + if err := handler.Initialize(); err != nil { + t.Fatalf("Failed to initialize: %v", err) + } + handler.loaded = true + handler.context = "test" + handler.contextValues = nil // Ensure values aren't already loaded + handler.schemaValidator.Shims = NewShims() + + // Create schema file + schemaDir := filepath.Join(tmpDir, "contexts", "_template") + if err := os.MkdirAll(schemaDir, 0755); err != nil { + t.Fatalf("Failed to create schema directory: %v", err) + } + schemaPath := filepath.Join(schemaDir, "schema.yaml") + schemaContent := `$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + test_key: + type: string +additionalProperties: false +` + if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { + t.Fatalf("Failed to write schema file: %v", err) + } + + // Create context directory and values.yaml with invalid content + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + valuesPath := filepath.Join(contextDir, "values.yaml") + valuesContent := `invalid_key: should_not_be_allowed +` + if err := os.WriteFile(valuesPath, []byte(valuesContent), 0644); err != nil { + t.Fatalf("Failed to write values.yaml: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then a validation error should be returned + if err == nil { + t.Fatal("Expected validation error, got nil") + } + if !strings.Contains(err.Error(), "validation failed") { + t.Errorf("Expected 'validation failed', got: %v", err) + } + }) + + t.Run("NoValuesYamlFileInitializesEmpty", func(t *testing.T) { + // Given a handler with no values.yaml file + handler, mocks := setup(t) + handler.loaded = true + handler.context = "test" + handler.contextValues = nil + + // Use real filesystem operations for this test + handler.shims = NewShims() + + // Create temp directory structure + tmpDir := t.TempDir() + mocks.Shell.GetProjectRootFunc = func() (string, error) { + return tmpDir, nil + } + + // Create context directory but NO values.yaml + contextDir := filepath.Join(tmpDir, "contexts", "test") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + + // When ensureValuesYamlLoaded is called + err := handler.ensureValuesYamlLoaded() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + // And contextValues should be initialized as empty + if handler.contextValues == nil { + t.Error("contextValues should be initialized") + } + if len(handler.contextValues) != 0 { + t.Errorf("Expected empty contextValues, got: %v", handler.contextValues) + } + }) +} diff --git a/pkg/pipelines/init.go b/pkg/pipelines/init.go index f16218b56..434e0836a 100644 --- a/pkg/pipelines/init.go +++ b/pkg/pipelines/init.go @@ -293,8 +293,12 @@ func (p *InitPipeline) Execute(ctx context.Context) error { return err } - // Save the configuration to windsor.yaml files - if err := p.configHandler.SaveConfig(); err != nil { + hasSetFlags := false + if setFlagsValue := ctx.Value("hasSetFlags"); setFlagsValue != nil { + hasSetFlags = setFlagsValue.(bool) + } + + if err := p.configHandler.SaveConfig(hasSetFlags); err != nil { return fmt.Errorf("failed to save configuration: %w", err) } diff --git a/pkg/pipelines/pipeline_test.go b/pkg/pipelines/pipeline_test.go index 564eab2cc..805359f9e 100644 --- a/pkg/pipelines/pipeline_test.go +++ b/pkg/pipelines/pipeline_test.go @@ -160,6 +160,26 @@ contexts: } } + // Create context directory and config file to ensure loaded flag is set + contextDir := filepath.Join(tmpDir, "contexts", "mock-context") + if err := os.MkdirAll(contextDir, 0755); err != nil { + t.Fatalf("Failed to create context directory: %v", err) + } + contextConfigPath := filepath.Join(contextDir, "windsor.yaml") + contextConfigYAML := ` +dns: + domain: mock.domain.com +network: + cidr_block: 10.0.0.0/24` + if err := os.WriteFile(contextConfigPath, []byte(contextConfigYAML), 0644); err != nil { + t.Fatalf("Failed to write context config: %v", err) + } + + // Load context config to set loaded flag + if err := configHandler.LoadContextConfig(); err != nil { + t.Fatalf("Failed to load context config: %v", err) + } + // Register shims shims := setupShims(t) injector.Register("shims", shims)