Restructure solution layout by module
This commit is contained in:
4
src/Scheduler/StellaOps.Scheduler.WebService/AGENTS.md
Normal file
4
src/Scheduler/StellaOps.Scheduler.WebService/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Scheduler.WebService — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Scheduler control plane per `docs/ARCHITECTURE_SCHEDULER.md`.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Auth;
|
||||
|
||||
public interface IScopeAuthorizer
|
||||
{
|
||||
void EnsureScope(HttpContext context, string requiredScope);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal static class GraphJobEventKinds
|
||||
{
|
||||
public const string GraphJobCompleted = "scheduler.graph.job.completed";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public interface ICartographerWebhookClient
|
||||
{
|
||||
Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public interface IGraphJobCompletionPublisher
|
||||
{
|
||||
Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
11
src/Scheduler/StellaOps.Scheduler.WebService/ISystemClock.cs
Normal file
11
src/Scheduler/StellaOps.Scheduler.WebService/ISystemClock.cs
Normal 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;
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
202
src/Scheduler/StellaOps.Scheduler.WebService/Program.cs
Normal file
202
src/Scheduler/StellaOps.Scheduler.WebService/Program.cs
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.WebService.Tests")]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
47
src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md
Normal file
47
src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md
Normal 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 §§1–2. | 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 §§1–2.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
32
src/Scheduler/StellaOps.Scheduler.Worker.Host/Program.cs
Normal file
32
src/Scheduler/StellaOps.Scheduler.Worker.Host/Program.cs
Normal 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;
|
||||
@@ -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>
|
||||
416
src/Scheduler/StellaOps.Scheduler.sln
Normal file
416
src/Scheduler/StellaOps.Scheduler.sln
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Scheduler.ImpactIndex — Agent Charter
|
||||
|
||||
## Mission
|
||||
Build the global impact index per `docs/ARCHITECTURE_SCHEDULER.md` (roaring bitmaps, selectors, snapshotting).
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Scheduler.Models — Agent Charter
|
||||
|
||||
## Mission
|
||||
Define Scheduler DTOs (Schedule, Run, ImpactSet, Selector, DeltaSummary) per `docs/ARCHITECTURE_SCHEDULER.md`.
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.ImpactIndex")]
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
179
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Enums.cs
Normal file
179
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Enums.cs
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
378
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Run.cs
Normal file
378
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Run.cs
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
227
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Schedule.cs
Normal file
227
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Schedule.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
134
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Selector.cs
Normal file
134
src/Scheduler/__Libraries/StellaOps.Scheduler.Models/Selector.cs
Normal 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));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user