- 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.
322 lines
10 KiB
C#
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)
|
|
{
|
|
}
|
|
}
|