310 lines
11 KiB
C#
310 lines
11 KiB
C#
using System.Collections.Generic;
|
|
using System.IO.Pipelines;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading.Tasks;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using StellaOps.Scanner.WebService.Constants;
|
|
using StellaOps.Scanner.WebService.Contracts;
|
|
using StellaOps.Scanner.WebService.Domain;
|
|
using StellaOps.Scanner.WebService.Infrastructure;
|
|
using StellaOps.Scanner.WebService.Security;
|
|
using StellaOps.Scanner.WebService.Services;
|
|
|
|
namespace StellaOps.Scanner.WebService.Endpoints;
|
|
|
|
internal static class ScanEndpoints
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
public static void MapScanEndpoints(this RouteGroupBuilder apiGroup, string scansSegment)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(apiGroup);
|
|
|
|
var scans = apiGroup.MapGroup(NormalizeSegment(scansSegment));
|
|
|
|
scans.MapPost("/", HandleSubmitAsync)
|
|
.WithName("scanner.scans.submit")
|
|
.Produces<ScanSubmitResponse>(StatusCodes.Status202Accepted)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status409Conflict)
|
|
.RequireAuthorization(ScannerPolicies.ScansEnqueue);
|
|
|
|
scans.MapGet("/{scanId}", HandleStatusAsync)
|
|
.WithName("scanner.scans.status")
|
|
.Produces<ScanStatusResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
|
|
scans.MapGet("/{scanId}/events", HandleProgressStreamAsync)
|
|
.WithName("scanner.scans.events")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
}
|
|
|
|
private static async Task<IResult> HandleSubmitAsync(
|
|
ScanSubmitRequest request,
|
|
IScanCoordinator coordinator,
|
|
LinkGenerator links,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ArgumentNullException.ThrowIfNull(coordinator);
|
|
ArgumentNullException.ThrowIfNull(links);
|
|
|
|
if (request.Image is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid scan submission",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "Request image descriptor is required.");
|
|
}
|
|
|
|
var reference = request.Image.Reference;
|
|
var digest = request.Image.Digest;
|
|
if (string.IsNullOrWhiteSpace(reference) && string.IsNullOrWhiteSpace(digest))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid scan submission",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "Either image.reference or image.digest must be provided.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(digest) && !digest.Contains(':', StringComparison.Ordinal))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid scan submission",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "Image digest must include algorithm prefix (e.g. sha256:...).");
|
|
}
|
|
|
|
var target = new ScanTarget(reference, digest).Normalize();
|
|
var metadata = NormalizeMetadata(request.Metadata);
|
|
var submission = new ScanSubmission(
|
|
Target: target,
|
|
Force: request.Force,
|
|
ClientRequestId: request.ClientRequestId?.Trim(),
|
|
Metadata: metadata);
|
|
|
|
ScanSubmissionResult result;
|
|
try
|
|
{
|
|
result = await coordinator.SubmitAsync(submission, context.RequestAborted).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
|
|
var statusText = result.Snapshot.Status.ToString();
|
|
var location = links.GetPathByName(
|
|
httpContext: context,
|
|
endpointName: "scanner.scans.status",
|
|
values: new { scanId = result.Snapshot.ScanId.Value });
|
|
|
|
if (!string.IsNullOrWhiteSpace(location))
|
|
{
|
|
context.Response.Headers.Location = location;
|
|
}
|
|
|
|
var response = new ScanSubmitResponse(
|
|
ScanId: result.Snapshot.ScanId.Value,
|
|
Status: statusText,
|
|
Location: location,
|
|
Created: result.Created);
|
|
|
|
return Json(response, StatusCodes.Status202Accepted);
|
|
}
|
|
|
|
private static async Task<IResult> HandleStatusAsync(
|
|
string scanId,
|
|
IScanCoordinator coordinator,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(coordinator);
|
|
|
|
if (!ScanId.TryParse(scanId, out var parsed))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid scan identifier",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "Scan identifier is required.");
|
|
}
|
|
|
|
var snapshot = await coordinator.GetAsync(parsed, context.RequestAborted).ConfigureAwait(false);
|
|
if (snapshot is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Scan not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: "Requested scan could not be located.");
|
|
}
|
|
|
|
var response = new ScanStatusResponse(
|
|
ScanId: snapshot.ScanId.Value,
|
|
Status: snapshot.Status.ToString(),
|
|
Image: new ScanStatusTarget(snapshot.Target.Reference, snapshot.Target.Digest),
|
|
CreatedAt: snapshot.CreatedAt,
|
|
UpdatedAt: snapshot.UpdatedAt,
|
|
FailureReason: snapshot.FailureReason);
|
|
|
|
return Json(response, StatusCodes.Status200OK);
|
|
}
|
|
|
|
private static async Task<IResult> HandleProgressStreamAsync(
|
|
string scanId,
|
|
string? format,
|
|
IScanProgressReader progressReader,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(progressReader);
|
|
|
|
if (!ScanId.TryParse(scanId, out var parsed))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid scan identifier",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "Scan identifier is required.");
|
|
}
|
|
|
|
if (!progressReader.Exists(parsed))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Scan not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: "Requested scan could not be located.");
|
|
}
|
|
|
|
var streamFormat = string.Equals(format, "jsonl", StringComparison.OrdinalIgnoreCase)
|
|
? "jsonl"
|
|
: "sse";
|
|
|
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
|
context.Response.Headers.CacheControl = "no-store";
|
|
context.Response.Headers["X-Accel-Buffering"] = "no";
|
|
context.Response.Headers["Connection"] = "keep-alive";
|
|
|
|
if (streamFormat == "jsonl")
|
|
{
|
|
context.Response.ContentType = "application/x-ndjson";
|
|
}
|
|
else
|
|
{
|
|
context.Response.ContentType = "text/event-stream";
|
|
}
|
|
|
|
await foreach (var progressEvent in progressReader.SubscribeAsync(parsed, context.RequestAborted).WithCancellation(context.RequestAborted))
|
|
{
|
|
var payload = new
|
|
{
|
|
scanId = progressEvent.ScanId.Value,
|
|
sequence = progressEvent.Sequence,
|
|
state = progressEvent.State,
|
|
message = progressEvent.Message,
|
|
timestamp = progressEvent.Timestamp,
|
|
correlationId = progressEvent.CorrelationId,
|
|
data = progressEvent.Data
|
|
};
|
|
|
|
if (streamFormat == "jsonl")
|
|
{
|
|
await WriteJsonLineAsync(context.Response.BodyWriter, payload, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await WriteSseAsync(context.Response.BodyWriter, payload, progressEvent, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
await context.Response.BodyWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return Results.Empty;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
|
|
{
|
|
if (metadata is null || metadata.Count == 0)
|
|
{
|
|
return new Dictionary<string, string>();
|
|
}
|
|
|
|
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var pair in metadata)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(pair.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var key = pair.Key.Trim();
|
|
var value = pair.Value?.Trim() ?? string.Empty;
|
|
normalized[key] = value;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private static async Task WriteJsonLineAsync(PipeWriter writer, object payload, CancellationToken cancellationToken)
|
|
{
|
|
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
|
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
|
await writer.WriteAsync(jsonBytes, cancellationToken).ConfigureAwait(false);
|
|
await writer.WriteAsync(new[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task WriteSseAsync(PipeWriter writer, object payload, ScanProgressEvent progressEvent, CancellationToken cancellationToken)
|
|
{
|
|
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
|
var eventName = progressEvent.State.ToLowerInvariant();
|
|
var builder = new StringBuilder();
|
|
builder.Append("id: ").Append(progressEvent.Sequence).Append('\n');
|
|
builder.Append("event: ").Append(eventName).Append('\n');
|
|
builder.Append("data: ").Append(json).Append('\n');
|
|
builder.Append('\n');
|
|
|
|
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
|
await writer.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static IResult Json<T>(T value, int statusCode)
|
|
{
|
|
var payload = JsonSerializer.Serialize(value, SerializerOptions);
|
|
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
|
|
}
|
|
|
|
private static string NormalizeSegment(string segment)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(segment))
|
|
{
|
|
return "/scans";
|
|
}
|
|
|
|
var trimmed = segment.Trim('/');
|
|
return "/" + trimmed;
|
|
}
|
|
}
|