Repair router frontdoor convergence and live route contracts
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Authorization;
|
||||
|
||||
@@ -66,6 +67,8 @@ public sealed class AuthorizationMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedScopes = ResolveGatewayScopes(context);
|
||||
|
||||
foreach (var required in effectiveClaims)
|
||||
{
|
||||
var userClaims = context.User.Claims;
|
||||
@@ -78,13 +81,7 @@ public sealed class AuthorizationMiddleware
|
||||
else if (string.Equals(required.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(required.Type, "scp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Scope claims may be space-separated (RFC 6749 §3.3) or individual claims.
|
||||
// Check both: exact match on individual claims, and contains-within-space-separated.
|
||||
hasClaim = userClaims.Any(c =>
|
||||
c.Type == required.Type &&
|
||||
(c.Value == required.Value ||
|
||||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(s => string.Equals(s, required.Value, StringComparison.Ordinal))));
|
||||
hasClaim = HasRequiredScope(userClaims, resolvedScopes, required.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -108,6 +105,34 @@ public sealed class AuthorizationMiddleware
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static ISet<string>? ResolveGatewayScopes(HttpContext context)
|
||||
{
|
||||
return context.Items.TryGetValue(GatewayContextKeys.Scopes, out var scopesObj) &&
|
||||
scopesObj is ISet<string> scopes &&
|
||||
scopes.Count > 0
|
||||
? scopes
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool HasRequiredScope(
|
||||
IEnumerable<Claim> userClaims,
|
||||
ISet<string>? resolvedScopes,
|
||||
string requiredScope)
|
||||
{
|
||||
if (resolvedScopes is not null && resolvedScopes.Contains(requiredScope))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scope claims may be space-separated (RFC 6749 section 3.3) or individual claims.
|
||||
return userClaims.Any(c =>
|
||||
(string.Equals(c.Type, "scope", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(c.Type, "scp", StringComparison.OrdinalIgnoreCase)) &&
|
||||
(c.Value == requiredScope ||
|
||||
c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(s => string.Equals(s, requiredScope, StringComparison.Ordinal))));
|
||||
}
|
||||
|
||||
private static Task WriteUnauthorizedAsync(HttpContext context, EndpointDescriptor endpoint)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves container listener URLs from ASP.NET Core's explicit URL and port environment variables.
|
||||
/// </summary>
|
||||
public static class ContainerFrontdoorBindingResolver
|
||||
{
|
||||
public static IReadOnlyList<Uri> ResolveConfiguredUrls(
|
||||
string? serverUrls,
|
||||
string? explicitUrls,
|
||||
string? explicitHttpPorts,
|
||||
string? explicitHttpsPorts)
|
||||
{
|
||||
var rawUrls = !string.IsNullOrWhiteSpace(serverUrls)
|
||||
? SplitEntries(serverUrls)
|
||||
: !string.IsNullOrWhiteSpace(explicitUrls)
|
||||
? SplitEntries(explicitUrls)
|
||||
: BuildUrlsFromPorts(explicitHttpPorts, explicitHttpsPorts);
|
||||
|
||||
return rawUrls
|
||||
.Select(TryCreateListenUri)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.DistinctBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildUrlsFromPorts(string? explicitHttpPorts, string? explicitHttpsPorts)
|
||||
{
|
||||
foreach (var port in SplitEntries(explicitHttpPorts))
|
||||
{
|
||||
if (int.TryParse(port, out var value) && value > 0)
|
||||
{
|
||||
yield return $"http://0.0.0.0:{value}";
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var port in SplitEntries(explicitHttpsPorts))
|
||||
{
|
||||
if (int.TryParse(port, out var value) && value > 0)
|
||||
{
|
||||
yield return $"https://0.0.0.0:{value}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitEntries(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? []
|
||||
: value.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static Uri? TryCreateListenUri(string rawUrl)
|
||||
{
|
||||
var normalized = NormalizeWildcardHost(rawUrl);
|
||||
return Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ? uri : null;
|
||||
}
|
||||
|
||||
private static string NormalizeWildcardHost(string rawUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawUrl))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return rawUrl
|
||||
.Replace("://+", $"://{IPAddress.Any}", StringComparison.Ordinal)
|
||||
.Replace("://*", $"://{IPAddress.Any}", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
internal static class GatewayHealthThresholdPolicy
|
||||
{
|
||||
private static readonly TimeSpan DefaultMessagingHeartbeatInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
internal static void ApplyMinimums(HealthOptions options, GatewayMessagingTransportOptions messaging)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(messaging);
|
||||
|
||||
var heartbeatInterval = GatewayValueParser.ParseDuration(
|
||||
messaging.HeartbeatInterval,
|
||||
DefaultMessagingHeartbeatInterval);
|
||||
|
||||
if (heartbeatInterval <= TimeSpan.Zero)
|
||||
{
|
||||
heartbeatInterval = DefaultMessagingHeartbeatInterval;
|
||||
}
|
||||
|
||||
var minimumDegradedThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 2);
|
||||
var minimumStaleThreshold = TimeSpan.FromTicks(heartbeatInterval.Ticks * 3);
|
||||
|
||||
if (options.DegradedThreshold < minimumDegradedThreshold)
|
||||
{
|
||||
options.DegradedThreshold = minimumDegradedThreshold;
|
||||
}
|
||||
|
||||
if (options.StaleThreshold < minimumStaleThreshold)
|
||||
{
|
||||
options.StaleThreshold = minimumStaleThreshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,6 +433,14 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
scopes.Add("scheduler.runs.preview");
|
||||
scopes.Add("scheduler.runs.manage");
|
||||
}
|
||||
|
||||
// Legacy operator sessions still mint orch:quota and rely on the gateway to
|
||||
// resolve the equivalent fine-grained quota scopes before frontdoor auth.
|
||||
if (scopes.Contains("orch:quota"))
|
||||
{
|
||||
scopes.Add("quota.read");
|
||||
scopes.Add("quota.admin");
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreIdentityContext(HttpContext context, IdentityContext identity)
|
||||
|
||||
@@ -371,6 +371,7 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
|
||||
options.StaleThreshold = GatewayValueParser.ParseDuration(health.StaleThreshold, options.StaleThreshold);
|
||||
options.DegradedThreshold = GatewayValueParser.ParseDuration(health.DegradedThreshold, options.DegradedThreshold);
|
||||
options.CheckInterval = GatewayValueParser.ParseDuration(health.CheckInterval, options.CheckInterval);
|
||||
GatewayHealthThresholdPolicy.ApplyMinimums(options, gateway.Value.Transports.Messaging);
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<OpenApiAggregationOptions>()
|
||||
@@ -413,19 +414,18 @@ static bool ShouldApplyStellaOpsLocalBinding()
|
||||
|
||||
static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder)
|
||||
{
|
||||
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? string.Empty;
|
||||
var currentUrls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
|
||||
builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey),
|
||||
Environment.GetEnvironmentVariable("ASPNETCORE_URLS"),
|
||||
Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"),
|
||||
Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS"));
|
||||
|
||||
builder.WebHost.ConfigureKestrel((context, kestrel) =>
|
||||
{
|
||||
var defaultCert = LoadDefaultCertificate(context.Configuration);
|
||||
|
||||
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
foreach (var uri in currentUrls)
|
||||
{
|
||||
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var address = ResolveListenAddress(uri.Host);
|
||||
if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -537,4 +537,3 @@ static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOption
|
||||
|
||||
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Router Libraries (now in same module) -->
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
|
||||
|
||||
@@ -61,11 +61,12 @@
|
||||
},
|
||||
"Health": {
|
||||
"StaleThreshold": "30s",
|
||||
"DegradedThreshold": "15s",
|
||||
"DegradedThreshold": "20s",
|
||||
"CheckInterval": "5s"
|
||||
},
|
||||
"Routes": [
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
|
||||
@@ -79,9 +80,9 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://jobengine.stella-ops.local/api/releases" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/approvals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/jobengine", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine", "PreserveAuthHeaders": true },
|
||||
|
||||
@@ -705,12 +705,12 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
|
||||
private sealed class TemplateMatcher
|
||||
{
|
||||
private readonly RoutePattern _pattern;
|
||||
private readonly List<(string Segment, bool IsParameter, string? ParameterName)> _segments;
|
||||
private readonly List<(string Segment, bool IsParameter, string? ParameterName, bool IsCatchAll)> _segments;
|
||||
|
||||
public TemplateMatcher(RoutePattern pattern)
|
||||
{
|
||||
_pattern = pattern;
|
||||
_segments = new List<(string, bool, string?)>();
|
||||
_segments = new List<(string, bool, string?, bool)>();
|
||||
|
||||
foreach (var segment in pattern.PathSegments)
|
||||
{
|
||||
@@ -719,10 +719,10 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
|
||||
switch (segment.Parts[0])
|
||||
{
|
||||
case RoutePatternLiteralPart literal:
|
||||
_segments.Add((literal.Content, false, null));
|
||||
_segments.Add((literal.Content, false, null, false));
|
||||
break;
|
||||
case RoutePatternParameterPart param:
|
||||
_segments.Add((param.Name, true, param.Name));
|
||||
_segments.Add((param.Name, true, param.Name, param.IsCatchAll));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -735,7 +735,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
|
||||
RoutePatternParameterPart par => $"{{{par.Name}}}",
|
||||
_ => ""
|
||||
}));
|
||||
_segments.Add((combined, false, null));
|
||||
_segments.Add((combined, false, null, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,21 +746,22 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
|
||||
|
||||
var pathSegments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (pathSegments.Length != _segments.Count)
|
||||
if (pathSegments.Length != _segments.Count)
|
||||
{
|
||||
// Only explicit catch-all parameters may consume extra path segments.
|
||||
if (_segments.Count > 0 &&
|
||||
_segments[^1].IsParameter &&
|
||||
_segments[^1].IsCatchAll &&
|
||||
pathSegments.Length >= _segments.Count - 1)
|
||||
{
|
||||
// Check for catch-all
|
||||
if (_segments.Count > 0 &&
|
||||
_segments[^1].IsParameter &&
|
||||
pathSegments.Length >= _segments.Count - 1)
|
||||
// Handle catch-all: remaining path goes into last parameter
|
||||
for (var i = 0; i < _segments.Count - 1; i++)
|
||||
{
|
||||
// Handle catch-all: remaining path goes into last parameter
|
||||
for (var i = 0; i < _segments.Count - 1; i++)
|
||||
var (segment, isParam, paramName, _) = _segments[i];
|
||||
if (isParam)
|
||||
{
|
||||
var (segment, isParam, paramName) = _segments[i];
|
||||
if (isParam)
|
||||
{
|
||||
routeValues[paramName!] = pathSegments[i];
|
||||
}
|
||||
routeValues[paramName!] = pathSegments[i];
|
||||
}
|
||||
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
@@ -775,14 +776,14 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < _segments.Count; i++)
|
||||
{
|
||||
var (segment, isParam, paramName) = _segments[i];
|
||||
for (var i = 0; i < _segments.Count; i++)
|
||||
{
|
||||
var (segment, isParam, paramName, _) = _segments[i];
|
||||
|
||||
if (isParam)
|
||||
{
|
||||
routeValues[paramName!] = pathSegments[i];
|
||||
}
|
||||
if (isParam)
|
||||
{
|
||||
routeValues[paramName!] = pathSegments[i];
|
||||
}
|
||||
else if (!string.Equals(segment, pathSegments[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -157,11 +157,6 @@ public static class StellaRouterIntegrationHelper
|
||||
throw new InvalidOperationException("Router is enabled but no gateway endpoints are configured.");
|
||||
}
|
||||
|
||||
services.TryAddStellaRouter(
|
||||
serviceName,
|
||||
version,
|
||||
routerOptions);
|
||||
|
||||
var configuredTransports = routerOptions.Gateways
|
||||
.GroupBy(gateway => gateway.TransportType)
|
||||
.Select(group => (TransportType: group.Key, Gateway: group.First()))
|
||||
@@ -178,6 +173,18 @@ public static class StellaRouterIntegrationHelper
|
||||
resolvedMessagingSection,
|
||||
configuredTransports);
|
||||
|
||||
ApplyImplicitHeartbeatInterval(
|
||||
routerOptions,
|
||||
configuration,
|
||||
transportConfiguration,
|
||||
resolvedRouterOptionsSection,
|
||||
configuredTransports);
|
||||
|
||||
services.TryAddStellaRouter(
|
||||
serviceName,
|
||||
version,
|
||||
routerOptions);
|
||||
|
||||
var pluginLoader = CreateTransportPluginLoader(
|
||||
transportConfiguration,
|
||||
resolvedRouterOptionsSection);
|
||||
@@ -371,6 +378,33 @@ public static class StellaRouterIntegrationHelper
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static void ApplyImplicitHeartbeatInterval(
|
||||
StellaRouterOptionsBase routerOptions,
|
||||
IConfiguration configuration,
|
||||
IConfiguration transportConfiguration,
|
||||
string routerOptionsSection,
|
||||
IEnumerable<(TransportType TransportType, StellaRouterGatewayOptionsBase Gateway)> configuredTransports)
|
||||
{
|
||||
if (HasExplicitHeartbeatInterval(configuration, routerOptionsSection) ||
|
||||
!configuredTransports.Any(static transport => transport.TransportType == TransportType.Messaging))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolvedMessagingSection = BuildResolvedTransportSection(ToTransportPluginName(TransportType.Messaging));
|
||||
var fallback = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds);
|
||||
var heartbeatInterval = ParseDuration(
|
||||
transportConfiguration[$"{resolvedMessagingSection}:HeartbeatInterval"],
|
||||
fallback);
|
||||
|
||||
if (heartbeatInterval <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
routerOptions.HeartbeatIntervalSeconds = Math.Max(1, (int)Math.Ceiling(heartbeatInterval.TotalSeconds));
|
||||
}
|
||||
|
||||
private static void CopySectionValues(
|
||||
IConfigurationSection section,
|
||||
IDictionary<string, string?> destination,
|
||||
@@ -487,6 +521,13 @@ public static class StellaRouterIntegrationHelper
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasExplicitHeartbeatInterval(
|
||||
IConfiguration configuration,
|
||||
string routerOptionsSection)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(configuration[$"{routerOptionsSection}:HeartbeatIntervalSeconds"]);
|
||||
}
|
||||
|
||||
private static RouterTransportPluginLoader CreateTransportPluginLoader(
|
||||
IConfiguration configuration,
|
||||
string routerOptionsSection)
|
||||
|
||||
@@ -9,30 +9,58 @@ namespace StellaOps.Router.Transport.Messaging.Extensions;
|
||||
/// </summary>
|
||||
internal static class QueueWaitExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default fallback timeout when the queue supports Pub/Sub notifications.
|
||||
/// The wait returns early on notification; this is only a safety net.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan NotifiableTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan MinimumNotifiableTimeout = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan MaximumNotifiableTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Fallback delay for queues that do not implement <see cref="INotifiableQueue"/>.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PollingFallback = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the safety-net timeout for notifiable queues.
|
||||
/// The queue stays push-first; this timeout only covers missed notifications.
|
||||
/// </summary>
|
||||
internal static TimeSpan ResolveNotifiableTimeout(TimeSpan heartbeatInterval)
|
||||
{
|
||||
if (heartbeatInterval <= TimeSpan.Zero)
|
||||
{
|
||||
return MaximumNotifiableTimeout;
|
||||
}
|
||||
|
||||
var derivedSeconds = Math.Max(
|
||||
MinimumNotifiableTimeout.TotalSeconds,
|
||||
Math.Floor(heartbeatInterval.TotalSeconds / 3d));
|
||||
|
||||
var derivedTimeout = TimeSpan.FromSeconds(derivedSeconds);
|
||||
if (derivedTimeout > MaximumNotifiableTimeout)
|
||||
{
|
||||
return MaximumNotifiableTimeout;
|
||||
}
|
||||
|
||||
return derivedTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for new messages to become available on the queue.
|
||||
/// </summary>
|
||||
public static Task WaitForMessagesAsync<TMessage>(
|
||||
this IMessageQueue<TMessage> queue,
|
||||
TimeSpan heartbeatInterval,
|
||||
CancellationToken cancellationToken)
|
||||
where TMessage : class
|
||||
{
|
||||
if (queue is INotifiableQueue notifiable)
|
||||
{
|
||||
return notifiable.WaitForNotificationAsync(NotifiableTimeout, cancellationToken);
|
||||
return notifiable.WaitForNotificationAsync(ResolveNotifiableTimeout(heartbeatInterval), cancellationToken);
|
||||
}
|
||||
|
||||
return Task.Delay(PollingFallback, cancellationToken);
|
||||
}
|
||||
|
||||
public static Task WaitForMessagesAsync<TMessage>(
|
||||
this IMessageQueue<TMessage> queue,
|
||||
CancellationToken cancellationToken)
|
||||
where TMessage : class
|
||||
=> WaitForMessagesAsync(queue, TimeSpan.Zero, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
||||
// Wait for Pub/Sub notification instead of busy-polling.
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await _responseQueue.WaitForMessagesAsync(cancellationToken);
|
||||
await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
@@ -270,7 +270,7 @@ public sealed class MessagingTransportClient : ITransportClient, IMicroserviceTr
|
||||
// Wait for Pub/Sub notification instead of busy-polling.
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await _serviceIncomingQueue.WaitForMessagesAsync(cancellationToken);
|
||||
await _serviceIncomingQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
|
||||
@@ -173,7 +173,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
|
||||
// Wait for Pub/Sub notification instead of busy-polling.
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await _requestQueue.WaitForMessagesAsync(cancellationToken);
|
||||
await _requestQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
@@ -217,7 +217,7 @@ public sealed class MessagingTransportServer : ITransportServer, IDisposable
|
||||
// Wait for Pub/Sub notification instead of busy-polling.
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await _responseQueue.WaitForMessagesAsync(cancellationToken);
|
||||
await _responseQueue.WaitForMessagesAsync(_options.HeartbeatInterval, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
<RootNamespace>StellaOps.Router.Transport.Messaging</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Gateway.WebService.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Gateway.WebService.Authorization;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway;
|
||||
using Xunit;
|
||||
@@ -232,6 +233,33 @@ public sealed class AuthorizationMiddlewareTests
|
||||
_next.Verify(n => n(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ScopeRequirement_UsesResolvedGatewayScopes_WhenPresent()
|
||||
{
|
||||
var context = CreateHttpContextWithEndpoint(new[]
|
||||
{
|
||||
new Claim("scope", "orch:quota")
|
||||
});
|
||||
context.Items[GatewayContextKeys.Scopes] = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"orch:quota",
|
||||
"quota.read",
|
||||
"quota.admin"
|
||||
};
|
||||
|
||||
_claimsStore
|
||||
.Setup(s => s.GetEffectiveClaims("test-service", "GET", "/api/test"))
|
||||
.Returns(new List<ClaimRequirement>
|
||||
{
|
||||
new() { Type = "scope", Value = "quota.read" },
|
||||
new() { Type = "scope", Value = "orch:quota" }
|
||||
});
|
||||
|
||||
await _middleware.InvokeAsync(context);
|
||||
|
||||
_next.Verify(n => n(context), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ForbiddenResponse_ContainsErrorDetails()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Configuration;
|
||||
|
||||
public sealed class ContainerFrontdoorBindingResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveConfiguredUrls_NormalizesWildcardHostsFromExplicitUrls()
|
||||
{
|
||||
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
|
||||
serverUrls: null,
|
||||
explicitUrls: "http://+:80;http://*:8080",
|
||||
explicitHttpPorts: null,
|
||||
explicitHttpsPorts: null);
|
||||
|
||||
urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo(
|
||||
"http://0.0.0.0/",
|
||||
"http://0.0.0.0:8080/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveConfiguredUrls_BuildsUrlsFromPortEnvironment()
|
||||
{
|
||||
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
|
||||
serverUrls: null,
|
||||
explicitUrls: null,
|
||||
explicitHttpPorts: "80,8080",
|
||||
explicitHttpsPorts: "8443");
|
||||
|
||||
urls.Select(static uri => uri.AbsoluteUri).Should().BeEquivalentTo(
|
||||
"http://0.0.0.0/",
|
||||
"http://0.0.0.0:8080/",
|
||||
"https://0.0.0.0:8443/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveConfiguredUrls_PrefersServerUrlsWhenProvided()
|
||||
{
|
||||
var urls = ContainerFrontdoorBindingResolver.ResolveConfiguredUrls(
|
||||
serverUrls: "http://localhost:9090",
|
||||
explicitUrls: "http://+:80",
|
||||
explicitHttpPorts: "8080",
|
||||
explicitHttpsPorts: null);
|
||||
|
||||
urls.Select(static uri => uri.AbsoluteUri).Should().Equal("http://localhost:9090/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Configuration;
|
||||
|
||||
public sealed class GatewayHealthThresholdPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplyMinimums_RaisesThresholdsToHeartbeatContract()
|
||||
{
|
||||
var options = new HealthOptions
|
||||
{
|
||||
DegradedThreshold = TimeSpan.FromSeconds(15),
|
||||
StaleThreshold = TimeSpan.FromSeconds(30),
|
||||
CheckInterval = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
|
||||
var messaging = new GatewayMessagingTransportOptions
|
||||
{
|
||||
Enabled = true,
|
||||
HeartbeatInterval = "10s"
|
||||
};
|
||||
|
||||
GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging);
|
||||
|
||||
options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(20));
|
||||
options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyMinimums_PreservesHigherExplicitThresholds()
|
||||
{
|
||||
var options = new HealthOptions
|
||||
{
|
||||
DegradedThreshold = TimeSpan.FromSeconds(45),
|
||||
StaleThreshold = TimeSpan.FromSeconds(90),
|
||||
CheckInterval = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
|
||||
var messaging = new GatewayMessagingTransportOptions
|
||||
{
|
||||
Enabled = true,
|
||||
HeartbeatInterval = "10s"
|
||||
};
|
||||
|
||||
GatewayHealthThresholdPolicy.ApplyMinimums(options, messaging);
|
||||
|
||||
options.DegradedThreshold.Should().Be(TimeSpan.FromSeconds(45));
|
||||
options.StaleThreshold.Should().Be(TimeSpan.FromSeconds(90));
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,26 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
|
||||
Assert.Contains("admin", scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ExpandsLegacyQuotaScopeIntoResolvedQuotaScopes()
|
||||
{
|
||||
var middleware = CreateMiddleware();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Subject, "user"),
|
||||
new Claim(StellaOpsClaimTypes.Scope, "orch:quota")
|
||||
};
|
||||
var context = CreateHttpContext("/api/scan", claims);
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
var scopes = (HashSet<string>)context.Items[GatewayContextKeys.Scopes]!;
|
||||
Assert.Contains("orch:quota", scopes);
|
||||
Assert.Contains("quota.read", scopes);
|
||||
Assert.Contains("quota.admin", scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ScopesAreSortedDeterministically()
|
||||
{
|
||||
|
||||
@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| RGH-01-T | DONE | 2026-02-22: Added route-dispatch unit tests for microservice SPA fallback and API-prefix bypass behavior. |
|
||||
| RGH-03-T | DONE | 2026-03-05: Added deterministic route-table parity tests for unified search mappings across gateway runtime and compose configs; verified in gateway test run. |
|
||||
| LIVE-ROUTER-012-T1 | DONE | 2026-03-09: Added quota compatibility regressions for coarse-scope expansion and authorization against resolved gateway scopes. |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Router.Transport.Messaging.Extensions;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Tests.Transport;
|
||||
|
||||
public sealed class QueueWaitExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WaitForMessagesAsync_UsesHeartbeatDerivedTimeout_ForNotifiableQueues()
|
||||
{
|
||||
var queue = new RecordingNotifiableQueue();
|
||||
|
||||
await queue.WaitForMessagesAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
|
||||
|
||||
queue.LastTimeout.Should().Be(QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 3)]
|
||||
[InlineData(45, 5)]
|
||||
[InlineData(1, 1)]
|
||||
public void ResolveNotifiableTimeout_ClampsToExpectedBounds(int heartbeatSeconds, int expectedSeconds)
|
||||
{
|
||||
var timeout = QueueWaitExtensions.ResolveNotifiableTimeout(TimeSpan.FromSeconds(heartbeatSeconds));
|
||||
|
||||
timeout.Should().Be(TimeSpan.FromSeconds(expectedSeconds));
|
||||
}
|
||||
|
||||
private sealed class RecordingNotifiableQueue : IMessageQueue<TestMessage>, INotifiableQueue
|
||||
{
|
||||
public string ProviderName => "test";
|
||||
|
||||
public string QueueName => "test-queue";
|
||||
|
||||
public TimeSpan? LastTimeout { get; private set; }
|
||||
|
||||
public ValueTask<EnqueueResult> EnqueueAsync(
|
||||
TestMessage message,
|
||||
EnqueueOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<IReadOnlyList<IMessageLease<TestMessage>>> LeaseAsync(
|
||||
LeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<IReadOnlyList<IMessageLease<TestMessage>>> ClaimExpiredAsync(
|
||||
ClaimRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task WaitForNotificationAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
LastTimeout = timeout;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record TestMessage(string Value);
|
||||
}
|
||||
@@ -281,6 +281,92 @@ public sealed class AspNetRouterRequestDispatcherTests
|
||||
Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_DoesNotTreatTerminalRouteParameterAsImplicitCatchAll()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet(
|
||||
"/api/v1/notify/channels/{channelId}",
|
||||
(string channelId) => Guid.TryParse(channelId, out _)
|
||||
? Results.Ok(new { route = "detail", channelId })
|
||||
: Results.BadRequest(new { error = "channelId must be a GUID." }));
|
||||
|
||||
app.MapGet(
|
||||
"/api/v1/notify/channels/{channelId}/health",
|
||||
(string channelId) => Guid.TryParse(channelId, out _)
|
||||
? Results.Ok(new { route = "health", channelId })
|
||||
: Results.BadRequest(new { error = "channelId must be a GUID." }));
|
||||
|
||||
var endpointRouteBuilder = (IEndpointRouteBuilder)app;
|
||||
var endpointDataSource = new StaticEndpointDataSource(
|
||||
endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray());
|
||||
var dispatcher = new AspNetRouterRequestDispatcher(
|
||||
app.Services,
|
||||
endpointDataSource,
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "notify",
|
||||
Version = "1.0.0-alpha1",
|
||||
Region = "local",
|
||||
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
|
||||
},
|
||||
NullLogger<AspNetRouterRequestDispatcher>.Instance);
|
||||
|
||||
var response = await dispatcher.DispatchAsync(new RequestFrame
|
||||
{
|
||||
RequestId = "req-notify-health-1",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/notify/channels/e0000001-0000-0000-0000-000000000003/health",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
});
|
||||
|
||||
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
|
||||
Assert.Contains("\"route\":\"health\"", responseBody, StringComparison.Ordinal);
|
||||
Assert.Contains("\"channelId\":\"e0000001-0000-0000-0000-000000000003\"", responseBody, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ExplicitCatchAllRouteStillConsumesRemainingSegments()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var app = builder.Build();
|
||||
app.MapGet("/api/v1/files/{**path}", (string path) => Results.Ok(new { path }));
|
||||
|
||||
var endpointRouteBuilder = (IEndpointRouteBuilder)app;
|
||||
var endpointDataSource = new StaticEndpointDataSource(
|
||||
endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray());
|
||||
var dispatcher = new AspNetRouterRequestDispatcher(
|
||||
app.Services,
|
||||
endpointDataSource,
|
||||
new StellaRouterBridgeOptions
|
||||
{
|
||||
ServiceName = "files",
|
||||
Version = "1.0.0-alpha1",
|
||||
Region = "local",
|
||||
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
|
||||
},
|
||||
NullLogger<AspNetRouterRequestDispatcher>.Instance);
|
||||
|
||||
var response = await dispatcher.DispatchAsync(new RequestFrame
|
||||
{
|
||||
RequestId = "req-files-catchall-1",
|
||||
Method = "GET",
|
||||
Path = "/api/v1/files/a/b/c.txt",
|
||||
Headers = new Dictionary<string, string>(),
|
||||
Payload = ReadOnlyMemory<byte>.Empty
|
||||
});
|
||||
|
||||
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
|
||||
Assert.Contains("\"path\":\"a/b/c.txt\"", responseBody, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -216,11 +216,13 @@ public sealed class StellaRouterIntegrationHelperTests
|
||||
routerOptionsSection: "TimelineIndexer:Router");
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<StellaMicroserviceOptions>>().Value;
|
||||
var messaging = provider.GetRequiredService<IOptions<MessagingTransportOptions>>().Value;
|
||||
var valkey = provider.GetRequiredService<IOptions<StellaOps.Messaging.Transport.Valkey.ValkeyTransportOptions>>().Value;
|
||||
var transport = provider.GetRequiredService<IMicroserviceTransport>();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), options.HeartbeatInterval);
|
||||
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
|
||||
Assert.Equal("router:responses", messaging.ResponseQueueName);
|
||||
Assert.Equal("timelineindexer", messaging.ConsumerGroup);
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| HDR-CASE-01 | DONE | Added the lowercase `content-type` request-frame regression so JSON body dispatch survives Router frame round-trips. |
|
||||
| LIVE-ROUTER-012-T2 | DONE | 2026-03-09: Added ASP.NET bridge regressions to prevent implicit catch-all matching for terminal route parameters while preserving explicit `{**path}` behavior. |
|
||||
|
||||
Reference in New Issue
Block a user