feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys.
- Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries.
- Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads.
- Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options.
- Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads.
- Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features.
- Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
master
2025-11-03 10:02:29 +02:00
parent bf2bf4b395
commit b1e78fe412
215 changed files with 19441 additions and 12185 deletions

View File

@@ -1,173 +1,173 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public static class EventWebhookEndpointExtensions
{
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/events");
group.MapPost("/feedser-export", HandleFeedserExportAsync);
group.MapPost("/vexer-export", HandleVexerExportAsync);
}
private static async Task<IResult> HandleFeedserExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Feedser;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<FeedserExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("feedser", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleFeedserAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> HandleVexerExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Vexer;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<VexerExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("vexer", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleVexerAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
{
context.Request.EnableBuffering();
await using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var bodyBytes = buffer.ToArray();
context.Request.Body.Position = 0;
try
{
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (payload is null)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
}
return RequestPayload<T>.Success(payload, bodyBytes);
}
catch (JsonException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
catch (ValidationException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
}
private readonly struct RequestPayload<T>
{
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
{
Payload = payload;
RawBody = rawBody;
ErrorResult = error;
Succeeded = succeeded;
}
public T? Payload { get; }
public byte[] RawBody { get; }
public IResult? ErrorResult { get; }
public bool Succeeded { get; }
public static RequestPayload<T> Success(T payload, byte[] rawBody)
=> new(payload, rawBody, null, true);
public static RequestPayload<T> Failed(IResult error)
=> new(default, Array.Empty<byte>(), error, false);
}
}
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public static class EventWebhookEndpointExtensions
{
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/events");
group.MapPost("/conselier-export", HandleConselierExportAsync);
group.MapPost("/excitor-export", HandleExcitorExportAsync);
}
private static async Task<IResult> HandleConselierExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Conselier;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<ConselierExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("conselier", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleConselierAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> HandleExcitorExportAsync(
HttpContext httpContext,
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
[FromServices] IWebhookRequestAuthenticator authenticator,
[FromServices] IWebhookRateLimiter rateLimiter,
[FromServices] IInboundExportEventSink sink,
CancellationToken cancellationToken)
{
var webhookOptions = options.CurrentValue.Webhooks.Excitor;
if (!webhookOptions.Enabled)
{
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
var readResult = await ReadPayloadAsync<ExcitorExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
if (!readResult.Succeeded)
{
return readResult.ErrorResult!;
}
if (!rateLimiter.TryAcquire("excitor", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
{
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
if (retryAfter > TimeSpan.Zero)
{
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
return response;
}
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
if (!authResult.Succeeded)
{
return authResult.ToResult();
}
try
{
await sink.HandleExcitorAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
return Results.Accepted(value: new { status = "accepted" });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
{
context.Request.EnableBuffering();
await using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
var bodyBytes = buffer.ToArray();
context.Request.Body.Position = 0;
try
{
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (payload is null)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
}
return RequestPayload<T>.Success(payload, bodyBytes);
}
catch (JsonException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
catch (ValidationException ex)
{
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
}
}
private readonly struct RequestPayload<T>
{
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
{
Payload = payload;
RawBody = rawBody;
ErrorResult = error;
Succeeded = succeeded;
}
public T? Payload { get; }
public byte[] RawBody { get; }
public IResult? ErrorResult { get; }
public bool Succeeded { get; }
public static RequestPayload<T> Success(T payload, byte[] rawBody)
=> new(payload, rawBody, null, true);
public static RequestPayload<T> Failed(IResult error)
=> new(default, Array.Empty<byte>(), error, false);
}
}

View File

@@ -1,11 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IInboundExportEventSink
{
Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken);
Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IInboundExportEventSink
{
Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken);
Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken);
}

View File

@@ -1,33 +1,33 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
internal sealed class LoggingExportEventSink : IInboundExportEventSink
{
private readonly ILogger<LoggingExportEventSink> _logger;
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
{
_logger = logger;
}
public Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Feedser export webhook {ExportId} with {ChangedProducts} product keys.",
request.ExportId,
request.ChangedProductKeys.Count);
return Task.CompletedTask;
}
public Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Vexer export webhook {ExportId} with {ChangedClaims} claim changes.",
request.ExportId,
request.ChangedClaims.Count);
return Task.CompletedTask;
}
}
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
internal sealed class LoggingExportEventSink : IInboundExportEventSink
{
private readonly ILogger<LoggingExportEventSink> _logger;
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
{
_logger = logger;
}
public Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Conselier export webhook {ExportId} with {ChangedProducts} product keys.",
request.ExportId,
request.ChangedProductKeys.Count);
return Task.CompletedTask;
}
public Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received Excitor export webhook {ExportId} with {ChangedClaims} claim changes.",
request.ExportId,
request.ChangedClaims.Count);
return Task.CompletedTask;
}
}

View File

@@ -1,106 +1,106 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public sealed record FeedserExportEventRequest(
string ExportId,
IReadOnlyList<string> ChangedProductKeys,
IReadOnlyList<string>? Kev,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
{
if (source is null)
{
if (allowEmpty)
{
return ImmutableArray<string>.Empty;
}
throw new ValidationException($"{propertyName} must be specified.");
}
var cleaned = source
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (!allowEmpty && cleaned.Length == 0)
{
throw new ValidationException($"{propertyName} must contain at least one value.");
}
return cleaned;
}
}
public sealed record VexerExportEventRequest(
string ExportId,
IReadOnlyList<VexerClaimChange> ChangedClaims,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<VexerClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<VexerClaimChange> NormalizeClaims(IReadOnlyList<VexerClaimChange>? claims)
{
if (claims is null || claims.Count == 0)
{
throw new ValidationException("changedClaims must contain at least one entry.");
}
foreach (var claim in claims)
{
claim.Validate();
}
return claims;
}
}
public sealed record VexerClaimChange(
string ProductKey,
string VulnerabilityId,
string Status)
{
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
public string Status { get; } = Normalize(Status, nameof(Status));
internal void Validate()
{
_ = ProductKey;
_ = VulnerabilityId;
_ = Status;
}
private static string Normalize(string value, string propertyName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ValidationException($"{propertyName} must be provided.");
}
return value.Trim();
}
}
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public sealed record ConselierExportEventRequest(
string ExportId,
IReadOnlyList<string> ChangedProductKeys,
IReadOnlyList<string>? Kev,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
{
if (source is null)
{
if (allowEmpty)
{
return ImmutableArray<string>.Empty;
}
throw new ValidationException($"{propertyName} must be specified.");
}
var cleaned = source
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (!allowEmpty && cleaned.Length == 0)
{
throw new ValidationException($"{propertyName} must contain at least one value.");
}
return cleaned;
}
}
public sealed record ExcitorExportEventRequest(
string ExportId,
IReadOnlyList<ExcitorClaimChange> ChangedClaims,
WebhookEventWindow? Window)
{
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
public IReadOnlyList<ExcitorClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
public WebhookEventWindow? Window { get; } = Window;
private static IReadOnlyList<ExcitorClaimChange> NormalizeClaims(IReadOnlyList<ExcitorClaimChange>? claims)
{
if (claims is null || claims.Count == 0)
{
throw new ValidationException("changedClaims must contain at least one entry.");
}
foreach (var claim in claims)
{
claim.Validate();
}
return claims;
}
}
public sealed record ExcitorClaimChange(
string ProductKey,
string VulnerabilityId,
string Status)
{
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
public string Status { get; } = Normalize(Status, nameof(Status));
internal void Validate()
{
_ = ProductKey;
_ = VulnerabilityId;
_ = Status;
}
private static string Normalize(string value, string propertyName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ValidationException($"{propertyName} must be provided.");
}
return value.Trim();
}
}
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);

View File

@@ -1,18 +1,18 @@
namespace StellaOps.Scheduler.WebService.Options;
/// <summary>
/// Scheduler WebService event options (outbound + inbound).
/// </summary>
namespace StellaOps.Scheduler.WebService.Options;
/// <summary>
/// Scheduler WebService event options (outbound + inbound).
/// </summary>
using System;
using System.Collections.Generic;
public sealed class SchedulerEventsOptions
{
public GraphJobEventsOptions GraphJobs { get; set; } = new();
public SchedulerInboundWebhooksOptions Webhooks { get; set; } = new();
}
public sealed class SchedulerEventsOptions
{
public GraphJobEventsOptions GraphJobs { get; set; } = new();
public SchedulerInboundWebhooksOptions Webhooks { get; set; } = new();
}
public sealed class GraphJobEventsOptions
{
/// <summary>
@@ -50,91 +50,91 @@ public sealed class GraphJobEventsOptions
/// </summary>
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
public sealed class SchedulerInboundWebhooksOptions
{
public SchedulerWebhookOptions Feedser { get; set; } = SchedulerWebhookOptions.CreateDefault("feedser");
public SchedulerWebhookOptions Vexer { get; set; } = SchedulerWebhookOptions.CreateDefault("vexer");
}
public sealed class SchedulerWebhookOptions
{
private const string DefaultSignatureHeader = "X-Scheduler-Signature";
public SchedulerWebhookOptions()
{
SignatureHeader = DefaultSignatureHeader;
}
public bool Enabled { get; set; } = true;
/// <summary>
/// Require a client certificate to be presented (mTLS). Optional when HMAC is configured.
/// </summary>
public bool RequireClientCertificate { get; set; }
/// <summary>
/// Shared secret (Base64 or raw text) for HMAC-SHA256 signatures. Required if <see cref="RequireClientCertificate"/> is false.
/// </summary>
public string? HmacSecret { get; set; }
/// <summary>
/// Header name carrying the webhook signature (defaults to <c>X-Scheduler-Signature</c>).
/// </summary>
public string SignatureHeader { get; set; }
/// <summary>
/// Maximum number of accepted requests per sliding window.
/// </summary>
public int RateLimitRequests { get; set; } = 60;
/// <summary>
/// Sliding window duration in seconds for the rate limiter.
/// </summary>
public int RateLimitWindowSeconds { get; set; } = 60;
/// <summary>
/// Optional label used for logging/diagnostics; populated via <see cref="CreateDefault"/>.
/// </summary>
public string Name { get; set; } = string.Empty;
public static SchedulerWebhookOptions CreateDefault(string name)
=> new()
{
Name = name,
SignatureHeader = DefaultSignatureHeader,
RateLimitRequests = 120,
RateLimitWindowSeconds = 60
};
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(SignatureHeader))
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must specify a signature header when enabled.");
}
if (!RequireClientCertificate && string.IsNullOrWhiteSpace(HmacSecret))
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure either HMAC secret or mTLS enforcement.");
}
if (RateLimitRequests <= 0)
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a positive rate limit.");
}
if (RateLimitWindowSeconds <= 0)
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a rate limit window greater than zero seconds.");
}
}
public TimeSpan GetRateLimitWindow() => TimeSpan.FromSeconds(RateLimitWindowSeconds <= 0 ? 60 : RateLimitWindowSeconds);
}
public sealed class SchedulerInboundWebhooksOptions
{
public SchedulerWebhookOptions Conselier { get; set; } = SchedulerWebhookOptions.CreateDefault("conselier");
public SchedulerWebhookOptions Excitor { get; set; } = SchedulerWebhookOptions.CreateDefault("excitor");
}
public sealed class SchedulerWebhookOptions
{
private const string DefaultSignatureHeader = "X-Scheduler-Signature";
public SchedulerWebhookOptions()
{
SignatureHeader = DefaultSignatureHeader;
}
public bool Enabled { get; set; } = true;
/// <summary>
/// Require a client certificate to be presented (mTLS). Optional when HMAC is configured.
/// </summary>
public bool RequireClientCertificate { get; set; }
/// <summary>
/// Shared secret (Base64 or raw text) for HMAC-SHA256 signatures. Required if <see cref="RequireClientCertificate"/> is false.
/// </summary>
public string? HmacSecret { get; set; }
/// <summary>
/// Header name carrying the webhook signature (defaults to <c>X-Scheduler-Signature</c>).
/// </summary>
public string SignatureHeader { get; set; }
/// <summary>
/// Maximum number of accepted requests per sliding window.
/// </summary>
public int RateLimitRequests { get; set; } = 60;
/// <summary>
/// Sliding window duration in seconds for the rate limiter.
/// </summary>
public int RateLimitWindowSeconds { get; set; } = 60;
/// <summary>
/// Optional label used for logging/diagnostics; populated via <see cref="CreateDefault"/>.
/// </summary>
public string Name { get; set; } = string.Empty;
public static SchedulerWebhookOptions CreateDefault(string name)
=> new()
{
Name = name,
SignatureHeader = DefaultSignatureHeader,
RateLimitRequests = 120,
RateLimitWindowSeconds = 60
};
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(SignatureHeader))
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must specify a signature header when enabled.");
}
if (!RequireClientCertificate && string.IsNullOrWhiteSpace(HmacSecret))
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure either HMAC secret or mTLS enforcement.");
}
if (RateLimitRequests <= 0)
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a positive rate limit.");
}
if (RateLimitWindowSeconds <= 0)
{
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a rate limit window greater than zero seconds.");
}
}
public TimeSpan GetRateLimitWindow() => TimeSpan.FromSeconds(RateLimitWindowSeconds <= 0 ? 60 : RateLimitWindowSeconds);
}

