Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,4 @@
# StellaOps.Scheduler.WebService — Agent Charter
## Mission
Implement Scheduler control plane per `docs/ARCHITECTURE_SCHEDULER.md`.

View File

@@ -0,0 +1,26 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AnonymousAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,27 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class ClaimsTenantContextAccessor : ITenantContextAccessor
{
public TenantContext GetTenant(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
var principal = context.User ?? throw new UnauthorizedAccessException("Authentication required.");
if (principal.Identity?.IsAuthenticated != true)
{
throw new UnauthorizedAccessException("Authentication required.");
}
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Authenticated principal is missing required tenant claim.");
}
return new TenantContext(tenant.Trim());
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class HeaderScopeAuthorizer : IScopeAuthorizer
{
private const string ScopeHeader = "X-Scopes";
public void EnsureScope(HttpContext context, string requiredScope)
{
if (!context.Request.Headers.TryGetValue(ScopeHeader, out var values))
{
throw new UnauthorizedAccessException($"Missing required header '{ScopeHeader}'.");
}
var scopeBuffer = string.Join(' ', values.ToArray());
if (string.IsNullOrWhiteSpace(scopeBuffer))
{
throw new UnauthorizedAccessException($"Header '{ScopeHeader}' cannot be empty.");
}
var scopes = scopeBuffer
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains(requiredScope))
{
throw new InvalidOperationException($"Missing required scope '{requiredScope}'.");
}
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class HeaderTenantContextAccessor : ITenantContextAccessor
{
private const string TenantHeader = "X-Tenant-Id";
public TenantContext GetTenant(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(TenantHeader, out var values))
{
throw new UnauthorizedAccessException($"Missing required header '{TenantHeader}'.");
}
var tenantId = values.ToString().Trim();
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new UnauthorizedAccessException($"Header '{TenantHeader}' cannot be empty.");
}
return new TenantContext(tenantId);
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
public interface IScopeAuthorizer
{
void EnsureScope(HttpContext context, string requiredScope);
}

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Scheduler.WebService.Auth;
public interface ITenantContextAccessor
{
TenantContext GetTenant(HttpContext context);
}
public sealed record TenantContext(string TenantId);

View File

@@ -0,0 +1,61 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Scheduler.WebService.Auth;
internal sealed class TokenScopeAuthorizer : IScopeAuthorizer
{
public void EnsureScope(HttpContext context, string requiredScope)
{
ArgumentNullException.ThrowIfNull(context);
if (string.IsNullOrWhiteSpace(requiredScope))
{
return;
}
var principal = context.User ?? throw new UnauthorizedAccessException("Authentication required.");
if (principal.Identity?.IsAuthenticated != true)
{
throw new UnauthorizedAccessException("Authentication required.");
}
var normalizedRequired = StellaOpsScopes.Normalize(requiredScope) ?? requiredScope.Trim().ToLowerInvariant();
if (!HasScope(principal, normalizedRequired))
{
throw new InvalidOperationException($"Missing required scope '{normalizedRequired}'.");
}
}
private static bool HasScope(ClaimsPrincipal principal, string requiredScope)
{
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.Equals(claim.Value, requiredScope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (normalized is not null && string.Equals(normalized, requiredScope, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +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);
}

View File

@@ -0,0 +1,8 @@
using System;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IWebhookRateLimiter
{
bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter);
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
public interface IWebhookRequestAuthenticator
{
Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken);
}
internal sealed class WebhookRequestAuthenticator : IWebhookRequestAuthenticator
{
private readonly ILogger<WebhookRequestAuthenticator> _logger;
public WebhookRequestAuthenticator(
ILogger<WebhookRequestAuthenticator> logger)
{
_logger = logger;
}
public async Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken)
{
if (!options.Enabled)
{
return WebhookAuthenticationResult.Success();
}
if (options.RequireClientCertificate)
{
var certificate = context.Connection.ClientCertificate ?? await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false);
if (certificate is null)
{
_logger.LogWarning("Webhook {Name} rejected request without client certificate.", options.Name);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Client certificate required.");
}
}
if (!string.IsNullOrWhiteSpace(options.HmacSecret))
{
var headerName = string.IsNullOrWhiteSpace(options.SignatureHeader) ? "X-Scheduler-Signature" : options.SignatureHeader;
if (!context.Request.Headers.TryGetValue(headerName, out var signatureValues))
{
_logger.LogWarning("Webhook {Name} rejected request missing signature header {Header}.", options.Name, headerName);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Missing signature header.");
}
var providedSignature = signatureValues.ToString();
if (string.IsNullOrWhiteSpace(providedSignature))
{
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Signature header is empty.");
}
if (!VerifySignature(body.Span, options.HmacSecret!, providedSignature))
{
_logger.LogWarning("Webhook {Name} rejected request with invalid signature.", options.Name);
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Invalid signature.");
}
}
return WebhookAuthenticationResult.Success();
}
private static bool VerifySignature(ReadOnlySpan<byte> payload, string secret, string providedSignature)
{
byte[] secretBytes;
try
{
secretBytes = Convert.FromBase64String(secret);
}
catch (FormatException)
{
try
{
secretBytes = Convert.FromHexString(secret);
}
catch (FormatException)
{
secretBytes = Encoding.UTF8.GetBytes(secret);
}
}
using var hmac = new HMACSHA256(secretBytes);
var hash = hmac.ComputeHash(payload.ToArray());
var computedSignature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computedSignature),
Encoding.UTF8.GetBytes(providedSignature.Trim()));
}
}
public readonly record struct WebhookAuthenticationResult(bool Succeeded, int StatusCode, string? Message)
{
public static WebhookAuthenticationResult Success() => new(true, StatusCodes.Status200OK, null);
public static WebhookAuthenticationResult Fail(int statusCode, string message) => new(false, statusCode, message);
public IResult ToResult()
=> Succeeded ? Results.Ok() : Results.Json(new { error = Message ?? "Unauthorized" }, statusCode: StatusCode);
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Scheduler.WebService.EventWebhooks;
internal sealed class InMemoryWebhookRateLimiter : IWebhookRateLimiter, IDisposable
{
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
private readonly object _mutex = new();
public bool TryAcquire(string key, int limit, TimeSpan window, out TimeSpan retryAfter)
{
if (limit <= 0)
{
retryAfter = TimeSpan.Zero;
return true;
}
retryAfter = TimeSpan.Zero;
var now = DateTimeOffset.UtcNow;
lock (_mutex)
{
if (!_cache.TryGetValue(key, out Queue<DateTimeOffset>? hits))
{
hits = new Queue<DateTimeOffset>();
_cache.Set(key, hits, new MemoryCacheEntryOptions
{
SlidingExpiration = window.Add(window)
});
}
hits ??= new Queue<DateTimeOffset>();
while (hits.Count > 0 && now - hits.Peek() > window)
{
hits.Dequeue();
}
if (hits.Count >= limit)
{
var oldest = hits.Peek();
retryAfter = (oldest + window) - now;
if (retryAfter < TimeSpan.Zero)
{
retryAfter = TimeSpan.Zero;
}
return false;
}
hits.Enqueue(now);
return true;
}
}
public void Dispose()
{
_cache.Dispose();
}
}

View File

@@ -0,0 +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;
}
}

View File

@@ -0,0 +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);

View File

