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
620 lines
24 KiB
C#
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;
|
|
}
|
|
}
|