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 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 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 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 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 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 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 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(); foreach (var image in impactSet.Images.Take(sampleSize)) { sampleBuilder.Add(new ImpactPreviewSample( image.ImageDigest, image.Registry, image.Repository, image.Namespaces.IsDefault ? ImmutableArray.Empty : image.Namespaces, image.Tags.IsDefault ? ImmutableArray.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 ParseRunStates(StringValues values) { if (values.Count == 0) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); foreach (var value in values) { if (string.IsNullOrWhiteSpace(value)) { continue; } if (!Enum.TryParse(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 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 NormalizeStringInputs(ImmutableArray? values) { if (values is null || values.Value.IsDefaultOrEmpty) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); var seen = new HashSet(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 BuildMetadata(params (string Key, string? Value)[] pairs) { var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (key, value) in pairs) { if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) { continue; } metadata[key] = value!; } return metadata; } }