@@ -0,0 +1,102 @@
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.GraphJobs;
internal sealed class CartographerWebhookClient : ICartographerWebhookClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _httpClient;
private readonly IOptionsMonitor<SchedulerCartographerOptions> _options;
private readonly ILogger<CartographerWebhookClient> _logger;
public CartographerWebhookClient(
HttpClient httpClient,
IOptionsMonitor<SchedulerCartographerOptions> options,
ILogger<CartographerWebhookClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
{
var snapshot = _options.CurrentValue;
var webhook = snapshot.Webhook;
if (!webhook.Enabled)
{
_logger.LogDebug("Cartographer webhook disabled; skipping notification for job {JobId}.", notification.Job.Id);
return;
}
if (string.IsNullOrWhiteSpace(webhook.Endpoint))
{
_logger.LogWarning("Cartographer webhook endpoint not configured; unable to notify for job {JobId}.", notification.Job.Id);
return;
}
Uri endpointUri;
try
{
endpointUri = new Uri(webhook.Endpoint, UriKind.Absolute);
}
catch (Exception ex)
{
_logger.LogError(ex, "Invalid Cartographer webhook endpoint '{Endpoint}'.", webhook.Endpoint);
return;
}
var payload = new
{
tenantId = notification.TenantId,
jobId = notification.Job.Id,
jobType = notification.Job.Kind,
status = notification.Status.ToString().ToLowerInvariant(),
occurredAt = notification.OccurredAt,
resultUri = notification.ResultUri,
correlationId = notification.CorrelationId,
job = notification.Job,
error = notification.Error
};
using var request = new HttpRequestMessage(HttpMethod.Post, endpointUri)
{
Content = new StringContent(JsonSerializer.Serialize(payload, SerializerOptions), Encoding.UTF8, MediaTypeNames.Application.Json)
};
if (!string.IsNullOrWhiteSpace(webhook.ApiKey) && !string.IsNullOrWhiteSpace(webhook.ApiKeyHeader))
{
request.Headers.TryAddWithoutValidation(webhook.ApiKeyHeader!, webhook.ApiKey);
}
try
{
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Cartographer webhook responded {StatusCode} for job {JobId}: {Body}", (int)response.StatusCode, notification.Job.Id, body);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to invoke Cartographer webhook for job {JobId}.", notification.Job.Id);
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
internal sealed record GraphJobCompletedEvent
{
[JsonPropertyName("eventId")]
public required string EventId { get; init; }
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("tenant")]
public required string Tenant { get; init; }
[JsonPropertyName("ts")]
public required DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("payload")]
public required GraphJobCompletedPayload Payload { get; init; }
[JsonPropertyName("attributes")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
}
internal sealed record GraphJobCompletedPayload
{
[JsonPropertyName("jobType")]
public required string JobType { get; init; }
[JsonPropertyName("status")]
public required GraphJobStatus Status { get; init; }
[JsonPropertyName("occurredAt")]
public required DateTimeOffset OccurredAt { get; init; }
[JsonPropertyName("job")]
public required GraphJobResponse Job { get; init; }
[JsonPropertyName("resultUri")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ResultUri { get; init; }
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
internal static class GraphJobEventFactory
{
public static GraphJobCompletedEvent Create(GraphJobCompletionNotification notification)
{
var eventId = Guid.CreateVersion7().ToString("n");
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(notification.CorrelationId))
{
attributes["correlationId"] = notification.CorrelationId!;
}
if (!string.IsNullOrWhiteSpace(notification.Error))
{
attributes["error"] = notification.Error!;
}
var payload = new GraphJobCompletedPayload
{
JobType = notification.JobType.ToString().ToLowerInvariant(),
Status = notification.Status,
OccurredAt = notification.OccurredAt,
Job = notification.Job,
ResultUri = notification.ResultUri
};
return new GraphJobCompletedEvent
{
EventId = eventId,
Kind = GraphJobEventKinds.GraphJobCompleted,
Tenant = notification.TenantId,
Timestamp = notification.OccurredAt,
Payload = payload,
Attributes = attributes.Count == 0 ? null : attributes
};
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
internal static class GraphJobEventKinds
{
public const string GraphJobCompleted = "scheduler.graph.job.completed";
}

View File

@@ -0,0 +1,41 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
internal sealed class GraphJobEventPublisher : IGraphJobCompletionPublisher
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IOptionsMonitor<SchedulerEventsOptions> _options;
private readonly ILogger<GraphJobEventPublisher> _logger;
public GraphJobEventPublisher(
IOptionsMonitor<SchedulerEventsOptions> options,
ILogger<GraphJobEventPublisher> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
if (!options.GraphJobs.Enabled)
{
_logger.LogDebug("Graph job events disabled; skipping emission for {JobId}.", notification.Job.Id);
return Task.CompletedTask;
}
var envelope = GraphJobEventFactory.Create(notification);
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
_logger.LogInformation("{EventJson}", json);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public sealed record GraphBuildJobRequest
{
[Required]
public string SbomId { get; init; } = string.Empty;
[Required]
public string SbomVersionId { get; init; } = string.Empty;
[Required]
public string SbomDigest { get; init; } = string.Empty;
public string? GraphSnapshotId { get; init; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public GraphBuildJobTrigger? Trigger { get; init; }
public string? CorrelationId { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public sealed record GraphJobCompletionNotification(
string TenantId,
GraphJobQueryType JobType,
GraphJobStatus Status,
DateTimeOffset OccurredAt,
GraphJobResponse Job,
string? ResultUri,
string? CorrelationId,
string? Error);

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public sealed record GraphJobCompletionRequest
{
[Required]
public string JobId { get; init; } = string.Empty;
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public GraphJobQueryType JobType { get; init; }
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public GraphJobStatus Status { get; init; }
[Required]
public DateTimeOffset OccurredAt { get; init; }
public string? GraphSnapshotId { get; init; }
public string? ResultUri { get; init; }
public string? CorrelationId { get; init; }
public string? Error { get; init; }
}

View File

@@ -0,0 +1,161 @@
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using StellaOps.Auth.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public static class GraphJobEndpointExtensions
{
public static void MapGraphJobEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/graphs");
group.MapPost("/build", CreateGraphBuildJob);
group.MapPost("/overlays", CreateGraphOverlayJob);
group.MapGet("/jobs", GetGraphJobs);
group.MapPost("/hooks/completed", CompleteGraphJob);
group.MapGet("/overlays/lag", GetOverlayLagMetrics);
}
internal static async Task<IResult> CreateGraphBuildJob(
[FromBody] GraphBuildJobRequest request,
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer authorizer,
[FromServices] IGraphJobService jobService,
CancellationToken cancellationToken)
{
try
{
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphWrite);
var tenant = tenantAccessor.GetTenant(httpContext);
var job = await jobService.CreateBuildJobAsync(tenant.TenantId, request, cancellationToken);
return Results.Created($"/graphs/jobs/{job.Id}", GraphJobResponse.From(job));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status400BadRequest);
}
catch (KeyNotFoundException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status404NotFound);
}
}
internal static async Task<IResult> CreateGraphOverlayJob(
[FromBody] GraphOverlayJobRequest request,
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer authorizer,
[FromServices] IGraphJobService jobService,
CancellationToken cancellationToken)
{
try
{
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphWrite);
var tenant = tenantAccessor.GetTenant(httpContext);
var job = await jobService.CreateOverlayJobAsync(tenant.TenantId, request, cancellationToken);
return Results.Created($"/graphs/jobs/{job.Id}", GraphJobResponse.From(job));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status400BadRequest);
}
}
internal static async Task<IResult> GetGraphJobs(
[AsParameters] GraphJobQuery query,
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer authorizer,
[FromServices] IGraphJobService jobService,
CancellationToken cancellationToken)
{
try
{
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphRead);
var tenant = tenantAccessor.GetTenant(httpContext);
var jobs = await jobService.GetJobsAsync(tenant.TenantId, query, cancellationToken);
return Results.Ok(jobs);
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
}
internal static async Task<IResult> CompleteGraphJob(
[FromBody] GraphJobCompletionRequest request,
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer authorizer,
[FromServices] IGraphJobService jobService,
CancellationToken cancellationToken)
{
try
{
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphWrite);
var tenant = tenantAccessor.GetTenant(httpContext);
var response = await jobService.CompleteJobAsync(tenant.TenantId, request, cancellationToken);
return Results.Ok(response);
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (KeyNotFoundException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status404NotFound);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status400BadRequest);
}
}
internal static async Task<IResult> GetOverlayLagMetrics(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer authorizer,
[FromServices] IGraphJobService jobService,
CancellationToken cancellationToken)
{
try
{
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphRead);
var tenant = tenantAccessor.GetTenant(httpContext);
var metrics = await jobService.GetOverlayLagMetricsAsync(tenant.TenantId, cancellationToken);
return Results.Ok(metrics);
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public sealed record GraphJobQuery
{
[JsonConverter(typeof(JsonStringEnumConverter))]
public GraphJobQueryType? Type { get; init; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public GraphJobStatus? Status { get; init; }
public int? Limit { get; init; }
internal GraphJobQuery Normalize()
=> this with
{
Limit = Limit is null or <= 0 or > 200 ? 50 : Limit
};
}
public enum GraphJobQueryType
{
Build,
Overlay
}

View File

@@ -0,0 +1,45 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public sealed record GraphJobResponse
{
public required string Id { get; init; }
public required string TenantId { get; init; }
public required string Kind { get; init; }
public required GraphJobStatus Status { get; init; }
public required object Payload { get; init; }
public static GraphJobResponse From(GraphBuildJob job)
=> new()
{
Id = job.Id,
TenantId = job.TenantId,
Kind = "build",
Status = job.Status,
Payload = job
};
public static GraphJobResponse From(GraphOverlayJob job)
=> new()
{
Id = job.Id,
TenantId = job.TenantId,
Kind = "overlay",
Status = job.Status,
Payload = job
};
}
public sealed record GraphJobCollection(IReadOnlyList<GraphJobResponse> Jobs)
{
public static GraphJobCollection From(IEnumerable<GraphBuildJob> builds, IEnumerable<GraphOverlayJob> overlays)
{
var responses = builds.Select(GraphJobResponse.From)
.Concat(overlays.Select(GraphJobResponse.From))
.OrderBy(response => response.Id, StringComparer.Ordinal)
.ToArray();
return new GraphJobCollection(responses);
}
}

View File

@@ -0,0 +1,338 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
internal sealed class GraphJobService : IGraphJobService
{
private readonly IGraphJobStore _store;
private readonly ISystemClock _clock;
private readonly IGraphJobCompletionPublisher _completionPublisher;
private readonly ICartographerWebhookClient _cartographerWebhook;
public GraphJobService(
IGraphJobStore store,
ISystemClock clock,
IGraphJobCompletionPublisher completionPublisher,
ICartographerWebhookClient cartographerWebhook)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_completionPublisher = completionPublisher ?? throw new ArgumentNullException(nameof(completionPublisher));
_cartographerWebhook = cartographerWebhook ?? throw new ArgumentNullException(nameof(cartographerWebhook));
}
public async Task<GraphBuildJob> CreateBuildJobAsync(string tenantId, GraphBuildJobRequest request, CancellationToken cancellationToken)
{
Validate(request);
var trigger = request.Trigger ?? GraphBuildJobTrigger.SbomVersion;
var metadata = request.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
var now = _clock.UtcNow;
var id = GenerateIdentifier("gbj");
var job = new GraphBuildJob(
id,
tenantId,
request.SbomId.Trim(),
request.SbomVersionId.Trim(),
NormalizeDigest(request.SbomDigest),
GraphJobStatus.Pending,
trigger,
now,
request.GraphSnapshotId,
attempts: 0,
cartographerJobId: null,
correlationId: request.CorrelationId?.Trim(),
startedAt: null,
completedAt: null,
error: null,
metadata: metadata.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value)));
return await _store.AddAsync(job, cancellationToken);
}
public async Task<GraphOverlayJob> CreateOverlayJobAsync(string tenantId, GraphOverlayJobRequest request, CancellationToken cancellationToken)
{
Validate(request);
var subjects = (request.Subjects ?? Array.Empty<string>())
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Select(subject => subject.Trim())
.ToArray();
var metadata = request.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
var trigger = request.Trigger ?? GraphOverlayJobTrigger.Policy;
var now = _clock.UtcNow;
var id = GenerateIdentifier("goj");
var job = new GraphOverlayJob(
id: id,
tenantId: tenantId,
graphSnapshotId: request.GraphSnapshotId.Trim(),
overlayKind: request.OverlayKind,
overlayKey: request.OverlayKey.Trim(),
status: GraphJobStatus.Pending,
trigger: trigger,
createdAt: now,
subjects: subjects,
attempts: 0,
buildJobId: request.BuildJobId?.Trim(),
correlationId: request.CorrelationId?.Trim(),
metadata: metadata.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value)));
return await _store.AddAsync(job, cancellationToken);
}
public async Task<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken)
{
return await _store.GetJobsAsync(tenantId, query, cancellationToken);
}
public async Task<GraphJobResponse> CompleteJobAsync(string tenantId, GraphJobCompletionRequest request, CancellationToken cancellationToken)
{
if (request.Status is not (GraphJobStatus.Completed or GraphJobStatus.Failed or GraphJobStatus.Cancelled))
{
throw new ValidationException("Completion requires status completed, failed, or cancelled.");
}
var occurredAt = request.OccurredAt == default ? _clock.UtcNow : request.OccurredAt.ToUniversalTime();
switch (request.JobType)
{
case GraphJobQueryType.Build:
{
var existing = await _store.GetBuildJobAsync(tenantId, request.JobId, cancellationToken);
if (existing is null)
{
throw new KeyNotFoundException($"Graph build job '{request.JobId}' not found.");
}
var current = existing;
if (current.Status is GraphJobStatus.Pending or GraphJobStatus.Queued)
{
current = GraphJobStateMachine.EnsureTransition(current, GraphJobStatus.Running, occurredAt, attempts: current.Attempts);
}
var updated = GraphJobStateMachine.EnsureTransition(current, request.Status, occurredAt, attempts: current.Attempts + 1, errorMessage: request.Error);
var metadata = MergeMetadata(updated.Metadata, request.ResultUri);
var normalized = new GraphBuildJob(
id: updated.Id,
tenantId: updated.TenantId,
sbomId: updated.SbomId,
sbomVersionId: updated.SbomVersionId,
sbomDigest: updated.SbomDigest,
graphSnapshotId: request.GraphSnapshotId?.Trim() ?? updated.GraphSnapshotId,
status: updated.Status,
trigger: updated.Trigger,
attempts: updated.Attempts,
cartographerJobId: updated.CartographerJobId,
correlationId: request.CorrelationId?.Trim() ?? updated.CorrelationId,
createdAt: updated.CreatedAt,
startedAt: updated.StartedAt,
completedAt: updated.CompletedAt,
error: updated.Error,
metadata: metadata,
schemaVersion: updated.SchemaVersion);
var stored = await _store.UpdateAsync(normalized, cancellationToken);
var response = GraphJobResponse.From(stored);
await PublishCompletionAsync(tenantId, GraphJobQueryType.Build, request.Status, occurredAt, response, request.ResultUri, request.CorrelationId, request.Error, cancellationToken);
return response;
}
case GraphJobQueryType.Overlay:
{
var existing = await _store.GetOverlayJobAsync(tenantId, request.JobId, cancellationToken);
if (existing is null)
{
throw new KeyNotFoundException($"Graph overlay job '{request.JobId}' not found.");
}
var current = existing;
if (current.Status is GraphJobStatus.Pending or GraphJobStatus.Queued)
{
current = GraphJobStateMachine.EnsureTransition(current, GraphJobStatus.Running, occurredAt, attempts: current.Attempts);
}
var updated = GraphJobStateMachine.EnsureTransition(current, request.Status, occurredAt, attempts: current.Attempts + 1, errorMessage: request.Error);
var metadata = MergeMetadata(updated.Metadata, request.ResultUri);
var normalized = new GraphOverlayJob(
id: updated.Id,
tenantId: updated.TenantId,
graphSnapshotId: updated.GraphSnapshotId,
buildJobId: updated.BuildJobId,
overlayKind: updated.OverlayKind,
overlayKey: updated.OverlayKey,
subjects: updated.Subjects,
status: updated.Status,
trigger: updated.Trigger,
attempts: updated.Attempts,
correlationId: request.CorrelationId?.Trim() ?? updated.CorrelationId,
createdAt: updated.CreatedAt,
startedAt: updated.StartedAt,
completedAt: updated.CompletedAt,
error: updated.Error,
metadata: metadata,
schemaVersion: updated.SchemaVersion);
var stored = await _store.UpdateAsync(normalized, cancellationToken);
var response = GraphJobResponse.From(stored);
await PublishCompletionAsync(tenantId, GraphJobQueryType.Overlay, request.Status, occurredAt, response, request.ResultUri, request.CorrelationId, request.Error, cancellationToken);
return response;
}
default:
throw new ValidationException("Unsupported job type.");
}
}
public async Task<OverlayLagMetricsResponse> GetOverlayLagMetricsAsync(string tenantId, CancellationToken cancellationToken)
{
var now = _clock.UtcNow;
var overlayJobs = await _store.GetOverlayJobsAsync(tenantId, cancellationToken);
var pending = overlayJobs.Count(job => job.Status == GraphJobStatus.Pending);
var running = overlayJobs.Count(job => job.Status == GraphJobStatus.Running || job.Status == GraphJobStatus.Queued);
var completed = overlayJobs.Count(job => job.Status == GraphJobStatus.Completed);
var failed = overlayJobs.Count(job => job.Status == GraphJobStatus.Failed);
var cancelled = overlayJobs.Count(job => job.Status == GraphJobStatus.Cancelled);
var completedJobs = overlayJobs
.Where(job => job.Status == GraphJobStatus.Completed && job.CompletedAt is not null)
.OrderByDescending(job => job.CompletedAt)
.ToArray();
double? minLag = null;
double? maxLag = null;
double? avgLag = null;
List<OverlayLagEntry> recent = new();
if (completedJobs.Length > 0)
{
var lags = completedJobs
.Select(job => (now - job.CompletedAt!.Value).TotalSeconds)
.ToArray();
minLag = lags.Min();
maxLag = lags.Max();
avgLag = lags.Average();
recent = completedJobs
.Take(5)
.Select(job => new OverlayLagEntry(
JobId: job.Id,
CompletedAt: job.CompletedAt!.Value,
LagSeconds: (now - job.CompletedAt!.Value).TotalSeconds,
CorrelationId: job.CorrelationId,
ResultUri: job.Metadata.TryGetValue("resultUri", out var value) ? value : null))
.ToList();
}
return new OverlayLagMetricsResponse(
TenantId: tenantId,
Pending: pending,
Running: running,
Completed: completed,
Failed: failed,
Cancelled: cancelled,
MinLagSeconds: minLag,
MaxLagSeconds: maxLag,
AverageLagSeconds: avgLag,
RecentCompleted: recent);
}
private static void Validate(GraphBuildJobRequest request)
{
if (string.IsNullOrWhiteSpace(request.SbomId))
{
throw new ValidationException("sbomId is required.");
}
if (string.IsNullOrWhiteSpace(request.SbomVersionId))
{
throw new ValidationException("sbomVersionId is required.");
}
if (string.IsNullOrWhiteSpace(request.SbomDigest))
{
throw new ValidationException("sbomDigest is required.");
}
}
private static void Validate(GraphOverlayJobRequest request)
{
if (string.IsNullOrWhiteSpace(request.GraphSnapshotId))
{
throw new ValidationException("graphSnapshotId is required.");
}
if (string.IsNullOrWhiteSpace(request.OverlayKey))
{
throw new ValidationException("overlayKey is required.");
}
}
private static string GenerateIdentifier(string prefix)
=> $"{prefix}_{Guid.CreateVersion7().ToString("n")}";
private static string NormalizeDigest(string value)
{
var text = value.Trim();
if (!text.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException("sbomDigest must start with 'sha256:'.");
}
var digest = text[7..];
if (digest.Length != 64 || !digest.All(IsHex))
{
throw new ValidationException("sbomDigest must contain 64 hexadecimal characters.");
}
return $"sha256:{digest.ToLowerInvariant()}";
}
private static bool IsHex(char c)
=> (c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F');
private static ImmutableSortedDictionary<string, string> MergeMetadata(ImmutableSortedDictionary<string, string> existing, string? resultUri)
{
if (string.IsNullOrWhiteSpace(resultUri))
{
return existing;
}
var builder = existing.ToBuilder();
builder["resultUri"] = resultUri.Trim();
return builder.ToImmutableSortedDictionary(StringComparer.Ordinal);
}
private async Task PublishCompletionAsync(
string tenantId,
GraphJobQueryType jobType,
GraphJobStatus status,
DateTimeOffset occurredAt,
GraphJobResponse response,
string? resultUri,
string? correlationId,
string? error,
CancellationToken cancellationToken)
{
var notification = new GraphJobCompletionNotification(
tenantId,
jobType,
status,
occurredAt,
response,
resultUri,
correlationId,
error);
await _completionPublisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
await _cartographerWebhook.NotifyAsync(notification, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public sealed record GraphOverlayJobRequest
{
[Required]
public string GraphSnapshotId { get; init; } = string.Empty;
public string? BuildJobId { get; init; }
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public GraphOverlayKind OverlayKind { get; init; }
[Required]
public string OverlayKey { get; init; } = string.Empty;
public IReadOnlyList<string>? Subjects { get; init; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public GraphOverlayJobTrigger? Trigger { get; init; }
public string? CorrelationId { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scheduler.WebService.GraphJobs;
public interface ICartographerWebhookClient
{
Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scheduler.WebService.GraphJobs;
public interface IGraphJobCompletionPublisher
{
Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public interface IGraphJobService
{
Task<GraphBuildJob> CreateBuildJobAsync(string tenantId, GraphBuildJobRequest request, CancellationToken cancellationToken);
Task<GraphOverlayJob> CreateOverlayJobAsync(string tenantId, GraphOverlayJobRequest request, CancellationToken cancellationToken);
Task<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken);
Task<GraphJobResponse> CompleteJobAsync(string tenantId, GraphJobCompletionRequest request, CancellationToken cancellationToken);
Task<OverlayLagMetricsResponse> GetOverlayLagMetricsAsync(string tenantId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,22 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public interface IGraphJobStore
{
ValueTask<GraphBuildJob> AddAsync(GraphBuildJob job, CancellationToken cancellationToken);
ValueTask<GraphOverlayJob> AddAsync(GraphOverlayJob job, CancellationToken cancellationToken);
ValueTask<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken);
ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken);
ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken);
ValueTask<GraphBuildJob> UpdateAsync(GraphBuildJob job, CancellationToken cancellationToken);
ValueTask<GraphOverlayJob> UpdateAsync(GraphOverlayJob job, CancellationToken cancellationToken);
ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,83 @@
using System.Collections.Concurrent;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.GraphJobs;
internal sealed class InMemoryGraphJobStore : IGraphJobStore
{
private readonly ConcurrentDictionary<string, GraphBuildJob> _buildJobs = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, GraphOverlayJob> _overlayJobs = new(StringComparer.Ordinal);
public ValueTask<GraphBuildJob> AddAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
_buildJobs[job.Id] = job;
return ValueTask.FromResult(job);
}
public ValueTask<GraphOverlayJob> AddAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
_overlayJobs[job.Id] = job;
return ValueTask.FromResult(job);
}
public ValueTask<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken)
{
var normalized = query.Normalize();
var buildJobs = _buildJobs.Values
.Where(job => string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
.Where(job => normalized.Status is null || job.Status == normalized.Status)
.OrderByDescending(job => job.CreatedAt)
.Take(normalized.Limit ?? 50)
.ToArray();
var overlayJobs = _overlayJobs.Values
.Where(job => string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
.Where(job => normalized.Status is null || job.Status == normalized.Status)
.OrderByDescending(job => job.CreatedAt)
.Take(normalized.Limit ?? 50)
.ToArray();
return ValueTask.FromResult(GraphJobCollection.From(buildJobs, overlayJobs));
}
public ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
{
if (_buildJobs.TryGetValue(jobId, out var job) && string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
{
return ValueTask.FromResult<GraphBuildJob?>(job);
}
return ValueTask.FromResult<GraphBuildJob?>(null);
}
public ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
{
if (_overlayJobs.TryGetValue(jobId, out var job) && string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
{
return ValueTask.FromResult<GraphOverlayJob?>(job);
}
return ValueTask.FromResult<GraphOverlayJob?>(null);
}
public ValueTask<GraphBuildJob> UpdateAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
_buildJobs[job.Id] = job;
return ValueTask.FromResult(job);
}
public ValueTask<GraphOverlayJob> UpdateAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
_overlayJobs[job.Id] = job;
return ValueTask.FromResult(job);
}
public ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
{
var jobs = _overlayJobs.Values
.Where(job => string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
.ToArray();
return ValueTask.FromResult<IReadOnlyCollection<GraphOverlayJob>>(jobs);
}
}

View File

@@ -0,0 +1,55 @@
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.WebService.GraphJobs;
internal sealed class MongoGraphJobStore : IGraphJobStore
{
private readonly IGraphJobRepository _repository;
public MongoGraphJobStore(IGraphJobRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async ValueTask<GraphBuildJob> AddAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
await _repository.InsertAsync(job, cancellationToken);
return job;
}
public async ValueTask<GraphOverlayJob> AddAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
await _repository.InsertAsync(job, cancellationToken);
return job;
}
public async ValueTask<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken)
{
var normalized = query.Normalize();
var builds = normalized.Type is null or GraphJobQueryType.Build
? await _repository.ListBuildJobsAsync(tenantId, normalized.Status, normalized.Limit ?? 50, cancellationToken)
: Array.Empty<GraphBuildJob>();
var overlays = normalized.Type is null or GraphJobQueryType.Overlay
? await _repository.ListOverlayJobsAsync(tenantId, normalized.Status, normalized.Limit ?? 50, cancellationToken)
: Array.Empty<GraphOverlayJob>();
return GraphJobCollection.From(builds, overlays);
}
public async ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
=> await _repository.GetBuildJobAsync(tenantId, jobId, cancellationToken);
public async ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
=> await _repository.GetOverlayJobAsync(tenantId, jobId, cancellationToken);
public async ValueTask<GraphBuildJob> UpdateAsync(GraphBuildJob job, CancellationToken cancellationToken)
=> await _repository.ReplaceAsync(job, cancellationToken);
public async ValueTask<GraphOverlayJob> UpdateAsync(GraphOverlayJob job, CancellationToken cancellationToken)
=> await _repository.ReplaceAsync(job, cancellationToken);
public async ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
=> await _repository.ListOverlayJobsAsync(tenantId, cancellationToken);
}

View File

@@ -0,0 +1,17 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.WebService.GraphJobs;
internal sealed class NullCartographerWebhookClient : ICartographerWebhookClient
{
private readonly ILogger<NullCartographerWebhookClient> _logger;
public NullCartographerWebhookClient(ILogger<NullCartographerWebhookClient> logger)
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
{
_logger.LogDebug("Cartographer webhook suppressed for tenant {TenantId}, job {JobId} ({Status}).", notification.TenantId, notification.Job.Id, notification.Status);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.WebService.GraphJobs;
internal sealed class NullGraphJobCompletionPublisher : IGraphJobCompletionPublisher
{
private readonly ILogger<NullGraphJobCompletionPublisher> _logger;
public NullGraphJobCompletionPublisher(ILogger<NullGraphJobCompletionPublisher> logger)
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
{
_logger.LogDebug("Graph job completion notification suppressed for tenant {TenantId}, job {JobId} ({Status}).", notification.TenantId, notification.Job.Id, notification.Status);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Scheduler.WebService.GraphJobs;
public sealed record OverlayLagMetricsResponse(
string TenantId,
int Pending,
int Running,
int Completed,
int Failed,
int Cancelled,
double? MinLagSeconds,
double? MaxLagSeconds,
double? AverageLagSeconds,
IReadOnlyList<OverlayLagEntry> RecentCompleted);
public sealed record OverlayLagEntry(
string JobId,
DateTimeOffset CompletedAt,
double LagSeconds,
string? CorrelationId,
string? ResultUri);

View File

@@ -0,0 +1,76 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.Hosting;
internal static class SchedulerPluginHostFactory
{
public static PluginHostOptions Build(SchedulerOptions.PluginOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(contentRootPath))
{
throw new ArgumentException("Content root path must be provided for plug-in discovery.", nameof(contentRootPath));
}
var baseDirectory = ResolveBaseDirectory(options.BaseDirectory, contentRootPath);
var pluginsDirectory = ResolvePluginsDirectory(options.Directory, baseDirectory);
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
PrimaryPrefix = "StellaOps.Scheduler",
RecursiveSearch = options.RecursiveSearch,
EnsureDirectoryExists = options.EnsureDirectoryExists
};
if (options.OrderedPlugins.Count > 0)
{
foreach (var pluginName in options.OrderedPlugins)
{
hostOptions.PluginOrder.Add(pluginName);
}
}
if (options.SearchPatterns.Count > 0)
{
foreach (var pattern in options.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
}
else
{
hostOptions.SearchPatterns.Add("StellaOps.Scheduler.Plugin.*.dll");
}
return hostOptions;
}
private static string ResolveBaseDirectory(string? configuredBaseDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredBaseDirectory))
{
return Path.GetFullPath(Path.Combine(contentRootPath, ".."));
}
return Path.IsPathRooted(configuredBaseDirectory)
? configuredBaseDirectory
: Path.GetFullPath(Path.Combine(contentRootPath, configuredBaseDirectory));
}
private static string ResolvePluginsDirectory(string? configuredDirectory, string baseDirectory)
{
var pluginsDirectory = string.IsNullOrWhiteSpace(configuredDirectory)
? Path.Combine("plugins", "scheduler")
: configuredDirectory;
return Path.IsPathRooted(pluginsDirectory)
? pluginsDirectory
: Path.GetFullPath(Path.Combine(baseDirectory, pluginsDirectory));
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Scheduler.WebService;
public interface ISystemClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Scheduler.WebService.Options;
/// <summary>
/// Configuration controlling Authority-backed authentication for the Scheduler WebService.
/// </summary>
public sealed class SchedulerAuthorityOptions
{
public bool Enabled { get; set; } = false;
/// <summary>
/// Allows the service to run without enforcing Authority authentication (development/tests only).
/// </summary>
public bool AllowAnonymousFallback { get; set; }
/// <summary>
/// Authority issuer URL exposed via OpenID discovery.
/// </summary>
public string Issuer { get; set; } = string.Empty;
public bool RequireHttpsMetadata { get; set; } = true;
public string? MetadataAddress { get; set; }
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public IList<string> Audiences { get; } = new List<string>();
public IList<string> RequiredScopes { get; } = new List<string>();
public IList<string> RequiredTenants { get; } = new List<string>();
public IList<string> BypassNetworks { get; } = new List<string>();
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Scheduler Authority issuer must be configured when Authority is enabled.");
}
if (!Uri.TryCreate(Issuer.Trim(), UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("Scheduler Authority issuer must be an absolute URI.");
}
if (RequireHttpsMetadata && !uri.IsLoopback && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Scheduler Authority issuer must use HTTPS unless targeting loopback development.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Scheduler Authority back-channel timeout must be greater than zero seconds.");
}
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Scheduler Authority token clock skew must be between 0 and 300 seconds.");
}
}
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Scheduler.WebService.Options;
public sealed class SchedulerCartographerOptions
{
public CartographerWebhookOptions Webhook { get; set; } = new();
}
public sealed class CartographerWebhookOptions
{
public bool Enabled { get; set; }
public string? Endpoint { get; set; }
public string? ApiKeyHeader { get; set; }
public string? ApiKey { get; set; }
public int TimeoutSeconds { get; set; } = 10;
}

View File

@@ -0,0 +1,109 @@
namespace StellaOps.Scheduler.WebService.Options;
/// <summary>
/// Scheduler WebService event options (outbound + inbound).
/// </summary>
using System;
public sealed class SchedulerEventsOptions
{
public GraphJobEventsOptions GraphJobs { get; set; } = new();
public SchedulerInboundWebhooksOptions Webhooks { get; set; } = new();
}
public sealed class GraphJobEventsOptions
{
/// <summary>
/// Enables emission of legacy <c>scheduler.graph.job.completed@1</c> events.
/// </summary>
public bool Enabled { get; set; }
}
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);
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Scheduler.WebService.Options;
/// <summary>
/// Scheduler host configuration defaults consumed at startup for cross-cutting concerns
/// such as plug-in discovery.
/// </summary>
public sealed class SchedulerOptions
{
public PluginOptions Plugins { get; set; } = new();
public void Validate()
{
Plugins.Validate();
}
public sealed class PluginOptions
{
/// <summary>
/// Base directory resolving relative plug-in paths. Defaults to solution root.
/// </summary>
public string? BaseDirectory { get; set; }
/// <summary>
/// Directory containing plug-in binaries. Defaults to <c>plugins/scheduler</c>.
/// </summary>
public string? Directory { get; set; }
/// <summary>
/// Controls whether sub-directories are scanned for plug-ins.
/// </summary>
public bool RecursiveSearch { get; set; } = false;
/// <summary>
/// Ensures the plug-in directory exists on startup.
/// </summary>
public bool EnsureDirectoryExists { get; set; } = true;
/// <summary>
/// Explicit plug-in discovery patterns (supports globbing).
/// </summary>
public IList<string> SearchPatterns { get; } = new List<string>();
/// <summary>
/// Optional ordered plug-in assembly names (without extension).
/// </summary>
public IList<string> OrderedPlugins { get; } = new List<string>();
public void Validate()
{
foreach (var pattern in SearchPatterns)
{
if (string.IsNullOrWhiteSpace(pattern))
{
throw new InvalidOperationException("Scheduler plug-in search patterns cannot contain null or whitespace entries.");
}
}
foreach (var assemblyName in OrderedPlugins)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
throw new InvalidOperationException("Scheduler ordered plug-in entries cannot contain null or whitespace values.");
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal interface IPolicyRunService
{
Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken);
Task<IReadOnlyList<PolicyRunStatus>> ListAsync(string tenantId, PolicyRunQueryOptions options, CancellationToken cancellationToken);
Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,138 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal sealed class InMemoryPolicyRunService : IPolicyRunService
{
private readonly ConcurrentDictionary<string, PolicyRunStatus> _runs = new(StringComparer.Ordinal);
private readonly List<PolicyRunStatus> _orderedRuns = new();
private readonly object _gate = new();
public Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var runId = string.IsNullOrWhiteSpace(request.RunId)
? GenerateRunId(request.PolicyId, request.QueuedAt ?? DateTimeOffset.UtcNow)
: request.RunId;
var queuedAt = request.QueuedAt ?? DateTimeOffset.UtcNow;
var status = new PolicyRunStatus(
runId,
tenantId,
request.PolicyId ?? throw new ValidationException("policyId must be provided."),
request.PolicyVersion ?? throw new ValidationException("policyVersion must be provided."),
request.Mode,
PolicyRunExecutionStatus.Queued,
request.Priority,
queuedAt,
PolicyRunStats.Empty,
request.Inputs ?? PolicyRunInputs.Empty,
null,
null,
null,
null,
null,
0,
null,
null,
request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty,
SchedulerSchemaVersions.PolicyRunStatus);
lock (_gate)
{
if (_runs.TryGetValue(runId, out var existing))
{
return Task.FromResult(existing);
}
_runs[runId] = status;
_orderedRuns.Add(status);
}
return Task.FromResult(status);
}
public Task<IReadOnlyList<PolicyRunStatus>> ListAsync(string tenantId, PolicyRunQueryOptions options, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
List<PolicyRunStatus> snapshot;
lock (_gate)
{
snapshot = _orderedRuns
.Where(run => string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
.ToList();
}
if (options.PolicyId is { Length: > 0 } policyId)
{
snapshot = snapshot
.Where(run => string.Equals(run.PolicyId, policyId, StringComparison.OrdinalIgnoreCase))
.ToList();
}
if (options.Mode is { } mode)
{
snapshot = snapshot
.Where(run => run.Mode == mode)
.ToList();
}
if (options.Status is { } status)
{
snapshot = snapshot
.Where(run => run.Status == status)
.ToList();
}
if (options.QueuedAfter is { } since)
{
snapshot = snapshot
.Where(run => run.QueuedAt >= since)
.ToList();
}
var result = snapshot
.OrderByDescending(run => run.QueuedAt)
.ThenBy(run => run.RunId, StringComparer.Ordinal)
.Take(options.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<PolicyRunStatus>>(result);
}
public Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
if (!_runs.TryGetValue(runId, out var run))
{
return Task.FromResult<PolicyRunStatus?>(null);
}
if (!string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<PolicyRunStatus?>(null);
}
return Task.FromResult<PolicyRunStatus?>(run);
}
private static string GenerateRunId(string policyId, DateTimeOffset timestamp)
{
var normalizedPolicyId = string.IsNullOrWhiteSpace(policyId) ? "policy" : policyId.Trim();
var suffix = Guid.NewGuid().ToString("N")[..8];
return $"run:{normalizedPolicyId}:{timestamp:yyyyMMddTHHmmssZ}:{suffix}";
}
}

View File

@@ -0,0 +1,197 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal static class PolicyRunEndpointExtensions
{
private const string Scope = StellaOpsScopes.PolicyRun;
public static void MapPolicyRunEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/scheduler/policy/runs");
group.MapGet("/", ListPolicyRunsAsync);
group.MapGet("/{runId}", GetPolicyRunAsync);
group.MapPost("/", CreatePolicyRunAsync);
}
internal static async Task<IResult> ListPolicyRunsAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IPolicyRunService policyRunService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, Scope);
var tenant = tenantAccessor.GetTenant(httpContext);
var options = PolicyRunQueryOptions.FromRequest(httpContext.Request);
var runs = await policyRunService
.ListAsync(tenant.TenantId, options, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new PolicyRunCollectionResponse(runs));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
internal static async Task<IResult> GetPolicyRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IPolicyRunService policyRunService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, Scope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await policyRunService
.GetAsync(tenant.TenantId, runId, cancellationToken)
.ConfigureAwait(false);
return run is null
? Results.NotFound()
: Results.Ok(new PolicyRunResponse(run));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
internal static async Task<IResult> CreatePolicyRunAsync(
HttpContext httpContext,
PolicyRunCreateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IPolicyRunService policyRunService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, Scope);
var tenant = tenantAccessor.GetTenant(httpContext);
var actorId = SchedulerEndpointHelpers.ResolveActorId(httpContext);
var now = timeProvider.GetUtcNow();
if (request.PolicyVersion is null || request.PolicyVersion <= 0)
{
throw new ValidationException("policyVersion must be provided and greater than zero.");
}
if (string.IsNullOrWhiteSpace(request.PolicyId))
{
throw new ValidationException("policyId must be provided.");
}
var normalizedMetadata = NormalizeMetadata(request.Metadata);
var policyRunRequest = new PolicyRunRequest(
tenant.TenantId,
request.PolicyId,
request.PolicyVersion,
request.Mode,
request.Priority,
request.RunId,
now,
actorId,
request.CorrelationId,
normalizedMetadata,
request.Inputs ?? PolicyRunInputs.Empty,
request.SchemaVersion);
var status = await policyRunService
.EnqueueAsync(tenant.TenantId, policyRunRequest, cancellationToken)
.ConfigureAwait(false);
return Results.Created($"/api/v1/scheduler/policy/runs/{status.RunId}", new PolicyRunResponse(status));
}
catch (UnauthorizedAccessException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
}
catch (InvalidOperationException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
internal sealed record PolicyRunCollectionResponse(
[property: JsonPropertyName("runs")] IReadOnlyList<PolicyRunStatus> Runs);
internal sealed record PolicyRunResponse(
[property: JsonPropertyName("run")] PolicyRunStatus Run);
internal sealed record PolicyRunCreateRequest(
[property: JsonPropertyName("schemaVersion")] string? SchemaVersion,
[property: JsonPropertyName("policyId")] string PolicyId,
[property: JsonPropertyName("policyVersion")] int? PolicyVersion,
[property: JsonPropertyName("mode")] PolicyRunMode Mode = PolicyRunMode.Incremental,
[property: JsonPropertyName("priority")] PolicyRunPriority Priority = PolicyRunPriority.Normal,
[property: JsonPropertyName("runId")] string? RunId = null,
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata = null,
[property: JsonPropertyName("inputs")] PolicyRunInputs? Inputs = null);
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableSortedDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = pair.Key?.Trim();
var value = pair.Value?.Trim();
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(value))
{
continue;
}
var normalizedKey = key.ToLowerInvariant();
if (!builder.ContainsKey(normalizedKey))
{
builder[normalizedKey] = value;
}
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,120 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal sealed class PolicyRunQueryOptions
{
private const int DefaultLimit = 50;
private const int MaxLimit = 200;
private PolicyRunQueryOptions()
{
}
public string? PolicyId { get; private set; }
public PolicyRunMode? Mode { get; private set; }
public PolicyRunExecutionStatus? Status { get; private set; }
public DateTimeOffset? QueuedAfter { get; private set; }
public int Limit { get; private set; } = DefaultLimit;
public static PolicyRunQueryOptions FromRequest(HttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var options = new PolicyRunQueryOptions();
var query = request.Query;
if (query.TryGetValue("policyId", out var policyValues))
{
var policyId = policyValues.ToString().Trim();
if (!string.IsNullOrEmpty(policyId))
{
options.PolicyId = policyId;
}
}
options.Mode = ParseEnum<PolicyRunMode>(query, "mode");
options.Status = ParseEnum<PolicyRunExecutionStatus>(query, "status");
options.QueuedAfter = ParseTimestamp(query);
options.Limit = ParseLimit(query);
return options;
}
private static TEnum? ParseEnum<TEnum>(IQueryCollection query, string key)
where TEnum : struct, Enum
{
if (!query.TryGetValue(key, out var values) || values == StringValues.Empty)
{
return null;
}
var value = values.ToString().Trim();
if (string.IsNullOrEmpty(value))
{
return null;
}
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
throw new ValidationException($"Value '{value}' is not valid for parameter '{key}'.");
}
private static DateTimeOffset? ParseTimestamp(IQueryCollection query)
{
if (!query.TryGetValue("since", out var values) || values == StringValues.Empty)
{
return null;
}
var candidate = values.ToString().Trim();
if (string.IsNullOrEmpty(candidate))
{
return null;
}
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp))
{
return timestamp.ToUniversalTime();
}
throw new ValidationException($"Value '{candidate}' is not a valid ISO-8601 timestamp.");
}
private static int ParseLimit(IQueryCollection query)
{
if (!query.TryGetValue("limit", out var values) || values == StringValues.Empty)
{
return DefaultLimit;
}
var candidate = values.ToString().Trim();
if (string.IsNullOrEmpty(candidate))
{
return DefaultLimit;
}
if (!int.TryParse(candidate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
{
throw new ValidationException("Parameter 'limit' must be a positive integer.");
}
if (parsed > MaxLimit)
{
throw new ValidationException($"Parameter 'limit' must not exceed {MaxLimit}.");
}
return parsed;
}
}

View File

@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.WebService;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal sealed class PolicyRunService : IPolicyRunService
{
private readonly IPolicyRunJobRepository _repository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicyRunService> _logger;
public PolicyRunService(
IPolicyRunJobRepository repository,
TimeProvider timeProvider,
ILogger<PolicyRunService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicyRunStatus> EnqueueAsync(string tenantId, PolicyRunRequest request, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var now = _timeProvider.GetUtcNow();
var runId = string.IsNullOrWhiteSpace(request.RunId)
? GenerateRunId(request.PolicyId, now)
: request.RunId!;
// Idempotency: return existing job if present when a runId was supplied.
if (!string.IsNullOrWhiteSpace(request.RunId))
{
var existing = await _repository
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
_logger.LogDebug("Policy run job already exists for tenant {TenantId} and run {RunId}.", tenantId, runId);
return ToStatus(existing, now);
}
}
var jobId = SchedulerEndpointHelpers.GenerateIdentifier("policyjob");
var queuedAt = request.QueuedAt ?? now;
var metadata = request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
var job = new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: jobId,
TenantId: tenantId,
PolicyId: request.PolicyId,
PolicyVersion: request.PolicyVersion,
Mode: request.Mode,
Priority: request.Priority,
PriorityRank: -1,
RunId: runId,
RequestedBy: request.RequestedBy,
CorrelationId: request.CorrelationId,
Metadata: metadata,
Inputs: request.Inputs ?? PolicyRunInputs.Empty,
QueuedAt: queuedAt,
Status: PolicyRunJobStatus.Pending,
AttemptCount: 0,
LastAttemptAt: null,
LastError: null,
CreatedAt: now,
UpdatedAt: now,
AvailableAt: now,
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
await _repository.InsertAsync(job, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Enqueued policy run job {JobId} for tenant {TenantId} policy {PolicyId} (runId={RunId}, mode={Mode}).",
job.Id,
tenantId,
job.PolicyId,
job.RunId,
job.Mode);
return ToStatus(job, now);
}
public async Task<IReadOnlyList<PolicyRunStatus>> ListAsync(
string tenantId,
PolicyRunQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var statuses = options.Status is null
? null
: MapExecutionStatus(options.Status.Value);
var jobs = await _repository
.ListAsync(
tenantId,
options.PolicyId,
options.Mode,
statuses,
options.QueuedAfter,
options.Limit,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
return jobs
.Select(job => ToStatus(job, now))
.ToList();
}
public async Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
cancellationToken.ThrowIfCancellationRequested();
var job = await _repository
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (job is null)
{
return null;
}
var now = _timeProvider.GetUtcNow();
return ToStatus(job, now);
}
private static PolicyRunStatus ToStatus(PolicyRunJob job, DateTimeOffset now)
{
var status = MapExecutionStatus(job.Status);
var queuedAt = job.QueuedAt ?? job.CreatedAt;
var startedAt = job.SubmittedAt;
var finishedAt = job.CompletedAt ?? job.CancelledAt;
var metadata = job.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
var inputs = job.Inputs ?? PolicyRunInputs.Empty;
var policyVersion = job.PolicyVersion
?? throw new InvalidOperationException($"Policy run job '{job.Id}' is missing policyVersion.");
return new PolicyRunStatus(
job.RunId ?? job.Id,
job.TenantId,
job.PolicyId,
policyVersion,
job.Mode,
status,
job.Priority,
queuedAt,
job.Status == PolicyRunJobStatus.Pending ? null : startedAt,
finishedAt,
PolicyRunStats.Empty,
inputs,
determinismHash: null,
errorCode: null,
error: job.Status == PolicyRunJobStatus.Failed ? job.LastError : null,
attempts: job.AttemptCount,
traceId: null,
explainUri: null,
metadata,
SchedulerSchemaVersions.PolicyRunStatus);
}
private static PolicyRunExecutionStatus MapExecutionStatus(PolicyRunJobStatus status)
=> status switch
{
PolicyRunJobStatus.Pending => PolicyRunExecutionStatus.Queued,
PolicyRunJobStatus.Dispatching => PolicyRunExecutionStatus.Running,
PolicyRunJobStatus.Submitted => PolicyRunExecutionStatus.Running,
PolicyRunJobStatus.Completed => PolicyRunExecutionStatus.Succeeded,
PolicyRunJobStatus.Failed => PolicyRunExecutionStatus.Failed,
PolicyRunJobStatus.Cancelled => PolicyRunExecutionStatus.Cancelled,
_ => PolicyRunExecutionStatus.Queued
};
private static IReadOnlyCollection<PolicyRunJobStatus>? MapExecutionStatus(PolicyRunExecutionStatus status)
=> status switch
{
PolicyRunExecutionStatus.Queued => new[] { PolicyRunJobStatus.Pending },
PolicyRunExecutionStatus.Running => new[] { PolicyRunJobStatus.Dispatching, PolicyRunJobStatus.Submitted },
PolicyRunExecutionStatus.Succeeded => new[] { PolicyRunJobStatus.Completed },
PolicyRunExecutionStatus.Failed => new[] { PolicyRunJobStatus.Failed },
PolicyRunExecutionStatus.Cancelled => new[] { PolicyRunJobStatus.Cancelled },
PolicyRunExecutionStatus.ReplayPending => Array.Empty<PolicyRunJobStatus>(),
_ => null
};
private static string GenerateRunId(string policyId, DateTimeOffset timestamp)
{
var normalizedPolicyId = string.IsNullOrWhiteSpace(policyId) ? "policy" : policyId.Trim();
var suffix = Guid.NewGuid().ToString("N")[..8];
return $"run:{normalizedPolicyId}:{timestamp:yyyyMMddTHHmmssZ}:{suffix}";
}
}

View File

@@ -0,0 +1,202 @@
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();
});
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IWebhookRateLimiter, InMemoryWebhookRateLimiter>();
builder.Services.AddSingleton<IWebhookRequestAuthenticator, WebhookRequestAuthenticator>();
builder.Services.AddSingleton<IInboundExportEventSink, LoggingExportEventSink>();
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

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scheduler.WebService.Tests")]

View File

@@ -0,0 +1,130 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
namespace StellaOps.Scheduler.WebService.Runs;
internal sealed class InMemoryRunRepository : IRunRepository
{
private readonly ConcurrentDictionary<string, Run> _runs = new(StringComparer.Ordinal);
public Task InsertAsync(
Run run,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(run);
_runs[run.Id] = run;
return Task.CompletedTask;
}
public Task<bool> UpdateAsync(
Run run,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(run);
if (!_runs.TryGetValue(run.Id, out var existing))
{
return Task.FromResult(false);
}
if (!string.Equals(existing.TenantId, run.TenantId, StringComparison.Ordinal))
{
return Task.FromResult(false);
}
_runs[run.Id] = run;
return Task.FromResult(true);
}
public Task<Run?> GetAsync(
string tenantId,
string runId,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant id must be provided.", nameof(tenantId));
}
if (string.IsNullOrWhiteSpace(runId))
{
throw new ArgumentException("Run id must be provided.", nameof(runId));
}
if (_runs.TryGetValue(runId, out var run) && string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<Run?>(run);
}
return Task.FromResult<Run?>(null);
}
public Task<IReadOnlyList<Run>> ListAsync(
string tenantId,
RunQueryOptions? options = null,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant id must be provided.", nameof(tenantId));
}
options ??= new RunQueryOptions();
IEnumerable<Run> query = _runs.Values
.Where(run => string.Equals(run.TenantId, tenantId, StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(options.ScheduleId))
{
query = query.Where(run => string.Equals(run.ScheduleId, options.ScheduleId, StringComparison.Ordinal));
}
if (!options.States.IsDefaultOrEmpty)
{
var allowed = options.States.ToImmutableHashSet();
query = query.Where(run => allowed.Contains(run.State));
}
if (options.CreatedAfter is { } createdAfter)
{
query = query.Where(run => run.CreatedAt > createdAfter);
}
query = options.SortAscending
? query.OrderBy(run => run.CreatedAt).ThenBy(run => run.Id, StringComparer.Ordinal)
: query.OrderByDescending(run => run.CreatedAt).ThenByDescending(run => run.Id, StringComparer.Ordinal);
var limit = options.Limit is { } specified && specified > 0 ? specified : 50;
var result = query.Take(limit).ToArray();
return Task.FromResult<IReadOnlyList<Run>>(result);
}
public Task<IReadOnlyList<Run>> ListByStateAsync(
RunState state,
int limit = 50,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), limit, "Limit must be greater than zero.");
}
var result = _runs.Values
.Where(run => run.State == state)
.OrderBy(run => run.CreatedAt)
.ThenBy(run => run.Id, StringComparer.Ordinal)
.Take(limit)
.ToArray();
return Task.FromResult<IReadOnlyList<Run>>(result);
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.Runs;
internal sealed record RunCreateRequest(
[property: JsonPropertyName("scheduleId")] string? ScheduleId,
[property: JsonPropertyName("trigger")] RunTrigger Trigger = RunTrigger.Manual,
[property: JsonPropertyName("reason")] RunReason? Reason = null,
[property: JsonPropertyName("correlationId")] string? CorrelationId = null);
internal sealed record RunCollectionResponse(
[property: JsonPropertyName("runs")] IReadOnlyList<Run> Runs);
internal sealed record RunResponse(
[property: JsonPropertyName("run")] Run Run);
internal sealed record ImpactPreviewRequest(
[property: JsonPropertyName("scheduleId")] string? ScheduleId,
[property: JsonPropertyName("selector")] Selector? Selector,
[property: JsonPropertyName("productKeys")] ImmutableArray<string>? ProductKeys,
[property: JsonPropertyName("vulnerabilityIds")] ImmutableArray<string>? VulnerabilityIds,
[property: JsonPropertyName("usageOnly")] bool UsageOnly = true,
[property: JsonPropertyName("sampleSize")] int SampleSize = 10);
internal sealed record ImpactPreviewResponse(
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("usageOnly")] bool UsageOnly,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("snapshotId")] string? SnapshotId,
[property: JsonPropertyName("sample")] ImmutableArray<ImpactPreviewSample> Sample);
internal sealed record ImpactPreviewSample(
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("registry")] string Registry,
[property: JsonPropertyName("repository")] string Repository,
[property: JsonPropertyName("namespaces")] ImmutableArray<string> Namespaces,
[property: JsonPropertyName("tags")] ImmutableArray<string> Tags,
[property: JsonPropertyName("usedByEntrypoint")] bool UsedByEntrypoint);

View File