View File

@@ -1,203 +1,203 @@
using System.Linq;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Hosting;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Storage.Mongo;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.WebService;
using StellaOps.Scheduler.WebService.Auth;
using StellaOps.Scheduler.WebService.EventWebhooks;
using StellaOps.Scheduler.WebService.GraphJobs;
using StellaOps.Scheduler.WebService.GraphJobs.Events;
using StellaOps.Scheduler.WebService.Schedules;
using StellaOps.Scheduler.WebService.Options;
using StellaOps.Scheduler.WebService.Runs;
using StellaOps.Scheduler.WebService.PolicyRuns;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddSingleton<StellaOps.Scheduler.WebService.ISystemClock, StellaOps.Scheduler.WebService.SystemClock>();
builder.Services.TryAddSingleton(TimeProvider.System);
var authorityOptions = new SchedulerAuthorityOptions();
builder.Configuration.GetSection("Scheduler:Authority").Bind(authorityOptions);
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphRead, StringComparison.OrdinalIgnoreCase)))
{
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphRead);
}
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphWrite, StringComparison.OrdinalIgnoreCase)))
{
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
}
if (authorityOptions.Audiences.Count == 0)
{
authorityOptions.Audiences.Add("api://scheduler");
}
authorityOptions.Validate();
builder.Services.AddSingleton(authorityOptions);
using System.Linq;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Hosting;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Storage.Mongo;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.WebService;
using StellaOps.Scheduler.WebService.Auth;
using StellaOps.Scheduler.WebService.EventWebhooks;
using StellaOps.Scheduler.WebService.GraphJobs;
using StellaOps.Scheduler.WebService.GraphJobs.Events;
using StellaOps.Scheduler.WebService.Schedules;
using StellaOps.Scheduler.WebService.Options;
using StellaOps.Scheduler.WebService.Runs;
using StellaOps.Scheduler.WebService.PolicyRuns;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddSingleton<StellaOps.Scheduler.WebService.ISystemClock, StellaOps.Scheduler.WebService.SystemClock>();
builder.Services.TryAddSingleton(TimeProvider.System);
var authorityOptions = new SchedulerAuthorityOptions();
builder.Configuration.GetSection("Scheduler:Authority").Bind(authorityOptions);
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphRead, StringComparison.OrdinalIgnoreCase)))
{
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphRead);
}
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphWrite, StringComparison.OrdinalIgnoreCase)))
{
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
}
if (authorityOptions.Audiences.Count == 0)
{
authorityOptions.Audiences.Add("api://scheduler");
}
authorityOptions.Validate();
builder.Services.AddSingleton(authorityOptions);
builder.Services.AddOptions<SchedulerEventsOptions>()
.Bind(builder.Configuration.GetSection("Scheduler:Events"))
.PostConfigure(options =>
{
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
options.Webhooks.Feedser ??= SchedulerWebhookOptions.CreateDefault("feedser");
options.Webhooks.Vexer ??= SchedulerWebhookOptions.CreateDefault("vexer");
options.Webhooks.Feedser.Name = string.IsNullOrWhiteSpace(options.Webhooks.Feedser.Name)
? "feedser"
: options.Webhooks.Feedser.Name;
options.Webhooks.Vexer.Name = string.IsNullOrWhiteSpace(options.Webhooks.Vexer.Name)
? "vexer"
: options.Webhooks.Vexer.Name;
options.Webhooks.Feedser.Validate();
options.Webhooks.Vexer.Validate();
});
.PostConfigure(options =>
{
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
options.Webhooks.Conselier ??= SchedulerWebhookOptions.CreateDefault("conselier");
options.Webhooks.Excitor ??= SchedulerWebhookOptions.CreateDefault("excitor");
options.Webhooks.Conselier.Name = string.IsNullOrWhiteSpace(options.Webhooks.Conselier.Name)
? "conselier"
: options.Webhooks.Conselier.Name;
options.Webhooks.Excitor.Name = string.IsNullOrWhiteSpace(options.Webhooks.Excitor.Name)
? "excitor"
: options.Webhooks.Excitor.Name;
options.Webhooks.Conselier.Validate();
options.Webhooks.Excitor.Validate();
});
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IWebhookRateLimiter, InMemoryWebhookRateLimiter>();
builder.Services.AddSingleton<IWebhookRequestAuthenticator, WebhookRequestAuthenticator>();
builder.Services.AddSingleton<IInboundExportEventSink, LoggingExportEventSink>();
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
var cartographerOptions = builder.Configuration.GetSection("Scheduler:Cartographer").Get<SchedulerCartographerOptions>() ?? new SchedulerCartographerOptions();
builder.Services.AddSingleton(cartographerOptions);
builder.Services.AddOptions<SchedulerCartographerOptions>()
.Bind(builder.Configuration.GetSection("Scheduler:Cartographer"));
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
if (storageSection.Exists())
{
builder.Services.AddSchedulerMongoStorage(storageSection);
builder.Services.AddSingleton<IGraphJobStore, MongoGraphJobStore>();
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
}
else
{
builder.Services.AddSingleton<IGraphJobStore, InMemoryGraphJobStore>();
builder.Services.AddSingleton<IScheduleRepository, InMemoryScheduleRepository>();
builder.Services.AddSingleton<IRunRepository, InMemoryRunRepository>();
builder.Services.AddSingleton<IRunSummaryService, InMemoryRunSummaryService>();
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
}
builder.Services.AddSingleton<IGraphJobCompletionPublisher, GraphJobEventPublisher>();
if (cartographerOptions.Webhook.Enabled)
{
builder.Services.AddHttpClient<ICartographerWebhookClient, CartographerWebhookClient>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerCartographerOptions>>().CurrentValue;
client.Timeout = TimeSpan.FromSeconds(options.Webhook.TimeoutSeconds <= 0 ? 10 : options.Webhook.TimeoutSeconds);
});
}
else
{
builder.Services.AddSingleton<ICartographerWebhookClient, NullCartographerWebhookClient>();
}
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
builder.Services.AddImpactIndexStub();
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
schedulerOptions.Validate();
builder.Services.AddSingleton(schedulerOptions);
builder.Services.AddOptions<SchedulerOptions>()
.Bind(builder.Configuration.GetSection("Scheduler"))
.PostConfigure(options => options.Validate());
var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugins, builder.Environment.ContentRootPath);
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
if (authorityOptions.Enabled)
{
builder.Services.AddHttpContextAccessor();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = authorityOptions.Issuer;
resourceOptions.RequireHttpsMetadata = authorityOptions.RequireHttpsMetadata;
resourceOptions.MetadataAddress = authorityOptions.MetadataAddress;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authorityOptions.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authorityOptions.TokenClockSkewSeconds);
foreach (var audience in authorityOptions.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
foreach (var scope in authorityOptions.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
foreach (var tenant in authorityOptions.RequiredTenants)
{
resourceOptions.RequiredTenants.Add(tenant);
}
foreach (var network in authorityOptions.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
builder.Services.AddAuthorization();
builder.Services.AddScoped<ITenantContextAccessor, ClaimsTenantContextAccessor>();
builder.Services.AddScoped<IScopeAuthorizer, TokenScopeAuthorizer>();
}
else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Anonymous";
options.DefaultChallengeScheme = "Anonymous";
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
builder.Services.AddAuthorization();
builder.Services.AddScoped<ITenantContextAccessor, HeaderTenantContextAccessor>();
builder.Services.AddScoped<IScopeAuthorizer, HeaderScopeAuthorizer>();
}
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
if (!authorityOptions.Enabled)
{
app.Logger.LogWarning("Scheduler Authority authentication is disabled; relying on header-based development fallback.");
}
else if (authorityOptions.AllowAnonymousFallback)
{
app.Logger.LogWarning("Scheduler Authority authentication is enabled but anonymous fallback remains allowed. Disable fallback before production rollout.");
}
app.MapGet("/healthz", () => Results.Json(new { status = "ok" }));
app.MapGet("/readyz", () => Results.Json(new { status = "ready" }));
app.MapGraphJobEndpoints();
app.MapScheduleEndpoints();
app.MapRunEndpoints();
app.MapPolicyRunEndpoints();
app.MapSchedulerEventWebhookEndpoints();
app.Run();
public partial class Program;
var cartographerOptions = builder.Configuration.GetSection("Scheduler:Cartographer").Get<SchedulerCartographerOptions>() ?? new SchedulerCartographerOptions();
builder.Services.AddSingleton(cartographerOptions);
builder.Services.AddOptions<SchedulerCartographerOptions>()
.Bind(builder.Configuration.GetSection("Scheduler:Cartographer"));
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
if (storageSection.Exists())
{
builder.Services.AddSchedulerMongoStorage(storageSection);
builder.Services.AddSingleton<IGraphJobStore, MongoGraphJobStore>();
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
}
else
{
builder.Services.AddSingleton<IGraphJobStore, InMemoryGraphJobStore>();
builder.Services.AddSingleton<IScheduleRepository, InMemoryScheduleRepository>();
builder.Services.AddSingleton<IRunRepository, InMemoryRunRepository>();
builder.Services.AddSingleton<IRunSummaryService, InMemoryRunSummaryService>();
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
}
builder.Services.AddSingleton<IGraphJobCompletionPublisher, GraphJobEventPublisher>();
if (cartographerOptions.Webhook.Enabled)
{
builder.Services.AddHttpClient<ICartographerWebhookClient, CartographerWebhookClient>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerCartographerOptions>>().CurrentValue;
client.Timeout = TimeSpan.FromSeconds(options.Webhook.TimeoutSeconds <= 0 ? 10 : options.Webhook.TimeoutSeconds);
});
}
else
{
builder.Services.AddSingleton<ICartographerWebhookClient, NullCartographerWebhookClient>();
}
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
builder.Services.AddImpactIndexStub();
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
schedulerOptions.Validate();
builder.Services.AddSingleton(schedulerOptions);
builder.Services.AddOptions<SchedulerOptions>()
.Bind(builder.Configuration.GetSection("Scheduler"))
.PostConfigure(options => options.Validate());
var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugins, builder.Environment.ContentRootPath);
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
if (authorityOptions.Enabled)
{
builder.Services.AddHttpContextAccessor();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = authorityOptions.Issuer;
resourceOptions.RequireHttpsMetadata = authorityOptions.RequireHttpsMetadata;
resourceOptions.MetadataAddress = authorityOptions.MetadataAddress;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authorityOptions.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authorityOptions.TokenClockSkewSeconds);
foreach (var audience in authorityOptions.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
foreach (var scope in authorityOptions.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
foreach (var tenant in authorityOptions.RequiredTenants)
{
resourceOptions.RequiredTenants.Add(tenant);
}
foreach (var network in authorityOptions.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
builder.Services.AddAuthorization();
builder.Services.AddScoped<ITenantContextAccessor, ClaimsTenantContextAccessor>();
builder.Services.AddScoped<IScopeAuthorizer, TokenScopeAuthorizer>();
}
else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Anonymous";
options.DefaultChallengeScheme = "Anonymous";
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
builder.Services.AddAuthorization();
builder.Services.AddScoped<ITenantContextAccessor, HeaderTenantContextAccessor>();
builder.Services.AddScoped<IScopeAuthorizer, HeaderScopeAuthorizer>();
}
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
if (!authorityOptions.Enabled)
{
app.Logger.LogWarning("Scheduler Authority authentication is disabled; relying on header-based development fallback.");
}
else if (authorityOptions.AllowAnonymousFallback)
{
app.Logger.LogWarning("Scheduler Authority authentication is enabled but anonymous fallback remains allowed. Disable fallback before production rollout.");
}
app.MapGet("/healthz", () => Results.Json(new { status = "ok" }));
app.MapGet("/readyz", () => Results.Json(new { status = "ready" }));
app.MapGraphJobEndpoints();
app.MapScheduleEndpoints();
app.MapRunEndpoints();
app.MapPolicyRunEndpoints();
app.MapSchedulerEventWebhookEndpoints();
app.Run();
public partial class Program;

View File

@@ -5,7 +5,7 @@
| SCHED-WEB-16-101 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-MODELS-16-101 | Bootstrap Minimal API host with Authority OpTok + DPoP, health endpoints, plug-in discovery per architecture §§12. | Service boots with config validation; `/healthz`/`/readyz` pass; restart-only plug-ins enforced. |
| SCHED-WEB-16-102 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-101 | Implement schedules CRUD (tenant-scoped) with cron validation, pause/resume, audit logging. | CRUD operations tested; invalid cron inputs rejected; audit entries persisted. |
| SCHED-WEB-16-103 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-102 | Runs API (list/detail/cancel), ad-hoc run POST, and impact preview endpoints. | Integration tests cover run lifecycle; preview returns counts/sample; cancellation honoured. |
| SCHED-WEB-16-104 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feeder/Vexer exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
| SCHED-WEB-16-104 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feeder/Excitor exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
| SCHED-WEB-20-001 | DONE (2025-10-29) | Scheduler WebService Guild, Policy Guild | SCHED-WEB-16-101, POLICY-ENGINE-20-000 | Expose policy run scheduling APIs (`POST /policy/runs`, `GET /policy/runs`) with tenant scoping and RBAC enforcement for `policy:run`. | Endpoints documented; integration tests cover run creation/status; unauthorized access blocked. |
| SCHED-WEB-21-001 | DONE (2025-10-26) | Scheduler WebService Guild, Cartographer Guild | SCHED-WEB-16-101, SCHED-MODELS-21-001 | Expose graph build/overlay job APIs (`POST /graphs/build`, `GET /graphs/jobs`) with `graph:write`/`graph:read` enforcement and tenant scoping. | APIs documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover submission/status; unauthorized requests blocked; scope checks now reference `StellaOpsScopes`. |
| SCHED-WEB-21-002 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-001, CARTO-GRAPH-21-007 | Provide overlay lag metrics endpoint and webhook to notify Cartographer of job completions; include correlation IDs. | `POST /graphs/hooks/completed` + `GET /graphs/overlays/lag` documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover completion + metrics. |

View File

@@ -1,58 +1,58 @@
# SCHED-WEB-16-104 · Feedser/Vexer Webhook Endpoints
## Overview
Scheduler.WebService exposes inbound webhooks that allow Feedser and Vexer to
notify the planner when new exports are available. Each webhook validates the
payload, enforces signature requirements, and applies a per-endpoint rate
limit before queuing downstream processing.
| Endpoint | Description | AuthZ |
|----------|-------------|-------|
| `POST /events/feedser-export` | Ingest Feedser export metadata (`exportId`, `changedProductKeys`, optional KEV & window). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
| `POST /events/vexer-export` | Ingest Vexer export delta summary (`changedClaims`). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
## Security
* Webhooks require either:
* mTLS with trusted client certificates; **or**
* an HMAC-SHA256 signature in the `X-Scheduler-Signature` header. The
signature must be computed as `sha256=<hex>` over the raw request body.
* Requests without the required signature/certificate return `401`.
* Secrets are configured under `Scheduler:Events:Webhooks:{Feedser|Vexer}:HmacSecret`.
## Rate limiting
* Each webhook enforces a sliding-window limit (`RateLimitRequests` over
`RateLimitWindowSeconds`).
* Requests over the limit return `429` and include a `Retry-After` header.
* Defaults: 120 requests / 60 seconds. Adjust via configuration.
## Configuration
```
Scheduler:
Events:
Webhooks:
Feedser:
Enabled: true
HmacSecret: feedser-secret
RequireClientCertificate: false
RateLimitRequests: 120
RateLimitWindowSeconds: 60
Vexer:
Enabled: true
HmacSecret: vexer-secret
RequireClientCertificate: false
```
## Response envelope
On success the webhook returns `202 Accepted` and a JSON body:
```
{ "status": "accepted" }
```
Failures return problem JSON with `error` describing the violation.
# SCHED-WEB-16-104 · Conselier/Excitor Webhook Endpoints
## Overview
Scheduler.WebService exposes inbound webhooks that allow Conselier and Excitor to
notify the planner when new exports are available. Each webhook validates the
payload, enforces signature requirements, and applies a per-endpoint rate
limit before queuing downstream processing.
| Endpoint | Description | AuthZ |
|----------|-------------|-------|
| `POST /events/conselier-export` | Ingest Conselier export metadata (`exportId`, `changedProductKeys`, optional KEV & window). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
| `POST /events/excitor-export` | Ingest Excitor export delta summary (`changedClaims`). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
## Security
* Webhooks require either:
* mTLS with trusted client certificates; **or**
* an HMAC-SHA256 signature in the `X-Scheduler-Signature` header. The
signature must be computed as `sha256=<hex>` over the raw request body.
* Requests without the required signature/certificate return `401`.
* Secrets are configured under `Scheduler:Events:Webhooks:{Conselier|Excitor}:HmacSecret`.
## Rate limiting
* Each webhook enforces a sliding-window limit (`RateLimitRequests` over
`RateLimitWindowSeconds`).
* Requests over the limit return `429` and include a `Retry-After` header.
* Defaults: 120 requests / 60 seconds. Adjust via configuration.
## Configuration
```
Scheduler:
Events:
Webhooks:
Conselier:
Enabled: true
HmacSecret: conselier-secret
RequireClientCertificate: false
RateLimitRequests: 120
RateLimitWindowSeconds: 60
Excitor:
Enabled: true
HmacSecret: excitor-secret
RequireClientCertificate: false
```
## Response envelope
On success the webhook returns `202 Accepted` and a JSON body:
```
{ "status": "accepted" }
```
Failures return problem JSON with `error` describing the violation.

View File

@@ -1,168 +1,168 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Deterministic serializer for scheduler DTOs.
/// </summary>
public static class CanonicalJsonSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
{
[typeof(Schedule)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy",
},
[typeof(Selector)] = new[]
{
"scope",
"tenantId",
"namespaces",
"repositories",
"digests",
"includeTags",
"labels",
"resolvesTags",
},
[typeof(LabelSelector)] = new[]
{
"key",
"values",
},
[typeof(ScheduleOnlyIf)] = new[]
{
"lastReportOlderThanDays",
"policyRevision",
},
[typeof(ScheduleNotify)] = new[]
{
"onNewFindings",
"minSeverity",
"includeKev",
"includeQuietFindings",
},
[typeof(ScheduleLimits)] = new[]
{
"maxJobs",
"ratePerSecond",
"parallelism",
"burst",
},
[typeof(Run)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas",
},
[typeof(RunStats)] = new[]
{
"candidates",
"deduped",
"queued",
"completed",
"deltas",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Deterministic serializer for scheduler DTOs.
/// </summary>
public static class CanonicalJsonSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
{
[typeof(Schedule)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy",
},
[typeof(Selector)] = new[]
{
"scope",
"tenantId",
"namespaces",
"repositories",
"digests",
"includeTags",
"labels",
"resolvesTags",
},
[typeof(LabelSelector)] = new[]
{
"key",
"values",
},
[typeof(ScheduleOnlyIf)] = new[]
{
"lastReportOlderThanDays",
"policyRevision",
},
[typeof(ScheduleNotify)] = new[]
{
"onNewFindings",
"minSeverity",
"includeKev",
"includeQuietFindings",
},
[typeof(ScheduleLimits)] = new[]
{
"maxJobs",
"ratePerSecond",
"parallelism",
"burst",
},
[typeof(Run)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas",
},
[typeof(RunStats)] = new[]
{
"candidates",
"deduped",
"queued",
"completed",
"deltas",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
},
[typeof(RunReason)] = new[]
{
"manualReason",
"feedserExportId",
"vexerExportId",
"cursor",
"impactWindowFrom",
"impactWindowTo",
},
[typeof(DeltaSummary)] = new[]
{
"imageDigest",
"newFindings",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
"kevHits",
"topFindings",
"reportUrl",
"attestation",
"detectedAt",
},
[typeof(DeltaFinding)] = new[]
{
"purl",
"vulnerabilityId",
"severity",
"link",
},
[typeof(ImpactSet)] = new[]
{
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"conselierExportId",
"excitorExportId",
"cursor",
"impactWindowFrom",
"impactWindowTo",
},
[typeof(DeltaSummary)] = new[]
{
"imageDigest",
"newFindings",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
"kevHits",
"topFindings",
"reportUrl",
"attestation",
"detectedAt",
},
[typeof(DeltaFinding)] = new[]
{
"purl",
"vulnerabilityId",
"severity",
"link",
},
[typeof(ImpactSet)] = new[]
{
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"snapshotId",
},
[typeof(ImpactImage)] = new[]
{
"imageDigest",
"registry",
"repository",
"namespaces",
"tags",
"usedByEntrypoint",
"labels",
},
[typeof(AuditRecord)] = new[]
{
"id",
"tenantId",
"category",
"action",
"occurredAt",
"actor",
"entityId",
"scheduleId",
"runId",
"correlationId",
"metadata",
"message",
},
"registry",
"repository",
"namespaces",
"tags",
"usedByEntrypoint",
"labels",
},
[typeof(AuditRecord)] = new[]
{
"id",
"tenantId",
"category",
"action",
"occurredAt",
"actor",
"entityId",
"scheduleId",
"runId",
"correlationId",
"metadata",
"message",
},
[typeof(AuditActor)] = new[]
{
"actorId",
@@ -378,32 +378,32 @@ public static class CanonicalJsonSerializer
"note",
},
};
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(T value)
=> JsonSerializer.Serialize(value, PrettyOptions);
public static T Deserialize<T>(string json)
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
?? throw new InvalidOperationException($"Unable to deserialize {typeof(T).Name}.");
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = writeIndented,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
var resolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicResolver(resolver);
options.Converters.Add(new ScheduleModeConverter());
options.Converters.Add(new SelectorScopeConverter());
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(T value)
=> JsonSerializer.Serialize(value, PrettyOptions);
public static T Deserialize<T>(string json)
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
?? throw new InvalidOperationException($"Unable to deserialize {typeof(T).Name}.");
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = writeIndented,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
var resolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicResolver(resolver);
options.Converters.Add(new ScheduleModeConverter());
options.Converters.Add(new SelectorScopeConverter());
options.Converters.Add(new RunTriggerConverter());
options.Converters.Add(new RunStateConverter());
options.Converters.Add(new SeverityRankConverter());
@@ -418,53 +418,53 @@ public static class CanonicalJsonSerializer
options.Converters.Add(new PolicyRunJobStatusConverter());
return options;
}
private sealed class DeterministicResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver _inner;
public DeterministicResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options);
if (info is null)
{
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
}
if (info.Kind is JsonTypeInfoKind.Object && info.Properties.Count > 1)
{
var ordered = info.Properties
.OrderBy(property => ResolveOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
private static int ResolveOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order))
{
var index = Array.IndexOf(order, propertyName);
if (index >= 0)
{
return index;
}
}
return int.MaxValue;
}
}
}
private sealed class DeterministicResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver _inner;
public DeterministicResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options);
if (info is null)
{
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
}
if (info.Kind is JsonTypeInfoKind.Object && info.Properties.Count > 1)
{
var ordered = info.Properties
.OrderBy(property => ResolveOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
private static int ResolveOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order))
{
var index = Array.IndexOf(order, propertyName);
if (index >= 0)
{
return index;
}
}
return int.MaxValue;
}
}
}

