using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.Router.Config; /// /// Provides router configuration with hot-reload support. /// public sealed class RouterConfigProvider : IRouterConfigProvider, IDisposable { private readonly RouterConfigOptions _options; private readonly ILogger _logger; private readonly FileSystemWatcher? _watcher; private readonly SemaphoreSlim _reloadLock = new(1, 1); private readonly Timer? _debounceTimer; private RouterConfig _current; private bool _disposed; /// public event EventHandler? ConfigurationChanged; /// /// Initializes a new instance of the class. /// public RouterConfigProvider( IOptions options, ILogger logger) { _options = options.Value; _logger = logger; _current = LoadConfiguration(); if (_options.EnableHotReload && !string.IsNullOrEmpty(_options.ConfigPath) && File.Exists(_options.ConfigPath)) { var directory = Path.GetDirectoryName(Path.GetFullPath(_options.ConfigPath))!; var fileName = Path.GetFileName(_options.ConfigPath); _watcher = new FileSystemWatcher(directory) { Filter = fileName, NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size }; _debounceTimer = new Timer(OnDebounceElapsed, null, Timeout.Infinite, Timeout.Infinite); _watcher.Changed += OnFileChanged; _watcher.EnableRaisingEvents = true; _logger.LogInformation("Hot-reload enabled for configuration file: {Path}", _options.ConfigPath); } } /// public RouterConfig Current => _current; /// public RouterConfigOptions Options => _options; private void OnFileChanged(object sender, FileSystemEventArgs e) { // Debounce rapid file changes (e.g., from editors saving multiple times) _debounceTimer?.Change(_options.DebounceInterval, Timeout.InfiniteTimeSpan); } private void OnDebounceElapsed(object? state) { _ = ReloadAsyncInternal(); } private async Task ReloadAsyncInternal() { if (!await _reloadLock.WaitAsync(TimeSpan.Zero)) { // Another reload is in progress return; } try { var previous = _current; var newConfig = LoadConfiguration(); var validation = ValidateConfig(newConfig); if (!validation.IsValid) { if (_options.ThrowOnValidationError) { throw new ConfigurationException( $"Configuration validation failed: {string.Join("; ", validation.Errors)}"); } _logger.LogError( "Configuration validation failed, keeping previous: {Errors}", string.Join("; ", validation.Errors)); return; } foreach (var warning in validation.Warnings) { _logger.LogWarning("Configuration warning: {Warning}", warning); } _current = newConfig; _logger.LogInformation("Router configuration reloaded successfully"); ConfigurationChanged?.Invoke(this, new ConfigChangedEventArgs(previous, newConfig)); } catch (Exception ex) { _logger.LogError(ex, "Failed to reload configuration, keeping previous"); if (_options.ThrowOnValidationError) { throw; } } finally { _reloadLock.Release(); } } /// public async Task ReloadAsync(CancellationToken cancellationToken = default) { await _reloadLock.WaitAsync(cancellationToken); try { var previous = _current; var newConfig = LoadConfiguration(); var validation = ValidateConfig(newConfig); if (!validation.IsValid) { throw new ConfigurationException( $"Configuration validation failed: {string.Join("; ", validation.Errors)}"); } _current = newConfig; _logger.LogInformation("Router configuration reloaded successfully"); ConfigurationChanged?.Invoke(this, new ConfigChangedEventArgs(previous, newConfig)); } finally { _reloadLock.Release(); } } /// public ConfigValidationResult Validate() => ValidateConfig(_current); private RouterConfig LoadConfiguration() { var builder = new ConfigurationBuilder(); // Load from YAML file if specified if (!string.IsNullOrEmpty(_options.ConfigPath)) { var extension = Path.GetExtension(_options.ConfigPath).ToLowerInvariant(); var fullPath = Path.GetFullPath(_options.ConfigPath); if (File.Exists(fullPath)) { switch (extension) { case ".yaml": case ".yml": builder.AddYamlFile(fullPath, optional: true, reloadOnChange: false); break; case ".json": builder.AddJsonFile(fullPath, optional: true, reloadOnChange: false); break; default: _logger.LogWarning("Unknown configuration file extension: {Extension}", extension); break; } } else { _logger.LogWarning("Configuration file not found: {Path}", fullPath); } } // Add environment variable overrides builder.AddEnvironmentVariables(prefix: _options.EnvironmentVariablePrefix); var configuration = builder.Build(); var config = new RouterConfig(); configuration.Bind(config); return config; } private static ConfigValidationResult ValidateConfig(RouterConfig config) { var result = new ConfigValidationResult(); // Validate payload limits if (config.PayloadLimits.MaxRequestBytesPerCall <= 0) { result.Errors.Add("PayloadLimits.MaxRequestBytesPerCall must be positive"); } if (config.PayloadLimits.MaxRequestBytesPerConnection <= 0) { result.Errors.Add("PayloadLimits.MaxRequestBytesPerConnection must be positive"); } if (config.PayloadLimits.MaxAggregateInflightBytes <= 0) { result.Errors.Add("PayloadLimits.MaxAggregateInflightBytes must be positive"); } if (config.PayloadLimits.MaxRequestBytesPerCall > config.PayloadLimits.MaxRequestBytesPerConnection) { result.Warnings.Add("MaxRequestBytesPerCall is larger than MaxRequestBytesPerConnection"); } // Validate routing options if (config.Routing.DefaultTimeout <= TimeSpan.Zero) { result.Errors.Add("Routing.DefaultTimeout must be positive"); } // Validate services var serviceNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var service in config.Services) { if (string.IsNullOrWhiteSpace(service.ServiceName)) { result.Errors.Add("Service name cannot be empty"); continue; } if (!serviceNames.Add(service.ServiceName)) { result.Errors.Add($"Duplicate service name: {service.ServiceName}"); } foreach (var endpoint in service.Endpoints) { if (string.IsNullOrWhiteSpace(endpoint.Method)) { result.Errors.Add($"Service {service.ServiceName}: endpoint method cannot be empty"); } if (string.IsNullOrWhiteSpace(endpoint.Path)) { result.Errors.Add($"Service {service.ServiceName}: endpoint path cannot be empty"); } if (endpoint.DefaultTimeout.HasValue && endpoint.DefaultTimeout.Value <= TimeSpan.Zero) { result.Warnings.Add( $"Service {service.ServiceName}: endpoint {endpoint.Method} {endpoint.Path} has non-positive timeout"); } } } // Validate static instances foreach (var instance in config.StaticInstances) { if (string.IsNullOrWhiteSpace(instance.ServiceName)) { result.Errors.Add("Static instance service name cannot be empty"); } if (string.IsNullOrWhiteSpace(instance.Host)) { result.Errors.Add($"Static instance {instance.ServiceName}: host cannot be empty"); } if (instance.Port <= 0 || instance.Port > 65535) { result.Errors.Add($"Static instance {instance.ServiceName}: port must be between 1 and 65535"); } if (instance.Weight <= 0) { result.Warnings.Add($"Static instance {instance.ServiceName}: weight should be positive"); } } return result; } /// public void Dispose() { if (_disposed) return; _disposed = true; _watcher?.Dispose(); _debounceTimer?.Dispose(); _reloadLock.Dispose(); } } /// /// Exception thrown when configuration is invalid. /// public sealed class ConfigurationException : Exception { /// /// Initializes a new instance of the class. /// public ConfigurationException(string message) : base(message) { } /// /// Initializes a new instance of the class. /// public ConfigurationException(string message, Exception innerException) : base(message, innerException) { } }