feat(models): add agent profile validation

- Add profile validation for agent configuration
- Implement validation rules for profile fields
This commit is contained in:
rcourtman
2026-01-12 15:21:05 +00:00
parent 0ddbf37c59
commit 2fdf7906e6
2 changed files with 859 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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"])
}
}