Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/RuntimeEndpoints.cs
root 68da90a11a
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Restructure solution layout by module
2025-10-28 15:10:40 +02:00

254 lines
9.4 KiB
C#

using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Zastava.Core.Contracts;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class RuntimeEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapRuntimeEndpoints(this RouteGroupBuilder apiGroup, string runtimeSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var runtime = apiGroup
.MapGroup(NormalizeSegment(runtimeSegment))
.WithTags("Runtime");
runtime.MapPost("/events", HandleRuntimeEventsAsync)
.WithName("scanner.runtime.events.ingest")
.Produces<RuntimeEventsIngestResponseDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status429TooManyRequests)
.RequireAuthorization(ScannerPolicies.RuntimeIngest);
}
private static async Task<IResult> HandleRuntimeEventsAsync(
RuntimeEventsIngestRequestDto request,
IRuntimeEventIngestionService ingestionService,
IOptions<ScannerWebServiceOptions> options,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(ingestionService);
ArgumentNullException.ThrowIfNull(options);
var runtimeOptions = options.Value.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
var validationError = ValidateRequest(request, runtimeOptions, context, out var envelopes);
if (validationError is { } problem)
{
return problem;
}
var result = await ingestionService.IngestAsync(envelopes, request.BatchId, cancellationToken).ConfigureAwait(false);
if (result.IsPayloadTooLarge)
{
var extensions = new Dictionary<string, object?>
{
["payloadBytes"] = result.PayloadBytes,
["maxPayloadBytes"] = result.PayloadLimit
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Runtime event batch too large",
StatusCodes.Status400BadRequest,
detail: "Runtime batch payload exceeds configured budget.",
extensions: extensions);
}
if (result.IsRateLimited)
{
var retryAfterSeconds = Math.Max(1, (int)Math.Ceiling(result.RetryAfter.TotalSeconds));
context.Response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture);
var extensions = new Dictionary<string, object?>
{
["scope"] = result.RateLimitedScope,
["key"] = result.RateLimitedKey,
["retryAfterSeconds"] = retryAfterSeconds
};
return ProblemResultFactory.Create(
context,
ProblemTypes.RateLimited,
"Runtime ingestion rate limited",
StatusCodes.Status429TooManyRequests,
detail: "Runtime ingestion exceeded configured rate limits.",
extensions: extensions);
}
var payload = new RuntimeEventsIngestResponseDto
{
Accepted = result.Accepted,
Duplicates = result.Duplicates
};
return Json(payload, StatusCodes.Status202Accepted);
}
private static IResult? ValidateRequest(
RuntimeEventsIngestRequestDto request,
ScannerWebServiceOptions.RuntimeOptions runtimeOptions,
HttpContext context,
out IReadOnlyList<RuntimeEventEnvelope> envelopes)
{
envelopes = request.Events ?? Array.Empty<RuntimeEventEnvelope>();
if (envelopes.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: "events array must include at least one item.");
}
if (envelopes.Count > runtimeOptions.MaxBatchSize)
{
var extensions = new Dictionary<string, object?>
{
["maxBatchSize"] = runtimeOptions.MaxBatchSize,
["eventCount"] = envelopes.Count
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: "events array exceeds allowed batch size.",
extensions: extensions);
}
var seenEventIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < envelopes.Count; i++)
{
var envelope = envelopes[i];
if (envelope is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}] must not be null.");
}
if (!envelope.IsSupported())
{
var extensions = new Dictionary<string, object?>
{
["schemaVersion"] = envelope.SchemaVersion
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unsupported runtime schema version",
StatusCodes.Status400BadRequest,
detail: "Runtime event schemaVersion is not supported.",
extensions: extensions);
}
var runtimeEvent = envelope.Event;
if (runtimeEvent is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].event must not be null.");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.EventId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].eventId is required.");
}
if (!seenEventIds.Add(runtimeEvent.EventId))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"Duplicate eventId detected within batch ('{runtimeEvent.EventId}').");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.Tenant))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].tenant is required.");
}
if (string.IsNullOrWhiteSpace(runtimeEvent.Node))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].node is required.");
}
if (runtimeEvent.Workload is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid runtime ingest request",
StatusCodes.Status400BadRequest,
detail: $"events[{i}].workload is required.");
}
}
return null;
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/runtime";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
}