Files
git.stella-ops.org/src/Scheduler/StellaOps.Scheduler.WebService/Runs/RunEndpoints.cs
StellaOps Bot 564df71bfb
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
up
2025-12-13 00:20:26 +02:00

620 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
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.Postgres.Repositories;
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";
private const string ManageScope = "scheduler.runs.manage";
private const int DefaultRunListLimit = 50;
public static IEndpointRouteBuilder MapRunEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/runs");
group.MapGet("/", ListRunsAsync);
group.MapGet("/queue/lag", GetQueueLagAsync);
group.MapGet("/{runId}/deltas", GetRunDeltasAsync);
group.MapGet("/{runId}/stream", StreamRunAsync);
group.MapGet("/{runId}", GetRunAsync);
group.MapPost("/", CreateRunAsync);
group.MapPost("/{runId}/cancel", CancelRunAsync);
group.MapPost("/{runId}/retry", RetryRunAsync);
group.MapPost("/preview", PreviewImpactAsync);
return routes;
}
private static IResult GetQueueLagAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IQueueLagSummaryProvider queueLagProvider)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
tenantAccessor.GetTenant(httpContext);
var summary = queueLagProvider.Capture();
return Results.Ok(summary);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
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 cursor = SchedulerEndpointHelpers.TryParseRunCursor(httpContext.Request.Query.TryGetValue("cursor", out var cursorValues) ? cursorValues.ToString() : null);
var sortAscending = httpContext.Request.Query.TryGetValue("sort", out var sortValues) &&
sortValues.Any(value => string.Equals(value, "asc", StringComparison.OrdinalIgnoreCase));
var appliedLimit = limit ?? DefaultRunListLimit;
var options = new RunQueryOptions
{
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId,
States = states,
CreatedAfter = createdAfter,
Cursor = cursor,
Limit = appliedLimit,
SortAscending = sortAscending,
};
var runs = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
string? nextCursor = null;
if (runs.Count == appliedLimit && runs.Count > 0)
{
var last = runs[^1];
nextCursor = SchedulerEndpointHelpers.CreateRunCursor(last);
}
return Results.Ok(new RunCollectionResponse(runs, nextCursor));
}
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> GetRunDeltasAsync(
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 RunDeltaCollectionResponse(run.Deltas));
}
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, ManageScope);
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> RetryRunAsync(
HttpContext httpContext,
string runId,
[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, ManageScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await runRepository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(existing.ScheduleId))
{
return Results.BadRequest(new { error = "Run cannot be retried because it is not associated with a schedule." });
}
if (!RunStateMachine.IsTerminal(existing.State))
{
return Results.Conflict(new { error = "Run is not in a terminal state and cannot be retried." });
}
var schedule = await scheduleRepository.GetAsync(tenant.TenantId, existing.ScheduleId!, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return Results.BadRequest(new { error = "Associated schedule no longer exists." });
}
var now = timeProvider.GetUtcNow();
var newRunId = SchedulerEndpointHelpers.GenerateIdentifier("run");
var baselineReason = existing.Reason ?? RunReason.Empty;
var manualReason = string.IsNullOrWhiteSpace(baselineReason.ManualReason)
? $"retry-of:{existing.Id}"
: $"{baselineReason.ManualReason};retry-of:{existing.Id}";
var newReason = new RunReason(
manualReason,
baselineReason.ConselierExportId,
baselineReason.ExcitorExportId,
baselineReason.Cursor)
{
ImpactWindowFrom = baselineReason.ImpactWindowFrom,
ImpactWindowTo = baselineReason.ImpactWindowTo
};
var retryRun = new Run(
newRunId,
tenant.TenantId,
RunTrigger.Manual,
RunState.Planning,
RunStats.Empty,
now,
newReason,
existing.ScheduleId,
retryOf: existing.Id);
await runRepository.InsertAsync(retryRun, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(retryRun.ScheduleId))
{
await runSummaryService.ProjectAsync(retryRun, cancellationToken).ConfigureAwait(false);
}
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler.run",
"retry",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
RunId: retryRun.Id,
ScheduleId: retryRun.ScheduleId,
Metadata: BuildMetadata(
("state", retryRun.State.ToString().ToLowerInvariant()),
("retryOf", existing.Id),
("trigger", retryRun.Trigger.ToString().ToLowerInvariant()))),
cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/scheduler/runs/{retryRun.Id}", new RunResponse(retryRun));
}
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 StreamRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository runRepository,
[FromServices] IRunStreamCoordinator runStreamCoordinator,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await runRepository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (run is null)
{
await Results.NotFound().ExecuteAsync(httpContext);
return;
}
await runStreamCoordinator.StreamAsync(httpContext, tenant.TenantId, run, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Client disconnected; nothing to do.
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
if (!httpContext.Response.HasStarted)
{
await Results.BadRequest(new { error = ex.Message }).ExecuteAsync(httpContext);
}
}
}
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;
}
}