View File

@@ -1,66 +1,66 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Execution mode for a schedule.
/// </summary>
[JsonConverter(typeof(ScheduleModeConverter))]
public enum ScheduleMode
{
AnalysisOnly,
ContentRefresh,
}
/// <summary>
/// Selector scope determining which filters are applied.
/// </summary>
[JsonConverter(typeof(SelectorScopeConverter))]
public enum SelectorScope
{
AllImages,
ByNamespace,
ByRepository,
ByDigest,
ByLabels,
}
/// <summary>
/// Source that triggered a run.
/// </summary>
[JsonConverter(typeof(RunTriggerConverter))]
public enum RunTrigger
{
Cron,
Feedser,
Vexer,
Manual,
}
/// <summary>
/// Lifecycle state of a scheduler run.
/// </summary>
[JsonConverter(typeof(RunStateConverter))]
public enum RunState
{
Planning,
Queued,
Running,
Completed,
Error,
Cancelled,
}
/// <summary>
/// Severity rankings used in scheduler payloads.
/// </summary>
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Execution mode for a schedule.
/// </summary>
[JsonConverter(typeof(ScheduleModeConverter))]
public enum ScheduleMode
{
AnalysisOnly,
ContentRefresh,
}
/// <summary>
/// Selector scope determining which filters are applied.
/// </summary>
[JsonConverter(typeof(SelectorScopeConverter))]
public enum SelectorScope
{
AllImages,
ByNamespace,
ByRepository,
ByDigest,
ByLabels,
}
/// <summary>
/// Source that triggered a run.
/// </summary>
[JsonConverter(typeof(RunTriggerConverter))]
public enum RunTrigger
{
Cron,
Conselier,
Excitor,
Manual,
}
/// <summary>
/// Lifecycle state of a scheduler run.
/// </summary>
[JsonConverter(typeof(RunStateConverter))]
public enum RunState
{
Planning,
Queued,
Running,
Completed,
Error,
Cancelled,
}
/// <summary>
/// Severity rankings used in scheduler payloads.
/// </summary>
[JsonConverter(typeof(SeverityRankConverter))]
public enum SeverityRank
{
None = 0,
Info = 1,
Low = 2,
Medium = 3,
Low = 2,
Medium = 3,
High = 4,
Critical = 5,
Unknown = 6,

View File

@@ -1,378 +1,378 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Execution record for a scheduler run.
/// </summary>
public sealed record Run
{
public Run(
string id,
string tenantId,
RunTrigger trigger,
RunState state,
RunStats stats,
DateTimeOffset createdAt,
RunReason? reason = null,
string? scheduleId = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? finishedAt = null,
string? error = null,
IEnumerable<DeltaSummary>? deltas = null,
string? schemaVersion = null)
: this(
id,
tenantId,
trigger,
state,
stats,
reason ?? RunReason.Empty,
scheduleId,
Validation.NormalizeTimestamp(createdAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(finishedAt),
Validation.TrimToNull(error),
NormalizeDeltas(deltas),
schemaVersion)
{
}
[JsonConstructor]
public Run(
string id,
string tenantId,
RunTrigger trigger,
RunState state,
RunStats stats,
RunReason reason,
string? scheduleId,
DateTimeOffset createdAt,
DateTimeOffset? startedAt,
DateTimeOffset? finishedAt,
string? error,
ImmutableArray<DeltaSummary> deltas,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
Trigger = trigger;
State = state;
Stats = stats ?? throw new ArgumentNullException(nameof(stats));
Reason = reason ?? RunReason.Empty;
ScheduleId = Validation.TrimToNull(scheduleId);
CreatedAt = Validation.NormalizeTimestamp(createdAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
Error = Validation.TrimToNull(error);
Deltas = deltas.IsDefault
? ImmutableArray<DeltaSummary>.Empty
: deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray();
SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion);
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ScheduleId { get; }
public RunTrigger Trigger { get; }
public RunState State { get; init; }
public RunStats Stats { get; init; }
public RunReason Reason { get; }
public DateTimeOffset CreatedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? FinishedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<DeltaSummary> Deltas { get; } = ImmutableArray<DeltaSummary>.Empty;
private static ImmutableArray<DeltaSummary> NormalizeDeltas(IEnumerable<DeltaSummary>? deltas)
{
if (deltas is null)
{
return ImmutableArray<DeltaSummary>.Empty;
}
return deltas
.Where(static delta => delta is not null)
.Select(static delta => delta!)
.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Context describing why a run executed.
/// </summary>
public sealed record RunReason
{
public static RunReason Empty { get; } = new();
public RunReason(
string? manualReason = null,
string? feedserExportId = null,
string? vexerExportId = null,
string? cursor = null)
{
ManualReason = Validation.TrimToNull(manualReason);
FeedserExportId = Validation.TrimToNull(feedserExportId);
VexerExportId = Validation.TrimToNull(vexerExportId);
Cursor = Validation.TrimToNull(cursor);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ManualReason { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FeedserExportId { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? VexerExportId { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Cursor { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpactWindowFrom { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpactWindowTo { get; init; }
}
/// <summary>
/// Aggregated counters for a scheduler run.
/// </summary>
public sealed record RunStats
{
public static RunStats Empty { get; } = new();
public RunStats(
int candidates = 0,
int deduped = 0,
int queued = 0,
int completed = 0,
int deltas = 0,
int newCriticals = 0,
int newHigh = 0,
int newMedium = 0,
int newLow = 0)
{
Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates));
Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped));
Queued = Validation.EnsureNonNegative(queued, nameof(queued));
Completed = Validation.EnsureNonNegative(completed, nameof(completed));
Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas));
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
}
public int Candidates { get; } = 0;
public int Deduped { get; } = 0;
public int Queued { get; } = 0;
public int Completed { get; } = 0;
public int Deltas { get; } = 0;
public int NewCriticals { get; } = 0;
public int NewHigh { get; } = 0;
public int NewMedium { get; } = 0;
public int NewLow { get; } = 0;
}
/// <summary>
/// Snapshot of delta impact for an image processed in a run.
/// </summary>
public sealed record DeltaSummary
{
public DeltaSummary(
string imageDigest,
int newFindings,
int newCriticals,
int newHigh,
int newMedium,
int newLow,
IEnumerable<string>? kevHits = null,
IEnumerable<DeltaFinding>? topFindings = null,
string? reportUrl = null,
DeltaAttestation? attestation = null,
DateTimeOffset? detectedAt = null)
: this(
imageDigest,
Validation.EnsureNonNegative(newFindings, nameof(newFindings)),
Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)),
Validation.EnsureNonNegative(newHigh, nameof(newHigh)),
Validation.EnsureNonNegative(newMedium, nameof(newMedium)),
Validation.EnsureNonNegative(newLow, nameof(newLow)),
NormalizeKevHits(kevHits),
NormalizeFindings(topFindings),
Validation.TrimToNull(reportUrl),
attestation,
Validation.NormalizeTimestamp(detectedAt))
{
}
[JsonConstructor]
public DeltaSummary(
string imageDigest,
int newFindings,
int newCriticals,
int newHigh,
int newMedium,
int newLow,
ImmutableArray<string> kevHits,
ImmutableArray<DeltaFinding> topFindings,
string? reportUrl,
DeltaAttestation? attestation,
DateTimeOffset? detectedAt)
{
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings));
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
KevHits = kevHits.IsDefault ? ImmutableArray<string>.Empty : kevHits;
TopFindings = topFindings.IsDefault
? ImmutableArray<DeltaFinding>.Empty
: topFindings
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
.ToImmutableArray();
ReportUrl = Validation.TrimToNull(reportUrl);
Attestation = attestation;
DetectedAt = Validation.NormalizeTimestamp(detectedAt);
}
public string ImageDigest { get; }
public int NewFindings { get; }
public int NewCriticals { get; }
public int NewHigh { get; }
public int NewMedium { get; }
public int NewLow { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> KevHits { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<DeltaFinding> TopFindings { get; } = ImmutableArray<DeltaFinding>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReportUrl { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DeltaAttestation? Attestation { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? DetectedAt { get; }
private static ImmutableArray<string> NormalizeKevHits(IEnumerable<string>? kevHits)
=> Validation.NormalizeStringSet(kevHits, nameof(kevHits));
private static ImmutableArray<DeltaFinding> NormalizeFindings(IEnumerable<DeltaFinding>? findings)
{
if (findings is null)
{
return ImmutableArray<DeltaFinding>.Empty;
}
return findings
.Where(static finding => finding is not null)
.Select(static finding => finding!)
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Top finding entry included in delta summaries.
/// </summary>
public sealed record DeltaFinding
{
public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null)
{
Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl));
VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId));
Severity = severity;
Link = Validation.TrimToNull(link);
}
public string Purl { get; }
public string VulnerabilityId { get; }
public SeverityRank Severity { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Link { get; }
}
/// <summary>
/// Rekor/attestation information surfaced with a delta summary.
/// </summary>
public sealed record DeltaAttestation
{
public DeltaAttestation(string? uuid, bool? verified = null)
{
Uuid = Validation.TrimToNull(uuid);
Verified = verified;
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Uuid { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Verified { get; }
}
internal sealed class SeverityRankComparer : IComparer<SeverityRank>
{
public static SeverityRankComparer Instance { get; } = new();
private static readonly Dictionary<SeverityRank, int> Order = new()
{
[SeverityRank.Critical] = 0,
[SeverityRank.High] = 1,
[SeverityRank.Unknown] = 2,
[SeverityRank.Medium] = 3,
[SeverityRank.Low] = 4,
[SeverityRank.Info] = 5,
[SeverityRank.None] = 6,
};
public int Compare(SeverityRank x, SeverityRank y)
=> GetOrder(x).CompareTo(GetOrder(y));
private static int GetOrder(SeverityRank severity)
=> Order.TryGetValue(severity, out var value) ? value : int.MaxValue;
}
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Execution record for a scheduler run.
/// </summary>
public sealed record Run
{
public Run(
string id,
string tenantId,
RunTrigger trigger,
RunState state,
RunStats stats,
DateTimeOffset createdAt,
RunReason? reason = null,
string? scheduleId = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? finishedAt = null,
string? error = null,
IEnumerable<DeltaSummary>? deltas = null,
string? schemaVersion = null)
: this(
id,
tenantId,
trigger,
state,
stats,
reason ?? RunReason.Empty,
scheduleId,
Validation.NormalizeTimestamp(createdAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(finishedAt),
Validation.TrimToNull(error),
NormalizeDeltas(deltas),
schemaVersion)
{
}
[JsonConstructor]
public Run(
string id,
string tenantId,
RunTrigger trigger,
RunState state,
RunStats stats,
RunReason reason,
string? scheduleId,
DateTimeOffset createdAt,
DateTimeOffset? startedAt,
DateTimeOffset? finishedAt,
string? error,
ImmutableArray<DeltaSummary> deltas,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
Trigger = trigger;
State = state;
Stats = stats ?? throw new ArgumentNullException(nameof(stats));
Reason = reason ?? RunReason.Empty;
ScheduleId = Validation.TrimToNull(scheduleId);
CreatedAt = Validation.NormalizeTimestamp(createdAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
Error = Validation.TrimToNull(error);
Deltas = deltas.IsDefault
? ImmutableArray<DeltaSummary>.Empty
: deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray();
SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion);
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ScheduleId { get; }
public RunTrigger Trigger { get; }
public RunState State { get; init; }
public RunStats Stats { get; init; }
public RunReason Reason { get; }
public DateTimeOffset CreatedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? FinishedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<DeltaSummary> Deltas { get; } = ImmutableArray<DeltaSummary>.Empty;
private static ImmutableArray<DeltaSummary> NormalizeDeltas(IEnumerable<DeltaSummary>? deltas)
{
if (deltas is null)
{
return ImmutableArray<DeltaSummary>.Empty;
}
return deltas
.Where(static delta => delta is not null)
.Select(static delta => delta!)
.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Context describing why a run executed.
/// </summary>
public sealed record RunReason
{
public static RunReason Empty { get; } = new();
public RunReason(
string? manualReason = null,
string? conselierExportId = null,
string? excitorExportId = null,
string? cursor = null)
{
ManualReason = Validation.TrimToNull(manualReason);
ConselierExportId = Validation.TrimToNull(conselierExportId);
ExcitorExportId = Validation.TrimToNull(excitorExportId);
Cursor = Validation.TrimToNull(cursor);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ManualReason { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ConselierExportId { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ExcitorExportId { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Cursor { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpactWindowFrom { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpactWindowTo { get; init; }
}
/// <summary>
/// Aggregated counters for a scheduler run.
/// </summary>
public sealed record RunStats
{
public static RunStats Empty { get; } = new();
public RunStats(
int candidates = 0,
int deduped = 0,
int queued = 0,
int completed = 0,
int deltas = 0,
int newCriticals = 0,
int newHigh = 0,
int newMedium = 0,
int newLow = 0)
{
Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates));
Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped));
Queued = Validation.EnsureNonNegative(queued, nameof(queued));
Completed = Validation.EnsureNonNegative(completed, nameof(completed));
Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas));
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
}
public int Candidates { get; } = 0;
public int Deduped { get; } = 0;
public int Queued { get; } = 0;
public int Completed { get; } = 0;
public int Deltas { get; } = 0;
public int NewCriticals { get; } = 0;
public int NewHigh { get; } = 0;
public int NewMedium { get; } = 0;
public int NewLow { get; } = 0;
}
/// <summary>
/// Snapshot of delta impact for an image processed in a run.
/// </summary>
public sealed record DeltaSummary
{
public DeltaSummary(
string imageDigest,
int newFindings,
int newCriticals,
int newHigh,
int newMedium,
int newLow,
IEnumerable<string>? kevHits = null,
IEnumerable<DeltaFinding>? topFindings = null,
string? reportUrl = null,
DeltaAttestation? attestation = null,
DateTimeOffset? detectedAt = null)
: this(
imageDigest,
Validation.EnsureNonNegative(newFindings, nameof(newFindings)),
Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)),
Validation.EnsureNonNegative(newHigh, nameof(newHigh)),
Validation.EnsureNonNegative(newMedium, nameof(newMedium)),
Validation.EnsureNonNegative(newLow, nameof(newLow)),
NormalizeKevHits(kevHits),
NormalizeFindings(topFindings),
Validation.TrimToNull(reportUrl),
attestation,
Validation.NormalizeTimestamp(detectedAt))
{
}
[JsonConstructor]
public DeltaSummary(
string imageDigest,
int newFindings,
int newCriticals,
int newHigh,
int newMedium,
int newLow,
ImmutableArray<string> kevHits,
ImmutableArray<DeltaFinding> topFindings,
string? reportUrl,
DeltaAttestation? attestation,
DateTimeOffset? detectedAt)
{
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings));
NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals));
NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh));
NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium));
NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow));
KevHits = kevHits.IsDefault ? ImmutableArray<string>.Empty : kevHits;
TopFindings = topFindings.IsDefault
? ImmutableArray<DeltaFinding>.Empty
: topFindings
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
.ToImmutableArray();
ReportUrl = Validation.TrimToNull(reportUrl);
Attestation = attestation;
DetectedAt = Validation.NormalizeTimestamp(detectedAt);
}
public string ImageDigest { get; }
public int NewFindings { get; }
public int NewCriticals { get; }
public int NewHigh { get; }
public int NewMedium { get; }
public int NewLow { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> KevHits { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<DeltaFinding> TopFindings { get; } = ImmutableArray<DeltaFinding>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReportUrl { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DeltaAttestation? Attestation { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? DetectedAt { get; }
private static ImmutableArray<string> NormalizeKevHits(IEnumerable<string>? kevHits)
=> Validation.NormalizeStringSet(kevHits, nameof(kevHits));
private static ImmutableArray<DeltaFinding> NormalizeFindings(IEnumerable<DeltaFinding>? findings)
{
if (findings is null)
{
return ImmutableArray<DeltaFinding>.Empty;
}
return findings
.Where(static finding => finding is not null)
.Select(static finding => finding!)
.OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance)
.ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Top finding entry included in delta summaries.
/// </summary>
public sealed record DeltaFinding
{
public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null)
{
Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl));
VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId));
Severity = severity;
Link = Validation.TrimToNull(link);
}
public string Purl { get; }
public string VulnerabilityId { get; }
public SeverityRank Severity { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Link { get; }
}
/// <summary>
/// Rekor/attestation information surfaced with a delta summary.
/// </summary>
public sealed record DeltaAttestation
{
public DeltaAttestation(string? uuid, bool? verified = null)
{
Uuid = Validation.TrimToNull(uuid);
Verified = verified;
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Uuid { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Verified { get; }
}
internal sealed class SeverityRankComparer : IComparer<SeverityRank>
{
public static SeverityRankComparer Instance { get; } = new();
private static readonly Dictionary<SeverityRank, int> Order = new()
{
[SeverityRank.Critical] = 0,
[SeverityRank.High] = 1,
[SeverityRank.Unknown] = 2,
[SeverityRank.Medium] = 3,
[SeverityRank.Low] = 4,
[SeverityRank.Info] = 5,
[SeverityRank.None] = 6,
};
public int Compare(SeverityRank x, SeverityRank y)
=> GetOrder(x).CompareTo(GetOrder(y));
private static int GetOrder(SeverityRank severity)
=> Order.TryGetValue(severity, out var value) ? value : int.MaxValue;
}

View File

@@ -432,14 +432,14 @@ internal sealed class SchedulerEventPublisher : ISchedulerEventPublisher
return $"manual:{reason.ManualReason}";
}
if (!string.IsNullOrWhiteSpace(reason.FeedserExportId))
if (!string.IsNullOrWhiteSpace(reason.ConselierExportId))
{
return $"feedser:{reason.FeedserExportId}";
return $"conselier:{reason.ConselierExportId}";
}
if (!string.IsNullOrWhiteSpace(reason.VexerExportId))
if (!string.IsNullOrWhiteSpace(reason.ExcitorExportId))
{
return $"vexer:{reason.VexerExportId}";
return $"excitor:{reason.ExcitorExportId}";
}
return null;

View File

@@ -10,7 +10,7 @@ rescan activity in near real time.
- `scheduler.rescan.delta@1` — published once per runner segment when that
segment produced at least one meaningful delta (new critical/high findings or
KEV hits). Payload batches all impacted digests for the segment and includes
severity totals. Reason strings (manual trigger, Feedser/Vexer exports) flow
severity totals. Reason strings (manual trigger, Conselier/Excitor exports) flow
from the run reason when present.
- `scanner.report.ready@1` — published for every image the runner processes.
The payload mirrors the Scanner contract (verdict, summary buckets, DSSE

View File

@@ -1,78 +1,78 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class RunValidationTests
{
[Fact]
public void RunStatsRejectsNegativeValues()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(candidates: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deduped: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(queued: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(completed: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deltas: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newCriticals: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newHigh: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newMedium: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newLow: -1));
}
[Fact]
public void DeltaSummarySortsTopFindingsBySeverityThenId()
{
var summary = new DeltaSummary(
imageDigest: "sha256:0011",
newFindings: 3,
newCriticals: 1,
newHigh: 1,
newMedium: 1,
newLow: 0,
kevHits: new[] { "CVE-2025-0002", "CVE-2025-0001" },
topFindings: new[]
{
new DeltaFinding("pkg:maven/b", "CVE-2025-0002", SeverityRank.High),
new DeltaFinding("pkg:maven/a", "CVE-2024-0001", SeverityRank.Critical),
new DeltaFinding("pkg:maven/c", "CVE-2025-0008", SeverityRank.Medium),
},
reportUrl: "https://ui.example/reports/sha256:0011",
attestation: new DeltaAttestation(uuid: "rekor-1", verified: true),
detectedAt: DateTimeOffset.Parse("2025-10-18T00:01:02Z"));
Assert.Equal(new[] { "pkg:maven/a", "pkg:maven/b", "pkg:maven/c" }, summary.TopFindings.Select(f => f.Purl));
Assert.Equal(new[] { "CVE-2025-0001", "CVE-2025-0002" }, summary.KevHits);
}
[Fact]
public void RunSerializationIncludesDeterministicOrdering()
{
var stats = new RunStats(candidates: 10, deduped: 8, queued: 8, completed: 5, deltas: 3, newCriticals: 2);
var run = new Run(
id: "run_001",
tenantId: "tenant-alpha",
trigger: RunTrigger.Feedser,
state: RunState.Running,
stats: stats,
reason: new RunReason(feedserExportId: "exp-123"),
scheduleId: "sch_001",
createdAt: DateTimeOffset.Parse("2025-10-18T01:00:00Z"),
startedAt: DateTimeOffset.Parse("2025-10-18T01:00:05Z"),
finishedAt: null,
error: null,
deltas: new[]
{
new DeltaSummary(
imageDigest: "sha256:aaa",
newFindings: 1,
newCriticals: 1,
newHigh: 0,
newMedium: 0,
newLow: 0)
});
var json = CanonicalJsonSerializer.Serialize(run);
Assert.Equal(SchedulerSchemaVersions.Run, run.SchemaVersion);
Assert.Contains("\"trigger\":\"feedser\"", json, StringComparison.Ordinal);
Assert.Contains("\"stats\":{\"candidates\":10,\"deduped\":8,\"queued\":8,\"completed\":5,\"deltas\":3,\"newCriticals\":2,\"newHigh\":0,\"newMedium\":0,\"newLow\":0}", json, StringComparison.Ordinal);
}
}
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Models.Tests;
public sealed class RunValidationTests
{
[Fact]
public void RunStatsRejectsNegativeValues()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(candidates: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deduped: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(queued: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(completed: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deltas: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newCriticals: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newHigh: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newMedium: -1));
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newLow: -1));
}
[Fact]
public void DeltaSummarySortsTopFindingsBySeverityThenId()
{
var summary = new DeltaSummary(
imageDigest: "sha256:0011",
newFindings: 3,
newCriticals: 1,
newHigh: 1,
newMedium: 1,
newLow: 0,
kevHits: new[] { "CVE-2025-0002", "CVE-2025-0001" },
topFindings: new[]
{
new DeltaFinding("pkg:maven/b", "CVE-2025-0002", SeverityRank.High),
new DeltaFinding("pkg:maven/a", "CVE-2024-0001", SeverityRank.Critical),
new DeltaFinding("pkg:maven/c", "CVE-2025-0008", SeverityRank.Medium),
},
reportUrl: "https://ui.example/reports/sha256:0011",
attestation: new DeltaAttestation(uuid: "rekor-1", verified: true),
detectedAt: DateTimeOffset.Parse("2025-10-18T00:01:02Z"));
Assert.Equal(new[] { "pkg:maven/a", "pkg:maven/b", "pkg:maven/c" }, summary.TopFindings.Select(f => f.Purl));
Assert.Equal(new[] { "CVE-2025-0001", "CVE-2025-0002" }, summary.KevHits);
}
[Fact]
public void RunSerializationIncludesDeterministicOrdering()
{
var stats = new RunStats(candidates: 10, deduped: 8, queued: 8, completed: 5, deltas: 3, newCriticals: 2);
var run = new Run(
id: "run_001",
tenantId: "tenant-alpha",
trigger: RunTrigger.Conselier,
state: RunState.Running,
stats: stats,
reason: new RunReason(conselierExportId: "exp-123"),
scheduleId: "sch_001",
createdAt: DateTimeOffset.Parse("2025-10-18T01:00:00Z"),
startedAt: DateTimeOffset.Parse("2025-10-18T01:00:05Z"),
finishedAt: null,
error: null,
deltas: new[]
{
new DeltaSummary(
imageDigest: "sha256:aaa",
newFindings: 1,
newCriticals: 1,
newHigh: 0,
newMedium: 0,
newLow: 0)
});
var json = CanonicalJsonSerializer.Serialize(run);
Assert.Equal(SchedulerSchemaVersions.Run, run.SchemaVersion);
Assert.Contains("\"trigger\":\"conselier\"", json, StringComparison.Ordinal);
Assert.Contains("\"stats\":{\"candidates\":10,\"deduped\":8,\"queued\":8,\"completed\":5,\"deltas\":3,\"newCriticals\":2,\"newHigh\":0,\"newMedium\":0,\"newLow\":0}", json, StringComparison.Ordinal);
}
}

