diff --git a/internal/models/profile_validation.go b/internal/models/profile_validation.go new file mode 100644 index 000000000..92fae01f0 --- /dev/null +++ b/internal/models/profile_validation.go @@ -0,0 +1,365 @@ +package models + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// ConfigKeyDefinition defines a valid configuration key with its type and constraints. +type ConfigKeyDefinition struct { + Key string // Config key name + Type ConfigType // Expected value type + Description string // Human-readable description + Default interface{} // Default value (nil if required) + Required bool // Whether the key is required + Min *float64 // Minimum value for numbers + Max *float64 // Maximum value for numbers + Pattern string // Regex pattern for strings + Enum []string // Allowed values for enums +} + +// ConfigType represents the type of a configuration value. +type ConfigType string + +const ( + ConfigTypeString ConfigType = "string" + ConfigTypeBool ConfigType = "bool" + ConfigTypeInt ConfigType = "int" + ConfigTypeFloat ConfigType = "float" + ConfigTypeDuration ConfigType = "duration" + ConfigTypeEnum ConfigType = "enum" +) + +// ValidConfigKeys defines all valid agent configuration keys. +var ValidConfigKeys = []ConfigKeyDefinition{ + { + Key: "interval", + Type: ConfigTypeDuration, + Description: "Polling interval for metrics collection", + Default: "30s", + }, + { + Key: "enable_docker", + Type: ConfigTypeBool, + Description: "Enable Docker container monitoring", + Default: true, + }, + { + Key: "enable_system_metrics", + Type: ConfigTypeBool, + Description: "Enable system-level metrics (CPU, memory, disk)", + Default: true, + }, + { + Key: "enable_process_metrics", + Type: ConfigTypeBool, + Description: "Enable process-level metrics", + Default: false, + }, + { + Key: "enable_network_metrics", + Type: ConfigTypeBool, + Description: "Enable network interface metrics", + Default: true, + }, + { + Key: "log_level", + Type: ConfigTypeEnum, + Description: "Agent log verbosity level", + Default: "info", + Enum: []string{"debug", "info", "warn", "error"}, + }, + { + Key: "metric_buffer_size", + Type: ConfigTypeInt, + Description: "Size of the metric buffer before flush", + Default: 100, + Min: ptrFloat(10), + Max: ptrFloat(10000), + }, + { + Key: "connection_timeout", + Type: ConfigTypeDuration, + Description: "Timeout for server connections", + Default: "30s", + }, + { + Key: "retry_interval", + Type: ConfigTypeDuration, + Description: "Interval between connection retries", + Default: "5s", + }, + { + Key: "max_retries", + Type: ConfigTypeInt, + Description: "Maximum number of connection retries", + Default: 3, + Min: ptrFloat(0), + Max: ptrFloat(100), + }, + { + Key: "disk_paths", + Type: ConfigTypeString, + Description: "Comma-separated list of disk paths to monitor", + Default: "/", + }, + { + Key: "exclude_containers", + Type: ConfigTypeString, + Description: "Regex pattern for container names to exclude", + Default: "", + }, + { + Key: "include_containers", + Type: ConfigTypeString, + Description: "Regex pattern for container names to include (empty = all)", + Default: "", + }, + { + Key: "cpu_threshold_warning", + Type: ConfigTypeFloat, + Description: "CPU usage threshold for warnings (%)", + Default: 80.0, + Min: ptrFloat(0), + Max: ptrFloat(100), + }, + { + Key: "cpu_threshold_critical", + Type: ConfigTypeFloat, + Description: "CPU usage threshold for critical alerts (%)", + Default: 95.0, + Min: ptrFloat(0), + Max: ptrFloat(100), + }, + { + Key: "memory_threshold_warning", + Type: ConfigTypeFloat, + Description: "Memory usage threshold for warnings (%)", + Default: 80.0, + Min: ptrFloat(0), + Max: ptrFloat(100), + }, + { + Key: "memory_threshold_critical", + Type: ConfigTypeFloat, + Description: "Memory usage threshold for critical alerts (%)", + Default: 95.0, + Min: ptrFloat(0), + Max: ptrFloat(100), + }, + { + Key: "disk_threshold_warning", + Type: ConfigTypeFloat, + Description: "Disk usage threshold for warnings (%)", + Default: 80.0, + Min: ptrFloat(0), + Max: ptrFloat(100), + }, + { + Key: "disk_threshold_critical", + Type: ConfigTypeFloat, + Description: "Disk usage threshold for critical alerts (%)", + Default: 95.0, + Min: ptrFloat(0), + Max: ptrFloat(100), + }, +} + +// ValidationError represents a validation error for a config key. +type ValidationError struct { + Key string + Message string +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Key, e.Message) +} + +// ValidationResult holds the result of config validation. +type ValidationResult struct { + Valid bool + Errors []ValidationError + Warnings []ValidationError +} + +// ProfileValidator validates agent profile configurations. +type ProfileValidator struct { + keyDefs map[string]ConfigKeyDefinition +} + +// NewProfileValidator creates a new profile validator. +func NewProfileValidator() *ProfileValidator { + keyDefs := make(map[string]ConfigKeyDefinition) + for _, def := range ValidConfigKeys { + keyDefs[def.Key] = def + } + return &ProfileValidator{keyDefs: keyDefs} +} + +// Validate validates an agent profile configuration. +func (v *ProfileValidator) Validate(config AgentConfigMap) ValidationResult { + result := ValidationResult{Valid: true} + + // Check for unknown keys + for key := range config { + if _, ok := v.keyDefs[key]; !ok { + result.Warnings = append(result.Warnings, ValidationError{ + Key: key, + Message: "Unknown configuration key (will be ignored by agent)", + }) + } + } + + // Validate known keys + for key, value := range config { + def, ok := v.keyDefs[key] + if !ok { + continue // Skip unknown keys (already warned) + } + + if err := v.validateValue(def, value); err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Key: key, + Message: err.Error(), + }) + } + } + + // Check for required keys + for _, def := range v.keyDefs { + if def.Required { + if _, ok := config[def.Key]; !ok { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Key: def.Key, + Message: "Required configuration key is missing", + }) + } + } + } + + return result +} + +// validateValue validates a single configuration value against its definition. +func (v *ProfileValidator) validateValue(def ConfigKeyDefinition, value interface{}) error { + if value == nil { + if def.Required { + return fmt.Errorf("value cannot be null") + } + return nil + } + + switch def.Type { + case ConfigTypeString: + s, ok := value.(string) + if !ok { + return fmt.Errorf("expected string, got %T", value) + } + if def.Pattern != "" { + re, err := regexp.Compile(def.Pattern) + if err != nil { + return fmt.Errorf("invalid pattern in definition: %v", err) + } + if !re.MatchString(s) { + return fmt.Errorf("value does not match pattern %s", def.Pattern) + } + } + + case ConfigTypeBool: + if _, ok := value.(bool); !ok { + return fmt.Errorf("expected boolean, got %T", value) + } + + case ConfigTypeInt: + var num float64 + switch n := value.(type) { + case int: + num = float64(n) + case int64: + num = float64(n) + case float64: + if n != float64(int64(n)) { + return fmt.Errorf("expected integer, got float") + } + num = n + default: + return fmt.Errorf("expected integer, got %T", value) + } + if def.Min != nil && num < *def.Min { + return fmt.Errorf("value %v is below minimum %v", num, *def.Min) + } + if def.Max != nil && num > *def.Max { + return fmt.Errorf("value %v exceeds maximum %v", num, *def.Max) + } + + case ConfigTypeFloat: + var num float64 + switch n := value.(type) { + case int: + num = float64(n) + case int64: + num = float64(n) + case float64: + num = n + default: + return fmt.Errorf("expected number, got %T", value) + } + if def.Min != nil && num < *def.Min { + return fmt.Errorf("value %v is below minimum %v", num, *def.Min) + } + if def.Max != nil && num > *def.Max { + return fmt.Errorf("value %v exceeds maximum %v", num, *def.Max) + } + + case ConfigTypeDuration: + s, ok := value.(string) + if !ok { + return fmt.Errorf("expected duration string, got %T", value) + } + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format: %v", err) + } + + case ConfigTypeEnum: + s, ok := value.(string) + if !ok { + return fmt.Errorf("expected string, got %T", value) + } + found := false + for _, allowed := range def.Enum { + if strings.EqualFold(s, allowed) { + found = true + break + } + } + if !found { + return fmt.Errorf("value must be one of: %s", strings.Join(def.Enum, ", ")) + } + } + + return nil +} + +// GetConfigKeyDefinitions returns all valid configuration key definitions. +func GetConfigKeyDefinitions() []ConfigKeyDefinition { + return ValidConfigKeys +} + +// GetConfigKeyDefinition returns the definition for a specific key. +func GetConfigKeyDefinition(key string) (ConfigKeyDefinition, bool) { + for _, def := range ValidConfigKeys { + if def.Key == key { + return def, true + } + } + return ConfigKeyDefinition{}, false +} + +// ptrFloat returns a pointer to a float64. +func ptrFloat(v float64) *float64 { + return &v +} diff --git a/internal/models/profile_validation_test.go b/internal/models/profile_validation_test.go new file mode 100644 index 000000000..9002505c1 --- /dev/null +++ b/internal/models/profile_validation_test.go @@ -0,0 +1,494 @@ +package models + +import ( + "testing" +) + +func TestProfileValidator_ValidateStringType(t *testing.T) { + validator := NewProfileValidator() + + tests := []struct { + name string + config AgentConfigMap + wantErr bool + errKey string + }{ + { + name: "valid string", + config: AgentConfigMap{"disk_paths": "/,/home"}, + wantErr: false, + }, + { + name: "invalid string type", + config: AgentConfigMap{"disk_paths": 123}, + wantErr: true, + errKey: "disk_paths", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.Validate(tt.config) + if tt.wantErr && result.Valid { + t.Errorf("expected validation to fail, but it passed") + } + if !tt.wantErr && !result.Valid { + t.Errorf("expected validation to pass, but it failed: %v", result.Errors) + } + if tt.wantErr && len(result.Errors) > 0 { + found := false + for _, err := range result.Errors { + if err.Key == tt.errKey { + found = true + break + } + } + if !found { + t.Errorf("expected error for key %s, but got: %v", tt.errKey, result.Errors) + } + } + }) + } +} + +func TestProfileValidator_ValidateBoolType(t *testing.T) { + validator := NewProfileValidator() + + tests := []struct { + name string + config AgentConfigMap + wantErr bool + errKey string + }{ + { + name: "valid bool true", + config: AgentConfigMap{"enable_docker": true}, + wantErr: false, + }, + { + name: "valid bool false", + config: AgentConfigMap{"enable_docker": false}, + wantErr: false, + }, + { + name: "invalid bool type - string", + config: AgentConfigMap{"enable_docker": "true"}, + wantErr: true, + errKey: "enable_docker", + }, + { + name: "invalid bool type - int", + config: AgentConfigMap{"enable_docker": 1}, + wantErr: true, + errKey: "enable_docker", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.Validate(tt.config) + if tt.wantErr && result.Valid { + t.Errorf("expected validation to fail, but it passed") + } + if !tt.wantErr && !result.Valid { + t.Errorf("expected validation to pass, but it failed: %v", result.Errors) + } + }) + } +} + +func TestProfileValidator_ValidateIntType(t *testing.T) { + validator := NewProfileValidator() + + tests := []struct { + name string + config AgentConfigMap + wantErr bool + errKey string + }{ + { + name: "valid int", + config: AgentConfigMap{"metric_buffer_size": 100}, + wantErr: false, + }, + { + name: "valid int as float64 (JSON unmarshal)", + config: AgentConfigMap{"metric_buffer_size": float64(100)}, + wantErr: false, + }, + { + name: "int below minimum", + config: AgentConfigMap{"metric_buffer_size": 5}, + wantErr: true, + errKey: "metric_buffer_size", + }, + { + name: "int above maximum", + config: AgentConfigMap{"metric_buffer_size": 20000}, + wantErr: true, + errKey: "metric_buffer_size", + }, + { + name: "invalid int type - string", + config: AgentConfigMap{"metric_buffer_size": "100"}, + wantErr: true, + errKey: "metric_buffer_size", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.Validate(tt.config) + if tt.wantErr && result.Valid { + t.Errorf("expected validation to fail, but it passed") + } + if !tt.wantErr && !result.Valid { + t.Errorf("expected validation to pass, but it failed: %v", result.Errors) + } + }) + } +} + +func TestProfileValidator_ValidateFloatType(t *testing.T) { + validator := NewProfileValidator() + + tests := []struct { + name string + config AgentConfigMap + wantErr bool + errKey string + }{ + { + name: "valid float", + config: AgentConfigMap{"cpu_threshold_warning": 80.5}, + wantErr: false, + }, + { + name: "valid float as int", + config: AgentConfigMap{"cpu_threshold_warning": 80}, + wantErr: false, + }, + { + name: "float below minimum", + config: AgentConfigMap{"cpu_threshold_warning": -10.0}, + wantErr: true, + errKey: "cpu_threshold_warning", + }, + { + name: "float above maximum", + config: AgentConfigMap{"cpu_threshold_warning": 150.0}, + wantErr: true, + errKey: "cpu_threshold_warning", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.Validate(tt.config) + if tt.wantErr && result.Valid { + t.Errorf("expected validation to fail, but it passed") + } + if !tt.wantErr && !result.Valid { + t.Errorf("expected validation to pass, but it failed: %v", result.Errors) + } + }) + } +} + +func TestProfileValidator_ValidateDurationType(t *testing.T) { + validator := NewProfileValidator() + + tests := []struct { + name string + config AgentConfigMap + wantErr bool + errKey string + }{ + { + name: "valid duration seconds", + config: AgentConfigMap{"interval": "30s"}, + wantErr: false, + }, + { + name: "valid duration minutes", + config: AgentConfigMap{"interval": "5m"}, + wantErr: false, + }, + { + name: "valid duration hours", + config: AgentConfigMap{"interval": "1h"}, + wantErr: false, + }, + { + name: "valid duration complex", + config: AgentConfigMap{"interval": "1h30m45s"}, + wantErr: false, + }, + { + name: "invalid duration format", + config: AgentConfigMap{"interval": "30"}, + wantErr: true, + errKey: "interval", + }, + { + name: "invalid duration - not a string", + config: AgentConfigMap{"interval": 30}, + wantErr: true, + errKey: "interval", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.Validate(tt.config) + if tt.wantErr && result.Valid { + t.Errorf("expected validation to fail, but it passed") + } + if !tt.wantErr && !result.Valid { + t.Errorf("expected validation to pass, but it failed: %v", result.Errors) + } + }) + } +} + +func TestProfileValidator_ValidateEnumType(t *testing.T) { + validator := NewProfileValidator() + + tests := []struct { + name string + config AgentConfigMap + wantErr bool + errKey string + }{ + { + name: "valid enum debug", + config: AgentConfigMap{"log_level": "debug"}, + wantErr: false, + }, + { + name: "valid enum info", + config: AgentConfigMap{"log_level": "info"}, + wantErr: false, + }, + { + name: "valid enum warn", + config: AgentConfigMap{"log_level": "warn"}, + wantErr: false, + }, + { + name: "valid enum error", + config: AgentConfigMap{"log_level": "error"}, + wantErr: false, + }, + { + name: "invalid enum value", + config: AgentConfigMap{"log_level": "trace"}, + wantErr: true, + errKey: "log_level", + }, + { + name: "invalid enum type", + config: AgentConfigMap{"log_level": 1}, + wantErr: true, + errKey: "log_level", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.Validate(tt.config) + if tt.wantErr && result.Valid { + t.Errorf("expected validation to fail, but it passed") + } + if !tt.wantErr && !result.Valid { + t.Errorf("expected validation to pass, but it failed: %v", result.Errors) + } + }) + } +} + +func TestProfileValidator_UnknownKeysWarning(t *testing.T) { + validator := NewProfileValidator() + + config := AgentConfigMap{ + "interval": "30s", + "unknown_key": "some_value", + } + + result := validator.Validate(config) + + // Should still be valid (warnings don't fail validation) + if !result.Valid { + t.Errorf("expected validation to pass with warnings, but it failed: %v", result.Errors) + } + + // Should have a warning for unknown key + if len(result.Warnings) == 0 { + t.Errorf("expected a warning for unknown key, but got none") + } + + found := false + for _, w := range result.Warnings { + if w.Key == "unknown_key" { + found = true + break + } + } + if !found { + t.Errorf("expected warning for 'unknown_key', got: %v", result.Warnings) + } +} + +func TestProfileValidator_ComplexConfig(t *testing.T) { + validator := NewProfileValidator() + + config := AgentConfigMap{ + "interval": "30s", + "enable_docker": true, + "enable_system_metrics": true, + "enable_process_metrics": false, + "log_level": "info", + "metric_buffer_size": 100, + "cpu_threshold_warning": 80.0, + "cpu_threshold_critical": 95.0, + "disk_paths": "/,/home", + } + + result := validator.Validate(config) + + if !result.Valid { + t.Errorf("expected complex config to be valid, but got errors: %v", result.Errors) + } +} + +func TestProfileValidator_NullValue(t *testing.T) { + validator := NewProfileValidator() + + config := AgentConfigMap{ + "interval": nil, + } + + result := validator.Validate(config) + + // Null values should be valid for non-required keys + if !result.Valid { + t.Errorf("expected null value to be valid for non-required key, but got errors: %v", result.Errors) + } +} + +func TestGetConfigKeyDefinitions(t *testing.T) { + defs := GetConfigKeyDefinitions() + + if len(defs) == 0 { + t.Error("expected config key definitions to be non-empty") + } + + // Check some known keys exist + expectedKeys := []string{"interval", "enable_docker", "log_level", "metric_buffer_size"} + for _, key := range expectedKeys { + found := false + for _, def := range defs { + if def.Key == key { + found = true + break + } + } + if !found { + t.Errorf("expected key %s to be in definitions", key) + } + } +} + +func TestGetConfigKeyDefinition(t *testing.T) { + def, found := GetConfigKeyDefinition("interval") + if !found { + t.Error("expected to find 'interval' definition") + } + if def.Type != ConfigTypeDuration { + t.Errorf("expected 'interval' type to be Duration, got %s", def.Type) + } + + _, found = GetConfigKeyDefinition("nonexistent_key") + if found { + t.Error("expected not to find 'nonexistent_key' definition") + } +} + +func TestAgentProfile_MergedConfig(t *testing.T) { + parentProfile := AgentProfile{ + ID: "parent-1", + Name: "Parent Profile", + Config: AgentConfigMap{ + "interval": "30s", + "enable_docker": true, + "log_level": "info", + }, + } + + childProfile := AgentProfile{ + ID: "child-1", + Name: "Child Profile", + ParentID: "parent-1", + Config: AgentConfigMap{ + "interval": "10s", // Override parent + "log_level": "debug", // Override parent + }, + } + + profiles := []AgentProfile{parentProfile, childProfile} + + merged := childProfile.MergedConfig(profiles) + + // Child's interval should override parent's + if merged["interval"] != "10s" { + t.Errorf("expected interval to be '10s', got %v", merged["interval"]) + } + + // Child's log_level should override parent's + if merged["log_level"] != "debug" { + t.Errorf("expected log_level to be 'debug', got %v", merged["log_level"]) + } + + // Parent's enable_docker should be inherited + if merged["enable_docker"] != true { + t.Errorf("expected enable_docker to be true, got %v", merged["enable_docker"]) + } +} + +func TestAgentProfile_MergedConfigNoParent(t *testing.T) { + profile := AgentProfile{ + ID: "profile-1", + Name: "Standalone Profile", + Config: AgentConfigMap{ + "interval": "30s", + }, + } + + profiles := []AgentProfile{profile} + + merged := profile.MergedConfig(profiles) + + if merged["interval"] != "30s" { + t.Errorf("expected interval to be '30s', got %v", merged["interval"]) + } +} + +func TestAgentProfile_MergedConfigParentNotFound(t *testing.T) { + profile := AgentProfile{ + ID: "child-1", + Name: "Child Profile", + ParentID: "nonexistent-parent", + Config: AgentConfigMap{ + "interval": "10s", + }, + } + + profiles := []AgentProfile{profile} + + // Should return just the child's config if parent not found + merged := profile.MergedConfig(profiles) + + if merged["interval"] != "10s" { + t.Errorf("expected interval to be '10s', got %v", merged["interval"]) + } +}