254 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			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);
 | |
|     }
 | |
| }
 |