@@ -0,0 +1,419 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Primitives;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.Runs;
internal static class RunEndpoints
{
private const string ReadScope = "scheduler.runs.read";
private const string WriteScope = "scheduler.runs.write";
private const string PreviewScope = "scheduler.runs.preview";
public static IEndpointRouteBuilder MapRunEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/runs");
group.MapGet("/", ListRunsAsync);
group.MapGet("/{runId}", GetRunAsync);
group.MapPost("/", CreateRunAsync);
group.MapPost("/{runId}/cancel", CancelRunAsync);
group.MapPost("/preview", PreviewImpactAsync);
return routes;
}
private static async Task<IResult> ListRunsAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var scheduleId = httpContext.Request.Query.TryGetValue("scheduleId", out var scheduleValues)
? scheduleValues.ToString().Trim()
: null;
var states = ParseRunStates(httpContext.Request.Query.TryGetValue("state", out var stateValues) ? stateValues : StringValues.Empty);
var createdAfter = SchedulerEndpointHelpers.TryParseDateTimeOffset(httpContext.Request.Query.TryGetValue("createdAfter", out var createdAfterValues) ? createdAfterValues.ToString() : null);
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
var sortAscending = httpContext.Request.Query.TryGetValue("sort", out var sortValues) &&
sortValues.Any(value => string.Equals(value, "asc", StringComparison.OrdinalIgnoreCase));
var options = new RunQueryOptions
{
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId,
States = states,
CreatedAfter = createdAfter,
Limit = limit,
SortAscending = sortAscending,
};
var runs = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Ok(new RunCollectionResponse(runs));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> GetRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (run is null)
{
return Results.NotFound();
}
return Results.Ok(new RunResponse(run));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> CreateRunAsync(
HttpContext httpContext,
RunCreateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository scheduleRepository,
[FromServices] IRunRepository runRepository,
[FromServices] IRunSummaryService runSummaryService,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(request.ScheduleId))
{
throw new ValidationException("scheduleId must be provided when creating a run.");
}
var scheduleId = request.ScheduleId!.Trim();
if (scheduleId.Length == 0)
{
throw new ValidationException("scheduleId must contain a value.");
}
var schedule = await scheduleRepository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return Results.NotFound();
}
if (request.Trigger != RunTrigger.Manual)
{
throw new ValidationException("Only manual runs can be created via this endpoint.");
}
var now = timeProvider.GetUtcNow();
var runId = SchedulerEndpointHelpers.GenerateIdentifier("run");
var reason = request.Reason ?? RunReason.Empty;
var run = new Run(
runId,
tenant.TenantId,
request.Trigger,
RunState.Planning,
RunStats.Empty,
now,
reason,
schedule.Id);
await runRepository.InsertAsync(run, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(run.ScheduleId))
{
await runSummaryService.ProjectAsync(run, cancellationToken).ConfigureAwait(false);
}
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler.run",
"create",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
RunId: run.Id,
ScheduleId: schedule.Id,
Metadata: BuildMetadata(
("state", run.State.ToString().ToLowerInvariant()),
("trigger", run.Trigger.ToString().ToLowerInvariant()),
("correlationId", request.CorrelationId?.Trim()))),
cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/scheduler/runs/{run.Id}", new RunResponse(run));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> CancelRunAsync(
HttpContext httpContext,
string runId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IRunRepository repository,
[FromServices] IRunSummaryService runSummaryService,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (run is null)
{
return Results.NotFound();
}
if (RunStateMachine.IsTerminal(run.State))
{
return Results.Conflict(new { error = "Run is already in a terminal state." });
}
var now = timeProvider.GetUtcNow();
var cancelled = RunStateMachine.EnsureTransition(run, RunState.Cancelled, now);
var updated = await repository.UpdateAsync(cancelled, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!updated)
{
return Results.Conflict(new { error = "Run could not be updated because it changed concurrently." });
}
if (!string.IsNullOrWhiteSpace(cancelled.ScheduleId))
{
await runSummaryService.ProjectAsync(cancelled, cancellationToken).ConfigureAwait(false);
}
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler.run",
"cancel",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
RunId: cancelled.Id,
ScheduleId: cancelled.ScheduleId,
Metadata: BuildMetadata(("state", cancelled.State.ToString().ToLowerInvariant()))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new RunResponse(cancelled));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> PreviewImpactAsync(
HttpContext httpContext,
ImpactPreviewRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository scheduleRepository,
[FromServices] IImpactIndex impactIndex,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, PreviewScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var selector = await ResolveSelectorAsync(request, tenant.TenantId, scheduleRepository, cancellationToken).ConfigureAwait(false);
var normalizedProductKeys = NormalizeStringInputs(request.ProductKeys);
var normalizedVulnerabilityIds = NormalizeStringInputs(request.VulnerabilityIds);
ImpactSet impactSet;
if (!normalizedProductKeys.IsDefaultOrEmpty)
{
impactSet = await impactIndex.ResolveByPurlsAsync(normalizedProductKeys, request.UsageOnly, selector, cancellationToken).ConfigureAwait(false);
}
else if (!normalizedVulnerabilityIds.IsDefaultOrEmpty)
{
impactSet = await impactIndex.ResolveByVulnerabilitiesAsync(normalizedVulnerabilityIds, request.UsageOnly, selector, cancellationToken).ConfigureAwait(false);
}
else
{
impactSet = await impactIndex.ResolveAllAsync(selector, request.UsageOnly, cancellationToken).ConfigureAwait(false);
}
var sampleSize = Math.Clamp(request.SampleSize, 1, 50);
var sampleBuilder = ImmutableArray.CreateBuilder<ImpactPreviewSample>();
foreach (var image in impactSet.Images.Take(sampleSize))
{
sampleBuilder.Add(new ImpactPreviewSample(
image.ImageDigest,
image.Registry,
image.Repository,
image.Namespaces.IsDefault ? ImmutableArray<string>.Empty : image.Namespaces,
image.Tags.IsDefault ? ImmutableArray<string>.Empty : image.Tags,
image.UsedByEntrypoint));
}
var response = new ImpactPreviewResponse(
impactSet.Total,
impactSet.UsageOnly,
impactSet.GeneratedAt,
impactSet.SnapshotId,
sampleBuilder.ToImmutable());
return Results.Ok(response);
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static ImmutableArray<RunState> ParseRunStates(StringValues values)
{
if (values.Count == 0)
{
return ImmutableArray<RunState>.Empty;
}
var builder = ImmutableArray.CreateBuilder<RunState>();
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (!Enum.TryParse<RunState>(value, ignoreCase: true, out var parsed))
{
throw new ValidationException($"State '{value}' is not a valid run state.");
}
builder.Add(parsed);
}
return builder.ToImmutable();
}
private static async Task<Selector> ResolveSelectorAsync(
ImpactPreviewRequest request,
string tenantId,
IScheduleRepository scheduleRepository,
CancellationToken cancellationToken)
{
Selector? selector = null;
if (!string.IsNullOrWhiteSpace(request.ScheduleId))
{
var schedule = await scheduleRepository.GetAsync(tenantId, request.ScheduleId!, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
throw new KeyNotFoundException($"Schedule '{request.ScheduleId}' was not found for tenant '{tenantId}'.");
}
selector = schedule.Selection;
}
if (request.Selector is not null)
{
if (selector is not null && request.ScheduleId is not null)
{
throw new ValidationException("selector cannot be combined with scheduleId in the same request.");
}
selector = request.Selector;
}
if (selector is null)
{
throw new ValidationException("Either scheduleId or selector must be provided.");
}
return SchedulerEndpointHelpers.NormalizeSelector(selector, tenantId);
}
private static ImmutableArray<string> NormalizeStringInputs(ImmutableArray<string>? values)
{
if (values is null || values.Value.IsDefaultOrEmpty)
{
return ImmutableArray<string>.Empty;
}
var builder = ImmutableArray.CreateBuilder<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values.Value)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var trimmed = value.Trim();
if (seen.Add(trimmed))
{
builder.Add(trimmed);
}
}
return builder.ToImmutable();
}
private static IReadOnlyDictionary<string, string> BuildMetadata(params (string Key, string? Value)[] pairs)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in pairs)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
continue;
}
metadata[key] = value!;
}
return metadata;
}
}

View File

@@ -0,0 +1,127 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Services;
namespace StellaOps.Scheduler.WebService;
internal static class SchedulerEndpointHelpers
{
private const string ActorHeader = "X-Actor-Id";
private const string ActorNameHeader = "X-Actor-Name";
private const string ActorKindHeader = "X-Actor-Kind";
private const string TenantHeader = "X-Tenant-Id";
public static string GenerateIdentifier(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
{
throw new ArgumentException("Prefix must be provided.", nameof(prefix));
}
return $"{prefix.Trim()}_{Guid.NewGuid():N}";
}
public static string ResolveActorId(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.Request.Headers.TryGetValue(ActorHeader, out var values))
{
var actor = values.ToString().Trim();
if (!string.IsNullOrEmpty(actor))
{
return actor;
}
}
if (context.Request.Headers.TryGetValue(TenantHeader, out var tenant))
{
var tenantId = tenant.ToString().Trim();
if (!string.IsNullOrEmpty(tenantId))
{
return tenantId;
}
}
return "system";
}
public static AuditActor ResolveAuditActor(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
var actorId = context.Request.Headers.TryGetValue(ActorHeader, out var idHeader)
? idHeader.ToString().Trim()
: null;
var displayName = context.Request.Headers.TryGetValue(ActorNameHeader, out var nameHeader)
? nameHeader.ToString().Trim()
: null;
var kind = context.Request.Headers.TryGetValue(ActorKindHeader, out var kindHeader)
? kindHeader.ToString().Trim()
: null;
if (string.IsNullOrWhiteSpace(actorId))
{
actorId = context.Request.Headers.TryGetValue(TenantHeader, out var tenantHeader)
? tenantHeader.ToString().Trim()
: "system";
}
displayName = string.IsNullOrWhiteSpace(displayName) ? actorId : displayName;
kind = string.IsNullOrWhiteSpace(kind) ? "user" : kind;
return new AuditActor(actorId!, displayName!, kind!);
}
public static bool TryParseBoolean(string? value)
=> !string.IsNullOrWhiteSpace(value) &&
(string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "1", StringComparison.OrdinalIgnoreCase));
public static int? TryParsePositiveInt(string? value)
{
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0)
{
return parsed;
}
return null;
}
public static DateTimeOffset? TryParseDateTimeOffset(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
{
return parsed.ToUniversalTime();
}
throw new ValidationException($"Value '{value}' is not a valid ISO-8601 timestamp.");
}
public static Selector NormalizeSelector(Selector selection, string tenantId)
{
ArgumentNullException.ThrowIfNull(selection);
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant identifier must be provided.", nameof(tenantId));
}
return new Selector(
selection.Scope,
tenantId,
selection.Namespaces,
selection.Repositories,
selection.Digests,
selection.IncludeTags,
selection.Labels,
selection.ResolvesTags);
}
}

View File

@@ -0,0 +1,153 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
namespace StellaOps.Scheduler.WebService.Schedules;
internal sealed class InMemoryScheduleRepository : IScheduleRepository
{
private readonly ConcurrentDictionary<string, Schedule> _schedules = new(StringComparer.Ordinal);
public Task UpsertAsync(
Schedule schedule,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
_schedules[schedule.Id] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(
string tenantId,
string scheduleId,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<Schedule?>(schedule);
}
return Task.FromResult<Schedule?>(null);
}
public Task<IReadOnlyList<Schedule>> ListAsync(
string tenantId,
ScheduleQueryOptions? options = null,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
options ??= new ScheduleQueryOptions();
var query = _schedules.Values
.Where(schedule => string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal));
if (!options.IncludeDisabled)
{
query = query.Where(schedule => schedule.Enabled);
}
var result = query
.OrderBy(schedule => schedule.Name, StringComparer.Ordinal)
.Take(options.Limit ?? int.MaxValue)
.ToArray();
return Task.FromResult<IReadOnlyList<Schedule>>(result);
}
public Task<bool> SoftDeleteAsync(
string tenantId,
string scheduleId,
string deletedBy,
DateTimeOffset deletedAt,
MongoDB.Driver.IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
_schedules.TryRemove(scheduleId, out _);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
internal sealed class InMemoryRunSummaryService : IRunSummaryService
{
private readonly ConcurrentDictionary<(string TenantId, string ScheduleId), RunSummaryProjection> _summaries = new();
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
var scheduleId = run.ScheduleId ?? string.Empty;
var updatedAt = run.FinishedAt ?? run.StartedAt ?? run.CreatedAt;
var counters = new RunSummaryCounters(
Total: 0,
Planning: 0,
Queued: 0,
Running: 0,
Completed: 0,
Error: 0,
Cancelled: 0,
TotalDeltas: 0,
TotalNewCriticals: 0,
TotalNewHigh: 0,
TotalNewMedium: 0,
TotalNewLow: 0);
var projection = new RunSummaryProjection(
run.TenantId,
scheduleId,
updatedAt,
null,
ImmutableArray<RunSummarySnapshot>.Empty,
counters);
_summaries[(run.TenantId, scheduleId)] = projection;
return Task.FromResult(projection);
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_summaries.TryGetValue((tenantId, scheduleId), out var projection);
return Task.FromResult<RunSummaryProjection?>(projection);
}
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var projections = _summaries.Values
.Where(summary => string.Equals(summary.TenantId, tenantId, StringComparison.Ordinal))
.ToArray();
return Task.FromResult<IReadOnlyList<RunSummaryProjection>>(projections);
}
}
internal sealed class InMemorySchedulerAuditService : ISchedulerAuditService
{
public Task<AuditRecord> WriteAsync(SchedulerAuditEvent auditEvent, CancellationToken cancellationToken = default)
{
var occurredAt = auditEvent.OccurredAt ?? DateTimeOffset.UtcNow;
var record = new AuditRecord(
auditEvent.AuditId ?? $"audit_{Guid.NewGuid():N}",
auditEvent.TenantId,
auditEvent.Category,
auditEvent.Action,
occurredAt,
auditEvent.Actor,
auditEvent.EntityId,
auditEvent.ScheduleId,
auditEvent.RunId,
auditEvent.CorrelationId,
auditEvent.Metadata?.ToImmutableSortedDictionary(StringComparer.OrdinalIgnoreCase) ?? ImmutableSortedDictionary<string, string>.Empty,
auditEvent.Message);
return Task.FromResult(record);
}
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Projections;
namespace StellaOps.Scheduler.WebService.Schedules;
internal sealed record ScheduleCreateRequest(
[property: JsonPropertyName("name"), Required] string Name,
[property: JsonPropertyName("cronExpression"), Required] string CronExpression,
[property: JsonPropertyName("timezone"), Required] string Timezone,
[property: JsonPropertyName("mode")] ScheduleMode Mode = ScheduleMode.AnalysisOnly,
[property: JsonPropertyName("selection"), Required] Selector Selection = null!,
[property: JsonPropertyName("onlyIf")] ScheduleOnlyIf? OnlyIf = null,
[property: JsonPropertyName("notify")] ScheduleNotify? Notify = null,
[property: JsonPropertyName("limits")] ScheduleLimits? Limits = null,
[property: JsonPropertyName("subscribers")] ImmutableArray<string>? Subscribers = null,
[property: JsonPropertyName("enabled")] bool Enabled = true);
internal sealed record ScheduleUpdateRequest(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("cronExpression")] string? CronExpression,
[property: JsonPropertyName("timezone")] string? Timezone,
[property: JsonPropertyName("mode")] ScheduleMode? Mode,
[property: JsonPropertyName("selection")] Selector? Selection,
[property: JsonPropertyName("onlyIf")] ScheduleOnlyIf? OnlyIf,
[property: JsonPropertyName("notify")] ScheduleNotify? Notify,
[property: JsonPropertyName("limits")] ScheduleLimits? Limits,
[property: JsonPropertyName("subscribers")] ImmutableArray<string>? Subscribers);
internal sealed record ScheduleCollectionResponse(IReadOnlyList<ScheduleResponse> Schedules);
internal sealed record ScheduleResponse(Schedule Schedule, RunSummaryProjection? Summary);

View File

@@ -0,0 +1,397 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.Schedules;
internal static class ScheduleEndpoints
{
private const string ReadScope = "scheduler.schedules.read";
private const string WriteScope = "scheduler.schedules.write";
public static IEndpointRouteBuilder MapScheduleEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/schedules");
group.MapGet("/", ListSchedulesAsync);
group.MapGet("/{scheduleId}", GetScheduleAsync);
group.MapPost("/", CreateScheduleAsync);
group.MapPatch("/{scheduleId}", UpdateScheduleAsync);
group.MapPost("/{scheduleId}/pause", PauseScheduleAsync);
group.MapPost("/{scheduleId}/resume", ResumeScheduleAsync);
return routes;
}
private static async Task<IResult> ListSchedulesAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] IRunSummaryService runSummaryService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var includeDisabled = SchedulerEndpointHelpers.TryParseBoolean(httpContext.Request.Query.TryGetValue("includeDisabled", out var includeDisabledValues) ? includeDisabledValues.ToString() : null);
var includeDeleted = SchedulerEndpointHelpers.TryParseBoolean(httpContext.Request.Query.TryGetValue("includeDeleted", out var includeDeletedValues) ? includeDeletedValues.ToString() : null);
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
var options = new ScheduleQueryOptions
{
IncludeDisabled = includeDisabled,
IncludeDeleted = includeDeleted,
Limit = limit
};
var schedules = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
var summaries = await runSummaryService.ListAsync(tenant.TenantId, cancellationToken).ConfigureAwait(false);
var summaryLookup = summaries.ToDictionary(summary => summary.ScheduleId, summary => summary, StringComparer.Ordinal);
var response = new ScheduleCollectionResponse(
schedules.Select(schedule => new ScheduleResponse(schedule, summaryLookup.GetValueOrDefault(schedule.Id))).ToArray());
return Results.Ok(response);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> GetScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] IRunSummaryService runSummaryService,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var schedule = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return Results.NotFound();
}
var summary = await runSummaryService.GetAsync(tenant.TenantId, scheduleId, cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(schedule, summary));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> CreateScheduleAsync(
HttpContext httpContext,
ScheduleCreateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
[FromServices] ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
ValidateRequest(request);
var tenant = tenantAccessor.GetTenant(httpContext);
var now = timeProvider.GetUtcNow();
var selection = SchedulerEndpointHelpers.NormalizeSelector(request.Selection, tenant.TenantId);
var scheduleId = SchedulerEndpointHelpers.GenerateIdentifier("sch");
var subscribers = request.Subscribers ?? ImmutableArray<string>.Empty;
var schedule = new Schedule(
scheduleId,
tenant.TenantId,
request.Name.Trim(),
request.Enabled,
request.CronExpression.Trim(),
request.Timezone.Trim(),
request.Mode,
selection,
request.OnlyIf ?? ScheduleOnlyIf.Default,
request.Notify ?? ScheduleNotify.Default,
request.Limits ?? ScheduleLimits.Default,
subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
SchedulerSchemaVersions.Schedule);
await repository.UpsertAsync(schedule, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"create",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: schedule.Id,
Metadata: new Dictionary<string, string>
{
["cronExpression"] = schedule.CronExpression,
["timezone"] = schedule.Timezone
}),
cancellationToken).ConfigureAwait(false);
var response = new ScheduleResponse(schedule, null);
return Results.Created($"/api/v1/scheduler/schedules/{schedule.Id}", response);
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> UpdateScheduleAsync(
HttpContext httpContext,
string scheduleId,
ScheduleUpdateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
var updated = UpdateSchedule(existing, request, tenant.TenantId, timeProvider.GetUtcNow(), SchedulerEndpointHelpers.ResolveActorId(httpContext));
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"update",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: updated.Id,
Metadata: new Dictionary<string, string>
{
["updatedAt"] = updated.UpdatedAt.ToString("O")
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> PauseScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (!existing.Enabled)
{
return Results.Ok(new ScheduleResponse(existing, null));
}
var now = timeProvider.GetUtcNow();
var updated = new Schedule(
existing.Id,
existing.TenantId,
existing.Name,
enabled: false,
existing.CronExpression,
existing.Timezone,
existing.Mode,
existing.Selection,
existing.OnlyIf,
existing.Notify,
existing.Limits,
existing.Subscribers,
existing.CreatedAt,
existing.CreatedBy,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
existing.SchemaVersion);
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"pause",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: scheduleId,
Metadata: new Dictionary<string, string>
{
["enabled"] = "false"
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> ResumeScheduleAsync(
HttpContext httpContext,
string scheduleId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository repository,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, WriteScope);
var tenant = tenantAccessor.GetTenant(httpContext);
var existing = await repository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return Results.NotFound();
}
if (existing.Enabled)
{
return Results.Ok(new ScheduleResponse(existing, null));
}
var now = timeProvider.GetUtcNow();
var updated = new Schedule(
existing.Id,
existing.TenantId,
existing.Name,
enabled: true,
existing.CronExpression,
existing.Timezone,
existing.Mode,
existing.Selection,
existing.OnlyIf,
existing.Notify,
existing.Limits,
existing.Subscribers,
existing.CreatedAt,
existing.CreatedBy,
now,
SchedulerEndpointHelpers.ResolveActorId(httpContext),
existing.SchemaVersion);
await repository.UpsertAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler",
"resume",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
ScheduleId: scheduleId,
Metadata: new Dictionary<string, string>
{
["enabled"] = "true"
}),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new ScheduleResponse(updated, null));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static void ValidateRequest(ScheduleCreateRequest request)
{
if (request.Selection is null)
{
throw new ValidationException("Selection is required.");
}
}
private static Schedule UpdateSchedule(
Schedule existing,
ScheduleUpdateRequest request,
string tenantId,
DateTimeOffset updatedAt,
string actor)
{
var name = request.Name?.Trim() ?? existing.Name;
var cronExpression = request.CronExpression?.Trim() ?? existing.CronExpression;
var timezone = request.Timezone?.Trim() ?? existing.Timezone;
var mode = request.Mode ?? existing.Mode;
var selection = request.Selection is null
? existing.Selection
: SchedulerEndpointHelpers.NormalizeSelector(request.Selection, tenantId);
var onlyIf = request.OnlyIf ?? existing.OnlyIf;
var notify = request.Notify ?? existing.Notify;
var limits = request.Limits ?? existing.Limits;
var subscribers = request.Subscribers ?? existing.Subscribers;
return new Schedule(
existing.Id,
existing.TenantId,
name,
existing.Enabled,
cronExpression,
timezone,
mode,
selection,
onlyIf,
notify,
limits,
subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers,
existing.CreatedAt,
existing.CreatedBy,
updatedAt,
actor,
existing.SchemaVersion);
}
}

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.Storage.Mongo/StellaOps.Scheduler.Storage.Mongo.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,47 @@
# Scheduler WebService Task Board (Sprint 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| 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. |
## Policy Engine v2 (Sprint 20)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| 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. |
> 2025-10-29: Added `/api/v1/scheduler/policy/runs` create/list/get endpoints with in-memory queue, scope/tenant enforcement, and contract docs (`docs/SCHED-WEB-20-001-POLICY-RUNS.md`). Tests cover happy path + auth failures.
> 2025-10-26: Use canonical request/response samples from `samples/api/scheduler/policy-*.json`; serializer contract defined in `src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`.
| SCHED-WEB-20-002 | BLOCKED (waiting on SCHED-WORKER-20-301) | Scheduler WebService Guild | SCHED-WEB-20-001, SCHED-WORKER-20-301 | Provide simulation trigger endpoint returning diff preview metadata and job state for UI/CLI consumption. | Simulation endpoint returns deterministic diffs metadata; rate limits enforced; tests cover concurrency. |
> 2025-10-29: WebService requires Worker policy job orchestration + Policy Engine diff callbacks (POLICY-ENGINE-20-003/006) to provide simulation previews. Awaiting completion of SCHED-WORKER-20-301 before wiring API.
## Graph Explorer v1 (Sprint 21)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| 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. |
| SCHED-WEB-21-003 | DONE (2025-10-26) | Scheduler WebService Guild, Authority Core Guild | AUTH-GRAPH-21-001 | Replace temporary `X-Scopes`/`X-Tenant-Id` headers with Authority-issued OpTok verification and scope enforcement for graph endpoints. | Authentication configured via `AddStellaOpsResourceServerAuthentication`; authority scopes enforced end-to-end with `StellaOpsScopes`; header fallback limited to dev mode; tests updated. |
| SCHED-WEB-21-004 | DOING (2025-10-26) | Scheduler WebService Guild, Scheduler Storage Guild | SCHED-WEB-21-001, SCHED-STORAGE-16-201 | Persist graph job lifecycle to Mongo storage and publish `scheduler.graph.job.completed@1` events + outbound webhook to Cartographer. | Storage repositories updated; events emitted; webhook payload documented; integration tests cover storage + event flow. **Note:** Events currently log JSON envelopes while the shared platform bus is provisioned. Cartographer webhook now posts JSON payloads when configured; replace inline logging with bus publisher once the shared event transport is online. |
## StellaOps Console (Sprint 23)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-CONSOLE-23-001 | TODO | Scheduler WebService Guild, BE-Base Platform Guild | SCHED-WEB-16-103, SCHED-WEB-20-001 | Extend runs APIs with live progress SSE endpoints (`/console/runs/{id}/stream`), queue lag summaries, diff metadata fetch, retry/cancel hooks with RBAC enforcement, and deterministic pagination for history views consumed by Console. | SSE emits heartbeats/backoff headers, progress payload schema documented, unauthorized actions blocked in integration tests, metrics/logs expose queue lag + correlation IDs. |
## Policy Studio (Sprint 27)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-CONSOLE-27-001 | TODO | Scheduler WebService Guild, Policy Registry Guild | SCHED-WEB-16-103, REGISTRY-API-27-005 | Provide policy batch simulation orchestration endpoints (`/policies/simulations` POST/GET) exposing run creation, shard status, SSE progress, cancellation, and retries with RBAC enforcement. | API handles shard lifecycle with SSE heartbeats + retry headers; unauthorized requests rejected; integration tests cover submit/cancel/resume flows. |
| SCHED-CONSOLE-27-002 | TODO | Scheduler WebService Guild, Observability Guild | SCHED-CONSOLE-27-001 | Emit telemetry endpoints/metrics (`policy_simulation_queue_depth`, `policy_simulation_latency`) and webhook callbacks for completion/failure consumed by Registry. | Metrics exposed via gateway, dashboards seeded, webhook contract documented, integration tests validate metrics emission. |
## Vulnerability Explorer (Sprint 29)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-VULN-29-001 | TODO | Scheduler WebService Guild, Findings Ledger Guild | SCHED-WEB-16-103, SBOM-VULN-29-001 | Expose resolver job APIs (`POST /vuln/resolver/jobs`, `GET /vuln/resolver/jobs/{id}`) to trigger candidate recomputation per artifact/policy change with RBAC and rate limits. | Resolver APIs documented; integration tests cover submit/status/cancel; unauthorized requests rejected. |
| SCHED-VULN-29-002 | TODO | Scheduler WebService Guild, Observability Guild | SCHED-VULN-29-001 | Provide projector lag metrics endpoint and webhook notifications for backlog breaches consumed by DevOps dashboards. | Lag metrics exposed; webhook events triggered on thresholds; docs updated. |
## Notes
- 2025-10-27: Minimal API host now wires Authority, health endpoints, and restart-only plug-in discovery per architecture §§12.

View File

@@ -0,0 +1,183 @@
# SCHED-WEB-16-103 — Scheduler Run APIs
> Status: 2025-10-26 — **Developer preview** (schema solid, planner/worker integration pending).
## Endpoints
| Method | Path | Description | Scopes |
| ------ | ---- | ----------- | ------ |
| `GET` | `/api/v1/scheduler/runs` | List runs for the current tenant (filter by schedule, state, createdAfter). | `scheduler.runs.read` |
| `GET` | `/api/v1/scheduler/runs/{runId}` | Retrieve run details. | `scheduler.runs.read` |
| `POST` | `/api/v1/scheduler/runs` | Create an ad-hoc run bound to an existing schedule. | `scheduler.runs.write` |
| `POST` | `/api/v1/scheduler/runs/{runId}/cancel` | Transition a run to `cancelled` when still in a non-terminal state. | `scheduler.runs.write` |
| `POST` | `/api/v1/scheduler/runs/preview` | Resolve impacted images using the ImpactIndex without enqueuing work. | `scheduler.runs.preview` |
All endpoints require a tenant context (`X-Tenant-Id`) and the appropriate scheduler scopes. Development mode allows header-based auth; production deployments must rely on Authority-issued tokens (OpTok + DPoP).
## Create Run (manual trigger)
```http
POST /api/v1/scheduler/runs
X-Tenant-Id: tenant-alpha
Authorization: Bearer <OpTok>
```
```json
{
"scheduleId": "sch_4f2c7d9e0a2b4c64a0e7b5f9d65c1234",
"trigger": "manual",
"reason": {
"manualReason": "Nightly backfill"
},
"correlationId": "backfill-2025-10-26"
}
```
```json
HTTP/1.1 201 Created
Location: /api/v1/scheduler/runs/run_c7b4e9d2f6a04f8784a40476d8a2f771
{
"run": {
"schemaVersion": "scheduler.run@1",
"id": "run_c7b4e9d2f6a04f8784a40476d8a2f771",
"tenantId": "tenant-alpha",
"scheduleId": "sch_4f2c7d9e0a2b4c64a0e7b5f9d65c1234",
"trigger": "manual",
"state": "planning",
"stats": {
"candidates": 0,
"deduped": 0,
"queued": 0,
"completed": 0,
"deltas": 0,
"newCriticals": 0,
"newHigh": 0,
"newMedium": 0,
"newLow": 0
},
"reason": {
"manualReason": "Nightly backfill"
},
"createdAt": "2025-10-26T03:12:45Z"
}
}
```
## List Runs
```http
GET /api/v1/scheduler/runs?scheduleId=sch_4f2c7d9e0a2b4c64a0e7b5f9d65c1234&state=planning&limit=10
```
```json
{
"runs": [
{
"schemaVersion": "scheduler.run@1",
"id": "run_c7b4e9d2f6a04f8784a40476d8a2f771",
"tenantId": "tenant-alpha",
"scheduleId": "sch_4f2c7d9e0a2b4c64a0e7b5f9d65c1234",
"trigger": "manual",
"state": "planning",
"stats": {
"candidates": 0,
"deduped": 0,
"queued": 0,
"completed": 0,
"deltas": 0,
"newCriticals": 0,
"newHigh": 0,
"newMedium": 0,
"newLow": 0
},
"reason": {
"manualReason": "Nightly backfill"
},
"createdAt": "2025-10-26T03:12:45Z"
}
]
}
```
## Cancel Run
```http
POST /api/v1/scheduler/runs/run_c7b4e9d2f6a04f8784a40476d8a2f771/cancel
```
```json
{
"run": {
"schemaVersion": "scheduler.run@1",
"id": "run_c7b4e9d2f6a04f8784a40476d8a2f771",
"tenantId": "tenant-alpha",
"scheduleId": "sch_4f2c7d9e0a2b4c64a0e7b5f9d65c1234",
"trigger": "manual",
"state": "cancelled",
"stats": {
"candidates": 0,
"deduped": 0,
"queued": 0,
"completed": 0,
"deltas": 0,
"newCriticals": 0,
"newHigh": 0,
"newMedium": 0,
"newLow": 0
},
"reason": {
"manualReason": "Nightly backfill"
},
"createdAt": "2025-10-26T03:12:45Z",
"finishedAt": "2025-10-26T03:13:02Z"
}
}
```
## Impact Preview
`/api/v1/scheduler/runs/preview` resolves impacted images via the ImpactIndex without mutating state. When `scheduleId` is provided the schedule selector is reused; callers may alternatively supply an explicit selector.
```http
POST /api/v1/scheduler/runs/preview
```
```json
{
"scheduleId": "sch_4f2c7d9e0a2b4c64a0e7b5f9d65c1234",
"usageOnly": true,
"sampleSize": 5
}
```
```json
{
"total": 128,
"usageOnly": true,
"generatedAt": "2025-10-26T03:12:47Z",
"snapshotId": "impact-snapshot-20251026",
"sample": [
{
"imageDigest": "sha256:0b1f...",
"registry": "internal",
"repository": "service-api",
"namespaces": ["prod"],
"tags": ["prod-2025-10-01"],
"usedByEntrypoint": true
}
]
}
```
### Validation rules
* `scheduleId` is mandatory for run creation; ad-hoc selectors will be added alongside planner support.
* Cancelling a run already in a terminal state returns `409 Conflict`.
* Preview requires either `scheduleId` or `selector` (mutually exclusive).
* `sampleSize` is clamped to `1..50` to keep responses deterministic and lightweight.
### Integration notes
* Run creation and cancellation produce audit entries under category `scheduler.run` with correlation metadata when provided.
* The preview endpoint relies on the ImpactIndex stub in development. Production deployments must register the concrete index implementation before use.
* Planner/worker orchestration tasks will wire run creation to queueing in SCHED-WORKER-16-201/202.

