Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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;
}
}