Fix router frontdoor readiness and route contracts

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

View File

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

View File

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