diff --git a/internal/config/validator.go b/internal/config/validator.go new file mode 100644 index 0000000..4f923ed --- /dev/null +++ b/internal/config/validator.go @@ -0,0 +1,692 @@ +/* + * Copyright 2026 Safronov Grigorii + * + * Licensed under the CDDL, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * https://opensource.org/licenses/CDDL-1.0 + */ + +// Файл: internal/config/validator.go +// Назначение: Полная валидация файла конфигурации config.toml + +package config + +import ( + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// ValidationError представляет ошибку валидации (использует тип error) +type ValidationError struct { + Field string + Value interface{} + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error in %s: %s (value: %v)", e.Field, e.Message, e.Value) +} + +// ValidateConfigFull выполняет полную валидацию конфигурации (переименовано во избежание конфликта) +func ValidateConfigFull(cfg *Config) *ValidationResult { + result := &ValidationResult{ + Valid: true, + Errors: make([]error, 0), + Warnings: make([]error, 0), + } + + // Валидация всех секций + validateClusterConfig(cfg, result) + validateStorageConfig(cfg, result) + validateReplConfig(cfg, result) + validateLogConfig(cfg, result) + validateAPIConfig(cfg, result) + validateReplicationConfig(cfg, result) + validatePluginsConfig(cfg, result) + validateCompressionConfig(cfg, result) + validateWebUIConfig(cfg, result) + validatePerformanceConfig(cfg, result) + validateSecurityConfig(cfg, result) + validateMonitoringConfig(cfg, result) + validateRecoveryConfig(cfg, result) + validateWALConfig(cfg, result) + validateMVCCConfig(cfg, result) + validateTransactionsConfig(cfg, result) + validateACLConfig(cfg, result) + + // Проверка конфликтов портов + validatePortConflicts(cfg, result) + + // Проверка путей + validatePaths(cfg, result) + + result.Valid = len(result.Errors) == 0 + + return result +} + +// validateClusterConfig валидирует секцию cluster +func validateClusterConfig(cfg *Config, result *ValidationResult) { + // Имя кластера + if cfg.Cluster.Name == "" { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.name", + Value: cfg.Cluster.Name, + Message: "cluster name cannot be empty", + }) + } else if len(cfg.Cluster.Name) > 255 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.name", + Value: cfg.Cluster.Name, + Message: "cluster name too long (max 255 characters)", + }) + } else { + matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, cfg.Cluster.Name) + if !matched { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "cluster.name", + Value: cfg.Cluster.Name, + Message: "cluster name should contain only letters, numbers, underscores and hyphens", + }) + } + } + + // IP адрес узла + if cfg.Cluster.NodeIP == "" { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.node_ip", + Value: cfg.Cluster.NodeIP, + Message: "node_ip cannot be empty", + }) + } else if cfg.Cluster.NodeIP != "0.0.0.0" && net.ParseIP(cfg.Cluster.NodeIP) == nil { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.node_ip", + Value: cfg.Cluster.NodeIP, + Message: "invalid IP address", + }) + } + + // Порт узла + if cfg.Cluster.NodePort <= 0 || cfg.Cluster.NodePort > 65535 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.node_port", + Value: cfg.Cluster.NodePort, + Message: "port must be between 1 and 65535", + }) + } + + // Порт Raft + if cfg.Cluster.RaftPort <= 0 || cfg.Cluster.RaftPort > 65535 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.raft_port", + Value: cfg.Cluster.RaftPort, + Message: "port must be between 1 and 65535", + }) + } + + if cfg.Cluster.NodePort == cfg.Cluster.RaftPort { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster", + Value: fmt.Sprintf("node_port=%d, raft_port=%d", cfg.Cluster.NodePort, cfg.Cluster.RaftPort), + Message: "node_port and raft_port cannot be the same", + }) + } + + // Директория данных Raft + if cfg.Cluster.RaftDataDir == "" { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.raft_data_dir", + Value: cfg.Cluster.RaftDataDir, + Message: "raft_data_dir cannot be empty", + }) + } + + // Таймауты + if cfg.Cluster.HeartbeatTimeoutMs < 100 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "cluster.heartbeat_timeout_ms", + Value: cfg.Cluster.HeartbeatTimeoutMs, + Message: "heartbeat_timeout_ms is very low (minimum recommended: 100ms)", + }) + } + + if cfg.Cluster.ElectionTimeoutMs < 200 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "cluster.election_timeout_ms", + Value: cfg.Cluster.ElectionTimeoutMs, + Message: "election_timeout_ms is very low (minimum recommended: 200ms)", + }) + } + + // Snapshot интервал + if cfg.Cluster.SnapshotIntervalMin < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.snapshot_interval_min", + Value: cfg.Cluster.SnapshotIntervalMin, + Message: "snapshot_interval_min must be at least 1 minute", + }) + } + + // Snapshot порог + if cfg.Cluster.SnapshotThreshold < 100 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "cluster.snapshot_threshold", + Value: cfg.Cluster.SnapshotThreshold, + Message: "snapshot_threshold is very low (minimum recommended: 100)", + }) + } + + // Приоритетная зона + if cfg.Cluster.PriorityZone < 0 || cfg.Cluster.PriorityZone > 9 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.priority_zone", + Value: cfg.Cluster.PriorityZone, + Message: "priority_zone must be between 0 and 9", + }) + } + + // Список узлов + if !cfg.Cluster.Bootstrap && len(cfg.Cluster.Nodes) == 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "cluster.nodes", + Value: cfg.Cluster.Nodes, + Message: "nodes list cannot be empty when bootstrap is false", + }) + } + + for i, node := range cfg.Cluster.Nodes { + parts := strings.Split(node, ":") + if len(parts) != 2 { + result.Errors = append(result.Errors, &ValidationError{ + Field: fmt.Sprintf("cluster.nodes[%d]", i), + Value: node, + Message: "invalid node format, expected ip:port", + }) + continue + } + + if net.ParseIP(parts[0]) == nil && parts[0] != "localhost" { + result.Errors = append(result.Errors, &ValidationError{ + Field: fmt.Sprintf("cluster.nodes[%d].ip", i), + Value: parts[0], + Message: "invalid IP address", + }) + } + + port, err := strconv.Atoi(parts[1]) + if err != nil || port <= 0 || port > 65535 { + result.Errors = append(result.Errors, &ValidationError{ + Field: fmt.Sprintf("cluster.nodes[%d].port", i), + Value: parts[1], + Message: "invalid port number", + }) + } + } +} + +// validateStorageConfig валидирует секцию storage +func validateStorageConfig(cfg *Config, result *ValidationResult) { + if cfg.Storage.PageSizeMB <= 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "storage.page_size_mb", + Value: cfg.Storage.PageSizeMB, + Message: "page_size_mb must be positive", + }) + } else if cfg.Storage.PageSizeMB > 1024 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "storage.page_size_mb", + Value: cfg.Storage.PageSizeMB, + Message: "page_size_mb is very large (> 1GB), may cause performance issues", + }) + } + + if cfg.Storage.MaxCollections <= 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "storage.max_collections", + Value: cfg.Storage.MaxCollections, + Message: "max_collections must be positive", + }) + } + + if cfg.Storage.MaxDocumentsPerCollection <= 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "storage.max_documents_per_collection", + Value: cfg.Storage.MaxDocumentsPerCollection, + Message: "max_documents_per_collection must be positive", + }) + } +} + +// validateReplConfig валидирует секцию repl +func validateReplConfig(cfg *Config, result *ValidationResult) { + if cfg.Repl.HistorySize < 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "repl.history_size", + Value: cfg.Repl.HistorySize, + Message: "history_size cannot be negative", + }) + } +} + +// validateLogConfig валидирует секцию log +func validateLogConfig(cfg *Config, result *ValidationResult) { + validLevels := map[string]bool{ + "debug": true, "info": true, "warn": true, "error": true, "fatal": true, + } + + if cfg.Log.LogLevel != "" && !validLevels[strings.ToLower(cfg.Log.LogLevel)] { + result.Errors = append(result.Errors, &ValidationError{ + Field: "log.log_level", + Value: cfg.Log.LogLevel, + Message: "invalid log level, expected: debug, info, warn, error, fatal", + }) + } +} + +// validateAPIConfig валидирует секцию api +func validateAPIConfig(cfg *Config, result *ValidationResult) { + if cfg.API.Port <= 0 || cfg.API.Port > 65535 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "api.port", + Value: cfg.API.Port, + Message: "port must be between 1 and 65535", + }) + } +} + +// validateReplicationConfig валидирует секцию replication +func validateReplicationConfig(cfg *Config, result *ValidationResult) { + if cfg.Replication.Enabled && cfg.Replication.ReplicationTimeoutMs < 100 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "replication.replication_timeout_ms", + Value: cfg.Replication.ReplicationTimeoutMs, + Message: "replication_timeout_ms is very low (minimum recommended: 100ms)", + }) + } + + if cfg.Replication.MasterMaster && !cfg.Replication.Enabled { + result.Errors = append(result.Errors, &ValidationError{ + Field: "replication.master_master", + Value: cfg.Replication.MasterMaster, + Message: "master_master requires replication.enabled to be true", + }) + } + + if cfg.Replication.SyncReplication && !cfg.Replication.Enabled { + result.Errors = append(result.Errors, &ValidationError{ + Field: "replication.sync_replication", + Value: cfg.Replication.SyncReplication, + Message: "sync_replication requires replication.enabled to be true", + }) + } +} + +// validatePluginsConfig валидирует секцию plugins +func validatePluginsConfig(cfg *Config, result *ValidationResult) { + if cfg.Plugins.Enabled { + if cfg.Plugins.ScriptDir == "" { + result.Errors = append(result.Errors, &ValidationError{ + Field: "plugins.script_dir", + Value: cfg.Plugins.ScriptDir, + Message: "script_dir cannot be empty when plugins are enabled", + }) + } + + if cfg.Plugins.MaxCPUTimeMs < 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "plugins.max_cpu_time_ms", + Value: cfg.Plugins.MaxCPUTimeMs, + Message: "max_cpu_time_ms cannot be negative", + }) + } + + if cfg.Plugins.MaxMemoryMB < 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "plugins.max_memory_mb", + Value: cfg.Plugins.MaxMemoryMB, + Message: "max_memory_mb cannot be negative", + }) + } + + if cfg.Plugins.MaxExecutionTimeSec < 1 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "plugins.max_execution_time_sec", + Value: cfg.Plugins.MaxExecutionTimeSec, + Message: "max_execution_time_sec is very low (minimum 1 second)", + }) + } + } +} + +// validateCompressionConfig валидирует секцию compression +func validateCompressionConfig(cfg *Config, result *ValidationResult) { + if cfg.Compression.Enabled { + validAlgorithms := map[string]bool{"snappy": true, "lz4": true, "zstd": true} + if !validAlgorithms[cfg.Compression.Algorithm] { + result.Errors = append(result.Errors, &ValidationError{ + Field: "compression.algorithm", + Value: cfg.Compression.Algorithm, + Message: "invalid compression algorithm, expected: snappy, lz4, zstd", + }) + } + + if cfg.Compression.Level < 1 || cfg.Compression.Level > 9 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "compression.level", + Value: cfg.Compression.Level, + Message: "compression level must be between 1 and 9", + }) + } + + if cfg.Compression.MinSize < 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "compression.min_size", + Value: cfg.Compression.MinSize, + Message: "min_size cannot be negative", + }) + } + } +} + +// validateWebUIConfig валидирует секцию webui +func validateWebUIConfig(cfg *Config, result *ValidationResult) { + if cfg.WebUI.Enabled { + if cfg.WebUI.Port <= 0 || cfg.WebUI.Port > 65535 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "webui.port", + Value: cfg.WebUI.Port, + Message: "port must be between 1 and 65535", + }) + } + + validThemes := map[string]bool{"dark": true, "light": true} + if !validThemes[cfg.WebUI.Theme] { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "webui.theme", + Value: cfg.WebUI.Theme, + Message: "unknown theme, expected: dark, light", + }) + } + } +} + +// validatePerformanceConfig валидирует секцию performance +func validatePerformanceConfig(cfg *Config, result *ValidationResult) { + if cfg.Performance.BatchSize < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "performance.batch_size", + Value: cfg.Performance.BatchSize, + Message: "batch_size must be at least 1", + }) + } + + if cfg.Performance.MaxConnections < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "performance.max_connections", + Value: cfg.Performance.MaxConnections, + Message: "max_connections must be at least 1", + }) + } + + if cfg.Performance.MaxConnections > 10000 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "performance.max_connections", + Value: cfg.Performance.MaxConnections, + Message: "max_connections is very high, may cause resource exhaustion", + }) + } +} + +// validateSecurityConfig валидирует секцию security +func validateSecurityConfig(cfg *Config, result *ValidationResult) { + if cfg.Security.EnableTLS { + if cfg.Security.CertFile == "" { + result.Errors = append(result.Errors, &ValidationError{ + Field: "security.cert_file", + Value: cfg.Security.CertFile, + Message: "cert_file is required when TLS is enabled", + }) + } + + if cfg.Security.KeyFile == "" { + result.Errors = append(result.Errors, &ValidationError{ + Field: "security.key_file", + Value: cfg.Security.KeyFile, + Message: "key_file is required when TLS is enabled", + }) + } + + validVersions := map[string]bool{"1.0": true, "1.1": true, "1.2": true, "1.3": true} + if !validVersions[cfg.Security.MinVersion] { + result.Errors = append(result.Errors, &ValidationError{ + Field: "security.min_version", + Value: cfg.Security.MinVersion, + Message: "invalid TLS version, expected: 1.0, 1.1, 1.2, 1.3", + }) + } + } +} + +// validateMonitoringConfig валидирует секцию monitoring +func validateMonitoringConfig(cfg *Config, result *ValidationResult) { + if cfg.Monitoring.EnableMetrics { + if cfg.Monitoring.MetricsPort <= 0 || cfg.Monitoring.MetricsPort > 65535 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "monitoring.metrics_port", + Value: cfg.Monitoring.MetricsPort, + Message: "port must be between 1 and 65535", + }) + } + } + + if cfg.Monitoring.TraceSampleRate < 0 || cfg.Monitoring.TraceSampleRate > 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "monitoring.trace_sample_rate", + Value: cfg.Monitoring.TraceSampleRate, + Message: "trace_sample_rate must be between 0 and 1", + }) + } +} + +// validateRecoveryConfig валидирует секцию recovery +func validateRecoveryConfig(cfg *Config, result *ValidationResult) { + if cfg.Recovery.MaxRetrySec < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "recovery.max_retry_sec", + Value: cfg.Recovery.MaxRetrySec, + Message: "max_retry_sec must be at least 1 second", + }) + } + + if cfg.Recovery.DataReplicationTimeoutSec < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "recovery.data_replication_timeout_sec", + Value: cfg.Recovery.DataReplicationTimeoutSec, + Message: "data_replication_timeout_sec must be at least 1 second", + }) + } +} + +// validateWALConfig валидирует секцию wal +func validateWALConfig(cfg *Config, result *ValidationResult) { + if cfg.WAL.Enabled { + if cfg.WAL.SegmentSizeMB < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "wal.segment_size_mb", + Value: cfg.WAL.SegmentSizeMB, + Message: "segment_size_mb must be at least 1 MB", + }) + } + + if cfg.WAL.SyncIntervalSec < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "wal.sync_interval_sec", + Value: cfg.WAL.SyncIntervalSec, + Message: "sync_interval_sec must be at least 1 second", + }) + } + + if cfg.WAL.RecoveryWorkers < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "wal.recovery_workers", + Value: cfg.WAL.RecoveryWorkers, + Message: "recovery_workers must be at least 1", + }) + } + + if cfg.WAL.RecoveryWorkers > 32 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "wal.recovery_workers", + Value: cfg.WAL.RecoveryWorkers, + Message: "recovery_workers is very high (> 32), may cause performance issues", + }) + } + } +} + +// validateMVCCConfig валидирует секцию mvcc +func validateMVCCConfig(cfg *Config, result *ValidationResult) { + if cfg.MVCC.MaxVersionsPerDoc < 0 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "mvcc.max_versions_per_doc", + Value: cfg.MVCC.MaxVersionsPerDoc, + Message: "max_versions_per_doc cannot be negative", + }) + } + + if cfg.MVCC.MaxVersionsPerDoc > 100 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "mvcc.max_versions_per_doc", + Value: cfg.MVCC.MaxVersionsPerDoc, + Message: "max_versions_per_doc is very high (> 100), may cause excessive storage usage", + }) + } + + if cfg.MVCC.RetentionDays < 1 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "mvcc.retention_days", + Value: cfg.MVCC.RetentionDays, + Message: "retention_days is less than 1 day", + }) + } +} + +// validateTransactionsConfig валидирует секцию transactions +func validateTransactionsConfig(cfg *Config, result *ValidationResult) { + if cfg.Transactions.Enabled { + if cfg.Transactions.DefaultTimeoutSec < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "transactions.default_timeout_sec", + Value: cfg.Transactions.DefaultTimeoutSec, + Message: "default_timeout_sec must be at least 1 second", + }) + } + + if cfg.Transactions.DeadlockCheckIntervalSec < 1 { + result.Errors = append(result.Errors, &ValidationError{ + Field: "transactions.deadlock_check_interval_sec", + Value: cfg.Transactions.DeadlockCheckIntervalSec, + Message: "deadlock_check_interval_sec must be at least 1 second", + }) + } + } +} + +// validateACLConfig валидирует секцию acl +func validateACLConfig(cfg *Config, result *ValidationResult) { + if cfg.ACL.TemporaryGrantTTLHours < 1 { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: "acl.temporary_grant_ttl_hours", + Value: cfg.ACL.TemporaryGrantTTLHours, + Message: "temporary_grant_ttl_hours is very low", + }) + } +} + +// validatePortConflicts проверяет конфликты портов +func validatePortConflicts(cfg *Config, result *ValidationResult) { + ports := make(map[int]string) + + addPort := func(port int, name string) { + if existing, ok := ports[port]; ok { + result.Errors = append(result.Errors, &ValidationError{ + Field: name, + Value: port, + Message: fmt.Sprintf("port %d conflicts with %s", port, existing), + }) + } else { + ports[port] = name + } + } + + addPort(cfg.Cluster.NodePort, "cluster.node_port") + addPort(cfg.Cluster.RaftPort, "cluster.raft_port") + addPort(cfg.API.Port, "api.port") + + if cfg.WebUI.Enabled { + addPort(cfg.WebUI.Port, "webui.port") + } + + if cfg.Monitoring.EnableMetrics { + addPort(cfg.Monitoring.MetricsPort, "monitoring.metrics_port") + } +} + +// validatePaths проверяет существование путей +func validatePaths(cfg *Config, result *ValidationResult) { + // Проверяем директории на возможность создания + checkDir := func(path, fieldName string) { + if path == "" { + return + } + + dir := path + if !strings.HasSuffix(dir, "/") { + dir = filepath.Dir(dir) + } + + if dir == "" || dir == "." || dir == "/" { + return + } + + if err := os.MkdirAll(dir, 0755); err != nil { + result.Warnings = append(result.Warnings, &ValidationError{ + Field: fieldName, + Value: path, + Message: fmt.Sprintf("cannot create directory: %v", err), + }) + } + } + + checkDir(cfg.Cluster.RaftDataDir, "cluster.raft_data_dir") + checkDir(cfg.Log.LogFile, "log.log_file") + checkDir(cfg.Plugins.ScriptDir, "plugins.script_dir") + + if cfg.Security.EnableTLS { + checkFile := func(path, fieldName string) { + if path == "" { + return + } + if _, err := os.Stat(path); os.IsNotExist(err) { + result.Errors = append(result.Errors, &ValidationError{ + Field: fieldName, + Value: path, + Message: "file does not exist", + }) + } + } + + checkFile(cfg.Security.CertFile, "security.cert_file") + checkFile(cfg.Security.KeyFile, "security.key_file") + if cfg.Security.CAFile != "" { + checkFile(cfg.Security.CAFile, "security.ca_file") + } + } +}