View File

@@ -0,0 +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.

View File

@@ -0,0 +1,161 @@
# SCHED-WEB-20-001 — Policy Run Scheduling APIs
> Status: 2025-10-29 — **Developer preview** (Scheduler WebService stubs queue policy runs; Policy Engine orchestration wired in upcoming worker tasks).
## Endpoints
| Method | Path | Description | Scopes |
| ------ | ---- | ----------- | ------ |
| `POST` | `/api/v1/scheduler/policy/runs` | Enqueue a policy run request for the current tenant. | `policy:run` |
| `GET` | `/api/v1/scheduler/policy/runs` | List policy runs filtered by policy, mode, status, or timestamp. | `policy:run` |
| `GET` | `/api/v1/scheduler/policy/runs/{runId}` | Fetch a single policy run status. | `policy:run` |
All endpoints require a tenant context (`X-Tenant-Id`) plus the `policy:run` scope. Development mode honours header-based auth; production deployments must rely on Authority-issued tokens (OpTok + DPoP).
## Create Policy Run
```http
POST /api/v1/scheduler/policy/runs
X-Tenant-Id: tenant-alpha
Authorization: Bearer <OpTok>
```
```json
{
"policyId": "P-7",
"policyVersion": 4,
"mode": "incremental",
"priority": "normal",
"metadata": {
"source": "stella policy run",
"trigger": "cli"
},
"inputs": {
"sbomSet": ["sbom:S-318", "sbom:S-42"],
"advisoryCursor": "2025-10-26T13:59:00+00:00",
"vexCursor": "2025-10-26T13:58:30+00:00",
"environment": {"exposure": "internet", "sealed": false},
"captureExplain": true
}
}
```
```json
HTTP/1.1 201 Created
Location: /api/v1/scheduler/policy/runs/run:P-7:20251026T140500Z:1b2c3d4e
{
"run": {
"schemaVersion": "scheduler.policy-run-status@1",
"runId": "run:P-7:20251026T140500Z:1b2c3d4e",
"tenantId": "tenant-alpha",
"policyId": "P-7",
"policyVersion": 4,
"mode": "incremental",
"status": "queued",
"priority": "normal",
"queuedAt": "2025-10-26T14:05:00+00:00",
"stats": {
"components": 0,
"rulesFired": 0,
"findingsWritten": 0,
"vexOverrides": 0
},
"inputs": {
"sbomSet": ["sbom:S-318", "sbom:S-42"],
"advisoryCursor": "2025-10-26T13:59:00+00:00",
"vexCursor": "2025-10-26T13:58:30+00:00",
"environment": {"exposure": "internet", "sealed": false},
"captureExplain": true
}
}
}
```
## List Policy Runs
```http
GET /api/v1/scheduler/policy/runs?policyId=P-7&mode=incremental&status=queued&since=2025-10-26T00:00:00Z&limit=25
```
```json
{
"runs": [
{
"schemaVersion": "scheduler.policy-run-status@1",
"runId": "run:P-7:20251026T140500Z:1b2c3d4e",
"tenantId": "tenant-alpha",
"policyId": "P-7",
"policyVersion": 4,
"mode": "incremental",
"status": "queued",
"priority": "normal",
"queuedAt": "2025-10-26T14:05:00+00:00",
"stats": {
"components": 0,
"rulesFired": 0,
"findingsWritten": 0,
"vexOverrides": 0
},
"inputs": {
"sbomSet": ["sbom:S-318", "sbom:S-42"],
"advisoryCursor": "2025-10-26T13:59:00+00:00",
"vexCursor": "2025-10-26T13:58:30+00:00",
"environment": {"exposure": "internet", "sealed": false},
"captureExplain": true
}
}
]
}
```
### Query Parameters
| Parameter | Description | Example |
| --------- | ----------- | ------- |
| `policyId` | Filter by policy identifier (case-insensitive). | `policyId=P-7` |
| `mode` | Filter by run mode (`full`, `incremental`, `simulate`). | `mode=simulate` |
| `status` | Filter by execution status (`queued`, `running`, `succeeded`, `failed`, `canceled`, `replay_pending`). | `status=queued` |
| `since` | Return runs queued on/after the timestamp (ISO-8601). | `since=2025-10-26T00:00:00Z` |
| `limit` | Maximum number of results (default 50, max 200). | `limit=25` |
## Fetch Policy Run
```http
GET /api/v1/scheduler/policy/runs/run:P-7:20251026T140500Z:1b2c3d4e
```
```json
{
"run": {
"schemaVersion": "scheduler.policy-run-status@1",
"runId": "run:P-7:20251026T140500Z:1b2c3d4e",
"tenantId": "tenant-alpha",
"policyId": "P-7",
"policyVersion": 4,
"mode": "incremental",
"status": "queued",
"priority": "normal",
"queuedAt": "2025-10-26T14:05:00+00:00",
"stats": {
"components": 0,
"rulesFired": 0,
"findingsWritten": 0,
"vexOverrides": 0
},
"inputs": {
"sbomSet": ["sbom:S-318", "sbom:S-42"],
"advisoryCursor": "2025-10-26T13:59:00+00:00",
"vexCursor": "2025-10-26T13:58:30+00:00",
"environment": {"exposure": "internet", "sealed": false},
"captureExplain": true
}
}
}
```
## Notes
- The developer-preview implementation keeps run state in-memory; Policy Engine workers will replace this with durable storage + orchestration (`SCHED-WORKER-20-301/302`).
- Responses align with the canonical DTOs described in `src/Scheduler/__Libraries/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`.
- `runId` generation follows the `run:{policyId}:{timestamp}:{suffix}` convention to support deterministic replay logs.
- Additional filters (policy version, metadata) will be introduced once the Policy Engine incremental orchestrator lands.

View File

@@ -0,0 +1,137 @@
# SCHED-WEB-21-001 — Graph Job APIs
> Status: 2025-10-26 — **Complete** (developer preview)
Minimal API endpoints for Cartographer orchestration live under `/graphs`. Authentication now relies on Authority-issued bearer tokens carrying `graph:*` scopes. For development scenarios you can disable `Scheduler:Authority:Enabled` and continue using legacy headers:
- `X-Tenant-Id`: tenant identifier (matches Scheduler Models `tenantId`).
- `X-Scopes`: space-delimited scopes. `graph:write` is required for write operations, `graph:read` for queries.
Example configuration (`appsettings.json` or environment overrides):
```jsonc
{
"Scheduler": {
"Authority": {
"Enabled": true,
"Issuer": "https://authority.stella-ops.local",
"Audiences": [ "api://scheduler" ],
"RequiredScopes": [ "graph:read", "graph:write" ]
},
"Events": {
"GraphJobs": {
"Enabled": true
}
},
"Cartographer": {
"Webhook": {
"Enabled": true,
"Endpoint": "https://cartographer.stella-ops.local/hooks/graph/completed",
"ApiKeyHeader": "X-StellaOps-Webhook-Key",
"ApiKey": "change-me",
"TimeoutSeconds": 10
}
}
}
}
```
## Endpoints
### `POST /graphs/build`
Creates a `GraphBuildJob` in `pending` state.
Request body:
```jsonc
{
"sbomId": "sbom_alpha",
"sbomVersionId": "sbom_alpha_v1",
"sbomDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"trigger": "sbom-version",
"metadata": {
"sbomEventId": "sbom_evt_20251026"
}
}
```
Response: `201 Created`
```jsonc
{
"id": "gbj_018dc2f5902147e2b7f2ea05f5de1f3f",
"tenantId": "tenant-alpha",
"kind": "build",
"status": "pending",
"payload": {
"schemaVersion": "scheduler.graph-build-job@1",
"id": "gbj_018dc2f5902147e2b7f2ea05f5de1f3f",
"tenantId": "tenant-alpha",
"sbomId": "sbom_alpha",
"sbomVersionId": "sbom_alpha_v1",
"sbomDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"status": "pending",
"trigger": "sbom-version",
"createdAt": "2025-10-26T12:00:00Z",
"metadata": {
"sbomeventid": "sbom_evt_20251026"
}
}
}
```
### `POST /graphs/overlays`
Creates a `GraphOverlayJob` in `pending` state. Include optional `buildJobId` and `subjects` filters.
### `POST /graphs/hooks/completed`
Webhook invoked by Scheduler Worker once Cartographer finishes a build/overlay job. Requires `graph:write`.
```jsonc
{
"jobId": "goj_018dc2f5929b4f5c88ad1e43d0ab3b90",
"jobType": "Overlay",
"status": "Completed", // Completed | Failed | Cancelled
"occurredAt": "2025-10-26T12:02:45Z",
"correlationId": "corr-123",
"resultUri": "oras://cartographer/offline/tenant-alpha/graph_snap_20251026"
}
```
The endpoint advances the job through `running → terminal` transitions via `GraphJobStateMachine`, captures the latest correlation identifier, and stores the optional `resultUri` in metadata for downstream exports.
### `GET /graphs/overlays/lag`
Returns per-tenant overlay lag metrics (counts, min/max/average lag seconds, and last five completions with correlation IDs + result URIs). Requires `graph:read`.
### `GET /graphs/jobs`
Returns a combined `GraphJobCollection`. Query parameters:
| Parameter | Description |
|-----------|-------------|
| `type` | Optional filter (`build` or `overlay`). |
| `status` | Optional filter using `GraphJobStatus`. |
| `limit` | Maximum number of results (default 50, max 200). |
Response example:
```jsonc
{
"jobs": [
{
"id": "gbj_018dc2f5902147e2b7f2ea05f5de1f3f",
"tenantId": "tenant-alpha",
"kind": "build",
"status": "pending",
"payload": { /* graph build job */ }
}
]
}
```
## Integration tests
`StellaOps.Scheduler.WebService.Tests/GraphJobEndpointTests.cs` covers scope enforcement and the build-list happy path using the in-memory store. Future work should add overlay coverage once Cartographer adapters are available.
## Known gaps / TODO
- Persist jobs to Scheduler storage and publish `scheduler.graph.job.completed@1` events + outbound webhook to Cartographer (see new `SCHED-WEB-21-004`).
- Extend `GET /graphs/jobs` with pagination cursors shared with Cartographer/Console.

View File

@@ -0,0 +1,32 @@
using System.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo;
using StellaOps.Scheduler.Worker.DependencyInjection;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.Configure(options =>
{
options.ActivityTrackingOptions = ActivityTrackingOptions.TraceId
| ActivityTrackingOptions.SpanId
| ActivityTrackingOptions.ParentId;
});
builder.Services.AddSchedulerQueues(builder.Configuration);
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
if (storageSection.Exists())
{
builder.Services.AddSchedulerMongoStorage(storageSection);
}
builder.Services.AddSchedulerWorker(builder.Configuration.GetSection("Scheduler:Worker"));
var host = builder.Build();
await host.RunAsync();
public partial class Program;

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scheduler.Queue\StellaOps.Scheduler.Queue.csproj" />
<ProjectReference Include="..\StellaOps.Scheduler.Storage.Mongo\StellaOps.Scheduler.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Scheduler.Worker\StellaOps.Scheduler.Worker.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,416 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.WebService", "StellaOps.Scheduler.WebService\StellaOps.Scheduler.WebService.csproj", "{888F7FD1-820A-4784-B169-18A7E4629F77}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Models", "__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj", "{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Storage.Mongo", "__Libraries\StellaOps.Scheduler.Storage.Mongo\StellaOps.Scheduler.Storage.Mongo.csproj", "{33770BC5-6802-45AD-A866-10027DD360E2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.ImpactIndex", "__Libraries\StellaOps.Scheduler.ImpactIndex\StellaOps.Scheduler.ImpactIndex.csproj", "{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{214ED54A-FA25-4189-9F58-50D11F079ACF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{923FB293-CBBD-48DD-8FC1-74ED840935C9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{44FAF98E-B0AD-4AA6-9017-551B5A100F01}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{66B32B7D-0A46-4353-A3C2-0B6238238887}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Queue", "__Libraries\StellaOps.Scheduler.Queue\StellaOps.Scheduler.Queue.csproj", "{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Worker", "__Libraries\StellaOps.Scheduler.Worker\StellaOps.Scheduler.Worker.csproj", "{C48F2207-8974-43A4-B3D6-6A1761C37605}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "..\Notify\__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{F9395736-7220-409E-9C6F-DE083E00EBFF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue", "..\Notify\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj", "{827D179C-A229-439E-A878-4028F30CA670}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Worker.Host", "StellaOps.Scheduler.Worker.Host\StellaOps.Scheduler.Worker.Host.csproj", "{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.ImpactIndex.Tests", "__Tests\StellaOps.Scheduler.ImpactIndex.Tests\StellaOps.Scheduler.ImpactIndex.Tests.csproj", "{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Emit", "..\Scanner\__Libraries\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj", "{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Core", "..\Scanner\__Libraries\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj", "{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{4CF2A8EB-CB97-481D-843E-7F47D5979121}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Storage", "..\Scanner\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj", "{46839CB8-AB2A-4048-BC09-B837B1221F7D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Models.Tests", "__Tests\StellaOps.Scheduler.Models.Tests\StellaOps.Scheduler.Models.Tests.csproj", "{2F097B4B-8F38-45C3-8A42-90250E912F0C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Queue.Tests", "__Tests\StellaOps.Scheduler.Queue.Tests\StellaOps.Scheduler.Queue.Tests.csproj", "{7C22F6B7-095E-459B-BCCF-87098EA9F192}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Storage.Mongo.Tests", "__Tests\StellaOps.Scheduler.Storage.Mongo.Tests\StellaOps.Scheduler.Storage.Mongo.Tests.csproj", "{972CEB4D-510B-4701-B4A2-F14A85F11CC7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.WebService.Tests", "__Tests\StellaOps.Scheduler.WebService.Tests\StellaOps.Scheduler.WebService.Tests.csproj", "{7B4C9EAC-316E-4890-A715-7BB9C1577F96}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scheduler.Worker.Tests", "__Tests\StellaOps.Scheduler.Worker.Tests\StellaOps.Scheduler.Worker.Tests.csproj", "{D640DBB2-4251-44B3-B949-75FC6BF02B71}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{888F7FD1-820A-4784-B169-18A7E4629F77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Debug|Any CPU.Build.0 = Debug|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Debug|x64.ActiveCfg = Debug|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Debug|x64.Build.0 = Debug|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Debug|x86.ActiveCfg = Debug|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Debug|x86.Build.0 = Debug|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Release|Any CPU.ActiveCfg = Release|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Release|Any CPU.Build.0 = Release|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Release|x64.ActiveCfg = Release|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Release|x64.Build.0 = Release|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Release|x86.ActiveCfg = Release|Any CPU
{888F7FD1-820A-4784-B169-18A7E4629F77}.Release|x86.Build.0 = Release|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Debug|x64.ActiveCfg = Debug|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Debug|x64.Build.0 = Debug|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Debug|x86.ActiveCfg = Debug|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Debug|x86.Build.0 = Debug|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|Any CPU.Build.0 = Release|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|x64.ActiveCfg = Release|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|x64.Build.0 = Release|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|x86.ActiveCfg = Release|Any CPU
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B}.Release|x86.Build.0 = Release|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x64.ActiveCfg = Debug|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x64.Build.0 = Debug|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x86.ActiveCfg = Debug|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Debug|x86.Build.0 = Debug|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|Any CPU.Build.0 = Release|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x64.ActiveCfg = Release|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x64.Build.0 = Release|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x86.ActiveCfg = Release|Any CPU
{33770BC5-6802-45AD-A866-10027DD360E2}.Release|x86.Build.0 = Release|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|x64.ActiveCfg = Debug|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|x64.Build.0 = Debug|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|x86.ActiveCfg = Debug|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Debug|x86.Build.0 = Debug|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Release|Any CPU.Build.0 = Release|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Release|x64.ActiveCfg = Release|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Release|x64.Build.0 = Release|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Release|x86.ActiveCfg = Release|Any CPU
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2}.Release|x86.Build.0 = Release|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Debug|x64.ActiveCfg = Debug|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Debug|x64.Build.0 = Debug|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Debug|x86.ActiveCfg = Debug|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Debug|x86.Build.0 = Debug|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Release|Any CPU.Build.0 = Release|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Release|x64.ActiveCfg = Release|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Release|x64.Build.0 = Release|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Release|x86.ActiveCfg = Release|Any CPU
{2F9CDB3D-7BB5-46B6-A51B-49AB498CC959}.Release|x86.Build.0 = Release|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Debug|x64.ActiveCfg = Debug|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Debug|x64.Build.0 = Debug|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Debug|x86.ActiveCfg = Debug|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Debug|x86.Build.0 = Debug|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Release|Any CPU.Build.0 = Release|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Release|x64.ActiveCfg = Release|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Release|x64.Build.0 = Release|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Release|x86.ActiveCfg = Release|Any CPU
{214ED54A-FA25-4189-9F58-50D11F079ACF}.Release|x86.Build.0 = Release|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Debug|x64.ActiveCfg = Debug|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Debug|x64.Build.0 = Debug|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Debug|x86.ActiveCfg = Debug|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Debug|x86.Build.0 = Debug|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Release|Any CPU.Build.0 = Release|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Release|x64.ActiveCfg = Release|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Release|x64.Build.0 = Release|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Release|x86.ActiveCfg = Release|Any CPU
{923FB293-CBBD-48DD-8FC1-74ED840935C9}.Release|x86.Build.0 = Release|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Debug|x64.ActiveCfg = Debug|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Debug|x64.Build.0 = Debug|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Debug|x86.ActiveCfg = Debug|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Debug|x86.Build.0 = Debug|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Release|Any CPU.Build.0 = Release|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Release|x64.ActiveCfg = Release|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Release|x64.Build.0 = Release|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Release|x86.ActiveCfg = Release|Any CPU
{44FAF98E-B0AD-4AA6-9017-551B5A100F01}.Release|x86.Build.0 = Release|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Debug|x64.ActiveCfg = Debug|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Debug|x64.Build.0 = Debug|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Debug|x86.ActiveCfg = Debug|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Debug|x86.Build.0 = Debug|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Release|Any CPU.Build.0 = Release|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Release|x64.ActiveCfg = Release|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Release|x64.Build.0 = Release|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Release|x86.ActiveCfg = Release|Any CPU
{60EFD52D-AA51-4DE5-BC40-ED9DCC30802C}.Release|x86.Build.0 = Release|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Debug|x64.ActiveCfg = Debug|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Debug|x64.Build.0 = Debug|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Debug|x86.ActiveCfg = Debug|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Debug|x86.Build.0 = Debug|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Release|Any CPU.ActiveCfg = Release|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Release|Any CPU.Build.0 = Release|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Release|x64.ActiveCfg = Release|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Release|x64.Build.0 = Release|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Release|x86.ActiveCfg = Release|Any CPU
{66B32B7D-0A46-4353-A3C2-0B6238238887}.Release|x86.Build.0 = Release|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Debug|x64.ActiveCfg = Debug|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Debug|x64.Build.0 = Debug|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Debug|x86.ActiveCfg = Debug|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Debug|x86.Build.0 = Debug|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Release|Any CPU.Build.0 = Release|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Release|x64.ActiveCfg = Release|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Release|x64.Build.0 = Release|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Release|x86.ActiveCfg = Release|Any CPU
{E4F0FC41-BD88-4D5D-ADEE-5C74FDFACA4D}.Release|x86.Build.0 = Release|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Debug|x64.ActiveCfg = Debug|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Debug|x64.Build.0 = Debug|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Debug|x86.ActiveCfg = Debug|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Debug|x86.Build.0 = Debug|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Release|Any CPU.Build.0 = Release|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Release|x64.ActiveCfg = Release|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Release|x64.Build.0 = Release|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Release|x86.ActiveCfg = Release|Any CPU
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4}.Release|x86.Build.0 = Release|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Debug|x64.ActiveCfg = Debug|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Debug|x64.Build.0 = Debug|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Debug|x86.ActiveCfg = Debug|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Debug|x86.Build.0 = Debug|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Release|Any CPU.Build.0 = Release|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Release|x64.ActiveCfg = Release|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Release|x64.Build.0 = Release|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Release|x86.ActiveCfg = Release|Any CPU
{C48F2207-8974-43A4-B3D6-6A1761C37605}.Release|x86.Build.0 = Release|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Debug|x64.ActiveCfg = Debug|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Debug|x64.Build.0 = Debug|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Debug|x86.ActiveCfg = Debug|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Debug|x86.Build.0 = Debug|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Release|Any CPU.Build.0 = Release|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Release|x64.ActiveCfg = Release|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Release|x64.Build.0 = Release|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Release|x86.ActiveCfg = Release|Any CPU
{F9395736-7220-409E-9C6F-DE083E00EBFF}.Release|x86.Build.0 = Release|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Debug|Any CPU.Build.0 = Debug|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Debug|x64.ActiveCfg = Debug|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Debug|x64.Build.0 = Debug|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Debug|x86.ActiveCfg = Debug|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Debug|x86.Build.0 = Debug|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Release|Any CPU.ActiveCfg = Release|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Release|Any CPU.Build.0 = Release|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Release|x64.ActiveCfg = Release|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Release|x64.Build.0 = Release|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Release|x86.ActiveCfg = Release|Any CPU
{827D179C-A229-439E-A878-4028F30CA670}.Release|x86.Build.0 = Release|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Debug|x64.ActiveCfg = Debug|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Debug|x64.Build.0 = Debug|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Debug|x86.ActiveCfg = Debug|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Debug|x86.Build.0 = Debug|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Release|Any CPU.ActiveCfg = Release|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Release|Any CPU.Build.0 = Release|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Release|x64.ActiveCfg = Release|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Release|x64.Build.0 = Release|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Release|x86.ActiveCfg = Release|Any CPU
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082}.Release|x86.Build.0 = Release|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Debug|x64.ActiveCfg = Debug|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Debug|x64.Build.0 = Debug|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Debug|x86.ActiveCfg = Debug|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Debug|x86.Build.0 = Debug|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Release|Any CPU.Build.0 = Release|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Release|x64.ActiveCfg = Release|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Release|x64.Build.0 = Release|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Release|x86.ActiveCfg = Release|Any CPU
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F}.Release|x86.Build.0 = Release|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Debug|x64.ActiveCfg = Debug|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Debug|x64.Build.0 = Debug|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Debug|x86.ActiveCfg = Debug|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Debug|x86.Build.0 = Debug|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Release|Any CPU.Build.0 = Release|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Release|x64.ActiveCfg = Release|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Release|x64.Build.0 = Release|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Release|x86.ActiveCfg = Release|Any CPU
{11D72DD3-3752-4A6A-AA4A-5298D4FD6FA0}.Release|x86.Build.0 = Release|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Debug|x64.ActiveCfg = Debug|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Debug|x64.Build.0 = Debug|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Debug|x86.ActiveCfg = Debug|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Debug|x86.Build.0 = Debug|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Release|Any CPU.Build.0 = Release|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Release|x64.ActiveCfg = Release|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Release|x64.Build.0 = Release|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Release|x86.ActiveCfg = Release|Any CPU
{8D4FF2D1-D387-4F85-9A2E-A952B2AFAE7B}.Release|x86.Build.0 = Release|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Debug|x64.ActiveCfg = Debug|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Debug|x64.Build.0 = Debug|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Debug|x86.ActiveCfg = Debug|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Debug|x86.Build.0 = Debug|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Release|Any CPU.Build.0 = Release|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Release|x64.ActiveCfg = Release|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Release|x64.Build.0 = Release|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Release|x86.ActiveCfg = Release|Any CPU
{4CF2A8EB-CB97-481D-843E-7F47D5979121}.Release|x86.Build.0 = Release|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Debug|x64.ActiveCfg = Debug|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Debug|x64.Build.0 = Debug|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Debug|x86.ActiveCfg = Debug|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Debug|x86.Build.0 = Debug|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Release|Any CPU.Build.0 = Release|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Release|x64.ActiveCfg = Release|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Release|x64.Build.0 = Release|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Release|x86.ActiveCfg = Release|Any CPU
{FBBEE020-A7B1-41A8-AEC9-711A4F8B2097}.Release|x86.Build.0 = Release|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Debug|x64.ActiveCfg = Debug|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Debug|x64.Build.0 = Debug|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Debug|x86.ActiveCfg = Debug|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Debug|x86.Build.0 = Debug|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Release|Any CPU.Build.0 = Release|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Release|x64.ActiveCfg = Release|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Release|x64.Build.0 = Release|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Release|x86.ActiveCfg = Release|Any CPU
{46839CB8-AB2A-4048-BC09-B837B1221F7D}.Release|x86.Build.0 = Release|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Debug|x64.ActiveCfg = Debug|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Debug|x64.Build.0 = Debug|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Debug|x86.ActiveCfg = Debug|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Debug|x86.Build.0 = Debug|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Release|Any CPU.Build.0 = Release|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Release|x64.ActiveCfg = Release|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Release|x64.Build.0 = Release|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Release|x86.ActiveCfg = Release|Any CPU
{2F097B4B-8F38-45C3-8A42-90250E912F0C}.Release|x86.Build.0 = Release|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Debug|x64.ActiveCfg = Debug|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Debug|x64.Build.0 = Debug|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Debug|x86.ActiveCfg = Debug|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Debug|x86.Build.0 = Debug|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|Any CPU.Build.0 = Release|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|x64.ActiveCfg = Release|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|x64.Build.0 = Release|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|x86.ActiveCfg = Release|Any CPU
{7C22F6B7-095E-459B-BCCF-87098EA9F192}.Release|x86.Build.0 = Release|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x64.ActiveCfg = Debug|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x64.Build.0 = Debug|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x86.ActiveCfg = Debug|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Debug|x86.Build.0 = Debug|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|Any CPU.Build.0 = Release|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x64.ActiveCfg = Release|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x64.Build.0 = Release|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x86.ActiveCfg = Release|Any CPU
{972CEB4D-510B-4701-B4A2-F14A85F11CC7}.Release|x86.Build.0 = Release|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|x64.ActiveCfg = Debug|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|x64.Build.0 = Debug|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|x86.ActiveCfg = Debug|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Debug|x86.Build.0 = Debug|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Release|Any CPU.Build.0 = Release|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Release|x64.ActiveCfg = Release|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Release|x64.Build.0 = Release|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Release|x86.ActiveCfg = Release|Any CPU
{7B4C9EAC-316E-4890-A715-7BB9C1577F96}.Release|x86.Build.0 = Release|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Debug|x64.ActiveCfg = Debug|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Debug|x64.Build.0 = Debug|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Debug|x86.ActiveCfg = Debug|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Debug|x86.Build.0 = Debug|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Release|Any CPU.Build.0 = Release|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Release|x64.ActiveCfg = Release|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Release|x64.Build.0 = Release|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Release|x86.ActiveCfg = Release|Any CPU
{D640DBB2-4251-44B3-B949-75FC6BF02B71}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{382FA1C0-5F5F-424A-8485-7FED0ADE9F6B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{33770BC5-6802-45AD-A866-10027DD360E2} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{56209C24-3CE7-4F8E-8B8C-F052CB919DE2} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{6A62C12A-8742-4D1E-AEA7-8DDC3C722AC4} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{C48F2207-8974-43A4-B3D6-6A1761C37605} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{37FA8A12-E96E-4F23-AB72-8FA9DD9DA082} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{5ED2BF16-72CE-4DF1-917C-6D832427AE6F} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{2F097B4B-8F38-45C3-8A42-90250E912F0C} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{7C22F6B7-095E-459B-BCCF-87098EA9F192} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{972CEB4D-510B-4701-B4A2-F14A85F11CC7} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{7B4C9EAC-316E-4890-A715-7BB9C1577F96} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{D640DBB2-4251-44B3-B949-75FC6BF02B71} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,4 @@
# StellaOps.Scheduler.ImpactIndex — Agent Charter
## Mission
Build the global impact index per `docs/ARCHITECTURE_SCHEDULER.md` (roaring bitmaps, selectors, snapshotting).

View File

@@ -0,0 +1,615 @@
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.IO;
using System.IO.Enumeration;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.ImpactIndex;
/// <summary>
/// Fixture-backed implementation of <see cref="IImpactIndex"/> used while the real index is under construction.
/// </summary>
public sealed class FixtureImpactIndex : IImpactIndex
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
private readonly ImpactIndexStubOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<FixtureImpactIndex> _logger;
private readonly SemaphoreSlim _initializationLock = new(1, 1);
private FixtureIndexState? _state;
public FixtureImpactIndex(
ImpactIndexStubOptions options,
TimeProvider? timeProvider,
ILogger<FixtureImpactIndex> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<ImpactSet> ResolveByPurlsAsync(
IEnumerable<string> purls,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(purls);
ArgumentNullException.ThrowIfNull(selector);
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
var normalizedPurls = NormalizeKeys(purls);
if (normalizedPurls.Length == 0)
{
return CreateImpactSet(state, selector, Enumerable.Empty<FixtureMatch>(), usageOnly);
}
var matches = new List<FixtureMatch>();
foreach (var purl in normalizedPurls)
{
cancellationToken.ThrowIfCancellationRequested();
if (!state.PurlIndex.TryGetValue(purl, out var componentMatches))
{
continue;
}
foreach (var component in componentMatches)
{
var usedByEntrypoint = component.Component.UsedByEntrypoint;
if (usageOnly && !usedByEntrypoint)
{
continue;
}
matches.Add(new FixtureMatch(component.Image, usedByEntrypoint));
}
}
return CreateImpactSet(state, selector, matches, usageOnly);
}
public async ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
IEnumerable<string> vulnerabilityIds,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(vulnerabilityIds);
ArgumentNullException.ThrowIfNull(selector);
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
// The stub does not maintain a vulnerability → purl projection, so we return an empty result.
if (_logger.IsEnabled(LogLevel.Debug))
{
var first = vulnerabilityIds.FirstOrDefault(static id => !string.IsNullOrWhiteSpace(id));
if (first is not null)
{
_logger.LogDebug(
"ImpactIndex stub received ResolveByVulnerabilitiesAsync for '{VulnerabilityId}' but mappings are not available.",
first);
}
}
return CreateImpactSet(state, selector, Enumerable.Empty<FixtureMatch>(), usageOnly);
}
public async ValueTask<ImpactSet> ResolveAllAsync(
Selector selector,
bool usageOnly,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(selector);
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
var matches = state.ImagesByDigest.Values
.Select(image => new FixtureMatch(image, image.UsedByEntrypoint))
.Where(match => !usageOnly || match.UsedByEntrypoint);
return CreateImpactSet(state, selector, matches, usageOnly);
}
private async Task<FixtureIndexState> EnsureInitializedAsync(CancellationToken cancellationToken)
{
if (_state is not null)
{
return _state;
}
await _initializationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_state is not null)
{
return _state;
}
var state = await LoadAsync(cancellationToken).ConfigureAwait(false);
_state = state;
_logger.LogInformation(
"ImpactIndex stub loaded {ImageCount} fixture images from {SourceDescription}.",
state.ImagesByDigest.Count,
state.SourceDescription);
return state;
}
finally
{
_initializationLock.Release();
}
}
private async Task<FixtureIndexState> LoadAsync(CancellationToken cancellationToken)
{
var images = new List<FixtureImage>();
string? sourceDescription = null;
if (!string.IsNullOrWhiteSpace(_options.FixtureDirectory))
{
var directory = ResolveDirectoryPath(_options.FixtureDirectory!);
if (Directory.Exists(directory))
{
images.AddRange(await LoadFromDirectoryAsync(directory, cancellationToken).ConfigureAwait(false));
sourceDescription = directory;
}
else
{
_logger.LogWarning(
"ImpactIndex stub fixture directory '{Directory}' was not found. Falling back to embedded fixtures.",
directory);
}
}
if (images.Count == 0)
{
images.AddRange(await LoadFromResourcesAsync(cancellationToken).ConfigureAwait(false));
sourceDescription ??= "embedded:scheduler-impact-index-fixtures";
}
if (images.Count == 0)
{
throw new InvalidOperationException("No BOM-Index fixtures were found for the ImpactIndex stub.");
}
return BuildState(images, sourceDescription!, _options.SnapshotId);
}
private static string ResolveDirectoryPath(string path)
{
if (Path.IsPathRooted(path))
{
return path;
}
var basePath = AppContext.BaseDirectory;
return Path.GetFullPath(Path.Combine(basePath, path));
}
private static async Task<IReadOnlyList<FixtureImage>> LoadFromDirectoryAsync(
string directory,
CancellationToken cancellationToken)
{
var results = new List<FixtureImage>();
foreach (var file in Directory.EnumerateFiles(directory, "bom-index.json", SearchOption.AllDirectories)
.OrderBy(static file => file, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
await using var stream = File.OpenRead(file);
var document = await JsonSerializer.DeserializeAsync<BomIndexDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is null)
{
continue;
}
results.Add(CreateFixtureImage(document));
}
return results;
}
private static async Task<IReadOnlyList<FixtureImage>> LoadFromResourcesAsync(CancellationToken cancellationToken)
{
var assembly = typeof(FixtureImpactIndex).Assembly;
var resourceNames = assembly
.GetManifestResourceNames()
.Where(static name => name.EndsWith(".bom-index.json", StringComparison.OrdinalIgnoreCase))
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var results = new List<FixtureImage>(resourceNames.Length);
foreach (var resourceName in resourceNames)
{
cancellationToken.ThrowIfCancellationRequested();
await using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
continue;
}
var document = await JsonSerializer.DeserializeAsync<BomIndexDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is null)
{
continue;
}
results.Add(CreateFixtureImage(document));
}
return results;
}
private static FixtureIndexState BuildState(
IReadOnlyList<FixtureImage> images,
string sourceDescription,
string snapshotId)
{
var imagesByDigest = images
.GroupBy(static image => image.Digest, StringComparer.OrdinalIgnoreCase)
.ToImmutableDictionary(
static group => group.Key,
static group => group
.OrderBy(static image => image.Repository, StringComparer.Ordinal)
.ThenBy(static image => image.Registry, StringComparer.Ordinal)
.ThenBy(static image => image.Tags.Length, Comparer<int>.Default)
.First(),
StringComparer.OrdinalIgnoreCase);
var purlIndexBuilder = new Dictionary<string, List<FixtureComponentMatch>>(StringComparer.OrdinalIgnoreCase);
foreach (var image in images)
{
foreach (var component in image.Components)
{
if (!purlIndexBuilder.TryGetValue(component.Purl, out var list))
{
list = new List<FixtureComponentMatch>();
purlIndexBuilder[component.Purl] = list;
}
list.Add(new FixtureComponentMatch(image, component));
}
}
var purlIndex = purlIndexBuilder.ToImmutableDictionary(
static entry => entry.Key,
static entry => entry.Value
.OrderBy(static item => item.Image.Digest, StringComparer.Ordinal)
.Select(static item => new FixtureComponentMatch(item.Image, item.Component))
.ToImmutableArray(),
StringComparer.OrdinalIgnoreCase);
var generatedAt = images.Count == 0
? DateTimeOffset.UnixEpoch
: images.Max(static image => image.GeneratedAt);
return new FixtureIndexState(imagesByDigest, purlIndex, generatedAt, sourceDescription, snapshotId);
}
private ImpactSet CreateImpactSet(
FixtureIndexState state,
Selector selector,
IEnumerable<FixtureMatch> matches,
bool usageOnly)
{
var aggregated = new Dictionary<string, ImpactImageBuilder>(StringComparer.OrdinalIgnoreCase);
foreach (var match in matches)
{
if (!ImageMatchesSelector(match.Image, selector))
{
continue;
}
if (!aggregated.TryGetValue(match.Image.Digest, out var builder))
{
builder = new ImpactImageBuilder(match.Image);
aggregated[match.Image.Digest] = builder;
}
builder.MarkUsedByEntrypoint(match.UsedByEntrypoint);
}
var images = aggregated.Values
.Select(static builder => builder.Build())
.OrderBy(static image => image.ImageDigest, StringComparer.Ordinal)
.ToImmutableArray();
return new ImpactSet(
selector,
images,
usageOnly,
state.GeneratedAt == DateTimeOffset.UnixEpoch
? _timeProvider.GetUtcNow()
: state.GeneratedAt,
images.Length,
state.SnapshotId,
SchedulerSchemaVersions.ImpactSet);
}
private static bool ImageMatchesSelector(FixtureImage image, Selector selector)
{
if (selector is null)
{
return true;
}
if (selector.Digests.Length > 0 &&
!selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (selector.Repositories.Length > 0)
{
var repositoryMatch = selector.Repositories.Any(repo =>
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase));
if (!repositoryMatch)
{
return false;
}
}
if (selector.Namespaces.Length > 0)
{
if (image.Namespaces.IsDefaultOrEmpty)
{
return false;
}
var namespaceMatch = selector.Namespaces.Any(namespaceId =>
image.Namespaces.Contains(namespaceId, StringComparer.OrdinalIgnoreCase));
if (!namespaceMatch)
{
return false;
}
}
if (selector.IncludeTags.Length > 0)
{
if (image.Tags.IsDefaultOrEmpty)
{
return false;
}
var tagMatch = selector.IncludeTags.Any(pattern =>
MatchesAnyTag(image.Tags, pattern));
if (!tagMatch)
{
return false;
}
}
if (selector.Labels.Length > 0)
{
if (image.Labels.Count == 0)
{
return false;
}
foreach (var labelSelector in selector.Labels)
{
if (!image.Labels.TryGetValue(labelSelector.Key, out var value))
{
return false;
}
if (labelSelector.Values.Length > 0 &&
!labelSelector.Values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
return false;
}
}
}
return selector.Scope switch
{
SelectorScope.ByDigest => selector.Digests.Length == 0
? true
: selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase),
SelectorScope.ByRepository => selector.Repositories.Length == 0
? true
: selector.Repositories.Any(repo =>
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase)),
SelectorScope.ByNamespace => selector.Namespaces.Length == 0
? true
: !image.Namespaces.IsDefaultOrEmpty &&
selector.Namespaces.Any(namespaceId =>
image.Namespaces.Contains(namespaceId, StringComparer.OrdinalIgnoreCase)),
SelectorScope.ByLabels => selector.Labels.Length == 0
? true
: selector.Labels.All(label =>
image.Labels.TryGetValue(label.Key, out var value) &&
(label.Values.Length == 0 || label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))),
_ => true,
};
}
private static bool MatchesAnyTag(ImmutableArray<string> tags, string pattern)
{
foreach (var tag in tags)
{
if (FileSystemName.MatchesSimpleExpression(pattern, tag, ignoreCase: true))
{
return true;
}
}
return false;
}
private static FixtureImage CreateFixtureImage(BomIndexDocument document)
{
if (document.Image is null)
{
throw new InvalidOperationException("BOM-Index image metadata is required.");
}
var digest = Validation.EnsureDigestFormat(document.Image.Digest, "image.digest");
var (registry, repository) = SplitRepository(document.Image.Repository);
var tags = string.IsNullOrWhiteSpace(document.Image.Tag)
? ImmutableArray<string>.Empty
: ImmutableArray.Create(document.Image.Tag.Trim());
var components = (document.Components ?? Array.Empty<BomIndexComponent>())
.Where(static component => !string.IsNullOrWhiteSpace(component.Purl))
.Select(component => new FixtureComponent(
component.Purl!.Trim(),
component.Usage?.Any(static usage =>
usage.Equals("runtime", StringComparison.OrdinalIgnoreCase) ||
usage.Equals("usedByEntrypoint", StringComparison.OrdinalIgnoreCase)) == true))
.OrderBy(static component => component.Purl, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new FixtureImage(
digest,
registry,
repository,
ImmutableArray<string>.Empty,
tags,
ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase),
components,
document.GeneratedAt == default ? DateTimeOffset.UnixEpoch : document.GeneratedAt.ToUniversalTime(),
components.Any(static component => component.UsedByEntrypoint));
}
private static (string Registry, string Repository) SplitRepository(string repository)
{
var normalized = Validation.EnsureNotNullOrWhiteSpace(repository, nameof(repository));
var separatorIndex = normalized.IndexOf('/');
if (separatorIndex < 0)
{
return ("docker.io", normalized);
}
var registry = normalized[..separatorIndex];
var repo = normalized[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(repo))
{
throw new ArgumentException("Repository segment is required after registry.", nameof(repository));
}
return (registry.Trim(), repo.Trim());
}
private static string[] NormalizeKeys(IEnumerable<string> values)
{
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private readonly record struct FixtureMatch(FixtureImage Image, bool UsedByEntrypoint);
private sealed record FixtureImage(
string Digest,
string Registry,
string Repository,
ImmutableArray<string> Namespaces,
ImmutableArray<string> Tags,
ImmutableSortedDictionary<string, string> Labels,
ImmutableArray<FixtureComponent> Components,
DateTimeOffset GeneratedAt,
bool UsedByEntrypoint);
private sealed record FixtureComponent(string Purl, bool UsedByEntrypoint);
private sealed record FixtureComponentMatch(FixtureImage Image, FixtureComponent Component);
private sealed record FixtureIndexState(
ImmutableDictionary<string, FixtureImage> ImagesByDigest,
ImmutableDictionary<string, ImmutableArray<FixtureComponentMatch>> PurlIndex,
DateTimeOffset GeneratedAt,
string SourceDescription,
string SnapshotId);
private sealed class ImpactImageBuilder
{
private readonly FixtureImage _image;
private bool _usedByEntrypoint;
public ImpactImageBuilder(FixtureImage image)
{
_image = image;
}
public void MarkUsedByEntrypoint(bool usedByEntrypoint)
{
_usedByEntrypoint |= usedByEntrypoint;
}
public ImpactImage Build()
{
return new ImpactImage(
_image.Digest,
_image.Registry,
_image.Repository,
_image.Namespaces,
_image.Tags,
_usedByEntrypoint,
_image.Labels);
}
}
private sealed record BomIndexDocument
{
[JsonPropertyName("schema")]
public string? Schema { get; init; }
[JsonPropertyName("image")]
public BomIndexImage? Image { get; init; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("components")]
public IReadOnlyList<BomIndexComponent>? Components { get; init; }
}
private sealed record BomIndexImage
{
[JsonPropertyName("repository")]
public string Repository { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("tag")]
public string? Tag { get; init; }
}
private sealed record BomIndexComponent
{
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("usage")]
public IReadOnlyList<string>? Usage { get; init; }
}
}

