Fix router frontdoor readiness and route contracts

This commit is contained in:
master
2026-03-10 10:19:49 +02:00
parent eae2dfc9d4
commit 7acf0ae8f2
37 changed files with 1408 additions and 1914 deletions

View File

@@ -268,4 +268,6 @@ public sealed class GatewayHealthOptions
public string DegradedThreshold { get; set; } = "15s";
public string CheckInterval { get; set; } = "5s";
public List<string> RequiredMicroservices { get; set; } = [];
}

View File

@@ -40,6 +40,11 @@ public static class GatewayOptionsValidator
_ = GatewayValueParser.ParseDuration(options.Health.DegradedThreshold, TimeSpan.FromSeconds(15));
_ = GatewayValueParser.ParseDuration(options.Health.CheckInterval, TimeSpan.FromSeconds(5));
if (options.Health.RequiredMicroservices.Any(service => string.IsNullOrWhiteSpace(service)))
{
throw new InvalidOperationException("Gateway health required microservices must not contain empty values.");
}
ValidateRoutes(options.Routes);
}
@@ -133,6 +138,23 @@ public static class GatewayOptionsValidator
{
_ = GatewayValueParser.ParseDuration(route.DefaultTimeout, TimeSpan.FromSeconds(30));
}
if (route.IsRegex && !string.IsNullOrWhiteSpace(route.TranslatesTo))
{
var regex = new Regex(route.Path);
var groupCount = regex.GetGroupNumbers().Length;
var refs = Regex.Matches(route.TranslatesTo, @"\$(\d+)");
foreach (Match refMatch in refs)
{
var groupNum = int.Parse(refMatch.Groups[1].Value);
if (groupNum >= groupCount)
{
throw new InvalidOperationException(
$"{prefix}: TranslatesTo references ${groupNum} but regex only has {groupCount - 1} capture groups.");
}
}
}
break;
}
}

View File

@@ -20,7 +20,11 @@ public sealed class HealthCheckMiddleware
_next = next;
}
public async Task InvokeAsync(HttpContext context, GatewayServiceStatus status, GatewayMetrics metrics)
public async Task InvokeAsync(
HttpContext context,
GatewayServiceStatus status,
GatewayMetrics metrics,
GatewayReadinessEvaluator readinessEvaluator)
{
if (GatewayRoutes.IsMetricsPath(context.Request.Path))
{
@@ -37,28 +41,34 @@ public sealed class HealthCheckMiddleware
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/health/live", StringComparison.OrdinalIgnoreCase))
{
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status);
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status, readinessEvaluator.Evaluate(status));
return;
}
if (path.Equals("/health/ready", StringComparison.OrdinalIgnoreCase))
{
var readyStatus = status.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, readyStatus, "ready", status);
var readiness = readinessEvaluator.Evaluate(status);
var readyStatus = readiness.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, readyStatus, "ready", status, readiness);
return;
}
if (path.Equals("/health/startup", StringComparison.OrdinalIgnoreCase))
{
var startupStatus = status.IsStarted ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, startupStatus, "startup", status);
await WriteHealthAsync(context, startupStatus, "startup", status, readinessEvaluator.Evaluate(status));
return;
}
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status);
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status, readinessEvaluator.Evaluate(status));
}
private static Task WriteHealthAsync(HttpContext context, int statusCode, string status, GatewayServiceStatus serviceStatus)
private static Task WriteHealthAsync(
HttpContext context,
int statusCode,
string status,
GatewayServiceStatus serviceStatus,
GatewayReadinessReport readiness)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
@@ -67,7 +77,10 @@ public sealed class HealthCheckMiddleware
{
status,
started = serviceStatus.IsStarted,
ready = serviceStatus.IsReady,
ready = readiness.IsReady,
transportReady = serviceStatus.IsReady,
requiredMicroservices = readiness.RequiredMicroservices,
missingMicroservices = readiness.MissingMicroservices,
traceId = context.TraceIdentifier
};

View File

