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(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status429TooManyRequests) .RequireAuthorization(ScannerPolicies.RuntimeIngest); } private static async Task HandleRuntimeEventsAsync( RuntimeEventsIngestRequestDto request, IRuntimeEventIngestionService ingestionService, IOptions 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 { ["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 { ["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 envelopes) { envelopes = request.Events ?? Array.Empty(); 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 { ["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(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 { ["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 value, int statusCode) { var payload = JsonSerializer.Serialize(value, SerializerOptions); return Results.Content(payload, "application/json", Encoding.UTF8, statusCode); } }