Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal static class RunEndpoints
|
||||
{
|
||||
private const string ReadScope = "scheduler.runs.read";
|
||||
private const string WriteScope = "scheduler.runs.write";
|
||||
private const string PreviewScope = "scheduler.runs.preview";
|
||||
|
||||
public static IEndpointRouteBuilder MapRunEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/scheduler/runs");
|
||||
|
||||
group.MapGet("/", ListRunsAsync);
|
||||
group.MapGet("/{runId}", GetRunAsync);
|
||||
group.MapPost("/", CreateRunAsync);
|
||||
group.MapPost("/{runId}/cancel", CancelRunAsync);
|
||||
group.MapPost("/preview", PreviewImpactAsync);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRunsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IRunRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var scheduleId = httpContext.Request.Query.TryGetValue("scheduleId", out var scheduleValues)
|
||||
? scheduleValues.ToString().Trim()
|
||||
: null;
|
||||
|
||||
var states = ParseRunStates(httpContext.Request.Query.TryGetValue("state", out var stateValues) ? stateValues : StringValues.Empty);
|
||||
var createdAfter = SchedulerEndpointHelpers.TryParseDateTimeOffset(httpContext.Request.Query.TryGetValue("createdAfter", out var createdAfterValues) ? createdAfterValues.ToString() : null);
|
||||
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
|
||||
|
||||
var sortAscending = httpContext.Request.Query.TryGetValue("sort", out var sortValues) &&
|
||||
sortValues.Any(value => string.Equals(value, "asc", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var options = new RunQueryOptions
|
||||
{
|
||||
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId,
|
||||
States = states,
|
||||
CreatedAfter = createdAfter,
|
||||
Limit = limit,
|
||||
SortAscending = sortAscending,
|
||||
};
|
||||
|
||||
var runs = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new RunCollectionResponse(runs));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRunAsync(
|
||||
HttpContext httpContext,
|
||||
string runId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IRunRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new RunResponse(run));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRunAsync(
|
||||
HttpContext httpContext,
|
||||
RunCreateRequest request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IScheduleRepository scheduleRepository,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IRunSummaryService runSummaryService,
|
||||
[FromServices] ISchedulerAuditService auditService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ScheduleId))
|
||||
{
|
||||
throw new ValidationException("scheduleId must be provided when creating a run.");
|
||||
}
|
||||
|
||||
var scheduleId = request.ScheduleId!.Trim();
|
||||
if (scheduleId.Length == 0)
|
||||
{
|
||||
throw new ValidationException("scheduleId must contain a value.");
|
||||
}
|
||||
|
||||
var schedule = await scheduleRepository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (schedule is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (request.Trigger != RunTrigger.Manual)
|
||||
{
|
||||
throw new ValidationException("Only manual runs can be created via this endpoint.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var runId = SchedulerEndpointHelpers.GenerateIdentifier("run");
|
||||
var reason = request.Reason ?? RunReason.Empty;
|
||||
|
||||
var run = new Run(
|
||||
runId,
|
||||
tenant.TenantId,
|
||||
request.Trigger,
|
||||
RunState.Planning,
|
||||
RunStats.Empty,
|
||||
now,
|
||||
reason,
|
||||
schedule.Id);
|
||||
|
||||
await runRepository.InsertAsync(run, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(run.ScheduleId))
|
||||
{
|
||||
await runSummaryService.ProjectAsync(run, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await auditService.WriteAsync(
|
||||
new SchedulerAuditEvent(
|
||||
tenant.TenantId,
|
||||
"scheduler.run",
|
||||
"create",
|
||||
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
|
||||
RunId: run.Id,
|
||||
ScheduleId: schedule.Id,
|
||||
Metadata: BuildMetadata(
|
||||
("state", run.State.ToString().ToLowerInvariant()),
|
||||
("trigger", run.Trigger.ToString().ToLowerInvariant()),
|
||||
("correlationId", request.CorrelationId?.Trim()))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/scheduler/runs/{run.Id}", new RunResponse(run));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelRunAsync(
|
||||
HttpContext httpContext,
|
||||
string runId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IRunRepository repository,
|
||||
[FromServices] IRunSummaryService runSummaryService,
|
||||
[FromServices] ISchedulerAuditService auditService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (RunStateMachine.IsTerminal(run.State))
|
||||
{
|
||||
return Results.Conflict(new { error = "Run is already in a terminal state." });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var cancelled = RunStateMachine.EnsureTransition(run, RunState.Cancelled, now);
|
||||
var updated = await repository.UpdateAsync(cancelled, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!updated)
|
||||
{
|
||||
return Results.Conflict(new { error = "Run could not be updated because it changed concurrently." });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cancelled.ScheduleId))
|
||||
{
|
||||
await runSummaryService.ProjectAsync(cancelled, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await auditService.WriteAsync(
|
||||
new SchedulerAuditEvent(
|
||||
tenant.TenantId,
|
||||
"scheduler.run",
|
||||
"cancel",
|
||||
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
|
||||
RunId: cancelled.Id,
|
||||
ScheduleId: cancelled.ScheduleId,
|
||||
Metadata: BuildMetadata(("state", cancelled.State.ToString().ToLowerInvariant()))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new RunResponse(cancelled));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewImpactAsync(
|
||||
HttpContext httpContext,
|
||||
ImpactPreviewRequest request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IScheduleRepository scheduleRepository,
|
||||
[FromServices] IImpactIndex impactIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, PreviewScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var selector = await ResolveSelectorAsync(request, tenant.TenantId, scheduleRepository, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var normalizedProductKeys = NormalizeStringInputs(request.ProductKeys);
|
||||
var normalizedVulnerabilityIds = NormalizeStringInputs(request.VulnerabilityIds);
|
||||
|
||||
ImpactSet impactSet;
|
||||
if (!normalizedProductKeys.IsDefaultOrEmpty)
|
||||
{
|
||||
impactSet = await impactIndex.ResolveByPurlsAsync(normalizedProductKeys, request.UsageOnly, selector, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (!normalizedVulnerabilityIds.IsDefaultOrEmpty)
|
||||
{
|
||||
impactSet = await impactIndex.ResolveByVulnerabilitiesAsync(normalizedVulnerabilityIds, request.UsageOnly, selector, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
impactSet = await impactIndex.ResolveAllAsync(selector, request.UsageOnly, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var sampleSize = Math.Clamp(request.SampleSize, 1, 50);
|
||||
var sampleBuilder = ImmutableArray.CreateBuilder<ImpactPreviewSample>();
|
||||
foreach (var image in impactSet.Images.Take(sampleSize))
|
||||
{
|
||||
sampleBuilder.Add(new ImpactPreviewSample(
|
||||
image.ImageDigest,
|
||||
image.Registry,
|
||||
image.Repository,
|
||||
image.Namespaces.IsDefault ? ImmutableArray<string>.Empty : image.Namespaces,
|
||||
image.Tags.IsDefault ? ImmutableArray<string>.Empty : image.Tags,
|
||||
image.UsedByEntrypoint));
|
||||
}
|
||||
|
||||
var response = new ImpactPreviewResponse(
|
||||
impactSet.Total,
|
||||
impactSet.UsageOnly,
|
||||
impactSet.GeneratedAt,
|
||||
impactSet.SnapshotId,
|
||||
sampleBuilder.ToImmutable());
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<RunState> ParseRunStates(StringValues values)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return ImmutableArray<RunState>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<RunState>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<RunState>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
throw new ValidationException($"State '{value}' is not a valid run state.");
|
||||
}
|
||||
|
||||
builder.Add(parsed);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static async Task<Selector> ResolveSelectorAsync(
|
||||
ImpactPreviewRequest request,
|
||||
string tenantId,
|
||||
IScheduleRepository scheduleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Selector? selector = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ScheduleId))
|
||||
{
|
||||
var schedule = await scheduleRepository.GetAsync(tenantId, request.ScheduleId!, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (schedule is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Schedule '{request.ScheduleId}' was not found for tenant '{tenantId}'.");
|
||||
}
|
||||
|
||||
selector = schedule.Selection;
|
||||
}
|
||||
|
||||
if (request.Selector is not null)
|
||||
{
|
||||
if (selector is not null && request.ScheduleId is not null)
|
||||
{
|
||||
throw new ValidationException("selector cannot be combined with scheduleId in the same request.");
|
||||
}
|
||||
|
||||
selector = request.Selector;
|
||||
}
|
||||
|
||||
if (selector is null)
|
||||
{
|
||||
throw new ValidationException("Either scheduleId or selector must be provided.");
|
||||
}
|
||||
|
||||
return SchedulerEndpointHelpers.NormalizeSelector(selector, tenantId);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringInputs(ImmutableArray<string>? values)
|
||||
{
|
||||
if (values is null || values.Value.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in values.Value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (seen.Add(trimmed))
|
||||
{
|
||||
builder.Add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildMetadata(params (string Key, string? Value)[] pairs)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata[key] = value!;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user