@@ -1,4 +1,5 @@
using System.Net.WebSockets;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using StellaOps.Gateway.WebService.Configuration;
@@ -47,7 +48,7 @@ public sealed class RouteDispatchMiddleware
return;
}
var route = _resolver.Resolve(context.Request.Path);
var (route, regexMatch) = _resolver.Resolve(context.Request.Path);
if (route is null)
{
await _next(context);
@@ -83,13 +84,13 @@ public sealed class RouteDispatchMiddleware
await HandleStaticFile(context, route);
break;
case StellaOpsRouteType.ReverseProxy:
await HandleReverseProxy(context, route);
await HandleReverseProxy(context, route, regexMatch);
break;
case StellaOpsRouteType.WebSocket:
await HandleWebSocket(context, route);
break;
case StellaOpsRouteType.Microservice:
PrepareMicroserviceRoute(context, route);
PrepareMicroserviceRoute(context, route, regexMatch);
await _next(context);
break;
default:
@@ -178,17 +179,24 @@ public sealed class RouteDispatchMiddleware
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route)
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route, Match? regexMatch)
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var resolvedTranslatesTo = ResolveCaptureGroups(route.TranslatesTo, regexMatch);
var captureGroupsResolved = !string.Equals(resolvedTranslatesTo, route.TranslatesTo, StringComparison.Ordinal);
var remainingPath = requestPath;
if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
if (captureGroupsResolved)
{
// Capture groups resolved: TranslatesTo already contains the full target path.
remainingPath = string.Empty;
}
else if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
{
remainingPath = requestPath[route.Path.Length..];
}
var upstreamBase = route.TranslatesTo!.TrimEnd('/');
var upstreamBase = resolvedTranslatesTo!.TrimEnd('/');
var upstreamUri = new Uri($"{upstreamBase}{remainingPath}{context.Request.QueryString}");
var client = _httpClientFactory.CreateClient("RouteDispatch");
@@ -279,15 +287,34 @@ public sealed class RouteDispatchMiddleware
}
}
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route)
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route, Match? regexMatch)
{
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, route);
// If regex route with capture groups, resolve $1/$2/etc. in TranslatesTo
var effectiveRoute = route;
if (regexMatch is not null && !string.IsNullOrWhiteSpace(route.TranslatesTo))
{
var resolvedTranslatesTo = ResolveCaptureGroups(route.TranslatesTo, regexMatch);
if (!string.Equals(resolvedTranslatesTo, route.TranslatesTo, StringComparison.Ordinal))
{
effectiveRoute = new StellaOpsRoute
{
Type = route.Type,
Path = route.Path,
IsRegex = route.IsRegex,
TranslatesTo = resolvedTranslatesTo,
DefaultTimeout = route.DefaultTimeout,
PreserveAuthHeaders = route.PreserveAuthHeaders
};
}
}
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, effectiveRoute);
if (!string.Equals(translatedPath, context.Request.Path.Value, StringComparison.Ordinal))
{
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = translatedPath;
}
var targetMicroservice = ResolveRouteTargetMicroservice(route);
var targetMicroservice = ResolveRouteTargetMicroservice(effectiveRoute);
if (!string.IsNullOrWhiteSpace(targetMicroservice))
{
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = targetMicroservice;
@@ -300,6 +327,22 @@ public sealed class RouteDispatchMiddleware
}
}
private static string? ResolveCaptureGroups(string? translatesTo, Match? regexMatch)
{
if (regexMatch is null || string.IsNullOrWhiteSpace(translatesTo))
{
return translatesTo;
}
var resolved = translatesTo;
for (var i = regexMatch.Groups.Count - 1; i >= 1; i--)
{
resolved = resolved.Replace($"${i}", regexMatch.Groups[i].Value);
}
return resolved;
}
private static string ResolveTranslatedMicroservicePath(string? requestPathValue, StellaOpsRoute route)
{
var requestPath = string.IsNullOrWhiteSpace(requestPathValue) ? "/" : requestPathValue!;
@@ -314,12 +357,18 @@ public sealed class RouteDispatchMiddleware
return requestPath;
}
// For regex routes, the TranslatesTo (after capture group substitution)
// already contains the full target path. Use it directly.
if (route.IsRegex)
{
return NormalizePath(targetPrefix);
}
var normalizedRoutePath = NormalizePath(route.Path);
var normalizedRequestPath = NormalizePath(requestPath);
var remainingPath = normalizedRequestPath;
if (!route.IsRegex &&
normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
if (normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
{
remainingPath = normalizedRequestPath[normalizedRoutePath.Length..];
if (!remainingPath.StartsWith('/'))

View File

@@ -74,6 +74,7 @@ builder.Services.Replace(ServiceDescriptor.Singleton<StellaOps.Router.Gateway.Au
builder.Services.AddSingleton<GatewayServiceStatus>();
builder.Services.AddSingleton<GatewayMetrics>();
builder.Services.AddSingleton<GatewayReadinessEvaluator>();
// Load router transport plugins
var transportPluginLoader = new RouterTransportPluginLoader(
@@ -128,7 +129,7 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
IdentityEnvelopeIssuer = bootstrapOptions.Auth.IdentityEnvelopeIssuer,
IdentityEnvelopeTtl = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.Auth.IdentityEnvelopeTtlSeconds)),
JwtPassthroughPrefixes = bootstrapOptions.Routes
.Where(r => r.PreserveAuthHeaders)
.Where(r => r.PreserveAuthHeaders && !r.IsRegex)
.Select(r => r.Path)
.ToList(),
ApprovedAuthPassthroughPrefixes = [.. bootstrapOptions.Auth.ApprovedAuthPassthroughPrefixes],

View File

@@ -27,7 +27,7 @@ public sealed class StellaOpsRouteResolver
}
}
public StellaOpsRoute? Resolve(PathString path)
public (StellaOpsRoute? Route, Match? RegexMatch) Resolve(PathString path)
{
var pathValue = path.Value ?? string.Empty;
@@ -35,9 +35,10 @@ public sealed class StellaOpsRouteResolver
{
if (pattern is not null)
{
if (pattern.IsMatch(pathValue))
var match = pattern.Match(pathValue);
if (match.Success)
{
return route;
return (route, match);
}
}
else
@@ -47,12 +48,12 @@ public sealed class StellaOpsRouteResolver
pathValue.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase) &&
route.Path.EndsWith('/'))
{
return route;
return (route, null);
}
}
}
return null;
return (null, null);
}
/// <summary>

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
namespace StellaOps.Gateway.WebService.Services;
public sealed record GatewayReadinessReport
{
public required bool IsReady { get; init; }
public required bool Started { get; init; }
public required bool TransportReady { get; init; }
public IReadOnlyList<string> RequiredMicroservices { get; init; } = [];
public IReadOnlyList<string> MissingMicroservices { get; init; } = [];
}
public sealed class GatewayReadinessEvaluator
{
private readonly IGlobalRoutingState _routingState;
private readonly IOptions<GatewayOptions> _options;
public GatewayReadinessEvaluator(IGlobalRoutingState routingState, IOptions<GatewayOptions> options)
{
_routingState = routingState;
_options = options;
}
public GatewayReadinessReport Evaluate(GatewayServiceStatus status)
{
ArgumentNullException.ThrowIfNull(status);
var requiredMicroservices = NormalizeRequiredMicroservices(_options.Value.Health.RequiredMicroservices);
if (!status.IsStarted || !status.IsReady)
{
return new GatewayReadinessReport
{
IsReady = false,
Started = status.IsStarted,
TransportReady = status.IsReady,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = requiredMicroservices
};
}
if (requiredMicroservices.Length == 0)
{
return new GatewayReadinessReport
{
IsReady = true,
Started = true,
TransportReady = true,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = []
};
}
var registeredServices = _routingState.GetAllConnections()
.Where(connection => connection.Status is InstanceHealthStatus.Healthy or InstanceHealthStatus.Degraded)
.Select(connection => NormalizeServiceKey(connection.Instance.ServiceName))
.Where(serviceName => !string.IsNullOrWhiteSpace(serviceName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var missing = requiredMicroservices
.Where(required => !registeredServices.Any(registered => IsServiceMatch(registered, required)))
.ToArray();
return new GatewayReadinessReport
{
IsReady = missing.Length == 0,
Started = true,
TransportReady = true,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = missing
};
}
private static string[] NormalizeRequiredMicroservices(IEnumerable<string>? services)
{
return (services ?? [])
.Select(NormalizeServiceKey)
.Where(serviceName => !string.IsNullOrWhiteSpace(serviceName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()!;
}
private static bool IsServiceMatch(string? registeredServiceName, string requiredServiceName)
{
var normalizedRegistered = NormalizeServiceKey(registeredServiceName);
if (string.IsNullOrWhiteSpace(normalizedRegistered))
{
return false;
}
return string.Equals(normalizedRegistered, requiredServiceName, StringComparison.OrdinalIgnoreCase);
}
private static string? NormalizeServiceKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var normalized = value.Trim().ToLowerInvariant();
var portSeparator = normalized.IndexOf(':');
if (portSeparator >= 0)
{
normalized = normalized[..portSeparator];
}
const string localDomain = ".stella-ops.local";
if (normalized.EndsWith(localDomain, StringComparison.Ordinal))
{
normalized = normalized[..^localDomain.Length];
}
normalized = StripSuffix(normalized, "-web");
normalized = StripSuffix(normalized, "-api");
normalized = StripSuffix(normalized, "-service");
normalized = StripSuffix(normalized, "-gateway");
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static string StripSuffix(string value, string suffix)
{
return value.EndsWith(suffix, StringComparison.Ordinal)
? value[..^suffix.Length]
: value;
}
}

View File

@@ -71,125 +71,91 @@
"Health": {
"StaleThreshold": "30s",
"DegradedThreshold": "20s",
"CheckInterval": "5s"
"CheckInterval": "5s",
"RequiredMicroservices": []
},
"Routes": [
{ "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" },
{ "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" },
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
{ "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs" },
{ "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" },
{ "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/policy/simulations", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/policy/shadow", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "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://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 },
{ "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/integrations", "TranslatesTo": "http://integrations.stella-ops.local/api/v1/integrations", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" },
{ "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" },
{ "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" },
{ "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" },
{ "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" },
{ "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/authority/quotas", "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "http://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "http://authority.stella-ops.local/api/v1/trust", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" },
{ "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" },
{ "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" },
{ "Type": "ReverseProxy", "Path": "/api/v1/search", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" },
{ "Type": "ReverseProxy", "Path": "/api/v1/watchlist", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist" },
{ "Type": "ReverseProxy", "Path": "/api/v1/resolve", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve" },
{ "Type": "ReverseProxy", "Path": "/api/v1/ops/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts" },
{ "Type": "ReverseProxy", "Path": "/api/v1/lineage", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage" },
{ "Type": "ReverseProxy", "Path": "/api/v1/export", "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export" },
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://exportcenter.stella-ops.local/v1/audit-bundles" },
{ "Type": "ReverseProxy", "Path": "/api/v1/triage", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage" },
{ "Type": "ReverseProxy", "Path": "/api/v1/governance", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance" },
{ "Type": "ReverseProxy", "Path": "/api/v1/determinization", "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization" },
{ "Type": "ReverseProxy", "Path": "/api/v1/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local/api/v1/opsmemory" },
{ "Type": "ReverseProxy", "Path": "/api/v1/secrets", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets" },
{ "Type": "ReverseProxy", "Path": "/api/v1/sources", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources" },
{ "Type": "ReverseProxy", "Path": "/api/v1/workflows", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows" },
{ "Type": "ReverseProxy", "Path": "/api/v1/witnesses", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses" },
{ "Type": "ReverseProxy", "Path": "/api/gate", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk-budget", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget" },
{ "Type": "ReverseProxy", "Path": "/api/fix-verification", "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification" },
{ "Type": "ReverseProxy", "Path": "/api/compare", "TranslatesTo": "http://sbomservice.stella-ops.local/api/compare" },
{ "Type": "ReverseProxy", "Path": "/api/change-traces", "TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces" },
{ "Type": "ReverseProxy", "Path": "/api/exceptions", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts" },
{ "Type": "ReverseProxy", "Path": "/api/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/gateway/rate-limits", "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" },
{ "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" },
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" },
{ "Type": "ReverseProxy", "Path": "/api/admin/plans", "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" },
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{ "Type": "Microservice", "Path": "^/api/v1/vulnerabilities(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities$1" },
{ "Type": "Microservice", "Path": "^/api/v1/watchlist(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist$1" },
{ "Type": "Microservice", "Path": "^/api/v1/triage(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/secrets(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sources(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/witnesses(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses$1" },
{ "Type": "Microservice", "Path": "^/api/v1/trust(.*)", "IsRegex": true, "TranslatesTo": "http://authority.stella-ops.local/api/v1/trust$1" },
{ "Type": "Microservice", "Path": "^/api/v1/evidence(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence$1" },
{ "Type": "Microservice", "Path": "^/api/v1/proofs(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs$1" },
{ "Type": "Microservice", "Path": "^/api/v1/verdicts(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/v1/approvals(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals$1" },
{ "Type": "Microservice", "Path": "^/api/v1/attestations(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sbom(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom$1" },
{ "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/resolve(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve$1" },
{ "Type": "Microservice", "Path": "^/api/v1/ops/binaryindex(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" },
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-control(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control$1" },
{ "Type": "Microservice", "Path": "^/api/v1/gateway/rate-limits(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits$1" },
{ "Type": "Microservice", "Path": "^/api/v1/reachability(.*)", "IsRegex": true, "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability$1" },
{ "Type": "Microservice", "Path": "^/api/v1/timeline(.*)", "IsRegex": true, "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline$1" },
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
{ "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" },
{ "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" },
{ "Type": "Microservice", "Path": "^/api/v2/security(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/security$1" },
{ "Type": "Microservice", "Path": "^/api/v2/topology(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/topology$1" },
{ "Type": "Microservice", "Path": "^/api/v2/integrations(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations$1" },
{ "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" },
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/fix-verification(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification$1" },
{ "Type": "Microservice", "Path": "^/api/verdicts(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/vuln-explorer(.*)", "IsRegex": true, "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer$1" },
{ "Type": "Microservice", "Path": "^/api/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/vex$1" },
{ "Type": "Microservice", "Path": "^/api/admin/plans(.*)", "IsRegex": true, "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans$1" },
{ "Type": "Microservice", "Path": "^/api/admin(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/admin$1" },
{ "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" },
{ "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" },
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
{ "Type": "Microservice", "Path": "^/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs$1" },
{ "Type": "Microservice", "Path": "^/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/v1/audit-bundles(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/v1/audit-bundles$1" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local/connect" },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known" },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks" },
{ "Type": "ReverseProxy", "Path": "/authority/console", "TranslatesTo": "http://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "http://authority.stella-ops.local/authority" },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "http://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/rekor", "TranslatesTo": "http://rekor.stella-ops.local:3322" },
{ "Type": "ReverseProxy", "Path": "/platform/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/api/v1/setup", "TranslatesTo": "http://platform.stella-ops.local/api/v1/setup" },
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "http://authority.stella-ops.local/authority", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "http://authority.stella-ops.local/console", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyEngine", "TranslatesTo": "http://policy-engine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/concelier", "TranslatesTo": "http://concelier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/attestor", "TranslatesTo": "http://attestor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notify", "TranslatesTo": "http://notify.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notifier", "TranslatesTo": "http://notifier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/scheduler", "TranslatesTo": "http://scheduler.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/cartographer", "TranslatesTo": "http://cartographer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/reachgraph", "TranslatesTo": "http://reachgraph.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/doctor", "TranslatesTo": "http://doctor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/integrations", "TranslatesTo": "http://integrations.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/replay", "TranslatesTo": "http://replay.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/exportcenter", "TranslatesTo": "http://exportcenter.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/evidencelocker", "TranslatesTo": "http://evidencelocker.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signer", "TranslatesTo": "http://signer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/riskengine", "TranslatesTo": "http://riskengine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vulnexplorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/advisoryai", "TranslatesTo": "http://advisoryai.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/unknowns", "TranslatesTo": "http://unknowns.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/timelineindexer", "TranslatesTo": "http://timelineindexer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/issuerdirectory", "TranslatesTo": "http://issuerdirectory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/symbols", "TranslatesTo": "http://symbols.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/packsregistry", "TranslatesTo": "http://packsregistry.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/registryTokenservice", "TranslatesTo": "http://registry-token.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapController", "TranslatesTo": "http://airgap-controller.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapTime", "TranslatesTo": "http://airgap-time.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/smremote", "TranslatesTo": "http://smremote.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{ "Type": "StaticFiles", "Path": "/", "TranslatesTo": "/app/wwwroot", "Headers": { "x-spa-fallback": "true" } },
{ "Type": "NotFoundPage", "Path": "/_error/404", "TranslatesTo": "/app/wwwroot/index.html" },
{ "Type": "ServerErrorPage", "Path": "/_error/500", "TranslatesTo": "/app/wwwroot/index.html" }

View File

@@ -31,14 +31,6 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy;
private int _inFlightRequestCount;
private double _errorRate;
private int _heartbeatCount;
/// <summary>
/// Number of heartbeats between periodic re-registration (HELLO re-send).
/// Ensures the gateway picks up the service after a gateway restart.
/// Default: every 30 heartbeats (~5 min at 10s intervals).
/// </summary>
private const int ReRegistrationInterval = 30;
/// <inheritdoc />
public IReadOnlyList<ConnectionState> Connections => [.. _connections.Values];
@@ -136,21 +128,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
// Listen for transport death to trigger automatic reconnection
_microserviceTransport.TransportDied += OnTransportDied;
var instance = new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(
instance,
_endpoints,
_schemas,
_openApiInfo,
cancellationToken);
await SendRegistrationRefreshAsync(cancellationToken);
}
else
{
@@ -330,20 +308,7 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
if (_microserviceTransport is null || _endpoints is null) return;
var instance = new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(
instance,
_endpoints,
_schemas,
_openApiInfo,
_cts.Token);
await SendRegistrationRefreshAsync(_cts.Token);
_logger.LogInformation(
"Messaging transport reconnected for {ServiceName}/{Version}",
@@ -361,57 +326,52 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
{
var nextHeartbeatDueUtc = DateTime.UtcNow + _options.HeartbeatInterval;
var nextRegistrationRefreshDueUtc = DateTime.UtcNow + _options.RegistrationRefreshInterval;
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(_options.HeartbeatInterval, cancellationToken);
var nowUtc = DateTime.UtcNow;
var delay = Min(nextHeartbeatDueUtc, nextRegistrationRefreshDueUtc) - nowUtc;
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, cancellationToken);
nowUtc = DateTime.UtcNow;
}
_heartbeatCount++;
// Periodically re-send HELLO to handle gateway restarts.
// The gateway loses all connection state on restart, and services
// only send HELLO once on initial connect. This ensures recovery.
if (_heartbeatCount % ReRegistrationInterval == 0 && _microserviceTransport is not null && _endpoints is not null)
if (_microserviceTransport is not null &&
_endpoints is not null &&
nowUtc >= nextRegistrationRefreshDueUtc)
{
try
{
var instance = new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
await _microserviceTransport.ConnectAsync(
instance,
_endpoints,
_schemas,
_openApiInfo,
cancellationToken);
_logger.LogDebug("Periodic re-registration sent (heartbeat #{Count})", _heartbeatCount);
await SendRegistrationRefreshAsync(cancellationToken);
_logger.LogDebug(
"Sent periodic HELLO refresh for {ServiceName}/{Version}",
_options.ServiceName,
_options.Version);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send periodic re-registration");
_logger.LogWarning(ex, "Failed to send periodic HELLO refresh");
}
nextRegistrationRefreshDueUtc = nowUtc + _options.RegistrationRefreshInterval;
}
// Build heartbeat payload with current status and metrics
var heartbeat = new HeartbeatPayload
if (_microserviceTransport is not null && nowUtc >= nextHeartbeatDueUtc)
{
InstanceId = _options.InstanceId,
Status = _currentStatus,
InFlightRequestCount = _inFlightRequestCount,
ErrorRate = _errorRate,
TimestampUtc = DateTime.UtcNow
};
var heartbeat = new HeartbeatPayload
{
InstanceId = _options.InstanceId,
Status = _currentStatus,
InFlightRequestCount = _inFlightRequestCount,
ErrorRate = _errorRate,
TimestampUtc = nowUtc
};
// Send heartbeat via transport
if (_microserviceTransport is not null)
{
try
{
await _microserviceTransport.SendHeartbeatAsync(heartbeat, cancellationToken);
@@ -426,12 +386,13 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
{
_logger.LogWarning(ex, "Failed to send heartbeat");
}
}
// Update connection state local heartbeat times
foreach (var connection in _connections.Values)
{
connection.LastHeartbeatUtc = DateTime.UtcNow;
foreach (var connection in _connections.Values)
{
connection.LastHeartbeatUtc = nowUtc;
}
nextHeartbeatDueUtc = nowUtc + _options.HeartbeatInterval;
}
}
catch (OperationCanceledException)
@@ -446,6 +407,34 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa
}
}
private async Task SendRegistrationRefreshAsync(CancellationToken cancellationToken)
{
if (_microserviceTransport is null || _endpoints is null)
{
return;
}
await _microserviceTransport.ConnectAsync(
CreateInstanceDescriptor(),
_endpoints,
_schemas,
_openApiInfo,
cancellationToken);
}
private InstanceDescriptor CreateInstanceDescriptor()
{
return new InstanceDescriptor
{
InstanceId = _options.InstanceId,
ServiceName = _options.ServiceName,
Version = _options.Version,
Region = _options.Region
};
}
private static DateTime Min(DateTime left, DateTime right) => left <= right ? left : right;
private static IReadOnlyDictionary<string, SchemaDefinition> MergeSchemaDefinitions(
IReadOnlyDictionary<string, SchemaDefinition>? generatedSchemas,
IReadOnlyDictionary<string, SchemaDefinition>? discoveredSchemas)

View File

@@ -56,6 +56,12 @@ public sealed partial class StellaMicroserviceOptions
/// </summary>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(45);
/// <summary>
/// Gets or sets the maximum interval between HELLO refreshes on an already-live transport.
/// Default: 10 seconds so gateway restarts converge quickly without waiting for a full heartbeat window.
/// </summary>
public TimeSpan RegistrationRefreshInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets or sets the maximum reconnect backoff.
/// Default: 1 minute.
@@ -88,6 +94,12 @@ public sealed partial class StellaMicroserviceOptions
if (Routers.Count == 0)
throw new InvalidOperationException("At least one router endpoint is required.");
if (HeartbeatInterval <= TimeSpan.Zero)
throw new InvalidOperationException("HeartbeatInterval must be positive.");
if (RegistrationRefreshInterval <= TimeSpan.Zero)
throw new InvalidOperationException("RegistrationRefreshInterval must be positive.");
foreach (var router in Routers)
{
if (string.IsNullOrWhiteSpace(router.Host))

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0387-A | DONE | Applied 2026-01-13; superseded by AUDIT-0598-A. |
| AUDIT-0598-A | DONE | Applied 2026-01-13; hotlist fixes and tests. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| ROUTER-READY-004-M | DONE | 2026-03-10: Replaced the fixed 30-heartbeat HELLO replay cadence with an explicit bounded registration refresh interval so gateway restarts converge within seconds. |

View File

@@ -80,6 +80,7 @@ public static class StellaRouterExtensions
microserviceOptions.InstanceId = options.InstanceId;
microserviceOptions.ServiceDescription = options.ServiceDescription;
microserviceOptions.HeartbeatInterval = options.HeartbeatInterval;
microserviceOptions.RegistrationRefreshInterval = options.RegistrationRefreshInterval;
microserviceOptions.ReconnectBackoffInitial = options.ReconnectBackoffInitial;
microserviceOptions.ReconnectBackoffMax = options.ReconnectBackoffMax;
@@ -226,6 +227,11 @@ public static class StellaRouterExtensions
errors.Add("HeartbeatInterval must be positive");
}
if (options.RegistrationRefreshInterval <= TimeSpan.Zero)
{
errors.Add("RegistrationRefreshInterval must be positive");
}
if (errors.Count > 0)
{
throw new InvalidOperationException(

View File

@@ -44,6 +44,7 @@ public static class StellaRouterIntegrationHelper
opts.EnableStellaEndpoints = false;
opts.DefaultTimeout = TimeSpan.FromSeconds(routerOptions.DefaultTimeoutSeconds);
opts.HeartbeatInterval = TimeSpan.FromSeconds(routerOptions.HeartbeatIntervalSeconds);
opts.RegistrationRefreshInterval = TimeSpan.FromSeconds(routerOptions.RegistrationRefreshIntervalSeconds);
opts.AuthorizationTrustMode = routerOptions.AuthorizationTrustMode;
opts.IdentityEnvelopeSigningKey = routerOptions.IdentityEnvelopeSigningKey;
opts.IdentityEnvelopeClockSkewSeconds = routerOptions.IdentityEnvelopeClockSkewSeconds;
@@ -179,6 +180,10 @@ public static class StellaRouterIntegrationHelper
transportConfiguration,
resolvedRouterOptionsSection,
configuredTransports);
ApplyImplicitRegistrationRefreshInterval(
routerOptions,
configuration,
resolvedRouterOptionsSection);
services.TryAddStellaRouter(
serviceName,
@@ -405,6 +410,30 @@ public static class StellaRouterIntegrationHelper
routerOptions.HeartbeatIntervalSeconds = Math.Max(1, (int)Math.Ceiling(heartbeatInterval.TotalSeconds));
}
private static bool HasExplicitRegistrationRefreshInterval(
IConfiguration configuration,
string routerOptionsSection)
{
return !string.IsNullOrWhiteSpace(configuration[$"{routerOptionsSection}:RegistrationRefreshIntervalSeconds"]);
}
private static void ApplyImplicitRegistrationRefreshInterval(
StellaRouterOptionsBase routerOptions,
IConfiguration configuration,
string routerOptionsSection)
{
if (HasExplicitRegistrationRefreshInterval(configuration, routerOptionsSection))
{
return;
}
routerOptions.RegistrationRefreshIntervalSeconds = Math.Max(
1,
Math.Min(
routerOptions.RegistrationRefreshIntervalSeconds,
routerOptions.HeartbeatIntervalSeconds));
}
private static void CopySectionValues(
IConfigurationSection section,
IDictionary<string, string?> destination,

View File

@@ -103,6 +103,12 @@ public sealed class StellaRouterOptions
/// </summary>
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(45);
/// <summary>
/// Maximum interval between HELLO refreshes on an already-live transport.
/// Default: 10 seconds.
/// </summary>
public TimeSpan RegistrationRefreshInterval { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Initial reconnect backoff delay.
/// Default: 1 second.

View File

@@ -43,6 +43,12 @@ public class StellaRouterOptionsBase
/// </summary>
public int HeartbeatIntervalSeconds { get; set; } = 45;
/// <summary>
/// Maximum interval in seconds between HELLO refreshes on an already-live transport.
/// Default: 10 seconds to keep gateway restarts from leaving the frontdoor cold for minutes.
/// </summary>
public int RegistrationRefreshIntervalSeconds { get; set; } = 10;
/// <summary>
/// Service trust mode for gateway-enforced authorization semantics.
/// Default: Hybrid.

View File

@@ -34,5 +34,5 @@ public sealed class StellaOpsRoute
/// of stripping them. Use for upstream services that perform their own JWT
/// validation (e.g., Authority admin API).
/// </summary>
public bool PreserveAuthHeaders { get; set; }
public bool PreserveAuthHeaders { get; set; } = true;
}

View File

@@ -1,5 +1,6 @@
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
namespace StellaOps.Router.Gateway.Middleware;
@@ -36,18 +37,36 @@ public sealed class EndpointResolutionMiddleware
? targetService as string
: null;
EndpointDescriptor? endpoint;
EndpointResolutionResult resolution;
if (!string.IsNullOrWhiteSpace(targetMicroserviceHint))
{
endpoint = ResolveEndpointForTargetService(routingState, method, path, targetMicroserviceHint!);
resolution = ResolveEndpointForTargetService(routingState, method, path, targetMicroserviceHint!);
}
else
{
endpoint = routingState.ResolveEndpoint(method, path);
resolution = new EndpointResolutionResult(routingState.ResolveEndpoint(method, path), false);
}
var endpoint = resolution.Endpoint;
if (endpoint is null)
{
if (!string.IsNullOrWhiteSpace(targetMicroserviceHint) && !resolution.ServiceRegistered)
{
await RouterErrorWriter.WriteAsync(
context,
statusCode: StatusCodes.Status503ServiceUnavailable,
error: "Target microservice unavailable",
message: "The configured microservice route exists, but the target service has not registered with the gateway yet.",
service: targetMicroserviceHint,
details: new Dictionary<string, object?>
{
["translatedPath"] = path,
["targetMicroservice"] = targetMicroserviceHint
},
cancellationToken: context.RequestAborted);
return;
}
await RouterErrorWriter.WriteAsync(
context,
statusCode: StatusCodes.Status404NotFound,
@@ -62,85 +81,108 @@ public sealed class EndpointResolutionMiddleware
await _next(context);
}
private static EndpointDescriptor? ResolveEndpointForTargetService(
private static EndpointResolutionResult ResolveEndpointForTargetService(
IGlobalRoutingState routingState,
string method,
string path,
string targetServiceHint)
{
var normalizedHint = NormalizeServiceKey(targetServiceHint);
if (string.IsNullOrWhiteSpace(normalizedHint))
var exactHint = NormalizeServiceKey(targetServiceHint, preserveGatewaySuffix: true);
if (string.IsNullOrWhiteSpace(exactHint))
{
return null;
return new EndpointResolutionResult(null, false);
}
EndpointDescriptor? bestEndpoint = null;
var bestScore = int.MinValue;
var aliasHint = NormalizeServiceKey(targetServiceHint);
var allowAliasMatching = !IsExplicitServiceHint(exactHint);
var candidates = new List<(EndpointDescriptor Endpoint, ConnectionState Connection, int ServiceScore)>();
var serviceRegistered = false;
foreach (var connection in routingState.GetAllConnections())
{
var serviceScore = GetServiceMatchScore(connection.Instance.ServiceName, normalizedHint);
var serviceScore = GetServiceMatchScore(connection.Instance.ServiceName, exactHint, aliasHint, allowAliasMatching);
if (serviceScore < 0)
{
continue;
}
foreach (var endpoint in connection.Endpoints.Values)
serviceRegistered = true;
foreach (var candidateEndpoint in connection.Endpoints.Values)
{
if (!string.Equals(endpoint.Method, method, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(candidateEndpoint.Method, method, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var matcher = new PathMatcher(endpoint.Path);
var matcher = new PathMatcher(candidateEndpoint.Path);
if (!matcher.IsMatch(path))
{
continue;
}
var endpointScore = (serviceScore * 1000) + endpoint.Path.Length;
if (endpointScore <= bestScore)
{
continue;
}
bestEndpoint = endpoint;
bestScore = endpointScore;
candidates.Add((candidateEndpoint, connection, serviceScore));
}
}
return bestEndpoint;
var selectedEndpoint = candidates
.OrderByDescending(candidate => candidate.ServiceScore)
.ThenByDescending(candidate => candidate.Endpoint.Path.Length)
.ThenByDescending(candidate => GetHealthRank(candidate.Connection.Status))
.ThenByDescending(candidate => candidate.Connection.LastHeartbeatUtc)
.ThenBy(candidate => candidate.Connection.ConnectionId, StringComparer.Ordinal)
.Select(candidate => candidate.Endpoint)
.FirstOrDefault();
return new EndpointResolutionResult(selectedEndpoint, serviceRegistered);
}
private static int GetServiceMatchScore(string? serviceName, string normalizedHint)
private static int GetServiceMatchScore(
string? serviceName,
string exactHint,
string? aliasHint,
bool allowAliasMatching)
{
var normalizedService = NormalizeServiceKey(serviceName);
if (string.IsNullOrWhiteSpace(normalizedService))
var exactService = NormalizeServiceKey(serviceName, preserveGatewaySuffix: true);
if (string.IsNullOrWhiteSpace(exactService))
{
return -1;
}
if (string.Equals(serviceName, normalizedHint, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedService, normalizedHint, StringComparison.OrdinalIgnoreCase))
if (string.Equals(serviceName, exactHint, StringComparison.OrdinalIgnoreCase) ||
string.Equals(exactService, exactHint, StringComparison.OrdinalIgnoreCase))
{
return 6;
}
var aliasService = NormalizeServiceKey(serviceName);
if (!string.IsNullOrWhiteSpace(aliasHint) &&
string.Equals(aliasService, aliasHint, StringComparison.OrdinalIgnoreCase))
{
return 5;
}
if (serviceName is not null &&
(serviceName.StartsWith(normalizedHint + "-", StringComparison.OrdinalIgnoreCase) ||
serviceName.StartsWith(normalizedHint + "_", StringComparison.OrdinalIgnoreCase)))
if (!allowAliasMatching || string.IsNullOrWhiteSpace(aliasHint))
{
return -1;
}
if (exactService.StartsWith(aliasHint + "-", StringComparison.OrdinalIgnoreCase) ||
exactService.StartsWith(aliasHint + "_", StringComparison.OrdinalIgnoreCase))
{
return 4;
}
if (normalizedService.StartsWith(normalizedHint, StringComparison.OrdinalIgnoreCase) ||
normalizedHint.StartsWith(normalizedService, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(aliasService) &&
(aliasService.StartsWith(aliasHint, StringComparison.OrdinalIgnoreCase) ||
aliasHint.StartsWith(aliasService, StringComparison.OrdinalIgnoreCase)))
{
return 3;
}
if (normalizedService.Contains(normalizedHint, StringComparison.OrdinalIgnoreCase) ||
normalizedHint.Contains(normalizedService, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(aliasService) &&
(aliasService.Contains(aliasHint, StringComparison.OrdinalIgnoreCase) ||
aliasHint.Contains(aliasService, StringComparison.OrdinalIgnoreCase)))
{
return 2;
}
@@ -148,7 +190,7 @@ public sealed class EndpointResolutionMiddleware
return -1;
}
private static string? NormalizeServiceKey(string? value)
private static string? NormalizeServiceKey(string? value, bool preserveGatewaySuffix = false)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -172,17 +214,42 @@ public sealed class EndpointResolutionMiddleware
normalized = StripSuffix(normalized, "-web");
normalized = StripSuffix(normalized, "-api");
normalized = StripSuffix(normalized, "-service");
normalized = StripSuffix(normalized, "-gateway");
if (!preserveGatewaySuffix)
{
normalized = StripSuffix(normalized, "-gateway");
}
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static bool IsExplicitServiceHint(string? hint)
{
if (string.IsNullOrWhiteSpace(hint))
{
return false;
}
return hint.Contains('-', StringComparison.Ordinal) ||
hint.Contains('_', StringComparison.Ordinal);
}
private static string StripSuffix(string value, string suffix)
{
return value.EndsWith(suffix, StringComparison.Ordinal)
? value[..^suffix.Length]
: value;
}
private static int GetHealthRank(InstanceHealthStatus status) => status switch
{
InstanceHealthStatus.Healthy => 4,
InstanceHealthStatus.Degraded => 3,
InstanceHealthStatus.Unknown => 2,
InstanceHealthStatus.Draining => 1,
_ => 0
};
private sealed record EndpointResolutionResult(EndpointDescriptor? Endpoint, bool ServiceRegistered);
}

View File

@@ -1,6 +1,7 @@
using StellaOps.Router.Common;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using System.Collections.Concurrent;
@@ -85,7 +86,8 @@ internal sealed class InMemoryRoutingState : IGlobalRoutingState
/// <inheritdoc />
public EndpointDescriptor? ResolveEndpoint(string method, string path)
{
// First try exact match
var matches = new List<(EndpointDescriptor Endpoint, ConnectionState Connection)>();
foreach (var ((m, p), matcher) in _pathMatchers)
{
if (!string.Equals(m, method, StringComparison.OrdinalIgnoreCase))
@@ -101,14 +103,20 @@ internal sealed class InMemoryRoutingState : IGlobalRoutingState
if (_connections.TryGetValue(connectionId, out var conn) &&
conn.Endpoints.TryGetValue((m, p), out var endpoint))
{
return endpoint;
matches.Add((endpoint, conn));
}
}
}
}
}
return null;
return matches
.OrderByDescending(match => match.Endpoint.Path.Length)
.ThenByDescending(match => GetHealthRank(match.Connection.Status))
.ThenByDescending(match => match.Connection.LastHeartbeatUtc)
.ThenBy(match => match.Connection.ConnectionId, StringComparer.Ordinal)
.Select(match => match.Endpoint)
.FirstOrDefault();
}
/// <inheritdoc />
@@ -194,4 +202,13 @@ internal sealed class InMemoryRoutingState : IGlobalRoutingState
string.Equals(existing.Instance.InstanceId, candidate.Instance.InstanceId, StringComparison.Ordinal) &&
string.Equals(existing.Instance.Region, candidate.Instance.Region, StringComparison.OrdinalIgnoreCase);
}
private static int GetHealthRank(InstanceHealthStatus status) => status switch
{
InstanceHealthStatus.Healthy => 4,
InstanceHealthStatus.Degraded => 3,
InstanceHealthStatus.Unknown => 2,
InstanceHealthStatus.Draining => 1,
_ => 0
};
}

View File

@@ -353,6 +353,56 @@ public sealed class GatewayOptionsValidatorTests
Assert.Null(exception);
}
[Fact]
public void Validate_RegexWithValidCaptureGroupRefs_DoesNotThrow()
{
var options = CreateValidOptions();
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)(.*)",
IsRegex = true,
TranslatesTo = "http://$1.stella-ops.local/api/v1/$1$2"
});
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
Assert.Null(exception);
}
[Fact]
public void Validate_RegexWithInvalidCaptureGroupRef_Throws()
{
var options = CreateValidOptions();
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)",
IsRegex = true,
TranslatesTo = "http://$1.stella-ops.local/$2"
});
var exception = Assert.Throws<InvalidOperationException>(() =>
GatewayOptionsValidator.Validate(options));
Assert.Contains("$2", exception.Message, StringComparison.Ordinal);
Assert.Contains("capture group", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_RegexWithNoTranslatesTo_DoesNotThrow()
{
var options = CreateValidOptions();
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)(.*)",
IsRegex = true
});
var exception = Record.Exception(() => GatewayOptionsValidator.Validate(options));
Assert.Null(exception);
}
[Theory]
[InlineData(null)]
[InlineData("")]

View File

@@ -4,17 +4,33 @@ namespace StellaOps.Gateway.WebService.Tests.Configuration;
public sealed class GatewayRouteSearchMappingsTests
{
private static readonly (string Path, string Target, string RouteType)[] RequiredMappings =
private static readonly (string Path, string Target, string RouteType, bool IsRegex)[] RequiredMappings =
[
("/api/v1/search", "http://advisoryai.stella-ops.local/v1/search", "ReverseProxy"),
("/api/v1/advisory-ai", "http://advisoryai.stella-ops.local/v1/advisory-ai", "ReverseProxy")
("^/api/v1/search(.*)", "http://advisoryai.stella-ops.local/v1/search$1", "Microservice", true),
("^/api/v1/advisory-ai(.*)", "http://advisoryai.stella-ops.local/v1/advisory-ai$1", "Microservice", true),
("^/api/v1/watchlist(.*)", "http://attestor.stella-ops.local/api/v1/watchlist$1", "Microservice", true),
("^/api/v1/audit(.*)", "http://timeline.stella-ops.local/api/v1/audit$1", "Microservice", true),
("^/api/v1/advisory-sources(.*)", "http://concelier.stella-ops.local/api/v1/advisory-sources$1", "Microservice", true),
("^/api/v1/notifier/delivery(.*)", "http://notifier.stella-ops.local/api/v2/notify/deliveries$1", "Microservice", true),
("^/api/v2/context(.*)", "http://platform.stella-ops.local/api/v2/context$1", "Microservice", true),
("^/api/v2/releases(.*)", "http://platform.stella-ops.local/api/v2/releases$1", "Microservice", true),
("^/api/v2/security(.*)", "http://platform.stella-ops.local/api/v2/security$1", "Microservice", true),
("^/api/v2/topology(.*)", "http://platform.stella-ops.local/api/v2/topology$1", "Microservice", true),
("^/api/v2/integrations(.*)", "http://platform.stella-ops.local/api/v2/integrations$1", "Microservice", true),
("^/api/jobengine(.*)", "http://orchestrator.stella-ops.local/api/jobengine$1", "Microservice", true),
("^/api/scheduler(.*)", "http://scheduler.stella-ops.local/api/scheduler$1", "Microservice", true)
];
private static readonly (string Path, string AppSettingsTarget, string LocalTarget)[] RequiredReverseProxyMappings =
[
("/connect", "http://authority.stella-ops.local/connect", "http://authority.stella-ops.local/connect"),
("/authority/console", "http://authority.stella-ops.local/console", "https://authority.stella-ops.local/console")
];
public static TheoryData<string> RouteConfigPaths => new()
{
"src/Router/StellaOps.Gateway.WebService/appsettings.json",
"devops/compose/router-gateway-local.json",
"devops/compose/router-gateway-local.reverseproxy.json"
"devops/compose/router-gateway-local.json"
};
[Theory]
@@ -38,17 +54,19 @@ public sealed class GatewayRouteSearchMappingsTests
route.GetProperty("Path").GetString() ?? string.Empty,
route.TryGetProperty("TranslatesTo", out var translatesTo)
? translatesTo.GetString() ?? string.Empty
: string.Empty))
: string.Empty,
route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean()))
.ToList();
var catchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "/api", StringComparison.Ordinal));
foreach (var (requiredPath, requiredTarget, requiredType) in RequiredMappings)
foreach (var (requiredPath, requiredTarget, requiredType, requiredIsRegex) in RequiredMappings)
{
var route = routes.FirstOrDefault(candidate => string.Equals(candidate.Path, requiredPath, StringComparison.Ordinal));
Assert.True(route is not null, $"Missing route '{requiredPath}' in {configRelativePath}.");
Assert.Equal(requiredType, route!.Type);
Assert.Equal(requiredTarget, route!.TranslatesTo);
Assert.Equal(requiredIsRegex, route!.IsRegex);
if (catchAllIndex >= 0)
{
@@ -57,6 +75,46 @@ public sealed class GatewayRouteSearchMappingsTests
}
}
[Theory]
[MemberData(nameof(RouteConfigPaths))]
public void RouteTable_ContainsRequiredReverseProxyMappings(string configRelativePath)
{
var repoRoot = FindRepositoryRoot();
var configPath = Path.Combine(repoRoot, configRelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(configPath), $"Config file not found: {configPath}");
using var stream = File.OpenRead(configPath);
using var document = JsonDocument.Parse(stream);
var routes = document.RootElement
.GetProperty("Gateway")
.GetProperty("Routes")
.EnumerateArray()
.Select(route => new RouteEntry(
Index: -1,
route.GetProperty("Type").GetString() ?? string.Empty,
route.GetProperty("Path").GetString() ?? string.Empty,
route.TryGetProperty("TranslatesTo", out var translatesTo)
? translatesTo.GetString() ?? string.Empty
: string.Empty,
route.TryGetProperty("IsRegex", out var isRegex) && isRegex.GetBoolean()))
.ToList();
var isLocalComposeConfig = string.Equals(
configRelativePath,
"devops/compose/router-gateway-local.json",
StringComparison.Ordinal);
foreach (var (requiredPath, appSettingsTarget, localTarget) in RequiredReverseProxyMappings)
{
var route = routes.FirstOrDefault(candidate => string.Equals(candidate.Path, requiredPath, StringComparison.Ordinal));
Assert.True(route is not null, $"Missing route '{requiredPath}' in {configRelativePath}.");
Assert.Equal("ReverseProxy", route!.Type);
Assert.Equal(isLocalComposeConfig ? localTarget : appSettingsTarget, route.TranslatesTo);
Assert.False(route.IsRegex);
}
}
private static string FindRepositoryRoot()
{
for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent)
@@ -70,5 +128,5 @@ public sealed class GatewayRouteSearchMappingsTests
throw new InvalidOperationException($"Unable to locate repository root from {AppContext.BaseDirectory}.");
}
private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo);
private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo, bool IsRegex);
}

View File

@@ -1,9 +1,10 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Gateway.WebService.Routing;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
@@ -51,6 +52,93 @@ public sealed class GatewayIntegrationTests : IClassFixture<GatewayWebApplicatio
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task HealthReady_ReturnsServiceUnavailable_WhenRequiredMicroserviceIsMissing()
{
using var factory = CreateFactoryWithRequiredServices("policy");
var client = factory.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("policy", payload.RootElement.GetProperty("missingMicroservices")[0].GetString());
}
[Fact]
public async Task HealthReady_ReturnsOk_WhenRequiredMicroserviceIsRegistered()
{
using var factory = CreateFactoryWithRequiredServices("policy");
using (var scope = factory.Services.CreateScope())
{
var routingState = scope.ServiceProvider.GetRequiredService<IGlobalRoutingState>();
routingState.AddConnection(new ConnectionState
{
ConnectionId = "conn-policy",
Instance = new InstanceDescriptor
{
InstanceId = "policy-01",
ServiceName = "policy-gateway",
Version = "1.0.0",
Region = "test"
},
Status = InstanceHealthStatus.Healthy,
TransportType = TransportType.Messaging
});
}
var client = factory.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task HealthReady_ReturnsServiceUnavailable_WhenOnlySiblingServiceIsRegistered()
{
using var factory = CreateFactoryWithRequiredServices("policy");
using (var scope = factory.Services.CreateScope())
{
var routingState = scope.ServiceProvider.GetRequiredService<IGlobalRoutingState>();
routingState.AddConnection(new ConnectionState
{
ConnectionId = "conn-policy-engine",
Instance = new InstanceDescriptor
{
InstanceId = "policy-engine-01",
ServiceName = "policy-engine",
Version = "1.0.0",
Region = "test"
},
Status = InstanceHealthStatus.Healthy,
TransportType = TransportType.Messaging
});
}
var client = factory.CreateClient();
var response = await client.GetAsync("/health/ready");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("policy", payload.RootElement.GetProperty("missingMicroservices")[0].GetString());
}
private WebApplicationFactory<Program> CreateFactoryWithRequiredServices(params string[] requiredServices)
{
return new GatewayWebApplicationFactory().WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.PostConfigure<GatewayOptions>(options =>
{
options.Health.RequiredMicroservices = requiredServices.ToList();
});
});
});
}
[Fact]
public async Task OpenApiJson_ReturnsValidOpenApiDocument()
{

View File

@@ -259,6 +259,137 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
}
}
[Fact]
public async Task InvokeAsync_RegexCatchAll_CaptureGroupSubstitution_ResolvesServiceAndPath()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/([^/]+)(.*)",
IsRegex = true,
TranslatesTo = "http://$1.stella-ops.local/api/v1/$1$2"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/scanner/health";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
Assert.Equal(
"scanner",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
// Path matches request: no translation needed
Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath));
}
[Fact]
public async Task InvokeAsync_RegexAlias_CaptureGroupSubstitution_ResolvesCorrectService()
{
// Arrange
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/vulnerabilities(.*)",
IsRegex = true,
TranslatesTo = "http://scanner.stella-ops.local/api/v1/vulnerabilities$1"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/vulnerabilities/cve-2024-1234";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
Assert.Equal(
"scanner",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
[Fact]
public async Task InvokeAsync_RegexCatchAll_WithPathRewrite_SetsTranslatedPath()
{
// Arrange: advisoryai /api/v1/search -> /v1/search (path rewrite via capture group)
var resolver = new StellaOpsRouteResolver(
[
new StellaOpsRoute
{
Type = StellaOpsRouteType.Microservice,
Path = @"^/api/v1/search(.*)",
IsRegex = true,
TranslatesTo = "http://advisoryai.stella-ops.local/v1/search$1"
}
]);
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
var nextCalled = false;
var middleware = new RouteDispatchMiddleware(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
resolver,
httpClientFactory.Object,
NullLogger<RouteDispatchMiddleware>.Instance);
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/search/entities";
// Act
await middleware.InvokeAsync(context);
// Assert
Assert.True(nextCalled);
Assert.Equal(
"advisoryai",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
Assert.Equal(
"/v1/search/entities",
context.Items[RouterHttpContextKeys.TranslatedRequestPath] as string);
}
[Fact]
public async Task InvokeAsync_MicroserviceApiPath_DoesNotUseSpaFallback()
{

View File

@@ -29,8 +29,9 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/dashboard"));
Assert.NotNull(result);
Assert.Equal("/dashboard", result.Path);
Assert.NotNull(result.Route);
Assert.Equal("/dashboard", result.Route.Path);
Assert.Null(result.RegexMatch);
}
[Fact]
@@ -41,8 +42,9 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/app/index.html"));
Assert.NotNull(result);
Assert.Equal("/app", result.Path);
Assert.NotNull(result.Route);
Assert.Equal("/app", result.Route.Path);
Assert.Null(result.RegexMatch);
}
[Fact]
@@ -53,9 +55,28 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/api/v2/data"));
Assert.NotNull(result);
Assert.True(result.IsRegex);
Assert.Equal(@"^/api/v[0-9]+/.*", result.Path);
Assert.NotNull(result.Route);
Assert.True(result.Route.IsRegex);
Assert.Equal(@"^/api/v[0-9]+/.*", result.Route.Path);
Assert.NotNull(result.RegexMatch);
Assert.True(result.RegexMatch.Success);
}
[Fact]
public void Resolve_RegexRoute_ReturnsCaptureGroups()
{
var route = MakeRoute(
@"^/api/v1/([^/]+)(.*)",
isRegex: true,
translatesTo: "http://$1.stella-ops.local/api/v1/$1$2");
var resolver = new StellaOpsRouteResolver(new[] { route });
var result = resolver.Resolve(new PathString("/api/v1/scanner/health"));
Assert.NotNull(result.Route);
Assert.NotNull(result.RegexMatch);
Assert.Equal("scanner", result.RegexMatch.Groups[1].Value);
Assert.Equal("/health", result.RegexMatch.Groups[2].Value);
}
[Fact]
@@ -66,7 +87,8 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/unknown"));
Assert.Null(result);
Assert.Null(result.Route);
Assert.Null(result.RegexMatch);
}
[Fact]
@@ -78,8 +100,8 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/api/resource"));
Assert.NotNull(result);
Assert.Equal("http://first:5000", result.TranslatesTo);
Assert.NotNull(result.Route);
Assert.Equal("http://first:5000", result.Route.TranslatesTo);
}
[Fact]
@@ -90,7 +112,7 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/not-found"));
Assert.Null(result);
Assert.Null(result.Route);
}
[Fact]
@@ -101,7 +123,7 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/error"));
Assert.Null(result);
Assert.Null(result.Route);
}
[Fact]
@@ -112,8 +134,8 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/APP"));
Assert.NotNull(result);
Assert.Equal("/app", result.Path);
Assert.NotNull(result.Route);
Assert.Equal("/app", result.Route.Path);
}
[Fact]
@@ -123,6 +145,6 @@ public sealed class StellaOpsRouteResolverTests
var result = resolver.Resolve(new PathString("/anything"));
Assert.Null(result);
Assert.Null(result.Route);
}
}

