Add unit tests for Router configuration and transport layers
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

- 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:
StellaOps Bot
2025-12-05 08:01:47 +02:00
parent 635c70e828
commit 6a299d231f
294 changed files with 28434 additions and 1329 deletions

View File

@@ -0,0 +1,94 @@
namespace StellaOps.Router.Config;
/// <summary>
/// Provides access to router configuration with hot-reload support.
/// </summary>
public interface IRouterConfigProvider
{
/// <summary>
/// Gets the current router configuration.
/// </summary>
RouterConfig Current { get; }
/// <summary>
/// Gets the current router configuration options.
/// </summary>
RouterConfigOptions Options { get; }
/// <summary>
/// Raised when the configuration is reloaded.
/// </summary>
event EventHandler<ConfigChangedEventArgs>? ConfigurationChanged;
/// <summary>
/// Reloads the configuration from the source.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the reload operation.</returns>
Task ReloadAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Validates the current configuration.
/// </summary>
/// <returns>Validation result.</returns>
ConfigValidationResult Validate();
}
/// <summary>
/// Event arguments for configuration changes.
/// </summary>
public sealed class ConfigChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConfigChangedEventArgs"/> class.
/// </summary>
/// <param name="previous">The previous configuration.</param>
/// <param name="current">The current configuration.</param>
public ConfigChangedEventArgs(RouterConfig previous, RouterConfig current)
{
Previous = previous;
Current = current;
ChangedAt = DateTime.UtcNow;
}
/// <summary>
/// Gets the previous configuration.
/// </summary>
public RouterConfig Previous { get; }
/// <summary>
/// Gets the current configuration.
/// </summary>
public RouterConfig Current { get; }
/// <summary>
/// Gets the time the configuration was changed.
/// </summary>
public DateTime ChangedAt { get; }
}
/// <summary>
/// Result of configuration validation.
/// </summary>
public sealed class ConfigValidationResult
{
/// <summary>
/// Gets whether the configuration is valid.
/// </summary>
public bool IsValid => Errors.Count == 0;
/// <summary>
/// Gets the validation errors.
/// </summary>
public List<string> Errors { get; init; } = [];
/// <summary>
/// Gets the validation warnings.
/// </summary>
public List<string> Warnings { get; init; } = [];
/// <summary>
/// A successful validation result.
/// </summary>
public static ConfigValidationResult Success => new();
}

View File

@@ -12,8 +12,18 @@ public sealed class RouterConfig
/// </summary>
public PayloadLimits PayloadLimits { get; set; } = new();
/// <summary>
/// Gets or sets the routing options.
/// </summary>
public RoutingOptions Routing { get; set; } = new();
/// <summary>
/// Gets or sets the service configurations.
/// </summary>
public List<ServiceConfig> Services { get; set; } = [];
/// <summary>
/// Gets or sets the static instance configurations.
/// </summary>
public List<StaticInstanceConfig> StaticInstances { get; set; } = [];
}

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Router.Config;
/// <summary>
/// Options for the router configuration provider.
/// </summary>
public sealed class RouterConfigOptions
{
/// <summary>
/// Gets or sets the path to the router configuration file (YAML or JSON).
/// </summary>
public string? ConfigPath { get; set; }
/// <summary>
/// Gets or sets the environment variable prefix for overrides.
/// Default: "STELLAOPS_ROUTER_".
/// </summary>
public string EnvironmentVariablePrefix { get; set; } = "STELLAOPS_ROUTER_";
/// <summary>
/// Gets or sets whether to enable hot-reload of configuration.
/// </summary>
public bool EnableHotReload { get; set; } = true;
/// <summary>
/// Gets or sets the debounce interval for file change notifications.
/// </summary>
public TimeSpan DebounceInterval { get; set; } = TimeSpan.FromMilliseconds(500);
/// <summary>
/// Gets or sets whether to throw on configuration validation errors.
/// If false, keeps the previous valid configuration.
/// </summary>
public bool ThrowOnValidationError { get; set; } = false;
/// <summary>
/// Gets or sets the configuration section name in appsettings.json.
/// </summary>
public string ConfigurationSection { get; set; } = "Router";
}

View 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)
{
}
}

View File

