Files
git.stella-ops.org/src/__Libraries/StellaOps.Router.Config/RouterConfigProvider.cs
StellaOps Bot 6a299d231f
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Add unit tests for Router configuration and transport layers
- Implemented tests for RouterConfig, RoutingOptions, StaticInstanceConfig, and RouterConfigOptions to ensure default values are set correctly.
- Added tests for RouterConfigProvider to validate configurations and ensure defaults are returned when no file is specified.
- Created tests for ConfigValidationResult to check success and error scenarios.
- Developed tests for ServiceCollectionExtensions to verify service registration for RouterConfig.
- Introduced UdpTransportTests to validate serialization, connection, request-response, and error handling in UDP transport.
- Added scripts for signing authority gaps and hashing DevPortal SDK snippets.
2025-12-05 08:01:47 +02:00

322 lines
10 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Router.Config;
/// <summary>
/// Provides router configuration with hot-reload support.
/// </summary>
public sealed class RouterConfigProvider : IRouterConfigProvider, IDisposable
{
private readonly RouterConfigOptions _options;
private readonly ILogger<RouterConfigProvider> _logger;
private readonly FileSystemWatcher? _watcher;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
private readonly Timer? _debounceTimer;
private RouterConfig _current;
private bool _disposed;
/// <inheritdoc />
public event EventHandler<ConfigChangedEventArgs>? ConfigurationChanged;
/// <summary>
/// Initializes a new instance of the <see cref="RouterConfigProvider"/> class.
/// </summary>
public RouterConfigProvider(
IOptions<RouterConfigOptions> options,
ILogger<RouterConfigProvider> 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);
}
}
/// <inheritdoc />
public RouterConfig Current => _current;
/// <inheritdoc />
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();
}
}
/// <inheritdoc />
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();
}
}
/// <inheritdoc />
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<string>(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;
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_watcher?.Dispose();
_debounceTimer?.Dispose();
_reloadLock.Dispose();
}
}
/// <summary>
/// Exception thrown when configuration is invalid.
/// </summary>
public sealed class ConfigurationException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationException"/> class.
/// </summary>
public ConfigurationException(string message) : base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationException"/> class.
/// </summary>
public ConfigurationException(string message, Exception innerException) : base(message, innerException)
{
}
}