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.
This commit is contained in:
321
src/__Libraries/StellaOps.Router.Config/RouterConfigProvider.cs
Normal file
321
src/__Libraries/StellaOps.Router.Config/RouterConfigProvider.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user