View File

@@ -0,0 +1,46 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.ImpactIndex;
/// <summary>
/// Provides read access to the scheduler impact index.
/// </summary>
public interface IImpactIndex
{
/// <summary>
/// Resolves the impacted image set for the provided package URLs.
/// </summary>
/// <param name="purls">Package URLs to look up.</param>
/// <param name="usageOnly">When true, restricts results to components marked as runtime/entrypoint usage.</param>
/// <param name="selector">Selector scoping the query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask<ImpactSet> ResolveByPurlsAsync(
IEnumerable<string> purls,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves impacted images by vulnerability identifiers if the index has the mapping available.
/// </summary>
/// <param name="vulnerabilityIds">Vulnerability identifiers to look up.</param>
/// <param name="usageOnly">When true, restricts results to components marked as runtime/entrypoint usage.</param>
/// <param name="selector">Selector scoping the query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
IEnumerable<string> vulnerabilityIds,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves all tracked images for the provided selector.
/// </summary>
/// <param name="selector">Selector scoping the query.</param>
/// <param name="usageOnly">When true, restricts results to images with entrypoint usage.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask<ImpactSet> ResolveAllAsync(
Selector selector,
bool usageOnly,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Scheduler.ImpactIndex;
internal sealed record ImpactImageRecord(
int ImageId,
string TenantId,
string Digest,
string Registry,
string Repository,
ImmutableArray<string> Namespaces,
ImmutableArray<string> Tags,
ImmutableSortedDictionary<string, string> Labels,
DateTimeOffset GeneratedAt,
ImmutableArray<string> Components,
ImmutableArray<string> EntrypointComponents);

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Scheduler.ImpactIndex;
/// <summary>
/// ServiceCollection helpers for wiring the fixture-backed impact index.
/// </summary>
public static class ImpactIndexServiceCollectionExtensions
{
public static IServiceCollection AddImpactIndexStub(
this IServiceCollection services,
Action<ImpactIndexStubOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
var options = new ImpactIndexStubOptions();
configure?.Invoke(options);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(options);
services.TryAddSingleton<IImpactIndex, FixtureImpactIndex>();
return services;
}
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Scheduler.ImpactIndex;
/// <summary>
/// Options controlling the fixture-backed impact index stub.
/// </summary>
public sealed class ImpactIndexStubOptions
{
/// <summary>
/// Optional absolute or relative directory containing BOM-Index JSON fixtures.
/// When not supplied or not found, embedded fixtures ship with the assembly are used instead.
/// </summary>
public string? FixtureDirectory { get; set; }
/// <summary>
/// Snapshot identifier reported in the generated <see cref="StellaOps.Scheduler.Models.ImpactSet"/>.
/// Defaults to <c>samples/impact-index-stub</c>.
/// </summary>
public string SnapshotId { get; set; } = "samples/impact-index-stub";
}

View File

@@ -0,0 +1,119 @@
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using Collections.Special;
namespace StellaOps.Scheduler.ImpactIndex.Ingestion;
internal sealed record BomIndexComponent(string Key, bool UsedByEntrypoint);
internal sealed record BomIndexDocument(string ImageDigest, DateTimeOffset GeneratedAt, ImmutableArray<BomIndexComponent> Components);
internal static class BomIndexReader
{
private const int HeaderMagicLength = 7;
private static readonly byte[] Magic = Encoding.ASCII.GetBytes("BOMIDX1");
public static BomIndexDocument Read(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
Span<byte> magicBuffer = stackalloc byte[HeaderMagicLength];
if (reader.Read(magicBuffer) != HeaderMagicLength || !magicBuffer.SequenceEqual(Magic))
{
throw new InvalidOperationException("Invalid BOM index header magic.");
}
var version = reader.ReadUInt16();
if (version != 1)
{
throw new NotSupportedException($"Unsupported BOM index version '{version}'.");
}
var flags = reader.ReadUInt16();
var hasEntrypoints = (flags & 0x1) == 1;
var digestLength = reader.ReadUInt16();
var digestBytes = reader.ReadBytes(digestLength);
var imageDigest = Encoding.UTF8.GetString(digestBytes);
var generatedAtMicros = reader.ReadInt64();
var generatedAt = DateTimeOffset.FromUnixTimeMilliseconds(generatedAtMicros / 1000)
.AddTicks((generatedAtMicros % 1000) * TimeSpan.TicksPerMillisecond / 1000);
var layerCount = checked((int)reader.ReadUInt32());
var componentCount = checked((int)reader.ReadUInt32());
var entrypointCount = checked((int)reader.ReadUInt32());
// Layer table (we only need to skip entries but validate length)
for (var i = 0; i < layerCount; i++)
{
_ = ReadUtf8String(reader);
}
var componentKeys = new string[componentCount];
for (var i = 0; i < componentCount; i++)
{
componentKeys[i] = ReadUtf8String(reader);
}
for (var i = 0; i < componentCount; i++)
{
var length = reader.ReadUInt32();
if (length > 0)
{
var payload = reader.ReadBytes(checked((int)length));
using var bitmapStream = new MemoryStream(payload, writable: false);
_ = RoaringBitmap.Deserialize(bitmapStream);
}
}
var entrypointPresence = new bool[componentCount];
if (hasEntrypoints && entrypointCount > 0)
{
// Entrypoint table (skip strings)
for (var i = 0; i < entrypointCount; i++)
{
_ = ReadUtf8String(reader);
}
for (var i = 0; i < componentCount; i++)
{
var length = reader.ReadUInt32();
if (length == 0)
{
entrypointPresence[i] = false;
continue;
}
var payload = reader.ReadBytes(checked((int)length));
using var bitmapStream = new MemoryStream(payload, writable: false);
var bitmap = RoaringBitmap.Deserialize(bitmapStream);
entrypointPresence[i] = bitmap.Any();
}
}
var builder = ImmutableArray.CreateBuilder<BomIndexComponent>(componentCount);
for (var i = 0; i < componentCount; i++)
{
var key = componentKeys[i];
builder.Add(new BomIndexComponent(key, entrypointPresence[i]));
}
return new BomIndexDocument(imageDigest, generatedAt, builder.MoveToImmutable());
}
private static string ReadUtf8String(BinaryReader reader)
{
var length = reader.ReadUInt16();
if (length == 0)
{
return string.Empty;
}
var bytes = reader.ReadBytes(length);
return Encoding.UTF8.GetString(bytes);
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Immutable;
using System.IO;
namespace StellaOps.Scheduler.ImpactIndex.Ingestion;
/// <summary>
/// Describes a BOM-Index ingestion payload for the scheduler impact index.
/// </summary>
public sealed record ImpactIndexIngestionRequest
{
public required string TenantId { get; init; }
public required string ImageDigest { get; init; }
public required string Registry { get; init; }
public required string Repository { get; init; }
public ImmutableArray<string> Namespaces { get; init; } = ImmutableArray<string>.Empty;
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
public ImmutableSortedDictionary<string, string> Labels { get; init; } = ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase);
public required Stream BomIndexStream { get; init; }
= Stream.Null;
}

View File

@@ -0,0 +1,15 @@
# ImpactIndex Stub Removal Tracker
- **Created:** 2025-10-20
- **Owner:** Scheduler ImpactIndex Guild
- **Reference Task:** SCHED-IMPACT-16-300 (fixture-backed stub)
## Exit Reminder
Replace `FixtureImpactIndex` with the roaring bitmap-backed implementation once SCHED-IMPACT-16-301/302 are completed, then delete:
1. Stub classes (`FixtureImpactIndex`, `ImpactIndexStubOptions`, `ImpactIndexServiceCollectionExtensions`).
2. Embedded sample fixture wiring in `StellaOps.Scheduler.ImpactIndex.csproj`.
3. Temporary unit tests in `StellaOps.Scheduler.ImpactIndex.Tests`.
Remove this file when the production ImpactIndex replaces the stub.

View File

@@ -0,0 +1,481 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Collections.Special;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.ImpactIndex.Ingestion;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.ImpactIndex;
/// <summary>
/// Roaring bitmap-backed implementation of the scheduler impact index.
/// </summary>
public sealed class RoaringImpactIndex : IImpactIndex
{
private readonly object _gate = new();
private readonly Dictionary<string, int> _imageIds = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, ImpactImageRecord> _images = new();
private readonly Dictionary<string, RoaringBitmap> _containsByPurl = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, RoaringBitmap> _usedByEntrypointByPurl = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<RoaringImpactIndex> _logger;
private readonly TimeProvider _timeProvider;
public RoaringImpactIndex(ILogger<RoaringImpactIndex> logger, TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task IngestAsync(ImpactIndexIngestionRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.BomIndexStream);
using var buffer = new MemoryStream();
await request.BomIndexStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
buffer.Position = 0;
var document = BomIndexReader.Read(buffer);
if (!string.Equals(document.ImageDigest, request.ImageDigest, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"BOM-Index digest mismatch. Header '{document.ImageDigest}', request '{request.ImageDigest}'.");
}
var tenantId = request.TenantId ?? throw new ArgumentNullException(nameof(request.TenantId));
var registry = request.Registry ?? throw new ArgumentNullException(nameof(request.Registry));
var repository = request.Repository ?? throw new ArgumentNullException(nameof(request.Repository));
var namespaces = request.Namespaces.IsDefault ? ImmutableArray<string>.Empty : request.Namespaces;
var tags = request.Tags.IsDefault ? ImmutableArray<string>.Empty : request.Tags;
var labels = request.Labels.Count == 0
? ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase)
: request.Labels;
var componentKeys = document.Components
.Select(component => component.Key)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var entrypointComponents = document.Components
.Where(component => component.UsedByEntrypoint)
.Select(component => component.Key)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
lock (_gate)
{
var imageId = EnsureImageId(request.ImageDigest);
if (_images.TryGetValue(imageId, out var existing))
{
RemoveImageComponents(existing);
}
var metadata = new ImpactImageRecord(
imageId,
tenantId,
request.ImageDigest,
registry,
repository,
namespaces,
tags,
labels,
document.GeneratedAt,
componentKeys,
entrypointComponents);
_images[imageId] = metadata;
_imageIds[request.ImageDigest] = imageId;
foreach (var key in componentKeys)
{
var bitmap = _containsByPurl.GetValueOrDefault(key);
_containsByPurl[key] = AddImageToBitmap(bitmap, imageId);
}
foreach (var key in entrypointComponents)
{
var bitmap = _usedByEntrypointByPurl.GetValueOrDefault(key);
_usedByEntrypointByPurl[key] = AddImageToBitmap(bitmap, imageId);
}
}
_logger.LogInformation(
"ImpactIndex ingested BOM-Index for {Digest} ({TenantId}/{Repository}). Components={ComponentCount} EntrypointComponents={EntrypointCount}",
request.ImageDigest,
tenantId,
repository,
componentKeys.Length,
entrypointComponents.Length);
}
public ValueTask<ImpactSet> ResolveByPurlsAsync(
IEnumerable<string> purls,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default)
=> ValueTask.FromResult(ResolveByPurlsCore(purls, usageOnly, selector));
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
IEnumerable<string> vulnerabilityIds,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default)
=> ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
public ValueTask<ImpactSet> ResolveAllAsync(
Selector selector,
bool usageOnly,
CancellationToken cancellationToken = default)
=> ValueTask.FromResult(ResolveAllCore(selector, usageOnly));
private ImpactSet ResolveByPurlsCore(IEnumerable<string> purls, bool usageOnly, Selector selector)
{
ArgumentNullException.ThrowIfNull(purls);
ArgumentNullException.ThrowIfNull(selector);
var normalized = purls
.Where(static purl => !string.IsNullOrWhiteSpace(purl))
.Select(static purl => purl.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalized.Length == 0)
{
return CreateEmptyImpactSet(selector, usageOnly);
}
RoaringBitmap imageIds;
lock (_gate)
{
imageIds = RoaringBitmap.Create(Array.Empty<int>());
foreach (var purl in normalized)
{
if (_containsByPurl.TryGetValue(purl, out var bitmap))
{
imageIds = imageIds | bitmap;
}
}
}
return BuildImpactSet(imageIds, selector, usageOnly);
}
private ImpactSet ResolveAllCore(Selector selector, bool usageOnly)
{
ArgumentNullException.ThrowIfNull(selector);
RoaringBitmap bitmap;
lock (_gate)
{
var ids = _images.Keys.OrderBy(id => id).ToArray();
bitmap = RoaringBitmap.Create(ids);
}
return BuildImpactSet(bitmap, selector, usageOnly);
}
private ImpactSet BuildImpactSet(RoaringBitmap imageIds, Selector selector, bool usageOnly)
{
var images = new List<ImpactImage>();
var latestGeneratedAt = DateTimeOffset.MinValue;
lock (_gate)
{
foreach (var imageId in imageIds)
{
if (!_images.TryGetValue(imageId, out var metadata))
{
continue;
}
if (!ImageMatchesSelector(metadata, selector))
{
continue;
}
if (usageOnly && metadata.EntrypointComponents.Length == 0)
{
continue;
}
if (metadata.GeneratedAt > latestGeneratedAt)
{
latestGeneratedAt = metadata.GeneratedAt;
}
images.Add(new ImpactImage(
metadata.Digest,
metadata.Registry,
metadata.Repository,
metadata.Namespaces,
metadata.Tags,
metadata.EntrypointComponents.Length > 0,
metadata.Labels));
}
}
if (images.Count == 0)
{
return CreateEmptyImpactSet(selector, usageOnly);
}
images.Sort(static (left, right) => string.Compare(left.ImageDigest, right.ImageDigest, StringComparison.Ordinal));
var generatedAt = latestGeneratedAt == DateTimeOffset.MinValue ? _timeProvider.GetUtcNow() : latestGeneratedAt;
return new ImpactSet(
selector,
images.ToImmutableArray(),
usageOnly,
generatedAt,
images.Count,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
{
return new ImpactSet(
selector,
ImmutableArray<ImpactImage>.Empty,
usageOnly,
_timeProvider.GetUtcNow(),
0,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private static bool ImageMatchesSelector(ImpactImageRecord image, Selector selector)
{
if (selector.TenantId is not null && !string.Equals(selector.TenantId, image.TenantId, StringComparison.Ordinal))
{
return false;
}
if (!MatchesScope(image, selector))
{
return false;
}
if (selector.Digests.Length > 0 && !selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (selector.Repositories.Length > 0)
{
var repoMatch = selector.Repositories.Any(repo =>
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase));
if (!repoMatch)
{
return false;
}
}
if (selector.Namespaces.Length > 0)
{
if (image.Namespaces.IsDefaultOrEmpty)
{
return false;
}
var namespaceMatch = selector.Namespaces.Any(ns => image.Namespaces.Contains(ns, StringComparer.OrdinalIgnoreCase));
if (!namespaceMatch)
{
return false;
}
}
if (selector.IncludeTags.Length > 0)
{
if (image.Tags.IsDefaultOrEmpty)
{
return false;
}
var tagMatch = selector.IncludeTags.Any(pattern => image.Tags.Any(tag => MatchesTagPattern(tag, pattern)));
if (!tagMatch)
{
return false;
}
}
if (selector.Labels.Length > 0)
{
if (image.Labels.Count == 0)
{
return false;
}
foreach (var label in selector.Labels)
{
if (!image.Labels.TryGetValue(label.Key, out var value))
{
return false;
}
if (label.Values.Length > 0 && !label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
return false;
}
}
}
return true;
}
private void RemoveImageComponents(ImpactImageRecord record)
{
foreach (var key in record.Components)
{
if (_containsByPurl.TryGetValue(key, out var bitmap))
{
var updated = RemoveImageFromBitmap(bitmap, record.ImageId);
if (updated is null)
{
_containsByPurl.Remove(key);
}
else
{
_containsByPurl[key] = updated;
}
}
}
foreach (var key in record.EntrypointComponents)
{
if (_usedByEntrypointByPurl.TryGetValue(key, out var bitmap))
{
var updated = RemoveImageFromBitmap(bitmap, record.ImageId);
if (updated is null)
{
_usedByEntrypointByPurl.Remove(key);
}
else
{
_usedByEntrypointByPurl[key] = updated;
}
}
}
}
private static RoaringBitmap AddImageToBitmap(RoaringBitmap? bitmap, int imageId)
{
if (bitmap is null)
{
return RoaringBitmap.Create(new[] { imageId });
}
if (bitmap.Any(id => id == imageId))
{
return bitmap;
}
var merged = bitmap
.Concat(new[] { imageId })
.Distinct()
.OrderBy(id => id)
.ToArray();
return RoaringBitmap.Create(merged);
}
private static RoaringBitmap? RemoveImageFromBitmap(RoaringBitmap bitmap, int imageId)
{
var remaining = bitmap
.Where(id => id != imageId)
.OrderBy(id => id)
.ToArray();
if (remaining.Length == 0)
{
return null;
}
return RoaringBitmap.Create(remaining);
}
private static bool MatchesScope(ImpactImageRecord image, Selector selector)
{
return selector.Scope switch
{
SelectorScope.AllImages => true,
SelectorScope.ByDigest => selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase),
SelectorScope.ByRepository => selector.Repositories.Any(repo =>
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase)),
SelectorScope.ByNamespace => !image.Namespaces.IsDefaultOrEmpty && selector.Namespaces.Any(ns => image.Namespaces.Contains(ns, StringComparer.OrdinalIgnoreCase)),
SelectorScope.ByLabels => selector.Labels.All(label =>
image.Labels.TryGetValue(label.Key, out var value) &&
(label.Values.Length == 0 || label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))),
_ => true,
};
}
private static bool MatchesTagPattern(string tag, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return false;
}
if (pattern == "*")
{
return true;
}
if (!pattern.Contains('*') && !pattern.Contains('?'))
{
return string.Equals(tag, pattern, StringComparison.OrdinalIgnoreCase);
}
var escaped = Regex.Escape(pattern)
.Replace("\\*", ".*")
.Replace("\\?", ".");
return Regex.IsMatch(tag, $"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
private int EnsureImageId(string digest)
{
if (_imageIds.TryGetValue(digest, out var existing))
{
return existing;
}
var candidate = ComputeDeterministicId(digest);
while (_images.ContainsKey(candidate))
{
candidate = (candidate + 1) & int.MaxValue;
if (candidate == 0)
{
candidate = 1;
}
}
_imageIds[digest] = candidate;
return candidate;
}
private static int ComputeDeterministicId(string digest)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(digest));
for (var offset = 0; offset <= bytes.Length - sizeof(int); offset += sizeof(int))
{
var value = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, sizeof(int))) & int.MaxValue;
if (value != 0)
{
return value;
}
}
return digest.GetHashCode(StringComparison.OrdinalIgnoreCase) & int.MaxValue;
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\..\samples\scanner\images\**\bom-index.json"
Link="Fixtures\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="RoaringBitmap" Version="0.0.9" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
# Scheduler ImpactIndex Task Board (Sprint 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-IMPACT-16-300 | DONE (2025-10-20) | Scheduler ImpactIndex Guild | SAMPLES-10-001 | **STUB** ingest/query using fixtures to unblock Scheduler planning (remove by SP16 end). | Stub merges fixture BOM-Index, query API returns deterministic results, removal note tracked. |
| SCHED-IMPACT-16-301 | DONE (2025-10-26) | Scheduler ImpactIndex Guild | SCANNER-EMIT-10-605 | Implement ingestion of per-image BOM-Index sidecars into roaring bitmap store (contains/usedBy). | Ingestion tests process sample SBOM index; bitmaps persisted; deterministic IDs assigned. |
| SCHED-IMPACT-16-302 | DONE (2025-10-26) | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Provide query APIs (ResolveByPurls, ResolveByVulns, ResolveAll, selectors) with tenant/namespace filters. | Query functions tested; performance benchmarks documented; selectors enforce filters. |
| SCHED-IMPACT-16-303 | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Snapshot/compaction + invalidation for removed images; persistence to RocksDB/Redis per architecture. | Snapshot routine implemented; invalidation tests pass; docs describe recovery. |
> Removal tracking note: see `src/Scheduler/__Libraries/StellaOps.Scheduler.ImpactIndex/REMOVAL_NOTE.md` for follow-up actions once the roaring bitmap implementation lands.

View File

@@ -0,0 +1,4 @@
# StellaOps.Scheduler.Models — Agent Charter
## Mission
Define Scheduler DTOs (Schedule, Run, ImpactSet, Selector, DeltaSummary) per `docs/ARCHITECTURE_SCHEDULER.md`.

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scheduler.ImpactIndex")]

View File

@@ -0,0 +1,120 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Audit log entry capturing schedule/run lifecycle events.
/// </summary>
public sealed record AuditRecord
{
public AuditRecord(
string id,
string tenantId,
string category,
string action,
DateTimeOffset occurredAt,
AuditActor actor,
string? entityId = null,
string? scheduleId = null,
string? runId = null,
string? correlationId = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? message = null)
: this(
id,
tenantId,
Validation.EnsureSimpleIdentifier(category, nameof(category)),
Validation.EnsureSimpleIdentifier(action, nameof(action)),
Validation.NormalizeTimestamp(occurredAt),
actor,
Validation.TrimToNull(entityId),
Validation.TrimToNull(scheduleId),
Validation.TrimToNull(runId),
Validation.TrimToNull(correlationId),
Validation.NormalizeMetadata(metadata),
Validation.TrimToNull(message))
{
}
[JsonConstructor]
public AuditRecord(
string id,
string tenantId,
string category,
string action,
DateTimeOffset occurredAt,
AuditActor actor,
string? entityId,
string? scheduleId,
string? runId,
string? correlationId,
ImmutableSortedDictionary<string, string> metadata,
string? message)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
Category = Validation.EnsureSimpleIdentifier(category, nameof(category));
Action = Validation.EnsureSimpleIdentifier(action, nameof(action));
OccurredAt = Validation.NormalizeTimestamp(occurredAt);
Actor = actor ?? throw new ArgumentNullException(nameof(actor));
EntityId = Validation.TrimToNull(entityId);
ScheduleId = Validation.TrimToNull(scheduleId);
RunId = Validation.TrimToNull(runId);
CorrelationId = Validation.TrimToNull(correlationId);
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
Metadata = materializedMetadata.Count > 0
? materializedMetadata.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
Message = Validation.TrimToNull(message);
}
public string Id { get; }
public string TenantId { get; }
public string Category { get; }
public string Action { get; }
public DateTimeOffset OccurredAt { get; }
public AuditActor Actor { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? EntityId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ScheduleId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RunId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; }
}
/// <summary>
/// Actor associated with an audit entry.
/// </summary>
public sealed record AuditActor
{
public AuditActor(string actorId, string displayName, string kind)
{
ActorId = Validation.EnsureSimpleIdentifier(actorId, nameof(actorId));
DisplayName = Validation.EnsureName(displayName, nameof(displayName));
Kind = Validation.EnsureSimpleIdentifier(kind, nameof(kind));
}
public string ActorId { get; }
public string DisplayName { get; }
public string Kind { get; }
}

