308 lines
11 KiB
C#
308 lines
11 KiB
C#
using System.Collections.Immutable;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using StellaOps.Scanner.ReachabilityDrift;
|
|
using StellaOps.Scanner.ReachabilityDrift.Services;
|
|
using StellaOps.Scanner.Storage.Repositories;
|
|
using StellaOps.Scanner.WebService.Constants;
|
|
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 ReachabilityDriftEndpoints
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
Converters = { new JsonStringEnumConverter() }
|
|
};
|
|
|
|
public static void MapReachabilityDriftScanEndpoints(this RouteGroupBuilder scansGroup)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scansGroup);
|
|
|
|
// GET /scans/{scanId}/drift?baseScanId=...&language=dotnet&includeFullPath=false
|
|
scansGroup.MapGet("/{scanId}/drift", HandleGetDriftAsync)
|
|
.WithName("scanner.scans.reachability-drift")
|
|
.WithTags("ReachabilityDrift")
|
|
.Produces<ReachabilityDriftResult>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
}
|
|
|
|
public static void MapReachabilityDriftRootEndpoints(this RouteGroupBuilder apiGroup)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(apiGroup);
|
|
|
|
var driftGroup = apiGroup.MapGroup("/drift");
|
|
|
|
// GET /drift/{driftId}/sinks?direction=became_reachable&offset=0&limit=100
|
|
driftGroup.MapGet("/{driftId:guid}/sinks", HandleListSinksAsync)
|
|
.WithName("scanner.drift.sinks")
|
|
.WithTags("ReachabilityDrift")
|
|
.Produces<DriftedSinksResponseDto>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
|
}
|
|
|
|
private static async Task<IResult> HandleGetDriftAsync(
|
|
string scanId,
|
|
string? baseScanId,
|
|
string? language,
|
|
bool? includeFullPath,
|
|
IScanCoordinator coordinator,
|
|
ICallGraphSnapshotRepository callGraphSnapshots,
|
|
CodeChangeFactExtractor codeChangeFactExtractor,
|
|
ICodeChangeRepository codeChangeRepository,
|
|
ReachabilityDriftDetector driftDetector,
|
|
IReachabilityDriftResultRepository driftRepository,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(coordinator);
|
|
ArgumentNullException.ThrowIfNull(callGraphSnapshots);
|
|
ArgumentNullException.ThrowIfNull(codeChangeFactExtractor);
|
|
ArgumentNullException.ThrowIfNull(codeChangeRepository);
|
|
ArgumentNullException.ThrowIfNull(driftDetector);
|
|
ArgumentNullException.ThrowIfNull(driftRepository);
|
|
|
|
if (!ScanId.TryParse(scanId, out var headScan))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid scan identifier",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "Scan identifier is required.");
|
|
}
|
|
|
|
var resolvedLanguage = string.IsNullOrWhiteSpace(language) ? "dotnet" : language.Trim();
|
|
|
|
var headSnapshot = await coordinator.GetAsync(headScan, cancellationToken).ConfigureAwait(false);
|
|
if (headSnapshot is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Scan not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: "Requested scan could not be located.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(baseScanId))
|
|
{
|
|
var existing = await driftRepository.TryGetLatestForHeadAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (existing is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Drift result not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: $"No reachability drift result recorded for scan {scanId} (language={resolvedLanguage}).");
|
|
}
|
|
|
|
return Json(existing, StatusCodes.Status200OK);
|
|
}
|
|
|
|
if (!ScanId.TryParse(baseScanId, out var baseScan))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid base scan identifier",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "Query parameter 'baseScanId' must be a valid scan id.");
|
|
}
|
|
|
|
var baselineSnapshot = await coordinator.GetAsync(baseScan, cancellationToken).ConfigureAwait(false);
|
|
if (baselineSnapshot is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Base scan not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: "Base scan could not be located.");
|
|
}
|
|
|
|
var baseGraph = await callGraphSnapshots.TryGetLatestAsync(baseScan.Value, resolvedLanguage, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (baseGraph is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Base call graph not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: $"No call graph snapshot found for base scan {baseScan.Value} (language={resolvedLanguage}).");
|
|
}
|
|
|
|
var headGraph = await callGraphSnapshots.TryGetLatestAsync(headScan.Value, resolvedLanguage, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (headGraph is null)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Head call graph not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: $"No call graph snapshot found for head scan {headScan.Value} (language={resolvedLanguage}).");
|
|
}
|
|
|
|
try
|
|
{
|
|
var codeChanges = codeChangeFactExtractor.Extract(baseGraph, headGraph);
|
|
await codeChangeRepository.StoreAsync(codeChanges, cancellationToken).ConfigureAwait(false);
|
|
|
|
var drift = driftDetector.Detect(
|
|
baseGraph,
|
|
headGraph,
|
|
codeChanges,
|
|
includeFullPath: includeFullPath == true);
|
|
|
|
await driftRepository.StoreAsync(drift, cancellationToken).ConfigureAwait(false);
|
|
return Json(drift, StatusCodes.Status200OK);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid drift request",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: ex.Message);
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> HandleListSinksAsync(
|
|
Guid driftId,
|
|
string? direction,
|
|
int? offset,
|
|
int? limit,
|
|
IReachabilityDriftResultRepository driftRepository,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(driftRepository);
|
|
|
|
if (driftId == Guid.Empty)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid drift identifier",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "driftId must be a non-empty GUID.");
|
|
}
|
|
|
|
if (!TryParseDirection(direction, out var parsedDirection))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid direction",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "direction must be 'became_reachable' or 'became_unreachable'.");
|
|
}
|
|
|
|
var resolvedOffset = offset ?? 0;
|
|
if (resolvedOffset < 0)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid offset",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "offset must be >= 0.");
|
|
}
|
|
|
|
var resolvedLimit = limit ?? 100;
|
|
if (resolvedLimit <= 0 || resolvedLimit > 500)
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.Validation,
|
|
"Invalid limit",
|
|
StatusCodes.Status400BadRequest,
|
|
detail: "limit must be between 1 and 500.");
|
|
}
|
|
|
|
if (!await driftRepository.ExistsAsync(driftId, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
return ProblemResultFactory.Create(
|
|
context,
|
|
ProblemTypes.NotFound,
|
|
"Drift result not found",
|
|
StatusCodes.Status404NotFound,
|
|
detail: "Requested drift result could not be located.");
|
|
}
|
|
|
|
var sinks = await driftRepository.ListSinksAsync(
|
|
driftId,
|
|
parsedDirection,
|
|
resolvedOffset,
|
|
resolvedLimit,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new DriftedSinksResponseDto(
|
|
DriftId: driftId,
|
|
Direction: parsedDirection,
|
|
Offset: resolvedOffset,
|
|
Limit: resolvedLimit,
|
|
Count: sinks.Count,
|
|
Sinks: sinks.ToImmutableArray());
|
|
|
|
return Json(response, StatusCodes.Status200OK);
|
|
}
|
|
|
|
private static bool TryParseDirection(string? direction, out DriftDirection parsed)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(direction))
|
|
{
|
|
parsed = DriftDirection.BecameReachable;
|
|
return true;
|
|
}
|
|
|
|
var normalized = direction.Trim().ToLowerInvariant();
|
|
parsed = normalized switch
|
|
{
|
|
"became_reachable" or "newly_reachable" or "reachable" or "up" => DriftDirection.BecameReachable,
|
|
"became_unreachable" or "newly_unreachable" or "unreachable" or "down" => DriftDirection.BecameUnreachable,
|
|
_ => DriftDirection.BecameReachable
|
|
};
|
|
|
|
return normalized is "became_reachable"
|
|
or "newly_reachable"
|
|
or "reachable"
|
|
or "up"
|
|
or "became_unreachable"
|
|
or "newly_unreachable"
|
|
or "unreachable"
|
|
or "down";
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
internal sealed record DriftedSinksResponseDto(
|
|
Guid DriftId,
|
|
DriftDirection Direction,
|
|
int Offset,
|
|
int Limit,
|
|
int Count,
|
|
ImmutableArray<DriftedSink> Sinks);
|