View File

@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| 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. |
| ROUTER-READY-001-T | DONE | 2026-03-10: Added required-service readiness coverage and truthful warm-up status contracts for router frontdoor convergence. |

View File

@@ -31,6 +31,7 @@ public sealed class RouterConnectionManagerTests : IDisposable
Region = "test",
InstanceId = "test-instance-1",
HeartbeatInterval = TimeSpan.FromMilliseconds(50),
RegistrationRefreshInterval = TimeSpan.FromMilliseconds(20),
ReconnectBackoffInitial = TimeSpan.FromMilliseconds(10),
ReconnectBackoffMax = TimeSpan.FromMilliseconds(100)
};
@@ -385,6 +386,47 @@ public sealed class RouterConnectionManagerTests : IDisposable
capturedHeartbeat.ErrorRate.Should().Be(0.05);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_ReplaysHelloWithinRegistrationRefreshInterval()
{
// Arrange
_options.Routers.Add(new RouterEndpointConfig
{
Host = "localhost",
Port = 5000,
TransportType = TransportType.InMemory
});
var registrationReplayObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var connectCount = 0;
_transportMock
.Setup(t => t.ConnectAsync(
It.IsAny<InstanceDescriptor>(),
It.IsAny<IReadOnlyList<EndpointDescriptor>>(),
It.IsAny<IReadOnlyDictionary<string, SchemaDefinition>?>(),
It.IsAny<ServiceOpenApiInfo?>(),
It.IsAny<CancellationToken>()))
.Callback(() =>
{
if (Interlocked.Increment(ref connectCount) >= 2)
{
registrationReplayObserved.TrySetResult();
}
})
.Returns(Task.CompletedTask);
using var manager = CreateManager();
// Act
await manager.StartAsync(CancellationToken.None);
await registrationReplayObserved.Task.WaitAsync(TimeSpan.FromSeconds(2));
await manager.StopAsync(CancellationToken.None);
// Assert
connectCount.Should().BeGreaterThanOrEqualTo(2);
}
#endregion
#region Dispose Tests

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0393-T | DONE | Revalidated 2026-01-07; test coverage audit for Router StellaOps.Microservice.Tests. |
| AUDIT-0393-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| ROUTER-READY-004-T | DONE | 2026-03-10: Added deterministic coverage for bounded HELLO replay cadence after gateway restarts. |