View File

@@ -0,0 +1,470 @@
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",
"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",
},
[typeof(AuditActor)] = new[]
{
"actorId",
"displayName",
"kind",
},
[typeof(GraphBuildJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"sbomId",
"sbomVersionId",
"sbomDigest",
"graphSnapshotId",
"status",
"trigger",
"attempts",
"cartographerJobId",
"correlationId",
"createdAt",
"startedAt",
"completedAt",
"error",
"metadata",
},
[typeof(GraphOverlayJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"graphSnapshotId",
"buildJobId",
"overlayKind",
"overlayKey",
"subjects",
"status",
"trigger",
"attempts",
"correlationId",
"createdAt",
"startedAt",
"completedAt",
"error",
"metadata",
},
[typeof(PolicyRunRequest)] = new[]
{
"schemaVersion",
"tenantId",
"policyId",
"policyVersion",
"mode",
"priority",
"runId",
"queuedAt",
"requestedBy",
"correlationId",
"metadata",
"inputs",
},
[typeof(PolicyRunInputs)] = new[]
{
"sbomSet",
"advisoryCursor",
"vexCursor",
"environment",
"captureExplain",
},
[typeof(PolicyRunStatus)] = new[]
{
"schemaVersion",
"runId",
"tenantId",
"policyId",
"policyVersion",
"mode",
"status",
"priority",
"queuedAt",
"startedAt",
"finishedAt",
"determinismHash",
"errorCode",
"error",
"attempts",
"traceId",
"explainUri",
"metadata",
"stats",
"inputs",
},
[typeof(PolicyRunJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"policyId",
"policyVersion",
"mode",
"priority",
"priorityRank",
"runId",
"queuedAt",
"requestedBy",
"correlationId",
"metadata",
"inputs",
"status",
"attemptCount",
"lastAttemptAt",
"lastError",
"createdAt",
"updatedAt",
"availableAt",
"submittedAt",
"completedAt",
"leaseOwner",
"leaseExpiresAt",
"cancellationRequested",
"cancellationRequestedAt",
"cancellationReason",
"cancelledAt",
},
[typeof(PolicyRunStats)] = new[]
{
"components",
"rulesFired",
"findingsWritten",
"vexOverrides",
"quieted",
"suppressed",
"durationSeconds",
},
[typeof(PolicyDiffSummary)] = new[]
{
"schemaVersion",
"added",
"removed",
"unchanged",
"bySeverity",
"ruleHits",
},
[typeof(PolicyDiffSeverityDelta)] = new[]
{
"up",
"down",
},
[typeof(PolicyDiffRuleDelta)] = new[]
{
"ruleId",
"ruleName",
"up",
"down",
},
[typeof(PolicyExplainTrace)] = new[]
{
"schemaVersion",
"findingId",
"policyId",
"policyVersion",
"tenantId",
"runId",
"evaluatedAt",
"verdict",
"ruleChain",
"evidence",
"vexImpacts",
"history",
"metadata",
},
[typeof(PolicyExplainVerdict)] = new[]
{
"status",
"severity",
"quiet",
"score",
"rationale",
},
[typeof(PolicyExplainRule)] = new[]
{
"ruleId",
"ruleName",
"action",
"decision",
"score",
"condition",
},
[typeof(PolicyExplainEvidence)] = new[]
{
"type",
"reference",
"source",
"status",
"weight",
"justification",
"metadata",
},
[typeof(PolicyExplainVexImpact)] = new[]
{
"statementId",
"provider",
"status",
"accepted",
"justification",
"confidence",
},
[typeof(PolicyExplainHistoryEvent)] = new[]
{
"status",
"occurredAt",
"actor",
"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());
options.Converters.Add(new RunTriggerConverter());
options.Converters.Add(new RunStateConverter());
options.Converters.Add(new SeverityRankConverter());
options.Converters.Add(new GraphJobStatusConverter());
options.Converters.Add(new GraphBuildJobTriggerConverter());
options.Converters.Add(new GraphOverlayJobTriggerConverter());
options.Converters.Add(new GraphOverlayKindConverter());
options.Converters.Add(new PolicyRunModeConverter());
options.Converters.Add(new PolicyRunPriorityConverter());
options.Converters.Add(new PolicyRunExecutionStatusConverter());
options.Converters.Add(new PolicyVerdictStatusConverter());
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;
}
}
}

View File

@@ -0,0 +1,201 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
internal sealed class ScheduleModeConverter : HyphenatedEnumConverter<ScheduleMode>
{
protected override IReadOnlyDictionary<ScheduleMode, string> Map { get; } = new Dictionary<ScheduleMode, string>
{
[ScheduleMode.AnalysisOnly] = "analysis-only",
[ScheduleMode.ContentRefresh] = "content-refresh",
};
}
internal sealed class SelectorScopeConverter : HyphenatedEnumConverter<SelectorScope>
{
protected override IReadOnlyDictionary<SelectorScope, string> Map { get; } = new Dictionary<SelectorScope, string>
{
[SelectorScope.AllImages] = "all-images",
[SelectorScope.ByNamespace] = "by-namespace",
[SelectorScope.ByRepository] = "by-repo",
[SelectorScope.ByDigest] = "by-digest",
[SelectorScope.ByLabels] = "by-labels",
};
}
internal sealed class RunTriggerConverter : LowerCaseEnumConverter<RunTrigger>
{
}
internal sealed class RunStateConverter : LowerCaseEnumConverter<RunState>
{
}
internal sealed class SeverityRankConverter : LowerCaseEnumConverter<SeverityRank>
{
protected override string ConvertToString(SeverityRank value)
=> value switch
{
SeverityRank.None => "none",
SeverityRank.Info => "info",
SeverityRank.Low => "low",
SeverityRank.Medium => "medium",
SeverityRank.High => "high",
SeverityRank.Critical => "critical",
SeverityRank.Unknown => "unknown",
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null),
};
}
internal sealed class GraphJobStatusConverter : LowerCaseEnumConverter<GraphJobStatus>
{
}
internal sealed class GraphBuildJobTriggerConverter : HyphenatedEnumConverter<GraphBuildJobTrigger>
{
protected override IReadOnlyDictionary<GraphBuildJobTrigger, string> Map { get; } = new Dictionary<GraphBuildJobTrigger, string>
{
[GraphBuildJobTrigger.SbomVersion] = "sbom-version",
[GraphBuildJobTrigger.Backfill] = "backfill",
[GraphBuildJobTrigger.Manual] = "manual",
};
}
internal sealed class GraphOverlayJobTriggerConverter : HyphenatedEnumConverter<GraphOverlayJobTrigger>
{
protected override IReadOnlyDictionary<GraphOverlayJobTrigger, string> Map { get; } = new Dictionary<GraphOverlayJobTrigger, string>
{
[GraphOverlayJobTrigger.Policy] = "policy",
[GraphOverlayJobTrigger.Advisory] = "advisory",
[GraphOverlayJobTrigger.Vex] = "vex",
[GraphOverlayJobTrigger.SbomVersion] = "sbom-version",
[GraphOverlayJobTrigger.Manual] = "manual",
};
}
internal sealed class GraphOverlayKindConverter : LowerCaseEnumConverter<GraphOverlayKind>
{
}
internal sealed class PolicyRunModeConverter : LowerCaseEnumConverter<PolicyRunMode>
{
}
internal sealed class PolicyRunPriorityConverter : LowerCaseEnumConverter<PolicyRunPriority>
{
}
internal sealed class PolicyRunExecutionStatusConverter : JsonConverter<PolicyRunExecutionStatus>
{
private static readonly IReadOnlyDictionary<string, PolicyRunExecutionStatus> Reverse = new Dictionary<string, PolicyRunExecutionStatus>(StringComparer.OrdinalIgnoreCase)
{
["queued"] = PolicyRunExecutionStatus.Queued,
["running"] = PolicyRunExecutionStatus.Running,
["succeeded"] = PolicyRunExecutionStatus.Succeeded,
["failed"] = PolicyRunExecutionStatus.Failed,
["canceled"] = PolicyRunExecutionStatus.Cancelled,
["cancelled"] = PolicyRunExecutionStatus.Cancelled,
["replay_pending"] = PolicyRunExecutionStatus.ReplayPending,
["replay-pending"] = PolicyRunExecutionStatus.ReplayPending,
};
private static readonly IReadOnlyDictionary<PolicyRunExecutionStatus, string> Forward = new Dictionary<PolicyRunExecutionStatus, string>
{
[PolicyRunExecutionStatus.Queued] = "queued",
[PolicyRunExecutionStatus.Running] = "running",
[PolicyRunExecutionStatus.Succeeded] = "succeeded",
[PolicyRunExecutionStatus.Failed] = "failed",
[PolicyRunExecutionStatus.Cancelled] = "canceled",
[PolicyRunExecutionStatus.ReplayPending] = "replay_pending",
};
public override PolicyRunExecutionStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && Reverse.TryGetValue(value, out var status))
{
return status;
}
throw new JsonException($"Value '{value}' is not a valid {nameof(PolicyRunExecutionStatus)}.");
}
public override void Write(Utf8JsonWriter writer, PolicyRunExecutionStatus value, JsonSerializerOptions options)
{
if (!Forward.TryGetValue(value, out var text))
{
throw new JsonException($"Unable to serialize {nameof(PolicyRunExecutionStatus)} value '{value}'.");
}
writer.WriteStringValue(text);
}
}
internal sealed class PolicyVerdictStatusConverter : LowerCaseEnumConverter<PolicyVerdictStatus>
{
}
internal sealed class PolicyRunJobStatusConverter : LowerCaseEnumConverter<PolicyRunJobStatus>
{
}
internal abstract class HyphenatedEnumConverter<TEnum> : JsonConverter<TEnum>
where TEnum : struct, Enum
{
private readonly Dictionary<string, TEnum> _reverse;
protected HyphenatedEnumConverter()
{
_reverse = Map.ToDictionary(static pair => pair.Value, static pair => pair.Key, StringComparer.OrdinalIgnoreCase);
}
protected abstract IReadOnlyDictionary<TEnum, string> Map { get; }
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && _reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
{
if (Map.TryGetValue(value, out var text))
{
writer.WriteStringValue(text);
return;
}
throw new JsonException($"Unable to serialize {typeof(TEnum).Name} value '{value}'.");
}
}
internal class LowerCaseEnumConverter<TEnum> : JsonConverter<TEnum>
where TEnum : struct, Enum
{
private static readonly Dictionary<string, TEnum> Reverse = Enum
.GetValues<TEnum>()
.ToDictionary(static value => value.ToString().ToLowerInvariant(), static value => value, StringComparer.OrdinalIgnoreCase);
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && Reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
=> writer.WriteStringValue(ConvertToString(value));
protected virtual string ConvertToString(TEnum value)
=> value.ToString().ToLowerInvariant();
}

View File

