save progress
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user