View File

@@ -1,110 +1,110 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scheduler.Models;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class PlannerAndRunnerMessageTests
{
[Fact]
public void PlannerMessage_CanonicalSerialization_RoundTrips()
{
var schedule = new Schedule(
id: "sch-tenant-nightly",
tenantId: "tenant-alpha",
name: "Nightly Deltas",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: new ScheduleOnlyIf(lastReportOlderThanDays: 3),
notify: new ScheduleNotify(onNewFindings: true, SeverityRank.High, includeKev: true),
limits: new ScheduleLimits(maxJobs: 10, ratePerSecond: 5, parallelism: 3),
createdAt: DateTimeOffset.Parse("2025-10-01T02:00:00Z"),
createdBy: "system",
updatedAt: DateTimeOffset.Parse("2025-10-02T02:00:00Z"),
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty,
schemaVersion: "1.0.0");
var run = new Run(
id: "run-123",
tenantId: "tenant-alpha",
trigger: RunTrigger.Cron,
state: RunState.Planning,
stats: new RunStats(candidates: 5, deduped: 4, queued: 0, completed: 0, deltas: 0),
createdAt: DateTimeOffset.Parse("2025-10-02T02:05:00Z"),
reason: new RunReason(manualReason: null, feedserExportId: null, vexerExportId: null, cursor: null)
with { ImpactWindowFrom = "2025-10-01T00:00:00Z", ImpactWindowTo = "2025-10-02T00:00:00Z" },
scheduleId: "sch-tenant-nightly");
var impactSet = new ImpactSet(
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
images: new[]
{
new ImpactImage(
imageDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
registry: "registry",
repository: "repo",
namespaces: new[] { "prod" },
tags: new[] { "latest" },
usedByEntrypoint: true,
labels: new[] { KeyValuePair.Create("team", "appsec") })
},
usageOnly: true,
generatedAt: DateTimeOffset.Parse("2025-10-02T02:06:00Z"),
total: 1,
snapshotId: "snap-001");
var message = new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-1");
var json = CanonicalJsonSerializer.Serialize(message);
var roundTrip = CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(json);
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
}
[Fact]
public void RunnerSegmentMessage_RequiresAtLeastOneDigest()
{
var act = () => new RunnerSegmentQueueMessage(
segmentId: "segment-empty",
runId: "run-123",
tenantId: "tenant-alpha",
imageDigests: Array.Empty<string>());
act.Should().Throw<ArgumentException>();
}
[Fact]
public void RunnerSegmentMessage_CanonicalSerialization_RoundTrips()
{
var message = new RunnerSegmentQueueMessage(
segmentId: "segment-01",
runId: "run-123",
tenantId: "tenant-alpha",
imageDigests: new[]
{
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
scheduleId: "sch-tenant-nightly",
ratePerSecond: 25,
usageOnly: true,
attributes: new Dictionary<string, string>
{
["plannerShard"] = "0",
["priority"] = "kev"
},
correlationId: "corr-2");
var json = CanonicalJsonSerializer.Serialize(message);
var roundTrip = CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(json);
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scheduler.Models;
using Xunit;
namespace StellaOps.Scheduler.Queue.Tests;
public sealed class PlannerAndRunnerMessageTests
{
[Fact]
public void PlannerMessage_CanonicalSerialization_RoundTrips()
{
var schedule = new Schedule(
id: "sch-tenant-nightly",
tenantId: "tenant-alpha",
name: "Nightly Deltas",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: new ScheduleOnlyIf(lastReportOlderThanDays: 3),
notify: new ScheduleNotify(onNewFindings: true, SeverityRank.High, includeKev: true),
limits: new ScheduleLimits(maxJobs: 10, ratePerSecond: 5, parallelism: 3),
createdAt: DateTimeOffset.Parse("2025-10-01T02:00:00Z"),
createdBy: "system",
updatedAt: DateTimeOffset.Parse("2025-10-02T02:00:00Z"),
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty,
schemaVersion: "1.0.0");
var run = new Run(
id: "run-123",
tenantId: "tenant-alpha",
trigger: RunTrigger.Cron,
state: RunState.Planning,
stats: new RunStats(candidates: 5, deduped: 4, queued: 0, completed: 0, deltas: 0),
createdAt: DateTimeOffset.Parse("2025-10-02T02:05:00Z"),
reason: new RunReason(manualReason: null, conselierExportId: null, excitorExportId: null, cursor: null)
with { ImpactWindowFrom = "2025-10-01T00:00:00Z", ImpactWindowTo = "2025-10-02T00:00:00Z" },
scheduleId: "sch-tenant-nightly");
var impactSet = new ImpactSet(
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
images: new[]
{
new ImpactImage(
imageDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
registry: "registry",
repository: "repo",
namespaces: new[] { "prod" },
tags: new[] { "latest" },
usedByEntrypoint: true,
labels: new[] { KeyValuePair.Create("team", "appsec") })
},
usageOnly: true,
generatedAt: DateTimeOffset.Parse("2025-10-02T02:06:00Z"),
total: 1,
snapshotId: "snap-001");
var message = new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-1");
var json = CanonicalJsonSerializer.Serialize(message);
var roundTrip = CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(json);
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
}
[Fact]
public void RunnerSegmentMessage_RequiresAtLeastOneDigest()
{
var act = () => new RunnerSegmentQueueMessage(
segmentId: "segment-empty",
runId: "run-123",
tenantId: "tenant-alpha",
imageDigests: Array.Empty<string>());
act.Should().Throw<ArgumentException>();
}
[Fact]
public void RunnerSegmentMessage_CanonicalSerialization_RoundTrips()
{
var message = new RunnerSegmentQueueMessage(
segmentId: "segment-01",
runId: "run-123",
tenantId: "tenant-alpha",
imageDigests: new[]
{
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
scheduleId: "sch-tenant-nightly",
ratePerSecond: 25,
usageOnly: true,
attributes: new Dictionary<string, string>
{
["plannerShard"] = "0",
["priority"] = "kev"
},
correlationId: "corr-2");
var json = CanonicalJsonSerializer.Serialize(message);
var roundTrip = CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(json);
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
}
}

View File

@@ -1,128 +1,128 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class EventWebhookEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
static EventWebhookEndpointTests()
{
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Feedser__HmacSecret", FeedserSecret);
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Feedser__Enabled", "true");
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Vexer__HmacSecret", VexerSecret);
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Vexer__Enabled", "true");
}
private const string FeedserSecret = "feedser-secret";
private const string VexerSecret = "vexer-secret";
private readonly WebApplicationFactory<Program> _factory;
public EventWebhookEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task FeedserWebhook_AcceptsValidSignature()
{
using var client = _factory.CreateClient();
var payload = new
{
exportId = "feedser-exp-1",
changedProductKeys = new[] { "pkg:rpm/openssl", "pkg:deb/nginx" },
kev = new[] { "CVE-2024-0001" },
window = new { from = DateTimeOffset.UtcNow.AddHours(-1), to = DateTimeOffset.UtcNow }
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/feedser-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(FeedserSecret, json));
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
[Fact]
public async Task FeedserWebhook_RejectsInvalidSignature()
{
using var client = _factory.CreateClient();
var payload = new
{
exportId = "feedser-exp-2",
changedProductKeys = new[] { "pkg:nuget/log4net" }
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/feedser-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", "sha256=invalid");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task VexerWebhook_HonoursRateLimit()
{
using var restrictedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Scheduler:Events:Webhooks:Vexer:RateLimitRequests"] = "1",
["Scheduler:Events:Webhooks:Vexer:RateLimitWindowSeconds"] = "60"
});
});
});
using var client = restrictedFactory.CreateClient();
var payload = new
{
exportId = "vexer-exp-1",
changedClaims = new[]
{
new { productKey = "pkg:deb/openssl", vulnerabilityId = "CVE-2024-1234", status = "affected" }
}
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var first = new HttpRequestMessage(HttpMethod.Post, "/events/vexer-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
first.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(VexerSecret, json));
var firstResponse = await client.SendAsync(first);
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
using var second = new HttpRequestMessage(HttpMethod.Post, "/events/vexer-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
second.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(VexerSecret, json));
var secondResponse = await client.SendAsync(second);
Assert.Equal((HttpStatusCode)429, secondResponse.StatusCode);
Assert.True(secondResponse.Headers.Contains("Retry-After"));
}
private static string ComputeSignature(string secret, string payload)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class EventWebhookEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
static EventWebhookEndpointTests()
{
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Conselier__HmacSecret", ConselierSecret);
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Conselier__Enabled", "true");
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Excitor__HmacSecret", ExcitorSecret);
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Excitor__Enabled", "true");
}
private const string ConselierSecret = "conselier-secret";
private const string ExcitorSecret = "excitor-secret";
private readonly WebApplicationFactory<Program> _factory;
public EventWebhookEndpointTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task ConselierWebhook_AcceptsValidSignature()
{
using var client = _factory.CreateClient();
var payload = new
{
exportId = "conselier-exp-1",
changedProductKeys = new[] { "pkg:rpm/openssl", "pkg:deb/nginx" },
kev = new[] { "CVE-2024-0001" },
window = new { from = DateTimeOffset.UtcNow.AddHours(-1), to = DateTimeOffset.UtcNow }
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/conselier-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ConselierSecret, json));
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}
[Fact]
public async Task ConselierWebhook_RejectsInvalidSignature()
{
using var client = _factory.CreateClient();
var payload = new
{
exportId = "conselier-exp-2",
changedProductKeys = new[] { "pkg:nuget/log4net" }
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/conselier-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", "sha256=invalid");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task ExcitorWebhook_HonoursRateLimit()
{
using var restrictedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Scheduler:Events:Webhooks:Excitor:RateLimitRequests"] = "1",
["Scheduler:Events:Webhooks:Excitor:RateLimitWindowSeconds"] = "60"
});
});
});
using var client = restrictedFactory.CreateClient();
var payload = new
{
exportId = "excitor-exp-1",
changedClaims = new[]
{
new { productKey = "pkg:deb/openssl", vulnerabilityId = "CVE-2024-1234", status = "affected" }
}
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var first = new HttpRequestMessage(HttpMethod.Post, "/events/excitor-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
first.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ExcitorSecret, json));
var firstResponse = await client.SendAsync(first);
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
using var second = new HttpRequestMessage(HttpMethod.Post, "/events/excitor-export")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
second.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ExcitorSecret, json));
var secondResponse = await client.SendAsync(second);
Assert.Equal((HttpStatusCode)429, secondResponse.StatusCode);
Assert.True(secondResponse.Headers.Contains("Retry-After"));
}
private static string ComputeSignature(string secret, string payload)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,46 +1,46 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class SchedulerWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string?>("Scheduler:Authority:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Cartographer:Webhook:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Events:GraphJobs:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:Enabled", "true"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:HmacSecret", "feedser-secret"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:RateLimitRequests", "20"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:RateLimitWindowSeconds", "60"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:Enabled", "true"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:HmacSecret", "vexer-secret"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:RateLimitRequests", "20"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:RateLimitWindowSeconds", "60")
});
});
builder.ConfigureServices(services =>
{
services.Configure<SchedulerEventsOptions>(options =>
{
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
options.Webhooks.Feedser ??= SchedulerWebhookOptions.CreateDefault("feedser");
options.Webhooks.Vexer ??= SchedulerWebhookOptions.CreateDefault("vexer");
options.Webhooks.Feedser.HmacSecret = "feedser-secret";
options.Webhooks.Feedser.Enabled = true;
options.Webhooks.Vexer.HmacSecret = "vexer-secret";
options.Webhooks.Vexer.Enabled = true;
});
});
}
}
using System.Collections.Generic;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class SchedulerWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string?>("Scheduler:Authority:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Cartographer:Webhook:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Events:GraphJobs:Enabled", "false"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Conselier:Enabled", "true"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Conselier:HmacSecret", "conselier-secret"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Conselier:RateLimitRequests", "20"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Conselier:RateLimitWindowSeconds", "60"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:Enabled", "true"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:HmacSecret", "excitor-secret"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:RateLimitRequests", "20"),
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Excitor:RateLimitWindowSeconds", "60")
});
});
builder.ConfigureServices(services =>
{
services.Configure<SchedulerEventsOptions>(options =>
{
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
options.Webhooks.Conselier ??= SchedulerWebhookOptions.CreateDefault("conselier");
options.Webhooks.Excitor ??= SchedulerWebhookOptions.CreateDefault("excitor");
options.Webhooks.Conselier.HmacSecret = "conselier-secret";
options.Webhooks.Conselier.Enabled = true;
options.Webhooks.Excitor.HmacSecret = "excitor-secret";
options.Webhooks.Excitor.Enabled = true;
});
});
}
}