@@ -0,0 +1,179 @@
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>
[JsonConverter(typeof(SeverityRankConverter))]
public enum SeverityRank
{
None = 0,
Info = 1,
Low = 2,
Medium = 3,
High = 4,
Critical = 5,
Unknown = 6,
}
/// <summary>
/// Status lifecycle shared by graph build and overlay jobs.
/// </summary>
[JsonConverter(typeof(GraphJobStatusConverter))]
public enum GraphJobStatus
{
Pending,
Queued,
Running,
Completed,
Failed,
Cancelled,
}
/// <summary>
/// Trigger indicating why a graph build job was enqueued.
/// </summary>
[JsonConverter(typeof(GraphBuildJobTriggerConverter))]
public enum GraphBuildJobTrigger
{
SbomVersion,
Backfill,
Manual,
}
/// <summary>
/// Trigger indicating why a graph overlay job was enqueued.
/// </summary>
[JsonConverter(typeof(GraphOverlayJobTriggerConverter))]
public enum GraphOverlayJobTrigger
{
Policy,
Advisory,
Vex,
SbomVersion,
Manual,
}
/// <summary>
/// Overlay category applied to a graph snapshot.
/// </summary>
[JsonConverter(typeof(GraphOverlayKindConverter))]
public enum GraphOverlayKind
{
Policy,
Advisory,
Vex,
}
/// <summary>
/// Mode for policy runs executed by the Policy Engine.
/// </summary>
[JsonConverter(typeof(PolicyRunModeConverter))]
public enum PolicyRunMode
{
Full,
Incremental,
Simulate,
}
/// <summary>
/// Priority assigned to a policy run request.
/// </summary>
[JsonConverter(typeof(PolicyRunPriorityConverter))]
public enum PolicyRunPriority
{
Normal,
High,
Emergency,
}
/// <summary>
/// Execution status for policy runs tracked in policy_runs.
/// </summary>
[JsonConverter(typeof(PolicyRunExecutionStatusConverter))]
public enum PolicyRunExecutionStatus
{
Queued,
Running,
Succeeded,
Failed,
Cancelled,
ReplayPending,
}
/// <summary>
/// Resulting verdict for a policy evaluation.
/// </summary>
[JsonConverter(typeof(PolicyVerdictStatusConverter))]
public enum PolicyVerdictStatus
{
Passed,
Warned,
Blocked,
Quieted,
Ignored,
}
/// <summary>
/// Lifecycle status for scheduler policy run jobs.
/// </summary>
[JsonConverter(typeof(PolicyRunJobStatusConverter))]
public enum PolicyRunJobStatus
{
Pending,
Dispatching,
Submitted,
Completed,
Failed,
Cancelled,
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Job instructing Cartographer to materialize a graph snapshot for an SBOM version.
/// </summary>
public sealed record GraphBuildJob
{
public GraphBuildJob(
string id,
string tenantId,
string sbomId,
string sbomVersionId,
string sbomDigest,
GraphJobStatus status,
GraphBuildJobTrigger trigger,
DateTimeOffset createdAt,
string? graphSnapshotId = null,
int attempts = 0,
string? cartographerJobId = null,
string? correlationId = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? completedAt = null,
string? error = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? schemaVersion = null)
: this(
id,
tenantId,
sbomId,
sbomVersionId,
sbomDigest,
Validation.TrimToNull(graphSnapshotId),
status,
trigger,
Validation.EnsureNonNegative(attempts, nameof(attempts)),
Validation.TrimToNull(cartographerJobId),
Validation.TrimToNull(correlationId),
Validation.NormalizeTimestamp(createdAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(completedAt),
Validation.TrimToNull(error),
Validation.NormalizeMetadata(metadata),
schemaVersion)
{
}
[JsonConstructor]
public GraphBuildJob(
string id,
string tenantId,
string sbomId,
string sbomVersionId,
string sbomDigest,
string? graphSnapshotId,
GraphJobStatus status,
GraphBuildJobTrigger trigger,
int attempts,
string? cartographerJobId,
string? correlationId,
DateTimeOffset createdAt,
DateTimeOffset? startedAt,
DateTimeOffset? completedAt,
string? error,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
SbomId = Validation.EnsureId(sbomId, nameof(sbomId));
SbomVersionId = Validation.EnsureId(sbomVersionId, nameof(sbomVersionId));
SbomDigest = Validation.EnsureDigestFormat(sbomDigest, nameof(sbomDigest));
GraphSnapshotId = Validation.TrimToNull(graphSnapshotId);
Status = status;
Trigger = trigger;
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
CartographerJobId = Validation.TrimToNull(cartographerJobId);
CorrelationId = Validation.TrimToNull(correlationId);
CreatedAt = Validation.NormalizeTimestamp(createdAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
CompletedAt = Validation.NormalizeTimestamp(completedAt);
Error = Validation.TrimToNull(error);
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
Metadata = materializedMetadata.Count > 0
? materializedMetadata.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
SchemaVersion = SchedulerSchemaVersions.EnsureGraphBuildJob(schemaVersion);
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
public string SbomId { get; }
public string SbomVersionId { get; }
public string SbomDigest { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? GraphSnapshotId { get; init; }
public GraphJobStatus Status { get; init; }
public GraphBuildJobTrigger Trigger { get; }
public int Attempts { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CartographerJobId { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,241 @@
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Encapsulates allowed status transitions and invariants for graph jobs.
/// </summary>
public static class GraphJobStateMachine
{
private static readonly IReadOnlyDictionary<GraphJobStatus, GraphJobStatus[]> Adjacency = new Dictionary<GraphJobStatus, GraphJobStatus[]>
{
[GraphJobStatus.Pending] = new[] { GraphJobStatus.Pending, GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
[GraphJobStatus.Queued] = new[] { GraphJobStatus.Queued, GraphJobStatus.Running, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
[GraphJobStatus.Running] = new[] { GraphJobStatus.Running, GraphJobStatus.Completed, GraphJobStatus.Failed, GraphJobStatus.Cancelled },
[GraphJobStatus.Completed] = new[] { GraphJobStatus.Completed },
[GraphJobStatus.Failed] = new[] { GraphJobStatus.Failed },
[GraphJobStatus.Cancelled] = new[] { GraphJobStatus.Cancelled },
};
public static bool CanTransition(GraphJobStatus from, GraphJobStatus to)
{
if (!Adjacency.TryGetValue(from, out var allowed))
{
return false;
}
return allowed.Contains(to);
}
public static bool IsTerminal(GraphJobStatus status)
=> status is GraphJobStatus.Completed or GraphJobStatus.Failed or GraphJobStatus.Cancelled;
public static GraphBuildJob EnsureTransition(
GraphBuildJob job,
GraphJobStatus next,
DateTimeOffset timestamp,
int? attempts = null,
string? errorMessage = null)
{
ArgumentNullException.ThrowIfNull(job);
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
var current = job.Status;
if (!CanTransition(current, next))
{
throw new InvalidOperationException($"Graph build job transition from '{current}' to '{next}' is not allowed.");
}
var nextAttempts = attempts ?? job.Attempts;
if (nextAttempts < job.Attempts)
{
throw new InvalidOperationException("Graph job attempts cannot decrease.");
}
var startedAt = job.StartedAt;
var completedAt = job.CompletedAt;
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
{
startedAt = normalizedTimestamp;
}
if (IsTerminal(next))
{
completedAt ??= normalizedTimestamp;
}
string? nextError = null;
if (next == GraphJobStatus.Failed)
{
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
if (string.IsNullOrWhiteSpace(effectiveError))
{
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
}
nextError = effectiveError;
}
else if (!string.IsNullOrWhiteSpace(errorMessage))
{
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
}
var updated = job with
{
Status = next,
Attempts = nextAttempts,
StartedAt = startedAt,
CompletedAt = completedAt,
Error = nextError,
};
Validate(updated);
return updated;
}
public static GraphOverlayJob EnsureTransition(
GraphOverlayJob job,
GraphJobStatus next,
DateTimeOffset timestamp,
int? attempts = null,
string? errorMessage = null)
{
ArgumentNullException.ThrowIfNull(job);
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
var current = job.Status;
if (!CanTransition(current, next))
{
throw new InvalidOperationException($"Graph overlay job transition from '{current}' to '{next}' is not allowed.");
}
var nextAttempts = attempts ?? job.Attempts;
if (nextAttempts < job.Attempts)
{
throw new InvalidOperationException("Graph job attempts cannot decrease.");
}
var startedAt = job.StartedAt;
var completedAt = job.CompletedAt;
if (current != GraphJobStatus.Running && next == GraphJobStatus.Running && startedAt is null)
{
startedAt = normalizedTimestamp;
}
if (IsTerminal(next))
{
completedAt ??= normalizedTimestamp;
}
string? nextError = null;
if (next == GraphJobStatus.Failed)
{
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? job.Error : errorMessage.Trim();
if (string.IsNullOrWhiteSpace(effectiveError))
{
throw new InvalidOperationException("Transitioning to Failed requires a non-empty error message.");
}
nextError = effectiveError;
}
else if (!string.IsNullOrWhiteSpace(errorMessage))
{
throw new InvalidOperationException("Error message can only be provided when transitioning to Failed state.");
}
var updated = job with
{
Status = next,
Attempts = nextAttempts,
StartedAt = startedAt,
CompletedAt = completedAt,
Error = nextError,
};
Validate(updated);
return updated;
}
public static void Validate(GraphBuildJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (job.StartedAt is { } started && started < job.CreatedAt)
{
throw new InvalidOperationException("GraphBuildJob.StartedAt cannot be earlier than CreatedAt.");
}
if (job.CompletedAt is { } completed)
{
if (job.StartedAt is { } start && completed < start)
{
throw new InvalidOperationException("GraphBuildJob.CompletedAt cannot be earlier than StartedAt.");
}
if (!IsTerminal(job.Status))
{
throw new InvalidOperationException("GraphBuildJob.CompletedAt set while status is not terminal.");
}
}
else if (IsTerminal(job.Status))
{
throw new InvalidOperationException("Terminal graph build job states must include CompletedAt.");
}
if (job.Status == GraphJobStatus.Failed)
{
if (string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphBuildJob.Error must be populated when status is Failed.");
}
}
else if (!string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphBuildJob.Error must be null for non-failed states.");
}
}
public static void Validate(GraphOverlayJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (job.StartedAt is { } started && started < job.CreatedAt)
{
throw new InvalidOperationException("GraphOverlayJob.StartedAt cannot be earlier than CreatedAt.");
}
if (job.CompletedAt is { } completed)
{
if (job.StartedAt is { } start && completed < start)
{
throw new InvalidOperationException("GraphOverlayJob.CompletedAt cannot be earlier than StartedAt.");
}
if (!IsTerminal(job.Status))
{
throw new InvalidOperationException("GraphOverlayJob.CompletedAt set while status is not terminal.");
}
}
else if (IsTerminal(job.Status))
{
throw new InvalidOperationException("Terminal graph overlay job states must include CompletedAt.");
}
if (job.Status == GraphJobStatus.Failed)
{
if (string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphOverlayJob.Error must be populated when status is Failed.");
}
}
else if (!string.IsNullOrWhiteSpace(job.Error))
{
throw new InvalidOperationException("GraphOverlayJob.Error must be null for non-failed states.");
}
}
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Job that materializes or refreshes an overlay on top of an existing graph snapshot.
/// </summary>
public sealed record GraphOverlayJob
{
public GraphOverlayJob(
string id,
string tenantId,
string graphSnapshotId,
GraphOverlayKind overlayKind,
string overlayKey,
GraphJobStatus status,
GraphOverlayJobTrigger trigger,
DateTimeOffset createdAt,
IEnumerable<string>? subjects = null,
int attempts = 0,
string? buildJobId = null,
string? correlationId = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? completedAt = null,
string? error = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? schemaVersion = null)
: this(
id,
tenantId,
graphSnapshotId,
Validation.TrimToNull(buildJobId),
overlayKind,
Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey)),
Validation.NormalizeStringSet(subjects, nameof(subjects)),
status,
trigger,
Validation.EnsureNonNegative(attempts, nameof(attempts)),
Validation.TrimToNull(correlationId),
Validation.NormalizeTimestamp(createdAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(completedAt),
Validation.TrimToNull(error),
Validation.NormalizeMetadata(metadata),
schemaVersion)
{
}
[JsonConstructor]
public GraphOverlayJob(
string id,
string tenantId,
string graphSnapshotId,
string? buildJobId,
GraphOverlayKind overlayKind,
string overlayKey,
ImmutableArray<string> subjects,
GraphJobStatus status,
GraphOverlayJobTrigger trigger,
int attempts,
string? correlationId,
DateTimeOffset createdAt,
DateTimeOffset? startedAt,
DateTimeOffset? completedAt,
string? error,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
GraphSnapshotId = Validation.EnsureId(graphSnapshotId, nameof(graphSnapshotId));
BuildJobId = Validation.TrimToNull(buildJobId);
OverlayKind = overlayKind;
OverlayKey = Validation.EnsureNotNullOrWhiteSpace(overlayKey, nameof(overlayKey));
Subjects = subjects.IsDefault ? ImmutableArray<string>.Empty : subjects;
Status = status;
Trigger = trigger;
Attempts = Validation.EnsureNonNegative(attempts, nameof(attempts));
CorrelationId = Validation.TrimToNull(correlationId);
CreatedAt = Validation.NormalizeTimestamp(createdAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
CompletedAt = Validation.NormalizeTimestamp(completedAt);
Error = Validation.TrimToNull(error);
var materializedMetadata = metadata ?? ImmutableSortedDictionary<string, string>.Empty;
Metadata = materializedMetadata.Count > 0
? materializedMetadata.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
SchemaVersion = SchedulerSchemaVersions.EnsureGraphOverlayJob(schemaVersion);
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
public string GraphSnapshotId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? BuildJobId { get; init; }
public GraphOverlayKind OverlayKind { get; }
public string OverlayKey { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Subjects { get; } = ImmutableArray<string>.Empty;
public GraphJobStatus Status { get; init; }
public GraphOverlayJobTrigger Trigger { get; }
public int Attempts { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? StartedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,138 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Result from resolving impacted images for a selector.
/// </summary>
public sealed record ImpactSet
{
public ImpactSet(
Selector selector,
IEnumerable<ImpactImage> images,
bool usageOnly,
DateTimeOffset generatedAt,
int? total = null,
string? snapshotId = null,
string? schemaVersion = null)
: this(
selector,
NormalizeImages(images),
usageOnly,
Validation.NormalizeTimestamp(generatedAt),
total ?? images.Count(),
Validation.TrimToNull(snapshotId),
schemaVersion)
{
}
[JsonConstructor]
public ImpactSet(
Selector selector,
ImmutableArray<ImpactImage> images,
bool usageOnly,
DateTimeOffset generatedAt,
int total,
string? snapshotId,
string? schemaVersion = null)
{
Selector = selector ?? throw new ArgumentNullException(nameof(selector));
Images = images.IsDefault ? ImmutableArray<ImpactImage>.Empty : images;
UsageOnly = usageOnly;
GeneratedAt = Validation.NormalizeTimestamp(generatedAt);
Total = Validation.EnsureNonNegative(total, nameof(total));
SnapshotId = Validation.TrimToNull(snapshotId);
SchemaVersion = SchedulerSchemaVersions.EnsureImpactSet(schemaVersion);
}
public string SchemaVersion { get; }
public Selector Selector { get; }
public ImmutableArray<ImpactImage> Images { get; } = ImmutableArray<ImpactImage>.Empty;
public bool UsageOnly { get; }
public DateTimeOffset GeneratedAt { get; }
public int Total { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SnapshotId { get; }
private static ImmutableArray<ImpactImage> NormalizeImages(IEnumerable<ImpactImage> images)
{
ArgumentNullException.ThrowIfNull(images);
return images
.Where(static image => image is not null)
.Select(static image => image!)
.OrderBy(static image => image.ImageDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Impacted image descriptor returned from the impact index.
/// </summary>
public sealed record ImpactImage
{
public ImpactImage(
string imageDigest,
string registry,
string repository,
IEnumerable<string>? namespaces = null,
IEnumerable<string>? tags = null,
bool usedByEntrypoint = false,
IEnumerable<KeyValuePair<string, string>>? labels = null)
: this(
Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest)),
Validation.EnsureSimpleIdentifier(registry, nameof(registry)),
Validation.EnsureSimpleIdentifier(repository, nameof(repository)),
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
Validation.NormalizeTagPatterns(tags),
usedByEntrypoint,
Validation.NormalizeMetadata(labels))
{
}
[JsonConstructor]
public ImpactImage(
string imageDigest,
string registry,
string repository,
ImmutableArray<string> namespaces,
ImmutableArray<string> tags,
bool usedByEntrypoint,
ImmutableSortedDictionary<string, string> labels)
{
ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest));
Registry = Validation.EnsureSimpleIdentifier(registry, nameof(registry));
Repository = Validation.EnsureSimpleIdentifier(repository, nameof(repository));
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
Tags = tags.IsDefault ? ImmutableArray<string>.Empty : tags;
UsedByEntrypoint = usedByEntrypoint;
var materializedLabels = labels ?? ImmutableSortedDictionary<string, string>.Empty;
Labels = materializedLabels.Count > 0
? materializedLabels.WithComparers(StringComparer.Ordinal)
: ImmutableSortedDictionary<string, string>.Empty;
}
public string ImageDigest { get; }
public string Registry { get; }
public string Repository { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Tags { get; } = ImmutableArray<string>.Empty;
public bool UsedByEntrypoint { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Labels { get; } = ImmutableSortedDictionary<string, string>.Empty;
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
public sealed record PolicyRunJob(
string SchemaVersion,
string Id,
string TenantId,
string PolicyId,
int? PolicyVersion,
PolicyRunMode Mode,
PolicyRunPriority Priority,
int PriorityRank,
string? RunId,
string? RequestedBy,
string? CorrelationId,
ImmutableSortedDictionary<string, string>? Metadata,
PolicyRunInputs Inputs,
DateTimeOffset? QueuedAt,
PolicyRunJobStatus Status,
int AttemptCount,
DateTimeOffset? LastAttemptAt,
string? LastError,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset AvailableAt,
DateTimeOffset? SubmittedAt,
DateTimeOffset? CompletedAt,
string? LeaseOwner,
DateTimeOffset? LeaseExpiresAt,
bool CancellationRequested,
DateTimeOffset? CancellationRequestedAt,
string? CancellationReason,
DateTimeOffset? CancelledAt)
{
public string SchemaVersion { get; init; } = SchedulerSchemaVersions.EnsurePolicyRunJob(SchemaVersion);
public string Id { get; init; } = Validation.EnsureId(Id, nameof(Id));
public string TenantId { get; init; } = Validation.EnsureTenantId(TenantId, nameof(TenantId));
public string PolicyId { get; init; } = Validation.EnsureSimpleIdentifier(PolicyId, nameof(PolicyId));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? PolicyVersion { get; init; } = EnsurePolicyVersion(PolicyVersion);
public PolicyRunMode Mode { get; init; } = Mode;
public PolicyRunPriority Priority { get; init; } = Priority;
public int PriorityRank { get; init; } = PriorityRank >= 0 ? PriorityRank : GetPriorityRank(Priority);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RunId { get; init; } = NormalizeRunId(RunId);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RequestedBy { get; init; } = Validation.TrimToNull(RequestedBy);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; init; } = Validation.TrimToNull(CorrelationId);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableSortedDictionary<string, string>? Metadata { get; init; } = NormalizeMetadata(Metadata);
public PolicyRunInputs Inputs { get; init; } = Inputs ?? throw new ArgumentNullException(nameof(Inputs));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? QueuedAt { get; init; } = Validation.NormalizeTimestamp(QueuedAt);
public PolicyRunJobStatus Status { get; init; } = Status;
public int AttemptCount { get; init; } = Validation.EnsureNonNegative(AttemptCount, nameof(AttemptCount));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? LastAttemptAt { get; init; } = Validation.NormalizeTimestamp(LastAttemptAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LastError { get; init; } = Validation.TrimToNull(LastError);
public DateTimeOffset CreatedAt { get; init; } = NormalizeTimestamp(CreatedAt, nameof(CreatedAt));
public DateTimeOffset UpdatedAt { get; init; } = NormalizeTimestamp(UpdatedAt, nameof(UpdatedAt));
public DateTimeOffset AvailableAt { get; init; } = NormalizeTimestamp(AvailableAt, nameof(AvailableAt));
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? SubmittedAt { get; init; } = Validation.NormalizeTimestamp(SubmittedAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; } = Validation.NormalizeTimestamp(CompletedAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LeaseOwner { get; init; } = Validation.TrimToNull(LeaseOwner);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? LeaseExpiresAt { get; init; } = Validation.NormalizeTimestamp(LeaseExpiresAt);
public bool CancellationRequested { get; init; } = CancellationRequested;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CancellationRequestedAt { get; init; } = Validation.NormalizeTimestamp(CancellationRequestedAt);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CancellationReason { get; init; } = Validation.TrimToNull(CancellationReason);
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CancelledAt { get; init; } = Validation.NormalizeTimestamp(CancelledAt);
public PolicyRunRequest ToPolicyRunRequest(DateTimeOffset fallbackQueuedAt)
{
var queuedAt = QueuedAt ?? fallbackQueuedAt;
return new PolicyRunRequest(
TenantId,
PolicyId,
Mode,
Inputs,
Priority,
RunId,
PolicyVersion,
RequestedBy,
queuedAt,
CorrelationId,
Metadata);
}
private static int? EnsurePolicyVersion(int? value)
{
if (value is not null && value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(PolicyVersion), value, "Policy version must be positive.");
}
return value;
}
private static string? NormalizeRunId(string? runId)
{
var trimmed = Validation.TrimToNull(runId);
return trimmed is null ? null : Validation.EnsureId(trimmed, nameof(runId));
}
private static ImmutableSortedDictionary<string, string>? NormalizeMetadata(ImmutableSortedDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return null;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in metadata)
{
var normalizedKey = Validation.TrimToNull(key);
var normalizedValue = Validation.TrimToNull(value);
if (normalizedKey is null || normalizedValue is null)
{
continue;
}
builder[normalizedKey.ToLowerInvariant()] = normalizedValue;
}
return builder.Count == 0 ? null : builder.ToImmutable();
}
private static int GetPriorityRank(PolicyRunPriority priority)
=> priority switch
{
PolicyRunPriority.Emergency => 2,
PolicyRunPriority.High => 1,
_ => 0
};
private static DateTimeOffset NormalizeTimestamp(DateTimeOffset value, string propertyName)
{
var normalized = Validation.NormalizeTimestamp(value);
if (normalized == default)
{
throw new ArgumentException($"{propertyName} must be a valid timestamp.", propertyName);
}
return normalized;
}
}

View File

@@ -0,0 +1,930 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Request payload enqueued by the policy orchestrator/clients.
/// </summary>
public sealed record PolicyRunRequest
{
public PolicyRunRequest(
string tenantId,
string policyId,
PolicyRunMode mode,
PolicyRunInputs? inputs = null,
PolicyRunPriority priority = PolicyRunPriority.Normal,
string? runId = null,
int? policyVersion = null,
string? requestedBy = null,
DateTimeOffset? queuedAt = null,
string? correlationId = null,
ImmutableSortedDictionary<string, string>? metadata = null,
string? schemaVersion = null)
: this(
tenantId,
policyId,
policyVersion,
mode,
priority,
runId,
Validation.NormalizeTimestamp(queuedAt),
Validation.TrimToNull(requestedBy),
Validation.TrimToNull(correlationId),
metadata ?? ImmutableSortedDictionary<string, string>.Empty,
inputs ?? PolicyRunInputs.Empty,
schemaVersion)
{
}
[JsonConstructor]
public PolicyRunRequest(
string tenantId,
string policyId,
int? policyVersion,
PolicyRunMode mode,
PolicyRunPriority priority,
string? runId,
DateTimeOffset? queuedAt,
string? requestedBy,
string? correlationId,
ImmutableSortedDictionary<string, string> metadata,
PolicyRunInputs inputs,
string? schemaVersion = null)
{
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyRunRequest(schemaVersion);
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
if (policyVersion is not null && policyVersion <= 0)
{
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
}
PolicyVersion = policyVersion;
Mode = mode;
Priority = priority;
RunId = Validation.TrimToNull(runId) is { Length: > 0 } normalizedRunId
? Validation.EnsureId(normalizedRunId, nameof(runId))
: null;
QueuedAt = Validation.NormalizeTimestamp(queuedAt);
RequestedBy = Validation.TrimToNull(requestedBy);
CorrelationId = Validation.TrimToNull(correlationId);
var normalizedMetadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
Metadata = normalizedMetadata.Count == 0 ? null : normalizedMetadata;
Inputs = inputs ?? PolicyRunInputs.Empty;
}
public string SchemaVersion { get; }
public string TenantId { get; }
public string PolicyId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? PolicyVersion { get; }
public PolicyRunMode Mode { get; }
public PolicyRunPriority Priority { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RunId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? QueuedAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? RequestedBy { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CorrelationId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableSortedDictionary<string, string>? Metadata { get; }
public PolicyRunInputs Inputs { get; } = PolicyRunInputs.Empty;
}
/// <summary>
/// Scoped inputs for policy runs (SBOM set, cursors, environment).
/// </summary>
public sealed record PolicyRunInputs
{
public static PolicyRunInputs Empty { get; } = new();
public PolicyRunInputs(
IEnumerable<string>? sbomSet = null,
DateTimeOffset? advisoryCursor = null,
DateTimeOffset? vexCursor = null,
IEnumerable<KeyValuePair<string, object?>>? env = null,
bool captureExplain = false)
{
_sbomSet = NormalizeSbomSet(sbomSet);
_advisoryCursor = Validation.NormalizeTimestamp(advisoryCursor);
_vexCursor = Validation.NormalizeTimestamp(vexCursor);
_environment = NormalizeEnvironment(env);
CaptureExplain = captureExplain;
}
public PolicyRunInputs()
{
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> SbomSet
{
get => _sbomSet;
init => _sbomSet = NormalizeSbomSet(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? AdvisoryCursor
{
get => _advisoryCursor;
init => _advisoryCursor = Validation.NormalizeTimestamp(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? VexCursor
{
get => _vexCursor;
init => _vexCursor = Validation.NormalizeTimestamp(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IReadOnlyDictionary<string, JsonElement> Environment
{
get => _environment;
init => _environment = NormalizeEnvironment(value);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool CaptureExplain { get; init; }
private ImmutableArray<string> _sbomSet = ImmutableArray<string>.Empty;
private DateTimeOffset? _advisoryCursor;
private DateTimeOffset? _vexCursor;
private IReadOnlyDictionary<string, JsonElement> _environment = ImmutableSortedDictionary<string, JsonElement>.Empty;
private static ImmutableArray<string> NormalizeSbomSet(IEnumerable<string>? values)
=> Validation.NormalizeStringSet(values, nameof(SbomSet));
private static ImmutableArray<string> NormalizeSbomSet(ImmutableArray<string> values)
=> values.IsDefaultOrEmpty ? ImmutableArray<string>.Empty : NormalizeSbomSet(values.AsEnumerable());
private static IReadOnlyDictionary<string, JsonElement> NormalizeEnvironment(IEnumerable<KeyValuePair<string, object?>>? entries)
{
if (entries is null)
{
return ImmutableSortedDictionary<string, JsonElement>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, JsonElement>(StringComparer.Ordinal);
foreach (var entry in entries)
{
var key = Validation.TrimToNull(entry.Key);
if (key is null)
{
continue;
}
var normalizedKey = key.ToLowerInvariant();
var element = entry.Value switch
{
JsonElement jsonElement => jsonElement.Clone(),
JsonDocument jsonDocument => jsonDocument.RootElement.Clone(),
string text => JsonSerializer.SerializeToElement(text).Clone(),
bool boolean => JsonSerializer.SerializeToElement(boolean).Clone(),
int integer => JsonSerializer.SerializeToElement(integer).Clone(),
long longValue => JsonSerializer.SerializeToElement(longValue).Clone(),
double doubleValue => JsonSerializer.SerializeToElement(doubleValue).Clone(),
decimal decimalValue => JsonSerializer.SerializeToElement(decimalValue).Clone(),
null => JsonSerializer.SerializeToElement<object?>(null).Clone(),
_ => JsonSerializer.SerializeToElement(entry.Value, entry.Value.GetType()).Clone(),
};
builder[normalizedKey] = element;
}
return builder.ToImmutable();
}
private static IReadOnlyDictionary<string, JsonElement> NormalizeEnvironment(IReadOnlyDictionary<string, JsonElement>? environment)
{
if (environment is null || environment.Count == 0)
{
return ImmutableSortedDictionary<string, JsonElement>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, JsonElement>(StringComparer.Ordinal);
foreach (var entry in environment)
{
var key = Validation.TrimToNull(entry.Key);
if (key is null)
{
continue;
}
builder[key.ToLowerInvariant()] = entry.Value.Clone();
}
return builder.ToImmutable();
}
}
/// <summary>
/// Stored status for a policy run (policy_runs collection).
/// </summary>
public sealed record PolicyRunStatus
{
public PolicyRunStatus(
string runId,
string tenantId,
string policyId,
int policyVersion,
PolicyRunMode mode,
PolicyRunExecutionStatus status,
PolicyRunPriority priority,
DateTimeOffset queuedAt,
PolicyRunStats? stats = null,
PolicyRunInputs? inputs = null,
DateTimeOffset? startedAt = null,
DateTimeOffset? finishedAt = null,
string? determinismHash = null,
string? errorCode = null,
string? error = null,
int attempts = 0,
string? traceId = null,
string? explainUri = null,
ImmutableSortedDictionary<string, string>? metadata = null,
string? schemaVersion = null)
: this(
runId,
tenantId,
policyId,
policyVersion,
mode,
status,
priority,
Validation.NormalizeTimestamp(queuedAt),
Validation.NormalizeTimestamp(startedAt),
Validation.NormalizeTimestamp(finishedAt),
stats ?? PolicyRunStats.Empty,
inputs ?? PolicyRunInputs.Empty,
determinismHash,
Validation.TrimToNull(errorCode),
Validation.TrimToNull(error),
attempts,
Validation.TrimToNull(traceId),
Validation.TrimToNull(explainUri),
metadata ?? ImmutableSortedDictionary<string, string>.Empty,
schemaVersion)
{
}
[JsonConstructor]
public PolicyRunStatus(
string runId,
string tenantId,
string policyId,
int policyVersion,
PolicyRunMode mode,
PolicyRunExecutionStatus status,
PolicyRunPriority priority,
DateTimeOffset queuedAt,
DateTimeOffset? startedAt,
DateTimeOffset? finishedAt,
PolicyRunStats stats,
PolicyRunInputs inputs,
string? determinismHash,
string? errorCode,
string? error,
int attempts,
string? traceId,
string? explainUri,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyRunStatus(schemaVersion);
RunId = Validation.EnsureId(runId, nameof(runId));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
if (policyVersion <= 0)
{
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
}
PolicyVersion = policyVersion;
Mode = mode;
Status = status;
Priority = priority;
QueuedAt = Validation.NormalizeTimestamp(queuedAt);
StartedAt = Validation.NormalizeTimestamp(startedAt);
FinishedAt = Validation.NormalizeTimestamp(finishedAt);
Stats = stats ?? PolicyRunStats.Empty;
Inputs = inputs ?? PolicyRunInputs.Empty;
DeterminismHash = Validation.TrimToNull(determinismHash);
ErrorCode = Validation.TrimToNull(errorCode);
Error = Validation.TrimToNull(error);
Attempts = attempts < 0
? throw new ArgumentOutOfRangeException(nameof(attempts), attempts, "Attempts must be non-negative.")
: attempts;
TraceId = Validation.TrimToNull(traceId);
ExplainUri = Validation.TrimToNull(explainUri);
Metadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}
public string SchemaVersion { get; }
public string RunId { get; }
public string TenantId { get; }
public string PolicyId { get; }
public int PolicyVersion { get; }
public PolicyRunMode Mode { get; }
public PolicyRunExecutionStatus Status { get; init; }
public PolicyRunPriority Priority { get; init; }
public DateTimeOffset QueuedAt { 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? DeterminismHash { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ErrorCode { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Attempts { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TraceId { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ExplainUri { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; init; } = ImmutableSortedDictionary<string, string>.Empty;
public PolicyRunStats Stats { get; init; } = PolicyRunStats.Empty;
public PolicyRunInputs Inputs { get; init; } = PolicyRunInputs.Empty;
}
/// <summary>
/// Aggregated metrics captured for a policy run.
/// </summary>
public sealed record PolicyRunStats
{
public static PolicyRunStats Empty { get; } = new();
public PolicyRunStats(
int components = 0,
int rulesFired = 0,
int findingsWritten = 0,
int vexOverrides = 0,
int quieted = 0,
int suppressed = 0,
double? durationSeconds = null)
{
Components = Validation.EnsureNonNegative(components, nameof(components));
RulesFired = Validation.EnsureNonNegative(rulesFired, nameof(rulesFired));
FindingsWritten = Validation.EnsureNonNegative(findingsWritten, nameof(findingsWritten));
VexOverrides = Validation.EnsureNonNegative(vexOverrides, nameof(vexOverrides));
Quieted = Validation.EnsureNonNegative(quieted, nameof(quieted));
Suppressed = Validation.EnsureNonNegative(suppressed, nameof(suppressed));
DurationSeconds = durationSeconds is { } seconds && seconds < 0
? throw new ArgumentOutOfRangeException(nameof(durationSeconds), durationSeconds, "Duration must be non-negative.")
: durationSeconds;
}
public int Components { get; } = 0;
public int RulesFired { get; } = 0;
public int FindingsWritten { get; } = 0;
public int VexOverrides { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Quieted { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Suppressed { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? DurationSeconds { get; }
}
/// <summary>
/// Summary payload returned by simulations and run diffs.
/// </summary>
public sealed record PolicyDiffSummary
{
public PolicyDiffSummary(
int added,
int removed,
int unchanged,
IEnumerable<KeyValuePair<string, PolicyDiffSeverityDelta>>? bySeverity = null,
IEnumerable<PolicyDiffRuleDelta>? ruleHits = null,
string? schemaVersion = null)
: this(
Validation.EnsureNonNegative(added, nameof(added)),
Validation.EnsureNonNegative(removed, nameof(removed)),
Validation.EnsureNonNegative(unchanged, nameof(unchanged)),
NormalizeSeverity(bySeverity),
NormalizeRuleHits(ruleHits),
schemaVersion)
{
}
[JsonConstructor]
public PolicyDiffSummary(
int added,
int removed,
int unchanged,
ImmutableSortedDictionary<string, PolicyDiffSeverityDelta> bySeverity,
ImmutableArray<PolicyDiffRuleDelta> ruleHits,
string? schemaVersion = null)
{
Added = Validation.EnsureNonNegative(added, nameof(added));
Removed = Validation.EnsureNonNegative(removed, nameof(removed));
Unchanged = Validation.EnsureNonNegative(unchanged, nameof(unchanged));
BySeverity = NormalizeSeverity(bySeverity);
RuleHits = ruleHits.IsDefault ? ImmutableArray<PolicyDiffRuleDelta>.Empty : ruleHits;
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyDiffSummary(schemaVersion);
}
public string SchemaVersion { get; }
public int Added { get; }
public int Removed { get; }
public int Unchanged { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, PolicyDiffSeverityDelta> BySeverity { get; } = ImmutableSortedDictionary<string, PolicyDiffSeverityDelta>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyDiffRuleDelta> RuleHits { get; } = ImmutableArray<PolicyDiffRuleDelta>.Empty;
private static ImmutableSortedDictionary<string, PolicyDiffSeverityDelta> NormalizeSeverity(IEnumerable<KeyValuePair<string, PolicyDiffSeverityDelta>>? buckets)
{
if (buckets is null)
{
return ImmutableSortedDictionary<string, PolicyDiffSeverityDelta>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, PolicyDiffSeverityDelta>(StringComparer.OrdinalIgnoreCase);
foreach (var bucket in buckets)
{
var key = Validation.TrimToNull(bucket.Key);
if (key is null)
{
continue;
}
var normalizedKey = char.ToUpperInvariant(key[0]) + key[1..].ToLowerInvariant();
builder[normalizedKey] = bucket.Value ?? PolicyDiffSeverityDelta.Empty;
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyDiffRuleDelta> NormalizeRuleHits(IEnumerable<PolicyDiffRuleDelta>? ruleHits)
{
if (ruleHits is null)
{
return ImmutableArray<PolicyDiffRuleDelta>.Empty;
}
return ruleHits
.Where(static hit => hit is not null)
.Select(static hit => hit!)
.OrderBy(static hit => hit.RuleId, StringComparer.Ordinal)
.ThenBy(static hit => hit.RuleName, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Delta counts for a single severity bucket.
/// </summary>
public sealed record PolicyDiffSeverityDelta
{
public static PolicyDiffSeverityDelta Empty { get; } = new();
public PolicyDiffSeverityDelta(int up = 0, int down = 0)
{
Up = Validation.EnsureNonNegative(up, nameof(up));
Down = Validation.EnsureNonNegative(down, nameof(down));
}
public int Up { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Down { get; } = 0;
}
/// <summary>
/// Delta counts per rule for simulation reporting.
/// </summary>
public sealed record PolicyDiffRuleDelta
{
public PolicyDiffRuleDelta(string ruleId, string ruleName, int up = 0, int down = 0)
{
RuleId = Validation.EnsureSimpleIdentifier(ruleId, nameof(ruleId));
RuleName = Validation.EnsureName(ruleName, nameof(ruleName));
Up = Validation.EnsureNonNegative(up, nameof(up));
Down = Validation.EnsureNonNegative(down, nameof(down));
}
public string RuleId { get; }
public string RuleName { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Up { get; } = 0;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Down { get; } = 0;
}
/// <summary>
/// Canonical explain trace for a policy finding.
/// </summary>
public sealed record PolicyExplainTrace
{
public PolicyExplainTrace(
string findingId,
string policyId,
int policyVersion,
string tenantId,
string runId,
PolicyExplainVerdict verdict,
DateTimeOffset evaluatedAt,
IEnumerable<PolicyExplainRule>? ruleChain = null,
IEnumerable<PolicyExplainEvidence>? evidence = null,
IEnumerable<PolicyExplainVexImpact>? vexImpacts = null,
IEnumerable<PolicyExplainHistoryEvent>? history = null,
ImmutableSortedDictionary<string, string>? metadata = null,
string? schemaVersion = null)
: this(
findingId,
policyId,
policyVersion,
tenantId,
runId,
Validation.NormalizeTimestamp(evaluatedAt),
verdict,
NormalizeRuleChain(ruleChain),
NormalizeEvidence(evidence),
NormalizeVexImpacts(vexImpacts),
NormalizeHistory(history),
metadata ?? ImmutableSortedDictionary<string, string>.Empty,
schemaVersion)
{
}
[JsonConstructor]
public PolicyExplainTrace(
string findingId,
string policyId,
int policyVersion,
string tenantId,
string runId,
DateTimeOffset evaluatedAt,
PolicyExplainVerdict verdict,
ImmutableArray<PolicyExplainRule> ruleChain,
ImmutableArray<PolicyExplainEvidence> evidence,
ImmutableArray<PolicyExplainVexImpact> vexImpacts,
ImmutableArray<PolicyExplainHistoryEvent> history,
ImmutableSortedDictionary<string, string> metadata,
string? schemaVersion = null)
{
SchemaVersion = SchedulerSchemaVersions.EnsurePolicyExplainTrace(schemaVersion);
FindingId = Validation.EnsureSimpleIdentifier(findingId, nameof(findingId));
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
if (policyVersion <= 0)
{
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
}
PolicyVersion = policyVersion;
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
RunId = Validation.EnsureId(runId, nameof(runId));
EvaluatedAt = Validation.NormalizeTimestamp(evaluatedAt);
Verdict = verdict ?? throw new ArgumentNullException(nameof(verdict));
RuleChain = ruleChain.IsDefault ? ImmutableArray<PolicyExplainRule>.Empty : ruleChain;
Evidence = evidence.IsDefault ? ImmutableArray<PolicyExplainEvidence>.Empty : evidence;
VexImpacts = vexImpacts.IsDefault ? ImmutableArray<PolicyExplainVexImpact>.Empty : vexImpacts;
History = history.IsDefault ? ImmutableArray<PolicyExplainHistoryEvent>.Empty : history;
Metadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}
public string SchemaVersion { get; }
public string FindingId { get; }
public string PolicyId { get; }
public int PolicyVersion { get; }
public string TenantId { get; }
public string RunId { get; }
public DateTimeOffset EvaluatedAt { get; }
public PolicyExplainVerdict Verdict { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainRule> RuleChain { get; } = ImmutableArray<PolicyExplainRule>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainEvidence> Evidence { get; } = ImmutableArray<PolicyExplainEvidence>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainVexImpact> VexImpacts { get; } = ImmutableArray<PolicyExplainVexImpact>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<PolicyExplainHistoryEvent> History { get; } = ImmutableArray<PolicyExplainHistoryEvent>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
private static ImmutableArray<PolicyExplainRule> NormalizeRuleChain(IEnumerable<PolicyExplainRule>? rules)
{
if (rules is null)
{
return ImmutableArray<PolicyExplainRule>.Empty;
}
return rules
.Where(static rule => rule is not null)
.Select(static rule => rule!)
.ToImmutableArray();
}
private static ImmutableArray<PolicyExplainEvidence> NormalizeEvidence(IEnumerable<PolicyExplainEvidence>? evidence)
{
if (evidence is null)
{
return ImmutableArray<PolicyExplainEvidence>.Empty;
}
return evidence
.Where(static item => item is not null)
.Select(static item => item!)
.OrderBy(static item => item.Type, StringComparer.Ordinal)
.ThenBy(static item => item.Reference, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<PolicyExplainVexImpact> NormalizeVexImpacts(IEnumerable<PolicyExplainVexImpact>? impacts)
{
if (impacts is null)
{
return ImmutableArray<PolicyExplainVexImpact>.Empty;
}
return impacts
.Where(static impact => impact is not null)
.Select(static impact => impact!)
.OrderBy(static impact => impact.StatementId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<PolicyExplainHistoryEvent> NormalizeHistory(IEnumerable<PolicyExplainHistoryEvent>? history)
{
if (history is null)
{
return ImmutableArray<PolicyExplainHistoryEvent>.Empty;
}
return history
.Where(static entry => entry is not null)
.Select(static entry => entry!)
.OrderBy(static entry => entry.OccurredAt)
.ToImmutableArray();
}
}
/// <summary>
/// Verdict metadata for explain traces.
/// </summary>
public sealed record PolicyExplainVerdict
{
public PolicyExplainVerdict(
PolicyVerdictStatus status,
SeverityRank? severity = null,
bool quiet = false,
double? score = null,
string? rationale = null)
{
Status = status;
Severity = severity;
Quiet = quiet;
Score = score;
Rationale = Validation.TrimToNull(rationale);
}
public PolicyVerdictStatus Status { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SeverityRank? Severity { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Quiet { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Score { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Rationale { get; }
}
/// <summary>
/// Rule evaluation entry captured in explain traces.
/// </summary>
public sealed record PolicyExplainRule
{
public PolicyExplainRule(
string ruleId,
string ruleName,
string action,
string decision,
double score,
string? condition = null)
{
RuleId = Validation.EnsureSimpleIdentifier(ruleId, nameof(ruleId));
RuleName = Validation.EnsureName(ruleName, nameof(ruleName));
Action = Validation.TrimToNull(action) ?? throw new ArgumentNullException(nameof(action));
Decision = Validation.TrimToNull(decision) ?? throw new ArgumentNullException(nameof(decision));
Score = score;
Condition = Validation.TrimToNull(condition);
}
public string RuleId { get; }
public string RuleName { get; }
public string Action { get; }
public string Decision { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double Score { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Condition { get; }
}
/// <summary>
/// Evidence entry considered during policy evaluation.
/// </summary>
public sealed record PolicyExplainEvidence
{
public PolicyExplainEvidence(
string type,
string reference,
string source,
string status,
double weight = 0,
string? justification = null,
ImmutableSortedDictionary<string, string>? metadata = null)
{
Type = Validation.TrimToNull(type) ?? throw new ArgumentNullException(nameof(type));
Reference = Validation.TrimToNull(reference) ?? throw new ArgumentNullException(nameof(reference));
Source = Validation.TrimToNull(source) ?? throw new ArgumentNullException(nameof(source));
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Weight = weight;
Justification = Validation.TrimToNull(justification);
Metadata = (metadata ?? ImmutableSortedDictionary<string, string>.Empty)
.Select(static pair => new KeyValuePair<string, string>(
Validation.TrimToNull(pair.Key)?.ToLowerInvariant() ?? string.Empty,
Validation.TrimToNull(pair.Value) ?? string.Empty))
.Where(static pair => !string.IsNullOrEmpty(pair.Key) && !string.IsNullOrEmpty(pair.Value))
.DistinctBy(static pair => pair.Key, StringComparer.Ordinal)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
}
public string Type { get; }
public string Reference { get; }
public string Source { get; }
public string Status { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double Weight { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Justification { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary<string, string> Metadata { get; } = ImmutableSortedDictionary<string, string>.Empty;
}
/// <summary>
/// VEX statement impact summary captured in explain traces.
/// </summary>
public sealed record PolicyExplainVexImpact
{
public PolicyExplainVexImpact(
string statementId,
string provider,
string status,
bool accepted,
string? justification = null,
string? confidence = null)
{
StatementId = Validation.TrimToNull(statementId) ?? throw new ArgumentNullException(nameof(statementId));
Provider = Validation.TrimToNull(provider) ?? throw new ArgumentNullException(nameof(provider));
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Accepted = accepted;
Justification = Validation.TrimToNull(justification);
Confidence = Validation.TrimToNull(confidence);
}
public string StatementId { get; }
public string Provider { get; }
public string Status { get; }
public bool Accepted { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Justification { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Confidence { get; }
}
/// <summary>
/// History entry for a finding's policy lifecycle.
/// </summary>
public sealed record PolicyExplainHistoryEvent
{
public PolicyExplainHistoryEvent(
string status,
DateTimeOffset occurredAt,
string? actor = null,
string? note = null)
{
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
OccurredAt = Validation.NormalizeTimestamp(occurredAt);
Actor = Validation.TrimToNull(actor);
Note = Validation.TrimToNull(note);
}
public string Status { get; }
public DateTimeOffset OccurredAt { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Actor { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Note { get; }
}

View File

@@ -0,0 +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;
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Globalization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Convenience helpers for <see cref="RunReason"/> mutations.
/// </summary>
public static class RunReasonExtensions
{
/// <summary>
/// Returns a copy of <paramref name="reason"/> with impact window timestamps normalized to ISO-8601.
/// </summary>
public static RunReason WithImpactWindow(
this RunReason reason,
DateTimeOffset? from,
DateTimeOffset? to)
{
var normalizedFrom = Validation.NormalizeTimestamp(from);
var normalizedTo = Validation.NormalizeTimestamp(to);
if (normalizedFrom.HasValue && normalizedTo.HasValue && normalizedFrom > normalizedTo)
{
throw new ArgumentException("Impact window start must be earlier than or equal to end.");
}
return reason with
{
ImpactWindowFrom = normalizedFrom?.ToString("O", CultureInfo.InvariantCulture),
ImpactWindowTo = normalizedTo?.ToString("O", CultureInfo.InvariantCulture),
};
}
}

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Encapsulates allowed <see cref="RunState"/> transitions and invariants.
/// </summary>
public static class RunStateMachine
{
private static readonly IReadOnlyDictionary<RunState, RunState[]> Adjacency = new Dictionary<RunState, RunState[]>
{
[RunState.Planning] = new[] { RunState.Planning, RunState.Queued, RunState.Cancelled },
[RunState.Queued] = new[] { RunState.Queued, RunState.Running, RunState.Cancelled },
[RunState.Running] = new[] { RunState.Running, RunState.Completed, RunState.Error, RunState.Cancelled },
[RunState.Completed] = new[] { RunState.Completed },
[RunState.Error] = new[] { RunState.Error },
[RunState.Cancelled] = new[] { RunState.Cancelled },
};
public static bool CanTransition(RunState from, RunState to)
{
if (!Adjacency.TryGetValue(from, out var allowed))
{
return false;
}
return allowed.Contains(to);
}
public static bool IsTerminal(RunState state)
=> state is RunState.Completed or RunState.Error or RunState.Cancelled;
/// <summary>
/// Applies a state transition ensuring timestamps, stats, and error contracts stay consistent.
/// </summary>
public static Run EnsureTransition(
Run run,
RunState next,
DateTimeOffset timestamp,
Action<RunStatsBuilder>? mutateStats = null,
string? errorMessage = null)
{
ArgumentNullException.ThrowIfNull(run);
var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp);
var current = run.State;
if (!CanTransition(current, next))
{
throw new InvalidOperationException($"Run state transition from '{current}' to '{next}' is not allowed.");
}
var statsBuilder = new RunStatsBuilder(run.Stats);
mutateStats?.Invoke(statsBuilder);
var newStats = statsBuilder.Build();
var startedAt = run.StartedAt;
var finishedAt = run.FinishedAt;
if (current != RunState.Running && next == RunState.Running && startedAt is null)
{
startedAt = normalizedTimestamp;
}
if (IsTerminal(next))
{
finishedAt ??= normalizedTimestamp;
}
if (startedAt is { } start && start < run.CreatedAt)
{
throw new InvalidOperationException("Run started time cannot be earlier than created time.");
}
if (finishedAt is { } finish)
{
if (startedAt is { } startTime && finish < startTime)
{
throw new InvalidOperationException("Run finished time cannot be earlier than start time.");
}
if (!IsTerminal(next))
{
throw new InvalidOperationException("Finished time present but next state is not terminal.");
}
}
string? nextError = null;
if (next == RunState.Error)
{
var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? run.Error : errorMessage.Trim();
if (string.IsNullOrWhiteSpace(effectiveError))
{
throw new InvalidOperationException("Transitioning to Error requires a non-empty error message.");
}
nextError = effectiveError;
}
else if (!string.IsNullOrWhiteSpace(errorMessage))
{
throw new InvalidOperationException("Error message can only be provided when transitioning to Error state.");
}
var updated = run with
{
State = next,
Stats = newStats,
StartedAt = startedAt,
FinishedAt = finishedAt,
Error = nextError,
};
Validate(updated);
return updated;
}
public static void Validate(Run run)
{
ArgumentNullException.ThrowIfNull(run);
if (run.StartedAt is { } started && started < run.CreatedAt)
{
throw new InvalidOperationException("Run.StartedAt cannot be earlier than CreatedAt.");
}
if (run.FinishedAt is { } finished)
{
if (run.StartedAt is { } startedAt && finished < startedAt)
{
throw new InvalidOperationException("Run.FinishedAt cannot be earlier than StartedAt.");
}
if (!IsTerminal(run.State))
{
throw new InvalidOperationException("Run.FinishedAt set while state is not terminal.");
}
}
else if (IsTerminal(run.State))
{
throw new InvalidOperationException("Terminal run states must include FinishedAt.");
}
if (run.State == RunState.Error)
{
if (string.IsNullOrWhiteSpace(run.Error))
{
throw new InvalidOperationException("Run.Error must be populated when state is Error.");
}
}
else if (!string.IsNullOrWhiteSpace(run.Error))
{
throw new InvalidOperationException("Run.Error must be null for non-error states.");
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Helper that enforces monotonic <see cref="RunStats"/> updates.
/// </summary>
public sealed class RunStatsBuilder
{
private int _candidates;
private int _deduped;
private int _queued;
private int _completed;
private int _deltas;
private int _newCriticals;
private int _newHigh;
private int _newMedium;
private int _newLow;
public RunStatsBuilder(RunStats? baseline = null)
{
baseline ??= RunStats.Empty;
_candidates = baseline.Candidates;
_deduped = baseline.Deduped;
_queued = baseline.Queued;
_completed = baseline.Completed;
_deltas = baseline.Deltas;
_newCriticals = baseline.NewCriticals;
_newHigh = baseline.NewHigh;
_newMedium = baseline.NewMedium;
_newLow = baseline.NewLow;
}
public void SetCandidates(int value) => _candidates = EnsureMonotonic(value, _candidates, nameof(RunStats.Candidates));
public void IncrementCandidates(int value = 1) => SetCandidates(_candidates + value);
public void SetDeduped(int value) => _deduped = EnsureMonotonic(value, _deduped, nameof(RunStats.Deduped));
public void IncrementDeduped(int value = 1) => SetDeduped(_deduped + value);
public void SetQueued(int value) => _queued = EnsureMonotonic(value, _queued, nameof(RunStats.Queued));
public void IncrementQueued(int value = 1) => SetQueued(_queued + value);
public void SetCompleted(int value) => _completed = EnsureMonotonic(value, _completed, nameof(RunStats.Completed));
public void IncrementCompleted(int value = 1) => SetCompleted(_completed + value);
public void SetDeltas(int value) => _deltas = EnsureMonotonic(value, _deltas, nameof(RunStats.Deltas));
public void IncrementDeltas(int value = 1) => SetDeltas(_deltas + value);
public void SetNewCriticals(int value) => _newCriticals = EnsureMonotonic(value, _newCriticals, nameof(RunStats.NewCriticals));
public void IncrementNewCriticals(int value = 1) => SetNewCriticals(_newCriticals + value);
public void SetNewHigh(int value) => _newHigh = EnsureMonotonic(value, _newHigh, nameof(RunStats.NewHigh));
public void IncrementNewHigh(int value = 1) => SetNewHigh(_newHigh + value);
public void SetNewMedium(int value) => _newMedium = EnsureMonotonic(value, _newMedium, nameof(RunStats.NewMedium));
public void IncrementNewMedium(int value = 1) => SetNewMedium(_newMedium + value);
public void SetNewLow(int value) => _newLow = EnsureMonotonic(value, _newLow, nameof(RunStats.NewLow));
public void IncrementNewLow(int value = 1) => SetNewLow(_newLow + value);
public RunStats Build()
=> new(
candidates: _candidates,
deduped: _deduped,
queued: _queued,
completed: _completed,
deltas: _deltas,
newCriticals: _newCriticals,
newHigh: _newHigh,
newMedium: _newMedium,
newLow: _newLow);
private static int EnsureMonotonic(int value, int current, string fieldName)
{
Validation.EnsureNonNegative(value, fieldName);
if (value < current)
{
throw new InvalidOperationException($"RunStats.{fieldName} cannot decrease (current: {current}, attempted: {value}).");
}
return value;
}
}

View File

@@ -0,0 +1,227 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Scheduler configuration entity persisted in Mongo.
/// </summary>
public sealed record Schedule
{
public Schedule(
string id,
string tenantId,
string name,
bool enabled,
string cronExpression,
string timezone,
ScheduleMode mode,
Selector selection,
ScheduleOnlyIf? onlyIf,
ScheduleNotify? notify,
ScheduleLimits? limits,
DateTimeOffset createdAt,
string createdBy,
DateTimeOffset updatedAt,
string updatedBy,
ImmutableArray<string>? subscribers = null,
string? schemaVersion = null)
: this(
id,
tenantId,
name,
enabled,
cronExpression,
timezone,
mode,
selection,
onlyIf ?? ScheduleOnlyIf.Default,
notify ?? ScheduleNotify.Default,
limits ?? ScheduleLimits.Default,
subscribers ?? ImmutableArray<string>.Empty,
createdAt,
createdBy,
updatedAt,
updatedBy,
schemaVersion)
{
}
[JsonConstructor]
public Schedule(
string id,
string tenantId,
string name,
bool enabled,
string cronExpression,
string timezone,
ScheduleMode mode,
Selector selection,
ScheduleOnlyIf onlyIf,
ScheduleNotify notify,
ScheduleLimits limits,
ImmutableArray<string> subscribers,
DateTimeOffset createdAt,
string createdBy,
DateTimeOffset updatedAt,
string updatedBy,
string? schemaVersion = null)
{
Id = Validation.EnsureId(id, nameof(id));
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
Name = Validation.EnsureName(name, nameof(name));
Enabled = enabled;
CronExpression = Validation.EnsureCronExpression(cronExpression, nameof(cronExpression));
Timezone = Validation.EnsureTimezone(timezone, nameof(timezone));
Mode = mode;
Selection = selection ?? throw new ArgumentNullException(nameof(selection));
OnlyIf = onlyIf ?? ScheduleOnlyIf.Default;
Notify = notify ?? ScheduleNotify.Default;
Limits = limits ?? ScheduleLimits.Default;
Subscribers = (subscribers.IsDefault ? ImmutableArray<string>.Empty : subscribers)
.Select(static value => Validation.EnsureSimpleIdentifier(value, nameof(subscribers)))
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
CreatedAt = Validation.NormalizeTimestamp(createdAt);
CreatedBy = Validation.EnsureSimpleIdentifier(createdBy, nameof(createdBy));
UpdatedAt = Validation.NormalizeTimestamp(updatedAt);
UpdatedBy = Validation.EnsureSimpleIdentifier(updatedBy, nameof(updatedBy));
SchemaVersion = SchedulerSchemaVersions.EnsureSchedule(schemaVersion);
if (Selection.TenantId is not null && !string.Equals(Selection.TenantId, TenantId, StringComparison.Ordinal))
{
throw new ArgumentException("Selection tenant must match schedule tenant.", nameof(selection));
}
}
public string SchemaVersion { get; }
public string Id { get; }
public string TenantId { get; }
public string Name { get; }
public bool Enabled { get; }
public string CronExpression { get; }
public string Timezone { get; }
public ScheduleMode Mode { get; }
public Selector Selection { get; }
public ScheduleOnlyIf OnlyIf { get; }
public ScheduleNotify Notify { get; }
public ScheduleLimits Limits { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Subscribers { get; } = ImmutableArray<string>.Empty;
public DateTimeOffset CreatedAt { get; }
public string CreatedBy { get; }
public DateTimeOffset UpdatedAt { get; }
public string UpdatedBy { get; }
}
/// <summary>
/// Conditions that must hold before a schedule enqueues work.
/// </summary>
public sealed record ScheduleOnlyIf
{
public static ScheduleOnlyIf Default { get; } = new();
[JsonConstructor]
public ScheduleOnlyIf(int? lastReportOlderThanDays = null, string? policyRevision = null)
{
LastReportOlderThanDays = Validation.EnsurePositiveOrNull(lastReportOlderThanDays, nameof(lastReportOlderThanDays));
PolicyRevision = Validation.TrimToNull(policyRevision);
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? LastReportOlderThanDays { get; } = null;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PolicyRevision { get; } = null;
}
/// <summary>
/// Notification preferences for schedule outcomes.
/// </summary>
public sealed record ScheduleNotify
{
public static ScheduleNotify Default { get; } = new(onNewFindings: true, null, includeKev: true);
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev)
{
OnNewFindings = onNewFindings;
if (minSeverity is SeverityRank.Unknown or SeverityRank.None)
{
MinSeverity = minSeverity == SeverityRank.Unknown ? SeverityRank.Unknown : SeverityRank.Low;
}
else
{
MinSeverity = minSeverity;
}
IncludeKev = includeKev;
}
[JsonConstructor]
public ScheduleNotify(bool onNewFindings, SeverityRank? minSeverity, bool includeKev, bool includeQuietFindings = false)
: this(onNewFindings, minSeverity, includeKev)
{
IncludeQuietFindings = includeQuietFindings;
}
public bool OnNewFindings { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SeverityRank? MinSeverity { get; }
public bool IncludeKev { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool IncludeQuietFindings { get; }
}
/// <summary>
/// Execution limits that bound scheduler throughput.
/// </summary>
public sealed record ScheduleLimits
{
public static ScheduleLimits Default { get; } = new();
public ScheduleLimits(int? maxJobs = null, int? ratePerSecond = null, int? parallelism = null)
{
MaxJobs = Validation.EnsurePositiveOrNull(maxJobs, nameof(maxJobs));
RatePerSecond = Validation.EnsurePositiveOrNull(ratePerSecond, nameof(ratePerSecond));
Parallelism = Validation.EnsurePositiveOrNull(parallelism, nameof(parallelism));
}
[JsonConstructor]
public ScheduleLimits(int? maxJobs, int? ratePerSecond, int? parallelism, int? burst = null)
: this(maxJobs, ratePerSecond, parallelism)
{
Burst = Validation.EnsurePositiveOrNull(burst, nameof(burst));
}
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxJobs { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? RatePerSecond { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Parallelism { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Burst { get; }
}

View File

@@ -0,0 +1,454 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Upgrades scheduler documents emitted by earlier schema revisions to the latest DTOs.
/// </summary>
public static class SchedulerSchemaMigration
{
private static readonly ImmutableHashSet<string> ScheduleProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy");
private static readonly ImmutableHashSet<string> RunProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas");
private static readonly ImmutableHashSet<string> ImpactSetProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"snapshotId");
public static SchedulerSchemaMigrationResult<Schedule> UpgradeSchedule(JsonNode document, bool strict = false)
=> Upgrade(
document,
SchedulerSchemaVersions.Schedule,
SchedulerSchemaVersions.EnsureSchedule,
ScheduleProperties,
static json => CanonicalJsonSerializer.Deserialize<Schedule>(json),
ApplyScheduleLegacyFixups,
strict);
public static SchedulerSchemaMigrationResult<Run> UpgradeRun(JsonNode document, bool strict = false)
=> Upgrade(
document,
SchedulerSchemaVersions.Run,
SchedulerSchemaVersions.EnsureRun,
RunProperties,
static json => CanonicalJsonSerializer.Deserialize<Run>(json),
ApplyRunLegacyFixups,
strict);
public static SchedulerSchemaMigrationResult<ImpactSet> UpgradeImpactSet(JsonNode document, bool strict = false)
=> Upgrade(
document,
SchedulerSchemaVersions.ImpactSet,
SchedulerSchemaVersions.EnsureImpactSet,
ImpactSetProperties,
static json => CanonicalJsonSerializer.Deserialize<ImpactSet>(json),
ApplyImpactSetLegacyFixups,
strict);
private static SchedulerSchemaMigrationResult<T> Upgrade<T>(
JsonNode document,
string latestVersion,
Func<string?, string> ensureVersion,
ImmutableHashSet<string> knownProperties,
Func<string, T> deserialize,
Func<JsonObject, string, ImmutableArray<string>.Builder, bool> applyLegacyFixups,
bool strict)
{
ArgumentNullException.ThrowIfNull(document);
var (normalized, fromVersion) = Normalize(document, ensureVersion);
var warnings = ImmutableArray.CreateBuilder<string>();
if (!string.Equals(fromVersion, latestVersion, StringComparison.Ordinal))
{
var upgraded = applyLegacyFixups(normalized, fromVersion, warnings);
if (!upgraded)
{
throw new NotSupportedException($"Unsupported scheduler schema version '{fromVersion}', expected '{latestVersion}'.");
}
normalized["schemaVersion"] = latestVersion;
}
if (strict)
{
RemoveUnknownMembers(normalized, knownProperties, warnings, fromVersion);
}
var canonicalJson = normalized.ToJsonString(new JsonSerializerOptions
{
WriteIndented = false,
});
var value = deserialize(canonicalJson);
return new SchedulerSchemaMigrationResult<T>(
value,
fromVersion,
latestVersion,
warnings.ToImmutable());
}
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, Func<string?, string> ensureVersion)
{
if (node is not JsonObject obj)
{
throw new ArgumentException("Document must be a JSON object.", nameof(node));
}
if (obj.DeepClone() is not JsonObject clone)
{
throw new InvalidOperationException("Unable to clone scheduler document.");
}
string schemaVersion;
if (clone.TryGetPropertyValue("schemaVersion", out var value) &&
value is JsonValue jsonValue &&
jsonValue.TryGetValue(out string? rawVersion))
{
schemaVersion = ensureVersion(rawVersion);
}
else
{
schemaVersion = ensureVersion(null);
clone["schemaVersion"] = schemaVersion;
}
// Ensure schemaVersion is normalized in the clone.
clone["schemaVersion"] = schemaVersion;
return (clone, schemaVersion);
}
private static void RemoveUnknownMembers(
JsonObject json,
ImmutableHashSet<string> knownProperties,
ImmutableArray<string>.Builder warnings,
string schemaVersion)
{
var unknownKeys = json
.Where(static pair => pair.Key is not null)
.Select(pair => pair.Key!)
.Where(key => !knownProperties.Contains(key))
.ToArray();
foreach (var key in unknownKeys)
{
json.Remove(key);
warnings.Add($"Removed unknown property '{key}' from scheduler document (schemaVersion={schemaVersion}).");
}
}
private static bool ApplyScheduleLegacyFixups(
JsonObject json,
string fromVersion,
ImmutableArray<string>.Builder warnings)
{
switch (fromVersion)
{
case SchedulerSchemaVersions.ScheduleLegacy0:
var limits = EnsureObject(json, "limits", () => new JsonObject(), warnings, "schedule", fromVersion);
NormalizePositiveInt(limits, "maxJobs", warnings, "schedule.limits", fromVersion);
NormalizePositiveInt(limits, "ratePerSecond", warnings, "schedule.limits", fromVersion);
NormalizePositiveInt(limits, "parallelism", warnings, "schedule.limits", fromVersion);
NormalizePositiveInt(limits, "burst", warnings, "schedule.limits", fromVersion);
var notify = EnsureObject(json, "notify", () => new JsonObject(), warnings, "schedule", fromVersion);
NormalizeBoolean(notify, "onNewFindings", defaultValue: true, warnings, "schedule.notify", fromVersion);
NormalizeSeverity(notify, "minSeverity", warnings, "schedule.notify", fromVersion);
NormalizeBoolean(notify, "includeKev", defaultValue: true, warnings, "schedule.notify", fromVersion);
NormalizeBoolean(notify, "includeQuietFindings", defaultValue: false, warnings, "schedule.notify", fromVersion);
var onlyIf = EnsureObject(json, "onlyIf", () => new JsonObject(), warnings, "schedule", fromVersion);
NormalizePositiveInt(onlyIf, "lastReportOlderThanDays", warnings, "schedule.onlyIf", fromVersion, allowZero: false);
EnsureArray(json, "subscribers", warnings, "schedule", fromVersion);
return true;
default:
return false;
}
}
private static bool ApplyRunLegacyFixups(
JsonObject json,
string fromVersion,
ImmutableArray<string>.Builder warnings)
{
switch (fromVersion)
{
case SchedulerSchemaVersions.RunLegacy0:
var stats = EnsureObject(json, "stats", () => new JsonObject(), warnings, "run", fromVersion);
NormalizeNonNegativeInt(stats, "candidates", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "deduped", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "queued", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "completed", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "deltas", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newCriticals", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newHigh", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newMedium", warnings, "run.stats", fromVersion);
NormalizeNonNegativeInt(stats, "newLow", warnings, "run.stats", fromVersion);
EnsureObject(json, "reason", () => new JsonObject(), warnings, "run", fromVersion);
EnsureArray(json, "deltas", warnings, "run", fromVersion);
return true;
default:
return false;
}
}
private static bool ApplyImpactSetLegacyFixups(
JsonObject json,
string fromVersion,
ImmutableArray<string>.Builder warnings)
{
switch (fromVersion)
{
case SchedulerSchemaVersions.ImpactSetLegacy0:
var images = EnsureArray(json, "images", warnings, "impact-set", fromVersion);
NormalizeBoolean(json, "usageOnly", defaultValue: false, warnings, "impact-set", fromVersion);
if (!json.TryGetPropertyValue("total", out var totalNode) || !TryReadNonNegative(totalNode, out var total))
{
var computed = images.Count;
json["total"] = computed;
warnings.Add($"Backfilled impact set total with image count ({computed}) while upgrading from {fromVersion}.");
}
else
{
var computed = images.Count;
if (total != computed)
{
json["total"] = computed;
warnings.Add($"Normalized impact set total to image count ({computed}) while upgrading from {fromVersion}.");
}
}
return true;
default:
return false;
}
}
private static JsonObject EnsureObject(
JsonObject parent,
string propertyName,
Func<JsonObject> factory,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (parent.TryGetPropertyValue(propertyName, out var node) && node is JsonObject obj)
{
return obj;
}
var created = factory();
parent[propertyName] = created;
warnings.Add($"Inserted default '{context}.{propertyName}' object while upgrading from {fromVersion}.");
return created;
}
private static JsonArray EnsureArray(
JsonObject parent,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (parent.TryGetPropertyValue(propertyName, out var node) && node is JsonArray array)
{
return array;
}
var created = new JsonArray();
parent[propertyName] = created;
warnings.Add($"Inserted empty '{context}.{propertyName}' array while upgrading from {fromVersion}.");
return created;
}
private static void NormalizePositiveInt(
JsonObject obj,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion,
bool allowZero = false)
{
if (!obj.TryGetPropertyValue(propertyName, out var node))
{
return;
}
if (!TryReadInt(node, out var value))
{
obj.Remove(propertyName);
warnings.Add($"Removed invalid '{context}.{propertyName}' while upgrading from {fromVersion}.");
return;
}
if ((!allowZero && value <= 0) || (allowZero && value < 0))
{
obj.Remove(propertyName);
warnings.Add($"Removed non-positive '{context}.{propertyName}' value while upgrading from {fromVersion}.");
return;
}
obj[propertyName] = value;
}
private static void NormalizeNonNegativeInt(
JsonObject obj,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (!obj.TryGetPropertyValue(propertyName, out var node) || !TryReadNonNegative(node, out var value))
{
obj[propertyName] = 0;
warnings.Add($"Defaulted '{context}.{propertyName}' to 0 while upgrading from {fromVersion}.");
return;
}
obj[propertyName] = value;
}
private static void NormalizeBoolean(
JsonObject obj,
string propertyName,
bool defaultValue,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (!obj.TryGetPropertyValue(propertyName, out var node))
{
obj[propertyName] = defaultValue;
warnings.Add($"Defaulted '{context}.{propertyName}' to {defaultValue.ToString().ToLowerInvariant()} while upgrading from {fromVersion}.");
return;
}
if (node is JsonValue value && value.TryGetValue(out bool parsed))
{
obj[propertyName] = parsed;
return;
}
if (node is JsonValue strValue && strValue.TryGetValue(out string? text) &&
bool.TryParse(text, out var parsedFromString))
{
obj[propertyName] = parsedFromString;
return;
}
obj[propertyName] = defaultValue;
warnings.Add($"Normalized '{context}.{propertyName}' to {defaultValue.ToString().ToLowerInvariant()} while upgrading from {fromVersion}.");
}
private static void NormalizeSeverity(
JsonObject obj,
string propertyName,
ImmutableArray<string>.Builder warnings,
string context,
string fromVersion)
{
if (!obj.TryGetPropertyValue(propertyName, out var node))
{
return;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out string? text))
{
if (Enum.TryParse<SeverityRank>(text, ignoreCase: true, out var parsed))
{
obj[propertyName] = parsed.ToString().ToLowerInvariant();
return;
}
}
if (value.TryGetValue(out int numeric) && Enum.IsDefined(typeof(SeverityRank), numeric))
{
var enumValue = (SeverityRank)numeric;
obj[propertyName] = enumValue.ToString().ToLowerInvariant();
return;
}
}
obj.Remove(propertyName);
warnings.Add($"Removed invalid '{context}.{propertyName}' while upgrading from {fromVersion}.");
}
private static bool TryReadNonNegative(JsonNode? node, out int value)
=> TryReadInt(node, out value) && value >= 0;
private static bool TryReadInt(JsonNode? node, out int value)
{
if (node is JsonValue valueNode)
{
if (valueNode.TryGetValue(out int intValue))
{
value = intValue;
return true;
}
if (valueNode.TryGetValue(out long longValue) && longValue is >= int.MinValue and <= int.MaxValue)
{
value = (int)longValue;
return true;
}
if (valueNode.TryGetValue(out string? text) &&
int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
value = parsed;
return true;
}
}
value = 0;
return false;
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Immutable;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Result from upgrading a scheduler document to the latest schema version.
/// </summary>
/// <typeparam name="T">Target DTO type.</typeparam>
public sealed record SchedulerSchemaMigrationResult<T>(
T Value,
string FromVersion,
string ToVersion,
ImmutableArray<string> Warnings);

View File

@@ -0,0 +1,54 @@
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Canonical schema version identifiers for scheduler documents.
/// </summary>
public static class SchedulerSchemaVersions
{
public const string Schedule = "scheduler.schedule@1";
public const string Run = "scheduler.run@1";
public const string ImpactSet = "scheduler.impact-set@1";
public const string GraphBuildJob = "scheduler.graph-build-job@1";
public const string GraphOverlayJob = "scheduler.graph-overlay-job@1";
public const string PolicyRunRequest = "scheduler.policy-run-request@1";
public const string PolicyRunStatus = "scheduler.policy-run-status@1";
public const string PolicyDiffSummary = "scheduler.policy-diff-summary@1";
public const string PolicyExplainTrace = "scheduler.policy-explain-trace@1";
public const string PolicyRunJob = "scheduler.policy-run-job@1";
public const string ScheduleLegacy0 = "scheduler.schedule@0";
public const string RunLegacy0 = "scheduler.run@0";
public const string ImpactSetLegacy0 = "scheduler.impact-set@0";
public static string EnsureSchedule(string? value)
=> Normalize(value, Schedule);
public static string EnsureRun(string? value)
=> Normalize(value, Run);
public static string EnsureImpactSet(string? value)
=> Normalize(value, ImpactSet);
public static string EnsureGraphBuildJob(string? value)
=> Normalize(value, GraphBuildJob);
public static string EnsureGraphOverlayJob(string? value)
=> Normalize(value, GraphOverlayJob);
public static string EnsurePolicyRunRequest(string? value)
=> Normalize(value, PolicyRunRequest);
public static string EnsurePolicyRunStatus(string? value)
=> Normalize(value, PolicyRunStatus);
public static string EnsurePolicyDiffSummary(string? value)
=> Normalize(value, PolicyDiffSummary);
public static string EnsurePolicyExplainTrace(string? value)
=> Normalize(value, PolicyExplainTrace);
public static string EnsurePolicyRunJob(string? value)
=> Normalize(value, PolicyRunJob);
private static string Normalize(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}

View File

@@ -0,0 +1,134 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Selector filters used to resolve impacted assets.
/// </summary>
public sealed record Selector
{
public Selector(
SelectorScope scope,
string? tenantId = null,
IEnumerable<string>? namespaces = null,
IEnumerable<string>? repositories = null,
IEnumerable<string>? digests = null,
IEnumerable<string>? includeTags = null,
IEnumerable<LabelSelector>? labels = null,
bool resolvesTags = false)
: this(
scope,
tenantId,
Validation.NormalizeStringSet(namespaces, nameof(namespaces)),
Validation.NormalizeStringSet(repositories, nameof(repositories)),
Validation.NormalizeDigests(digests, nameof(digests)),
Validation.NormalizeTagPatterns(includeTags),
NormalizeLabels(labels),
resolvesTags)
{
}
[JsonConstructor]
public Selector(
SelectorScope scope,
string? tenantId,
ImmutableArray<string> namespaces,
ImmutableArray<string> repositories,
ImmutableArray<string> digests,
ImmutableArray<string> includeTags,
ImmutableArray<LabelSelector> labels,
bool resolvesTags)
{
Scope = scope;
TenantId = tenantId is null ? null : Validation.EnsureTenantId(tenantId, nameof(tenantId));
Namespaces = namespaces.IsDefault ? ImmutableArray<string>.Empty : namespaces;
Repositories = repositories.IsDefault ? ImmutableArray<string>.Empty : repositories;
Digests = digests.IsDefault ? ImmutableArray<string>.Empty : digests;
IncludeTags = includeTags.IsDefault ? ImmutableArray<string>.Empty : includeTags;
Labels = labels.IsDefault ? ImmutableArray<LabelSelector>.Empty : labels;
ResolvesTags = resolvesTags;
if (Scope is SelectorScope.ByDigest && Digests.Length == 0)
{
throw new ArgumentException("At least one digest is required when scope is by-digest.", nameof(digests));
}
if (Scope is SelectorScope.ByNamespace && Namespaces.Length == 0)
{
throw new ArgumentException("Namespaces are required when scope is by-namespace.", nameof(namespaces));
}
if (Scope is SelectorScope.ByRepository && Repositories.Length == 0)
{
throw new ArgumentException("Repositories are required when scope is by-repo.", nameof(repositories));
}
if (Scope is SelectorScope.ByLabels && Labels.Length == 0)
{
throw new ArgumentException("Labels are required when scope is by-labels.", nameof(labels));
}
}
public SelectorScope Scope { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TenantId { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Namespaces { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Repositories { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Digests { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> IncludeTags { get; } = ImmutableArray<string>.Empty;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<LabelSelector> Labels { get; } = ImmutableArray<LabelSelector>.Empty;
public bool ResolvesTags { get; }
private static ImmutableArray<LabelSelector> NormalizeLabels(IEnumerable<LabelSelector>? labels)
{
if (labels is null)
{
return ImmutableArray<LabelSelector>.Empty;
}
return labels
.Where(static label => label is not null)
.Select(static label => label!)
.OrderBy(static label => label.Key, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
/// Describes a label match (key and optional accepted values).
/// </summary>
public sealed record LabelSelector
{
public LabelSelector(string key, IEnumerable<string>? values = null)
: this(key, NormalizeValues(values))
{
}
[JsonConstructor]
public LabelSelector(string key, ImmutableArray<string> values)
{
Key = Validation.EnsureSimpleIdentifier(key, nameof(key));
Values = values.IsDefault ? ImmutableArray<string>.Empty : values;
}
public string Key { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray<string> Values { get; } = ImmutableArray<string>.Empty;
private static ImmutableArray<string> NormalizeValues(IEnumerable<string>? values)
=> Validation.NormalizeStringSet(values, nameof(values));
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show More