View File

@@ -205,6 +205,7 @@ public sealed class StellaRouterIntegrationHelperTests
["TimelineIndexer:Router:Messaging:RequestTimeout"] = "45s",
["TimelineIndexer:Router:Messaging:LeaseDuration"] = "4m",
["TimelineIndexer:Router:Messaging:HeartbeatInterval"] = "12s",
["TimelineIndexer:Router:RegistrationRefreshIntervalSeconds"] = "7",
["TimelineIndexer:Router:Messaging:valkey:ConnectionString"] = "cache.stella-ops.local:6379",
["TimelineIndexer:Router:Messaging:valkey:Database"] = "2"
});
@@ -223,6 +224,7 @@ public sealed class StellaRouterIntegrationHelperTests
Assert.True(result);
Assert.Equal(TimeSpan.FromSeconds(12), options.HeartbeatInterval);
Assert.Equal(TimeSpan.FromSeconds(7), options.RegistrationRefreshInterval);
Assert.Equal("router:requests:{service}", messaging.RequestQueueTemplate);
Assert.Equal("router:responses", messaging.ResponseQueueName);
Assert.Equal("timelineindexer", messaging.ConsumerGroup);

View File

@@ -2,6 +2,7 @@ using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Moq;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Gateway.Middleware;
@@ -103,7 +104,7 @@ public sealed class EndpointResolutionMiddlewareTests
}
[Fact]
public async Task Invoke_DoesNotFallbackToDifferentService_WhenTargetHintHasNoMatch()
public async Task Invoke_Returns503_WhenTargetServiceIsNotRegistered()
{
// Arrange
var context = new DefaultHttpContext();
@@ -144,12 +145,91 @@ public sealed class EndpointResolutionMiddlewareTests
// Assert
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
routingState.Verify(
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task Invoke_Returns503_WhenSpecificGatewayHintWouldOtherwiseMatchSiblingService()
{
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/policy/__router_smoke__";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/policy/__router_smoke__";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy-gateway";
var siblingEndpoint = new EndpointDescriptor
{
ServiceName = "policy-engine",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/policy/__router_smoke__"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-policy-engine", "policy-engine", siblingEndpoint)
]);
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(context, routingState.Object);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
routingState.Verify(
state => state.ResolveEndpoint(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task Invoke_Returns404_WhenTargetServiceIsRegisteredButEndpointDoesNotExist()
{
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/governance/not-real";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/governance/not-real";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy";
var otherEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-policy", "policy-gateway", otherEndpoint)
]);
var nextCalled = false;
var middleware = new EndpointResolutionMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
await middleware.Invoke(context, routingState.Object);
nextCalled.Should().BeFalse();
context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
public async Task Invoke_MatchesRouteHintWithServicePrefixAlias()
{
@@ -186,10 +266,55 @@ public sealed class EndpointResolutionMiddlewareTests
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("findings-ledger");
}
[Fact]
public async Task Invoke_WhenMultipleMatchingConnectionsExist_PrefersHealthyMostRecentEndpoint()
{
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/governance/staleness/config";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/governance/staleness/config";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy";
var staleEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
};
var healthyEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
Version = "1.0.1",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
};
var routingState = new Mock<IGlobalRoutingState>();
routingState
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-stale", "policy-gateway", staleEndpoint, InstanceHealthStatus.Unhealthy, new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc)),
CreateConnection("conn-healthy", "policy-gateway", healthyEndpoint, InstanceHealthStatus.Healthy, new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc))
]);
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
await middleware.Invoke(context, routingState.Object);
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(healthyEndpoint);
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("policy-gateway");
}
private static ConnectionState CreateConnection(
string connectionId,
string serviceName,
EndpointDescriptor endpoint)
EndpointDescriptor endpoint,
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
DateTime? lastHeartbeatUtc = null)
{
var instance = new InstanceDescriptor
{
@@ -203,6 +328,8 @@ public sealed class EndpointResolutionMiddlewareTests
{
ConnectionId = connectionId,
Instance = instance,
Status = status,
LastHeartbeatUtc = lastHeartbeatUtc ?? new DateTime(2026, 3, 10, 1, 0, 0, DateTimeKind.Utc),
TransportType = StellaOps.Router.Common.Enums.TransportType.Messaging
};

View File

@@ -54,15 +54,46 @@ public sealed class InMemoryRoutingStateTests
.Which.ConnectionId.Should().Be("conn-1");
}
[Fact]
public void ResolveEndpoint_WhenSamePathExistsAcrossVersions_PrefersHealthyMostRecentRegistration()
{
var state = new InMemoryRoutingState();
var staleConnection = CreateConnection(
connectionId: "conn-stale",
instanceId: "policy-gateway-old",
endpointPath: "/api/v1/governance/staleness/config",
version: "1.0.0",
status: InstanceHealthStatus.Unhealthy,
lastHeartbeatUtc: new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc));
var healthyConnection = CreateConnection(
connectionId: "conn-healthy",
instanceId: "policy-gateway-new",
endpointPath: "/api/v1/governance/staleness/config",
version: "1.0.1",
status: InstanceHealthStatus.Healthy,
lastHeartbeatUtc: new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc));
state.AddConnection(staleConnection);
state.AddConnection(healthyConnection);
var resolved = state.ResolveEndpoint("GET", "/api/v1/governance/staleness/config");
resolved.Should().NotBeNull();
resolved!.Version.Should().Be("1.0.1");
}
private static ConnectionState CreateConnection(
string connectionId,
string instanceId,
string endpointPath)
string endpointPath,
string version = "1.0.0",
InstanceHealthStatus status = InstanceHealthStatus.Healthy,
DateTime? lastHeartbeatUtc = null)
{
var endpoint = new EndpointDescriptor
{
ServiceName = "integrations",
Version = "1.0.0",
Version = version,
Method = "GET",
Path = endpointPath
};
@@ -73,10 +104,12 @@ public sealed class InMemoryRoutingStateTests
Instance = new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = "integrations",
Version = "1.0.0",
ServiceName = endpointPath.Contains("/governance/", StringComparison.Ordinal) ? "policy-gateway" : "integrations",
Version = version,
Region = "local"
},
Status = status,
LastHeartbeatUtc = lastHeartbeatUtc ?? new DateTime(2026, 3, 10, 1, 0, 0, DateTimeKind.Utc),
TransportType = TransportType.Messaging
};