View File

@@ -1,411 +1,411 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Planning;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PlannerBackgroundServiceTests
{
[Fact]
public async Task ExecuteAsync_RespectsTenantFairnessCap()
{
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T12:00:00Z"));
var runs = new[]
{
CreateRun("run-a1", "tenant-a", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(1), "schedule-a"),
CreateRun("run-a2", "tenant-a", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(2), "schedule-a"),
CreateRun("run-b1", "tenant-b", RunTrigger.Feedser, timeProvider.GetUtcNow().AddMinutes(3), "schedule-b"),
CreateRun("run-c1", "tenant-c", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(4), "schedule-c"),
};
var repository = new TestRunRepository(runs, Array.Empty<Run>());
var options = CreateOptions(maxConcurrentTenants: 2);
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
var snapshotRepository = new StubImpactSnapshotRepository();
var runSummaryService = new StubRunSummaryService(timeProvider);
var plannerQueue = new RecordingPlannerQueue();
var targetingService = new StubImpactTargetingService(timeProvider);
using var metrics = new SchedulerWorkerMetrics();
var executionService = new PlannerExecutionService(
scheduleRepository,
repository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
options,
timeProvider,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var service = new PlannerBackgroundService(
repository,
executionService,
options,
timeProvider,
NullLogger<PlannerBackgroundService>.Instance);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForConditionAsync(() => repository.UpdateCount >= 2);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
Assert.Equal(new[] { "run-a1", "run-b1" }, processedIds);
}
[Fact]
public async Task ExecuteAsync_PrioritizesManualAndEventTriggers()
{
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T18:00:00Z"));
var runs = new[]
{
CreateRun("run-cron", "tenant-alpha", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(1), "schedule-cron"),
CreateRun("run-feedser", "tenant-bravo", RunTrigger.Feedser, timeProvider.GetUtcNow().AddMinutes(2), "schedule-feedser"),
CreateRun("run-manual", "tenant-charlie", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(3), "schedule-manual"),
CreateRun("run-vexer", "tenant-delta", RunTrigger.Vexer, timeProvider.GetUtcNow().AddMinutes(4), "schedule-vexer"),
};
var repository = new TestRunRepository(runs, Array.Empty<Run>());
var options = CreateOptions(maxConcurrentTenants: 4);
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
var snapshotRepository = new StubImpactSnapshotRepository();
var runSummaryService = new StubRunSummaryService(timeProvider);
var plannerQueue = new RecordingPlannerQueue();
var targetingService = new StubImpactTargetingService(timeProvider);
using var metrics = new SchedulerWorkerMetrics();
var executionService = new PlannerExecutionService(
scheduleRepository,
repository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
options,
timeProvider,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var service = new PlannerBackgroundService(
repository,
executionService,
options,
timeProvider,
NullLogger<PlannerBackgroundService>.Instance);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForConditionAsync(() => repository.UpdateCount >= runs.Length);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
Assert.Equal(new[] { "run-manual", "run-feedser", "run-vexer", "run-cron" }, processedIds);
}
private static SchedulerWorkerOptions CreateOptions(int maxConcurrentTenants)
{
return new SchedulerWorkerOptions
{
Planner =
{
BatchSize = 20,
PollInterval = TimeSpan.FromMilliseconds(1),
IdleDelay = TimeSpan.FromMilliseconds(1),
MaxConcurrentTenants = maxConcurrentTenants,
MaxRunsPerMinute = int.MaxValue,
QueueLeaseDuration = TimeSpan.FromMinutes(5)
}
};
}
private static Run CreateRun(
string id,
string tenantId,
RunTrigger trigger,
DateTimeOffset createdAt,
string scheduleId)
=> new(
id: id,
tenantId: tenantId,
trigger: trigger,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: createdAt,
reason: RunReason.Empty,
scheduleId: scheduleId);
private static Schedule CreateSchedule(string scheduleId, string tenantId, DateTimeOffset now)
=> new(
id: scheduleId,
tenantId: tenantId,
name: $"Schedule-{scheduleId}",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: now,
createdBy: "system",
updatedAt: now,
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty);
private static async Task WaitForConditionAsync(Func<bool> predicate, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(1));
while (!predicate())
{
if (DateTime.UtcNow > deadline)
{
throw new TimeoutException("Planner background service did not reach expected state within the allotted time.");
}
await Task.Delay(10);
}
}
private sealed class TestRunRepository : IRunRepository
{
private readonly Queue<IReadOnlyList<Run>> _responses;
private readonly ConcurrentQueue<Run> _updates = new();
private int _updateCount;
public TestRunRepository(params IReadOnlyList<Run>[] responses)
{
if (responses is null)
{
throw new ArgumentNullException(nameof(responses));
}
_responses = new Queue<IReadOnlyList<Run>>(responses.Select(static runs => (IReadOnlyList<Run>)runs.ToArray()));
}
public int UpdateCount => Volatile.Read(ref _updateCount);
public IReadOnlyList<Run> UpdatedRuns => _updates.ToArray();
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_updates.Enqueue(run);
Interlocked.Increment(ref _updateCount);
return Task.FromResult(true);
}
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<Run?>(null);
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
if (state != RunState.Planning)
{
return Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
}
var next = _responses.Count > 0 ? _responses.Dequeue() : Array.Empty<Run>();
if (next.Count > limit)
{
next = next.Take(limit).ToArray();
}
return Task.FromResult(next);
}
}
private sealed class TestScheduleRepository : IScheduleRepository
{
public TestScheduleRepository(IEnumerable<Schedule> schedules)
{
ArgumentNullException.ThrowIfNull(schedules);
_schedules = new Dictionary<(string TenantId, string ScheduleId), Schedule>();
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
}
}
private readonly Dictionary<(string TenantId, string ScheduleId), Schedule> _schedules;
public Task UpsertAsync(Schedule schedule, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(string tenantId, string scheduleId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_schedules.TryGetValue((tenantId, scheduleId), out var schedule);
return Task.FromResult(schedule);
}
public Task<IReadOnlyList<Schedule>> ListAsync(string tenantId, ScheduleQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
var results = _schedules.Values.Where(schedule => schedule.TenantId == tenantId).ToArray();
return Task.FromResult<IReadOnlyList<Schedule>>(results);
}
public Task<bool> SoftDeleteAsync(string tenantId, string scheduleId, string deletedBy, DateTimeOffset deletedAt, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
var removed = _schedules.Remove((tenantId, scheduleId));
return Task.FromResult(removed);
}
}
private sealed class StubImpactSnapshotRepository : IImpactSnapshotRepository
{
public ImpactSet? LastSnapshot { get; private set; }
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
LastSnapshot = snapshot;
return Task.CompletedTask;
}
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
}
private sealed class StubRunSummaryService : IRunSummaryService
{
private readonly TimeProvider _timeProvider;
public StubRunSummaryService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
var projection = new RunSummaryProjection(
run.TenantId,
run.ScheduleId ?? string.Empty,
_timeProvider.GetUtcNow(),
null,
ImmutableArray<RunSummarySnapshot>.Empty,
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
return Task.FromResult(projection);
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
=> Task.FromResult<RunSummaryProjection?>(null);
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
}
private sealed class StubImpactTargetingService : IImpactTargetingService
{
private static readonly string DefaultDigest = "sha256:" + new string('a', 64);
private readonly TimeProvider _timeProvider;
public StubImpactTargetingService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
{
var image = new ImpactImage(
DefaultDigest,
registry: "registry.test",
repository: "repo/sample",
namespaces: new[] { selector.TenantId ?? "unknown" },
tags: new[] { "latest" },
usedByEntrypoint: true);
var impactSet = new ImpactSet(
selector,
ImmutableArray.Create(image),
usageOnly,
_timeProvider.GetUtcNow(),
total: 1,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
return ValueTask.FromResult(impactSet);
}
}
private sealed class RecordingPlannerQueue : ISchedulerPlannerQueue
{
private readonly ConcurrentQueue<PlannerQueueMessage> _messages = new();
public IReadOnlyList<PlannerQueueMessage> Messages => _messages.ToArray();
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(PlannerQueueMessage message, CancellationToken cancellationToken = default)
{
_messages.Enqueue(message);
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(message.Run.Id, Deduplicated: false));
}
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset initial)
{
_now = initial;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Planning;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PlannerBackgroundServiceTests
{
[Fact]
public async Task ExecuteAsync_RespectsTenantFairnessCap()
{
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T12:00:00Z"));
var runs = new[]
{
CreateRun("run-a1", "tenant-a", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(1), "schedule-a"),
CreateRun("run-a2", "tenant-a", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(2), "schedule-a"),
CreateRun("run-b1", "tenant-b", RunTrigger.Conselier, timeProvider.GetUtcNow().AddMinutes(3), "schedule-b"),
CreateRun("run-c1", "tenant-c", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(4), "schedule-c"),
};
var repository = new TestRunRepository(runs, Array.Empty<Run>());
var options = CreateOptions(maxConcurrentTenants: 2);
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
var snapshotRepository = new StubImpactSnapshotRepository();
var runSummaryService = new StubRunSummaryService(timeProvider);
var plannerQueue = new RecordingPlannerQueue();
var targetingService = new StubImpactTargetingService(timeProvider);
using var metrics = new SchedulerWorkerMetrics();
var executionService = new PlannerExecutionService(
scheduleRepository,
repository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
options,
timeProvider,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var service = new PlannerBackgroundService(
repository,
executionService,
options,
timeProvider,
NullLogger<PlannerBackgroundService>.Instance);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForConditionAsync(() => repository.UpdateCount >= 2);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
Assert.Equal(new[] { "run-a1", "run-b1" }, processedIds);
}
[Fact]
public async Task ExecuteAsync_PrioritizesManualAndEventTriggers()
{
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T18:00:00Z"));
var runs = new[]
{
CreateRun("run-cron", "tenant-alpha", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(1), "schedule-cron"),
CreateRun("run-conselier", "tenant-bravo", RunTrigger.Conselier, timeProvider.GetUtcNow().AddMinutes(2), "schedule-conselier"),
CreateRun("run-manual", "tenant-charlie", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(3), "schedule-manual"),
CreateRun("run-excitor", "tenant-delta", RunTrigger.Excitor, timeProvider.GetUtcNow().AddMinutes(4), "schedule-excitor"),
};
var repository = new TestRunRepository(runs, Array.Empty<Run>());
var options = CreateOptions(maxConcurrentTenants: 4);
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
var snapshotRepository = new StubImpactSnapshotRepository();
var runSummaryService = new StubRunSummaryService(timeProvider);
var plannerQueue = new RecordingPlannerQueue();
var targetingService = new StubImpactTargetingService(timeProvider);
using var metrics = new SchedulerWorkerMetrics();
var executionService = new PlannerExecutionService(
scheduleRepository,
repository,
snapshotRepository,
runSummaryService,
targetingService,
plannerQueue,
options,
timeProvider,
metrics,
NullLogger<PlannerExecutionService>.Instance);
var service = new PlannerBackgroundService(
repository,
executionService,
options,
timeProvider,
NullLogger<PlannerBackgroundService>.Instance);
await service.StartAsync(CancellationToken.None);
try
{
await WaitForConditionAsync(() => repository.UpdateCount >= runs.Length);
}
finally
{
await service.StopAsync(CancellationToken.None);
}
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
Assert.Equal(new[] { "run-manual", "run-conselier", "run-excitor", "run-cron" }, processedIds);
}
private static SchedulerWorkerOptions CreateOptions(int maxConcurrentTenants)
{
return new SchedulerWorkerOptions
{
Planner =
{
BatchSize = 20,
PollInterval = TimeSpan.FromMilliseconds(1),
IdleDelay = TimeSpan.FromMilliseconds(1),
MaxConcurrentTenants = maxConcurrentTenants,
MaxRunsPerMinute = int.MaxValue,
QueueLeaseDuration = TimeSpan.FromMinutes(5)
}
};
}
private static Run CreateRun(
string id,
string tenantId,
RunTrigger trigger,
DateTimeOffset createdAt,
string scheduleId)
=> new(
id: id,
tenantId: tenantId,
trigger: trigger,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: createdAt,
reason: RunReason.Empty,
scheduleId: scheduleId);
private static Schedule CreateSchedule(string scheduleId, string tenantId, DateTimeOffset now)
=> new(
id: scheduleId,
tenantId: tenantId,
name: $"Schedule-{scheduleId}",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: now,
createdBy: "system",
updatedAt: now,
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty);
private static async Task WaitForConditionAsync(Func<bool> predicate, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(1));
while (!predicate())
{
if (DateTime.UtcNow > deadline)
{
throw new TimeoutException("Planner background service did not reach expected state within the allotted time.");
}
await Task.Delay(10);
}
}
private sealed class TestRunRepository : IRunRepository
{
private readonly Queue<IReadOnlyList<Run>> _responses;
private readonly ConcurrentQueue<Run> _updates = new();
private int _updateCount;
public TestRunRepository(params IReadOnlyList<Run>[] responses)
{
if (responses is null)
{
throw new ArgumentNullException(nameof(responses));
}
_responses = new Queue<IReadOnlyList<Run>>(responses.Select(static runs => (IReadOnlyList<Run>)runs.ToArray()));
}
public int UpdateCount => Volatile.Read(ref _updateCount);
public IReadOnlyList<Run> UpdatedRuns => _updates.ToArray();
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_updates.Enqueue(run);
Interlocked.Increment(ref _updateCount);
return Task.FromResult(true);
}
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<Run?>(null);
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
if (state != RunState.Planning)
{
return Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
}
var next = _responses.Count > 0 ? _responses.Dequeue() : Array.Empty<Run>();
if (next.Count > limit)
{
next = next.Take(limit).ToArray();
}
return Task.FromResult(next);
}
}
private sealed class TestScheduleRepository : IScheduleRepository
{
public TestScheduleRepository(IEnumerable<Schedule> schedules)
{
ArgumentNullException.ThrowIfNull(schedules);
_schedules = new Dictionary<(string TenantId, string ScheduleId), Schedule>();
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
}
}
private readonly Dictionary<(string TenantId, string ScheduleId), Schedule> _schedules;
public Task UpsertAsync(Schedule schedule, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(string tenantId, string scheduleId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
_schedules.TryGetValue((tenantId, scheduleId), out var schedule);
return Task.FromResult(schedule);
}
public Task<IReadOnlyList<Schedule>> ListAsync(string tenantId, ScheduleQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
var results = _schedules.Values.Where(schedule => schedule.TenantId == tenantId).ToArray();
return Task.FromResult<IReadOnlyList<Schedule>>(results);
}
public Task<bool> SoftDeleteAsync(string tenantId, string scheduleId, string deletedBy, DateTimeOffset deletedAt, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
var removed = _schedules.Remove((tenantId, scheduleId));
return Task.FromResult(removed);
}
}
private sealed class StubImpactSnapshotRepository : IImpactSnapshotRepository
{
public ImpactSet? LastSnapshot { get; private set; }
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
{
LastSnapshot = snapshot;
return Task.CompletedTask;
}
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
=> Task.FromResult<ImpactSet?>(null);
}
private sealed class StubRunSummaryService : IRunSummaryService
{
private readonly TimeProvider _timeProvider;
public StubRunSummaryService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
var projection = new RunSummaryProjection(
run.TenantId,
run.ScheduleId ?? string.Empty,
_timeProvider.GetUtcNow(),
null,
ImmutableArray<RunSummarySnapshot>.Empty,
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
return Task.FromResult(projection);
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
=> Task.FromResult<RunSummaryProjection?>(null);
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
}
private sealed class StubImpactTargetingService : IImpactTargetingService
{
private static readonly string DefaultDigest = "sha256:" + new string('a', 64);
private readonly TimeProvider _timeProvider;
public StubImpactTargetingService(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
{
var image = new ImpactImage(
DefaultDigest,
registry: "registry.test",
repository: "repo/sample",
namespaces: new[] { selector.TenantId ?? "unknown" },
tags: new[] { "latest" },
usedByEntrypoint: true);
var impactSet = new ImpactSet(
selector,
ImmutableArray.Create(image),
usageOnly,
_timeProvider.GetUtcNow(),
total: 1,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
return ValueTask.FromResult(impactSet);
}
}
private sealed class RecordingPlannerQueue : ISchedulerPlannerQueue
{
private readonly ConcurrentQueue<PlannerQueueMessage> _messages = new();
public IReadOnlyList<PlannerQueueMessage> Messages => _messages.ToArray();
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(PlannerQueueMessage message, CancellationToken cancellationToken = default)
{
_messages.Enqueue(message);
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(message.Run.Id, Deduplicated: false));
}
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset initial)
{
_now = initial;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}