Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal interface IQueueLagSummaryProvider
|
||||
{
|
||||
QueueLagSummaryResponse Capture();
|
||||
}
|
||||
|
||||
internal sealed class QueueLagSummaryProvider : IQueueLagSummaryProvider
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public QueueLagSummaryProvider(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public QueueLagSummaryResponse Capture()
|
||||
{
|
||||
var samples = SchedulerQueueMetrics.CaptureDepthSamples();
|
||||
if (samples.Count == 0)
|
||||
{
|
||||
return new QueueLagSummaryResponse(
|
||||
_timeProvider.GetUtcNow(),
|
||||
0,
|
||||
0,
|
||||
ImmutableArray<QueueLagEntry>.Empty);
|
||||
}
|
||||
|
||||
var ordered = samples
|
||||
.OrderBy(static sample => sample.Transport, StringComparer.Ordinal)
|
||||
.ThenBy(static sample => sample.Queue, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<QueueLagEntry>(ordered.Length);
|
||||
long totalDepth = 0;
|
||||
long maxDepth = 0;
|
||||
|
||||
foreach (var sample in ordered)
|
||||
{
|
||||
totalDepth += sample.Depth;
|
||||
if (sample.Depth > maxDepth)
|
||||
{
|
||||
maxDepth = sample.Depth;
|
||||
}
|
||||
|
||||
builder.Add(new QueueLagEntry(sample.Transport, sample.Queue, sample.Depth));
|
||||
}
|
||||
|
||||
return new QueueLagSummaryResponse(
|
||||
_timeProvider.GetUtcNow(),
|
||||
totalDepth,
|
||||
maxDepth,
|
||||
builder.ToImmutable());
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ internal sealed record RunCreateRequest(
|
||||
[property: JsonPropertyName("reason")] RunReason? Reason = null,
|
||||
[property: JsonPropertyName("correlationId")] string? CorrelationId = null);
|
||||
|
||||
internal sealed record RunCollectionResponse(
|
||||
[property: JsonPropertyName("runs")] IReadOnlyList<Run> Runs);
|
||||
internal sealed record RunCollectionResponse(
|
||||
[property: JsonPropertyName("runs")] IReadOnlyList<Run> Runs,
|
||||
[property: JsonPropertyName("nextCursor")] string? NextCursor = null);
|
||||
|
||||
internal sealed record RunResponse(
|
||||
[property: JsonPropertyName("run")] Run Run);
|
||||
@@ -31,10 +32,24 @@ internal sealed record ImpactPreviewResponse(
|
||||
[property: JsonPropertyName("snapshotId")] string? SnapshotId,
|
||||
[property: JsonPropertyName("sample")] ImmutableArray<ImpactPreviewSample> Sample);
|
||||
|
||||
internal sealed record ImpactPreviewSample(
|
||||
[property: JsonPropertyName("imageDigest")] string ImageDigest,
|
||||
[property: JsonPropertyName("registry")] string Registry,
|
||||
[property: JsonPropertyName("repository")] string Repository,
|
||||
[property: JsonPropertyName("namespaces")] ImmutableArray<string> Namespaces,
|
||||
[property: JsonPropertyName("tags")] ImmutableArray<string> Tags,
|
||||
[property: JsonPropertyName("usedByEntrypoint")] bool UsedByEntrypoint);
|
||||
internal sealed record ImpactPreviewSample(
|
||||
[property: JsonPropertyName("imageDigest")] string ImageDigest,
|
||||
[property: JsonPropertyName("registry")] string Registry,
|
||||
[property: JsonPropertyName("repository")] string Repository,
|
||||
[property: JsonPropertyName("namespaces")] ImmutableArray<string> Namespaces,
|
||||
[property: JsonPropertyName("tags")] ImmutableArray<string> Tags,
|
||||
[property: JsonPropertyName("usedByEntrypoint")] bool UsedByEntrypoint);
|
||||
|
||||
internal sealed record RunDeltaCollectionResponse(
|
||||
[property: JsonPropertyName("deltas")] ImmutableArray<DeltaSummary> Deltas);
|
||||
|
||||
internal sealed record QueueLagSummaryResponse(
|
||||
[property: JsonPropertyName("capturedAt")] DateTimeOffset CapturedAt,
|
||||
[property: JsonPropertyName("totalDepth")] long TotalDepth,
|
||||
[property: JsonPropertyName("maxDepth")] long MaxDepth,
|
||||
[property: JsonPropertyName("queues")] ImmutableArray<QueueLagEntry> Queues);
|
||||
|
||||
internal sealed record QueueLagEntry(
|
||||
[property: JsonPropertyName("transport")] string Transport,
|
||||
[property: JsonPropertyName("queue")] string Queue,
|
||||
[property: JsonPropertyName("depth")] long Depth);
|
||||
|
||||
@@ -3,7 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Threading;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
@@ -15,31 +16,57 @@ 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";
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
{
|
||||
@@ -50,24 +77,35 @@ internal static class RunEndpoints
|
||||
? 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));
|
||||
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)
|
||||
{
|
||||
@@ -75,32 +113,59 @@ internal static class RunEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
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> 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,
|
||||
@@ -116,7 +181,7 @@ internal static class RunEndpoints
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
|
||||
scopeAuthorizer.EnsureScope(httpContext, ManageScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ScheduleId))
|
||||
@@ -184,11 +249,11 @@ internal static class RunEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelRunAsync(
|
||||
HttpContext httpContext,
|
||||
string runId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
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,
|
||||
@@ -243,9 +308,145 @@ internal static class RunEndpoints
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal interface IRunStreamCoordinator
|
||||
{
|
||||
Task StreamAsync(HttpContext context, string tenantId, Run initialRun, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RunStreamCoordinator : IRunStreamCoordinator
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IRunRepository _runRepository;
|
||||
private readonly IQueueLagSummaryProvider _queueLagProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RunStreamCoordinator> _logger;
|
||||
private readonly RunStreamOptions _options;
|
||||
|
||||
public RunStreamCoordinator(
|
||||
IRunRepository runRepository,
|
||||
IQueueLagSummaryProvider queueLagProvider,
|
||||
IOptions<RunStreamOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<RunStreamCoordinator> logger)
|
||||
{
|
||||
_runRepository = runRepository ?? throw new ArgumentNullException(nameof(runRepository));
|
||||
_queueLagProvider = queueLagProvider ?? throw new ArgumentNullException(nameof(queueLagProvider));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Validate();
|
||||
}
|
||||
|
||||
public async Task StreamAsync(HttpContext context, string tenantId, Run initialRun, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(initialRun);
|
||||
|
||||
var response = context.Response;
|
||||
ConfigureSseHeaders(response);
|
||||
await SseWriter.WriteRetryAsync(response, _options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lastRun = initialRun;
|
||||
await SseWriter.WriteEventAsync(response, "initial", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
await SseWriter.WriteEventAsync(response, "queueLag", _queueLagProvider.Capture(), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
await SseWriter.WriteEventAsync(response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (RunStateMachine.IsTerminal(lastRun.State))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "completed", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var pollTimer = new PeriodicTimer(_options.PollInterval);
|
||||
using var queueTimer = new PeriodicTimer(_options.QueueLagInterval);
|
||||
using var heartbeatTimer = new PeriodicTimer(_options.HeartbeatInterval);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var pollTask = pollTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var queueTask = queueTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var heartbeatTask = heartbeatTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
|
||||
var completed = await Task.WhenAny(pollTask, queueTask, heartbeatTask).ConfigureAwait(false);
|
||||
|
||||
if (completed == pollTask && await pollTask.ConfigureAwait(false))
|
||||
{
|
||||
var current = await _runRepository.GetAsync(tenantId, lastRun.Id, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
_logger.LogWarning("Run {RunId} disappeared while streaming; signalling notFound event.", lastRun.Id);
|
||||
await SseWriter.WriteEventAsync(response, "notFound", new RunNotFoundPayload(lastRun.Id), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
await EmitRunDifferencesAsync(response, lastRun, current, cancellationToken).ConfigureAwait(false);
|
||||
lastRun = current;
|
||||
|
||||
if (RunStateMachine.IsTerminal(lastRun.State))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "completed", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (completed == queueTask && await queueTask.ConfigureAwait(false))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "queueLag", _queueLagProvider.Capture(), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (completed == heartbeatTask && await heartbeatTask.ConfigureAwait(false))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Run stream cancelled for run {RunId}.", lastRun.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureSseHeaders(HttpResponse response)
|
||||
{
|
||||
response.StatusCode = StatusCodes.Status200OK;
|
||||
response.Headers.CacheControl = "no-store";
|
||||
response.Headers["X-Accel-Buffering"] = "no";
|
||||
response.Headers["Connection"] = "keep-alive";
|
||||
response.ContentType = "text/event-stream";
|
||||
}
|
||||
|
||||
private async Task EmitRunDifferencesAsync(HttpResponse response, Run previous, Run current, CancellationToken cancellationToken)
|
||||
{
|
||||
var stateChanged = current.State != previous.State || current.StartedAt != previous.StartedAt || current.FinishedAt != previous.FinishedAt || !string.Equals(current.Error, previous.Error, StringComparison.Ordinal);
|
||||
if (stateChanged)
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "stateChanged", RunStateChangedPayload.From(current), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(current.Stats, previous.Stats) && current.Stats != previous.Stats)
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "segmentProgress", RunStatsPayload.From(current), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!current.Deltas.SequenceEqual(previous.Deltas))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "deltaSummary", new RunDeltaPayload(current.Id, current.Deltas), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RunSnapshotPayload(
|
||||
[property: JsonPropertyName("run")] Run Run)
|
||||
{
|
||||
public static RunSnapshotPayload From(Run run)
|
||||
=> new(run);
|
||||
}
|
||||
|
||||
private sealed record RunStateChangedPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("state")] string State,
|
||||
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||
[property: JsonPropertyName("finishedAt")] DateTimeOffset? FinishedAt,
|
||||
[property: JsonPropertyName("error")] string? Error)
|
||||
{
|
||||
public static RunStateChangedPayload From(Run run)
|
||||
=> new(
|
||||
run.Id,
|
||||
run.State.ToString().ToLowerInvariant(),
|
||||
run.StartedAt,
|
||||
run.FinishedAt,
|
||||
run.Error);
|
||||
}
|
||||
|
||||
private sealed record RunStatsPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("stats")] RunStats Stats)
|
||||
{
|
||||
public static RunStatsPayload From(Run run)
|
||||
=> new(run.Id, run.Stats);
|
||||
}
|
||||
|
||||
private sealed record RunDeltaPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("deltas")] ImmutableArray<DeltaSummary> Deltas);
|
||||
|
||||
private sealed record HeartbeatPayload(
|
||||
[property: JsonPropertyName("ts")] DateTimeOffset Timestamp)
|
||||
{
|
||||
public static HeartbeatPayload Create(DateTimeOffset timestamp)
|
||||
=> new(timestamp);
|
||||
}
|
||||
|
||||
private sealed record RunNotFoundPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId);
|
||||
}
|
||||
|
||||
internal sealed class RunStreamOptions
|
||||
{
|
||||
private static readonly TimeSpan MinimumInterval = TimeSpan.FromMilliseconds(100);
|
||||
private static readonly TimeSpan MinimumReconnectDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
public TimeSpan QueueLagInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public RunStreamOptions Validate()
|
||||
{
|
||||
if (PollInterval < MinimumInterval)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(PollInterval), PollInterval, "Poll interval must be at least 100ms.");
|
||||
}
|
||||
|
||||
if (QueueLagInterval < MinimumInterval)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(QueueLagInterval), QueueLagInterval, "Queue lag interval must be at least 100ms.");
|
||||
}
|
||||
|
||||
if (HeartbeatInterval < MinimumInterval)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(HeartbeatInterval), HeartbeatInterval, "Heartbeat interval must be at least 100ms.");
|
||||
}
|
||||
|
||||
if (ReconnectDelay < MinimumReconnectDelay)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ReconnectDelay), ReconnectDelay, "Reconnect delay must be at least 500ms.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal static class SseWriter
|
||||
{
|
||||
public static async Task WriteRetryAsync(HttpResponse response, TimeSpan reconnectDelay, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
var milliseconds = (int)Math.Clamp(reconnectDelay.TotalMilliseconds, 1, int.MaxValue);
|
||||
await response.WriteAsync($"retry: {milliseconds}\r\n\r\n", cancellationToken).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task WriteEventAsync(HttpResponse response, string eventName, object payload, JsonSerializerOptions serializerOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
ArgumentNullException.ThrowIfNull(serializerOptions);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(eventName))
|
||||
{
|
||||
throw new ArgumentException("Event name must be provided.", nameof(eventName));
|
||||
}
|
||||
|
||||
await response.WriteAsync($"event: {eventName}\r\n", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, serializerOptions);
|
||||
using var reader = new StringReader(json);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
await response.WriteAsync($"data: {line}\r\n", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await response.WriteAsync("\r\n", cancellationToken).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user