@@ -0,0 +1,58 @@
namespace StellaOps.Router.Config;
/// <summary>
/// Routing behavior options.
/// </summary>
public sealed class RoutingOptions
{
/// <summary>
/// Gets or sets the local region for routing preferences.
/// </summary>
public string LocalRegion { get; set; } = "default";
/// <summary>
/// Gets or sets the neighbor regions for fallback routing.
/// </summary>
public List<string> NeighborRegions { get; set; } = [];
/// <summary>
/// Gets or sets the tie-breaker strategy for equal-weight instances.
/// </summary>
public TieBreakerStrategy TieBreaker { get; set; } = TieBreakerStrategy.RoundRobin;
/// <summary>
/// Gets or sets whether to prefer local region instances.
/// </summary>
public bool PreferLocalRegion { get; set; } = true;
/// <summary>
/// Gets or sets the default request timeout.
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Tie-breaker strategy for routing decisions.
/// </summary>
public enum TieBreakerStrategy
{
/// <summary>
/// Round-robin between equal-weight instances.
/// </summary>
RoundRobin,
/// <summary>
/// Random selection between equal-weight instances.
/// </summary>
Random,
/// <summary>
/// Select the least-loaded instance.
/// </summary>
LeastLoaded,
/// <summary>
/// Consistent hashing based on request attributes.
/// </summary>
ConsistentHash
}

View File

@@ -0,0 +1,108 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Router.Config;
/// <summary>
/// Extension methods for registering router configuration services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds router configuration services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configPath">Optional path to the configuration file.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddRouterConfig(
this IServiceCollection services,
string? configPath = null)
{
return services.AddRouterConfig(options =>
{
if (!string.IsNullOrEmpty(configPath))
{
options.ConfigPath = configPath;
}
});
}
/// <summary>
/// Adds router configuration services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Configuration action.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddRouterConfig(
this IServiceCollection services,
Action<RouterConfigOptions> configure)
{
services.Configure(configure);
services.AddSingleton<IRouterConfigProvider, RouterConfigProvider>();
return services;
}
/// <summary>
/// Adds router configuration services to the service collection, binding from IConfiguration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="sectionName">The configuration section name.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddRouterConfig(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Router")
{
var section = configuration.GetSection(sectionName);
services.Configure<RouterConfigOptions>(options =>
{
options.ConfigurationSection = sectionName;
});
services.Configure<RouterConfig>(section);
services.AddSingleton<IRouterConfigProvider, RouterConfigProvider>();
return services;
}
/// <summary>
/// Adds router configuration from a YAML file.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="yamlPath">Path to the YAML configuration file.</param>
/// <param name="enableHotReload">Whether to enable hot-reload.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddRouterConfigFromYaml(
this IServiceCollection services,
string yamlPath,
bool enableHotReload = true)
{
return services.AddRouterConfig(options =>
{
options.ConfigPath = yamlPath;
options.EnableHotReload = enableHotReload;
});
}
/// <summary>
/// Adds router configuration from a JSON file.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="jsonPath">Path to the JSON configuration file.</param>
/// <param name="enableHotReload">Whether to enable hot-reload.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddRouterConfigFromJson(
this IServiceCollection services,
string jsonPath,
bool enableHotReload = true)
{
return services.AddRouterConfig(options =>
{
options.ConfigPath = jsonPath;
options.EnableHotReload = enableHotReload;
});
}
}

View File

@@ -0,0 +1,49 @@
using StellaOps.Router.Common.Enums;
namespace StellaOps.Router.Config;
/// <summary>
/// Configuration for a statically-defined microservice instance.
/// </summary>
public sealed class StaticInstanceConfig
{
/// <summary>
/// Gets or sets the service name.
/// </summary>
public required string ServiceName { get; set; }
/// <summary>
/// Gets or sets the service version.
/// </summary>
public required string Version { get; set; }
/// <summary>
/// Gets or sets the region.
/// </summary>
public string Region { get; set; } = "default";
/// <summary>
/// Gets or sets the host name or IP address.
/// </summary>
public required string Host { get; set; }
/// <summary>
/// Gets or sets the port.
/// </summary>
public required int Port { get; set; }
/// <summary>
/// Gets or sets the transport type.
/// </summary>
public TransportType Transport { get; set; } = TransportType.Tcp;
/// <summary>
/// Gets or sets the instance weight for load balancing.
/// </summary>
public int Weight { get; set; } = 100;
/// <summary>
/// Gets or sets the instance metadata.
/// </summary>
public Dictionary<string, string> Metadata { get; set; } = [];
}

View File

@@ -5,8 +5,23 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Router.Config</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
</Project>