Fix router frontdoor readiness and route contracts
This commit is contained in:
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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('/'))
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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("")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user