consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
15
src/JobEngine/StellaOps.Scheduler.WebService/AGENTS.md
Normal file
15
src/JobEngine/StellaOps.Scheduler.WebService/AGENTS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# StellaOps.Scheduler.WebService — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Scheduler control plane per `docs/modules/scheduler/ARCHITECTURE.md`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/scheduler/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Development/test-only authentication handler that authenticates requests
|
||||
/// carrying header-based dev credentials (X-Tenant-Id + X-Scopes).
|
||||
/// When neither header is present the handler returns NoResult so ASP.NET
|
||||
/// Core issues a 401 challenge, matching production auth behavior.
|
||||
/// </summary>
|
||||
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private const string TenantHeader = "X-Tenant-Id";
|
||||
private static readonly string[] ScopeHeaders = ["X-StellaOps-Scopes", "X-Scopes"];
|
||||
|
||||
public AnonymousAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Require at least the tenant header for dev-auth to engage.
|
||||
// Without it, return NoResult so the pipeline issues a 401 challenge.
|
||||
if (!Request.Headers.TryGetValue(TenantHeader, out var tenantValues)
|
||||
|| string.IsNullOrWhiteSpace(tenantValues.ToString()))
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var tenantId = tenantValues.ToString().Trim();
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "anonymous"),
|
||||
new("stellaops:tenant", tenantId),
|
||||
// Coarse OIDC-style scopes so ASP.NET Core authorization policies pass.
|
||||
// Fine-grained scope enforcement happens inside endpoint handlers
|
||||
// via IScopeAuthorizer which reads the X-Scopes / X-StellaOps-Scopes header directly.
|
||||
new("scope",
|
||||
"scheduler:read scheduler:operate scheduler:admin " +
|
||||
"graph:read graph:write policy:simulate"),
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System.Security.Claims;
|
||||
|
||||
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,63 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Auth;
|
||||
|
||||
internal sealed class HeaderScopeAuthorizer : IScopeAuthorizer
|
||||
{
|
||||
private static readonly string[] ScopeHeaders = ["X-StellaOps-Scopes", "X-Scopes"];
|
||||
|
||||
public void EnsureScope(HttpContext context, string requiredScope)
|
||||
{
|
||||
Microsoft.Extensions.Primitives.StringValues values = default;
|
||||
bool found = false;
|
||||
foreach (var header in ScopeHeaders)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(header, out values))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
throw new UnauthorizedAccessException($"Missing required scope header (accepted: {string.Join(", ", ScopeHeaders)}).");
|
||||
}
|
||||
|
||||
var scopeBuffer = string.Join(' ', values.ToArray());
|
||||
if (string.IsNullOrWhiteSpace(scopeBuffer))
|
||||
{
|
||||
throw new UnauthorizedAccessException("Scope header cannot be empty.");
|
||||
}
|
||||
|
||||
var scopes = scopeBuffer
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (scopes.Contains(requiredScope))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Hierarchical match: fine-grained scope "scheduler.runs.read" is satisfied
|
||||
// by OIDC coarse-grained scope "scheduler:read" or "scheduler:admin".
|
||||
// Format: "{service}.{resource}.{action}" -> check "{service}:{action}" and "{service}:admin"
|
||||
var dotParts = requiredScope.Split('.');
|
||||
if (dotParts.Length >= 2)
|
||||
{
|
||||
var service = dotParts[0];
|
||||
var action = dotParts[^1];
|
||||
if (scopes.Contains($"{service}:{action}") || scopes.Contains($"{service}:admin"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Also check "operate" scope for write/manage actions
|
||||
if (action is "write" or "manage" or "preview" && scopes.Contains($"{service}:operate"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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,62 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System.Security.Claims;
|
||||
|
||||
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,182 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public static class EventWebhookEndpointExtensions
|
||||
{
|
||||
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
// Webhooks authenticate via HMAC-SHA256 signature, not tenant-scoped
|
||||
// JWT/header auth, so no RequireTenant() on the group.
|
||||
var group = builder.MapGroup("/events")
|
||||
.AllowAnonymous();
|
||||
|
||||
group.MapPost("/conselier-export", HandleConselierExportAsync)
|
||||
.WithName("HandleConselierExportWebhook")
|
||||
.WithDescription("Inbound webhook endpoint that receives Conselier VEX export events. Authentication is performed via HMAC-SHA256 signature validation on the request body. Rate-limited per the configured window. Returns 202 Accepted on success.");
|
||||
group.MapPost("/excitor-export", HandleExcitorExportAsync)
|
||||
.WithName("HandleExcitorExportWebhook")
|
||||
.WithDescription("Inbound webhook endpoint that receives Excitor VEX export events. Authentication is performed via HMAC-SHA256 signature validation on the request body. Rate-limited per the configured window. Returns 202 Accepted on success.");
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleConselierExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Conselier;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<ConselierExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("conselier", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleConselierAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExcitorExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Excitor;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<ExcitorExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("excitor", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleExcitorAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var bodyBytes = buffer.ToArray();
|
||||
context.Request.Body.Position = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
if (payload is null)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
|
||||
}
|
||||
|
||||
return RequestPayload<T>.Success(payload, bodyBytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct RequestPayload<T>
|
||||
{
|
||||
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
|
||||
{
|
||||
Payload = payload;
|
||||
RawBody = rawBody;
|
||||
ErrorResult = error;
|
||||
Succeeded = succeeded;
|
||||
}
|
||||
|
||||
public T? Payload { get; }
|
||||
|
||||
public byte[] RawBody { get; }
|
||||
|
||||
public IResult? ErrorResult { get; }
|
||||
|
||||
public bool Succeeded { get; }
|
||||
|
||||
public static RequestPayload<T> Success(T payload, byte[] rawBody)
|
||||
=> new(payload, rawBody, null, true);
|
||||
|
||||
public static RequestPayload<T> Failed(IResult error)
|
||||
=> new(default, Array.Empty<byte>(), error, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IInboundExportEventSink
|
||||
{
|
||||
Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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,108 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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,70 @@
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class InMemoryWebhookRateLimiter : IWebhookRateLimiter, IDisposable
|
||||
{
|
||||
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private readonly object _mutex = new();
|
||||
|
||||
public InMemoryWebhookRateLimiter(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
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 = _timeProvider.GetUtcNow();
|
||||
|
||||
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,34 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class LoggingExportEventSink : IInboundExportEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingExportEventSink> _logger;
|
||||
|
||||
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Conselier export webhook {ExportId} with {ChangedProducts} product keys.",
|
||||
request.ExportId,
|
||||
request.ChangedProductKeys.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Excitor export webhook {ExportId} with {ChangedClaims} claim changes.",
|
||||
request.ExportId,
|
||||
request.ChangedClaims.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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 ConselierExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<string> ChangedProductKeys,
|
||||
IReadOnlyList<string>? Kev,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
|
||||
|
||||
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
if (allowEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
throw new ValidationException($"{propertyName} must be specified.");
|
||||
}
|
||||
|
||||
var cleaned = source
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (!allowEmpty && cleaned.Length == 0)
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must contain at least one value.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExcitorExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<ExcitorClaimChange> ChangedClaims,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<ExcitorClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<ExcitorClaimChange> NormalizeClaims(IReadOnlyList<ExcitorClaimChange>? claims)
|
||||
{
|
||||
if (claims is null || claims.Count == 0)
|
||||
{
|
||||
throw new ValidationException("changedClaims must contain at least one entry.");
|
||||
}
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
claim.Validate();
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExcitorClaimChange(
|
||||
string ProductKey,
|
||||
string VulnerabilityId,
|
||||
string Status)
|
||||
{
|
||||
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
|
||||
|
||||
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
|
||||
|
||||
public string Status { get; } = Normalize(Status, nameof(Status));
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
_ = ProductKey;
|
||||
_ = VulnerabilityId;
|
||||
_ = Status;
|
||||
}
|
||||
|
||||
private static string Normalize(string value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must be provided.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
|
||||
@@ -0,0 +1,140 @@
|
||||
|
||||
using Dapper;
|
||||
using StellaOps.Scheduler.Persistence.Postgres;
|
||||
using StellaOps.Scheduler.Worker.Exceptions;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Exceptions;
|
||||
|
||||
public sealed class PostgresExceptionRepository : IExceptionRepository
|
||||
{
|
||||
private readonly SchedulerDataSource _dataSource;
|
||||
|
||||
public PostgresExceptionRepository(SchedulerDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
public async ValueTask<ExceptionRecord?> GetAsync(string exceptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE exception_id = @ExceptionId
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
var row = await conn.QuerySingleOrDefaultAsync(sql, new { ExceptionId = exceptionId });
|
||||
return row is null ? null : Map(row);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ExceptionRecord>> GetPendingActivationsAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE state = 'pending' AND activation_date <= @AsOf
|
||||
ORDER BY activation_date ASC;
|
||||
""";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new { AsOf = asOf });
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ExceptionRecord>> GetExpiredExceptionsAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE state = 'active' AND expiration_date <= @AsOf
|
||||
ORDER BY expiration_date ASC;
|
||||
""";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new { AsOf = asOf });
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ExceptionRecord>> GetExpiringExceptionsAsync(
|
||||
DateTimeOffset windowStart,
|
||||
DateTimeOffset windowEnd,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE state = 'active'
|
||||
AND expiration_date > @WindowStart
|
||||
AND expiration_date <= @WindowEnd
|
||||
ORDER BY expiration_date ASC;
|
||||
""";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new { WindowStart = windowStart, WindowEnd = windowEnd });
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask UpdateAsync(ExceptionRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
UPDATE scheduler.scheduler_exceptions
|
||||
SET state = @State::scheduler.exception_state,
|
||||
activation_date = @ActivationDate,
|
||||
expiration_date = @ExpirationDate,
|
||||
activated_at = @ActivatedAt,
|
||||
expired_at = @ExpiredAt,
|
||||
justification = @Justification
|
||||
WHERE exception_id = @ExceptionId;
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
record.ExceptionId,
|
||||
State = record.State.ToString().ToLowerInvariant(),
|
||||
record.ActivationDate,
|
||||
record.ExpirationDate,
|
||||
record.ActivatedAt,
|
||||
record.ExpiredAt,
|
||||
record.Justification
|
||||
});
|
||||
}
|
||||
|
||||
private static ExceptionRecord Map(dynamic row)
|
||||
{
|
||||
return new ExceptionRecord(
|
||||
(string)row.exception_id,
|
||||
(string)row.tenant_id,
|
||||
(string)row.policy_id,
|
||||
(string)row.vulnerability_id,
|
||||
(string?)row.component_purl,
|
||||
Enum.Parse<ExceptionState>((string)row.state, true),
|
||||
DateTime.SpecifyKind(row.created_at, DateTimeKind.Utc),
|
||||
row.activation_date is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.activation_date, DateTimeKind.Utc),
|
||||
row.expiration_date is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.expiration_date, DateTimeKind.Utc),
|
||||
row.activated_at is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.activated_at, DateTimeKind.Utc),
|
||||
row.expired_at is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.expired_at, DateTimeKind.Utc),
|
||||
(string?)row.justification,
|
||||
(string?)row.created_by);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using static StellaOps.Localization.T;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.FailureSignatures;
|
||||
|
||||
internal static class FailureSignatureEndpoints
|
||||
{
|
||||
private const string ReadScope = "scheduler.runs.read";
|
||||
|
||||
public static IEndpointRouteBuilder MapFailureSignatureEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/scheduler/failure-signatures")
|
||||
.RequireAuthorization(SchedulerPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/best-match", GetBestMatchAsync)
|
||||
.WithName("GetFailureSignatureBestMatch")
|
||||
.WithDescription(_t("scheduler.failure_signature.best_match_description"));
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBestMatchAsync(
|
||||
HttpContext httpContext,
|
||||
[FromQuery] string? scopeType,
|
||||
[FromQuery] string? scopeId,
|
||||
[FromQuery] string? toolchainHash,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IServiceProvider serviceProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopeType))
|
||||
{
|
||||
throw new ValidationException("scopeType must be provided.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopeId))
|
||||
{
|
||||
throw new ValidationException("scopeId must be provided.");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<FailureSignatureScopeType>(scopeType.Trim(), ignoreCase: true, out var parsedScopeType))
|
||||
{
|
||||
throw new ValidationException($"scopeType '{scopeType}' is not valid.");
|
||||
}
|
||||
|
||||
var repository = serviceProvider.GetService<IFailureSignatureRepository>();
|
||||
if (repository is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: _t("scheduler.error.failure_signature_storage_not_configured"),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var match = await repository
|
||||
.GetBestMatchAsync(
|
||||
tenant.TenantId,
|
||||
parsedScopeType,
|
||||
scopeId.Trim(),
|
||||
string.IsNullOrWhiteSpace(toolchainHash) ? null : toolchainHash.Trim(),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
return Results.Ok(new FailureSignatureBestMatchResponse(match));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record FailureSignatureBestMatchResponse
|
||||
{
|
||||
public FailureSignatureBestMatchResponse(FailureSignatureEntity signature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
|
||||
SignatureId = signature.SignatureId;
|
||||
ScopeType = signature.ScopeType.ToString().ToLowerInvariant();
|
||||
ScopeId = signature.ScopeId;
|
||||
ToolchainHash = signature.ToolchainHash;
|
||||
ErrorCode = signature.ErrorCode;
|
||||
ErrorCategory = signature.ErrorCategory?.ToString().ToLowerInvariant();
|
||||
PredictedOutcome = signature.PredictedOutcome.ToString().ToLowerInvariant();
|
||||
ConfidenceScore = signature.ConfidenceScore;
|
||||
OccurrenceCount = signature.OccurrenceCount;
|
||||
FirstSeenAt = signature.FirstSeenAt;
|
||||
LastSeenAt = signature.LastSeenAt;
|
||||
}
|
||||
|
||||
public Guid SignatureId { get; }
|
||||
public string ScopeType { get; }
|
||||
public string ScopeId { get; }
|
||||
public string ToolchainHash { get; }
|
||||
public string? ErrorCode { get; }
|
||||
public string? ErrorCategory { get; }
|
||||
public string PredictedOutcome { get; }
|
||||
public decimal? ConfidenceScore { get; }
|
||||
public int OccurrenceCount { get; }
|
||||
public DateTimeOffset FirstSeenAt { get; }
|
||||
public DateTimeOffset LastSeenAt { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
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,47 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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,44 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
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,187 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal sealed class GraphJobEventPublisher : IGraphJobCompletionPublisher, IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IOptionsMonitor<SchedulerEventsOptions> _options;
|
||||
private readonly IRedisConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<GraphJobEventPublisher> _logger;
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public GraphJobEventPublisher(
|
||||
IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
IRedisConnectionFactory connectionFactory,
|
||||
ILogger<GraphJobEventPublisher> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(notification));
|
||||
}
|
||||
|
||||
var options = _options.CurrentValue?.GraphJobs ?? new GraphJobEventsOptions();
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Graph job events disabled; skipping emission for {JobId}.", notification.Job.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(options.Driver, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Graph job events configured with unsupported driver '{Driver}'. Falling back to logging.",
|
||||
options.Driver);
|
||||
LogEnvelope(notification);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var database = await GetDatabaseAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
var payload = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
var entries = new[]
|
||||
{
|
||||
new NameValueEntry("event", payload),
|
||||
new NameValueEntry("kind", envelope.Kind),
|
||||
new NameValueEntry("tenant", envelope.Tenant),
|
||||
new NameValueEntry("occurredAt", envelope.Timestamp.ToString("O", CultureInfo.InvariantCulture)),
|
||||
new NameValueEntry("jobId", notification.Job.Id),
|
||||
new NameValueEntry("status", notification.Status.ToString())
|
||||
};
|
||||
|
||||
var streamKey = string.IsNullOrWhiteSpace(options.Stream) ? "stella.events" : options.Stream;
|
||||
var publishTask = CreatePublishTask(database, streamKey, entries, options.MaxStreamLength);
|
||||
|
||||
if (options.PublishTimeoutSeconds > 0)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(options.PublishTimeoutSeconds);
|
||||
await publishTask.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await publishTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Published graph job event {JobId} to stream {Stream}.", notification.Job.Id, streamKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to publish graph job completion for {JobId}; logging payload instead.", notification.Job.Id);
|
||||
LogEnvelope(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<RedisValue> CreatePublishTask(IDatabase database, string streamKey, NameValueEntry[] entries, long maxStreamLength)
|
||||
{
|
||||
if (maxStreamLength > 0)
|
||||
{
|
||||
var clamped = (int)Math.Min(maxStreamLength, int.MaxValue);
|
||||
return database.StreamAddAsync(streamKey, entries, maxLength: clamped, useApproximateMaxLength: true);
|
||||
}
|
||||
|
||||
return database.StreamAddAsync(streamKey, entries);
|
||||
}
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync(GraphJobEventsOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_connection is { IsConnected: true })
|
||||
{
|
||||
return _connection.GetDatabase();
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is null || !_connection.IsConnected)
|
||||
{
|
||||
var configuration = ConfigurationOptions.Parse(options.Dsn);
|
||||
configuration.AbortOnConnectFail = false;
|
||||
|
||||
if (options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName))
|
||||
{
|
||||
configuration.ClientName = clientName;
|
||||
}
|
||||
|
||||
if (options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl))
|
||||
{
|
||||
configuration.Ssl = ssl;
|
||||
}
|
||||
|
||||
if (options.DriverSettings.TryGetValue("password", out var password) && !string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
configuration.Password = password;
|
||||
}
|
||||
|
||||
_connection = await _connectionFactory.ConnectAsync(configuration, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Connected graph job publisher to Redis stream {Stream}.", options.Stream);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
|
||||
return _connection!.GetDatabase();
|
||||
}
|
||||
|
||||
private void LogEnvelope(GraphJobCompletionNotification notification)
|
||||
{
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
_logger.LogInformation("{EventJson}", json);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error while closing graph job Redis connection.");
|
||||
}
|
||||
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal interface IRedisConnectionFactory
|
||||
{
|
||||
Task<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic implementation of <see cref="IGraphJobCompletionPublisher"/> using StellaOps.Messaging abstractions.
|
||||
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
|
||||
/// </summary>
|
||||
internal sealed class MessagingGraphJobEventPublisher : IGraphJobCompletionPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IOptionsMonitor<SchedulerEventsOptions> _options;
|
||||
private readonly IEventStream<GraphJobCompletedEvent> _eventStream;
|
||||
private readonly ILogger<MessagingGraphJobEventPublisher> _logger;
|
||||
|
||||
public MessagingGraphJobEventPublisher(
|
||||
IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
IEventStreamFactory eventStreamFactory,
|
||||
ILogger<MessagingGraphJobEventPublisher> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(eventStreamFactory);
|
||||
|
||||
_options = options;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var eventsOptions = options.CurrentValue?.GraphJobs ?? new GraphJobEventsOptions();
|
||||
var streamKey = string.IsNullOrWhiteSpace(eventsOptions.Stream) ? "stella.events" : eventsOptions.Stream;
|
||||
var maxStreamLength = eventsOptions.MaxStreamLength > 0 ? eventsOptions.MaxStreamLength : (long?)null;
|
||||
|
||||
_eventStream = eventStreamFactory.Create<GraphJobCompletedEvent>(new EventStreamOptions
|
||||
{
|
||||
StreamName = streamKey,
|
||||
MaxLength = maxStreamLength,
|
||||
ApproximateTrimming = true,
|
||||
});
|
||||
|
||||
_logger.LogInformation("Initialized messaging graph job event publisher for stream {Stream}.", streamKey);
|
||||
}
|
||||
|
||||
public async Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
var options = _options.CurrentValue?.GraphJobs ?? new GraphJobEventsOptions();
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Graph job events disabled; skipping emission for {JobId}.", notification.Job.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
|
||||
var publishOptions = new EventPublishOptions
|
||||
{
|
||||
TenantId = envelope.Tenant,
|
||||
MaxStreamLength = options.MaxStreamLength > 0 ? options.MaxStreamLength : null,
|
||||
Headers = new Dictionary<string, string>
|
||||
{
|
||||
["kind"] = envelope.Kind,
|
||||
["occurredAt"] = envelope.Timestamp.ToString("O", CultureInfo.InvariantCulture),
|
||||
["jobId"] = notification.Job.Id,
|
||||
["status"] = notification.Status.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var publishTask = _eventStream.PublishAsync(envelope, publishOptions, cancellationToken);
|
||||
|
||||
if (options.PublishTimeoutSeconds > 0)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(options.PublishTimeoutSeconds);
|
||||
await publishTask.AsTask().WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await publishTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Published graph job event {JobId} to stream.", notification.Job.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to publish graph job completion for {JobId}; logging payload instead.", notification.Job.Id);
|
||||
LogEnvelope(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogEnvelope(GraphJobCompletionNotification notification)
|
||||
{
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
_logger.LogInformation("{EventJson}", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal sealed class RedisConnectionFactory : IRedisConnectionFactory
|
||||
{
|
||||
public async Task<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var completionSource = new TaskCompletionSource<IConnectionMultiplexer>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
cancellationToken.Register(() => completionSource.TrySetCanceled(cancellationToken));
|
||||
|
||||
try
|
||||
{
|
||||
var connection = await ConnectionMultiplexer.ConnectAsync(options).ConfigureAwait(false);
|
||||
completionSource.TrySetResult(connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completionSource.TrySetException(ex);
|
||||
}
|
||||
|
||||
return await completionSource.Task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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,31 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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,176 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public static class GraphJobEndpointExtensions
|
||||
{
|
||||
public static void MapGraphJobEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/graphs")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("/build", CreateGraphBuildJob)
|
||||
.WithName("CreateGraphBuildJob")
|
||||
.WithDescription("Enqueues a graph build job to construct the reachability graph for the specified tenant scope. Returns 201 Created with the new job ID. Requires graph.write scope.");
|
||||
group.MapPost("/overlays", CreateGraphOverlayJob)
|
||||
.WithName("CreateGraphOverlayJob")
|
||||
.WithDescription("Enqueues a graph overlay job to apply incremental VEX or policy updates onto an existing reachability graph. Returns 201 Created with the new job ID. Requires graph.write scope.");
|
||||
group.MapGet("/jobs", GetGraphJobs)
|
||||
.WithName("GetGraphJobs")
|
||||
.WithDescription("Lists graph jobs for the tenant with optional filters by status and job type. Returns a paginated collection ordered by creation time. Requires graph.read scope.");
|
||||
group.MapPost("/hooks/completed", CompleteGraphJob)
|
||||
.WithName("CompleteGraphJob")
|
||||
.WithDescription("Internal callback invoked by the Cartographer service to mark a graph job as completed and publish the completion event. Requires graph.write scope.");
|
||||
group.MapGet("/overlays/lag", GetOverlayLagMetrics)
|
||||
.WithName("GetGraphOverlayLagMetrics")
|
||||
.WithDescription("Returns lag metrics for overlay jobs including pending queue depth and processing rates. Used for SLO monitoring of graph overlay throughput. Requires graph.read scope.");
|
||||
}
|
||||
|
||||
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,28 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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,508 @@
|
||||
|
||||
using StellaOps.Cryptography.Digests;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class GraphJobService : IGraphJobService
|
||||
{
|
||||
private readonly IGraphJobStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGraphJobCompletionPublisher _completionPublisher;
|
||||
private readonly ICartographerWebhookClient _cartographerWebhook;
|
||||
|
||||
public GraphJobService(
|
||||
IGraphJobStore store,
|
||||
TimeProvider timeProvider,
|
||||
IGraphJobCompletionPublisher completionPublisher,
|
||||
ICartographerWebhookClient cartographerWebhook)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_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 = _timeProvider.GetUtcNow();
|
||||
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 = _timeProvider.GetUtcNow();
|
||||
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 ? _timeProvider.GetUtcNow() : request.OccurredAt.ToUniversalTime();
|
||||
var graphSnapshotId = Normalize(request.GraphSnapshotId);
|
||||
var correlationId = Normalize(request.CorrelationId);
|
||||
var resultUri = Normalize(request.ResultUri);
|
||||
var error = request.Status == GraphJobStatus.Failed ? Normalize(request.Error) : null;
|
||||
|
||||
switch (request.JobType)
|
||||
{
|
||||
case GraphJobQueryType.Build:
|
||||
{
|
||||
var existing = await _store.GetBuildJobAsync(tenantId, request.JobId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Graph build job '{request.JobId}' not found.");
|
||||
}
|
||||
|
||||
return await CompleteBuildJobInternal(
|
||||
tenantId,
|
||||
existing,
|
||||
request.Status,
|
||||
occurredAt,
|
||||
graphSnapshotId,
|
||||
correlationId,
|
||||
resultUri,
|
||||
error,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
case GraphJobQueryType.Overlay:
|
||||
{
|
||||
var existing = await _store.GetOverlayJobAsync(tenantId, request.JobId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Graph overlay job '{request.JobId}' not found.");
|
||||
}
|
||||
|
||||
return await CompleteOverlayJobInternal(
|
||||
tenantId,
|
||||
existing,
|
||||
request.Status,
|
||||
occurredAt,
|
||||
graphSnapshotId,
|
||||
correlationId,
|
||||
resultUri,
|
||||
error,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ValidationException("Unsupported job type.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GraphJobResponse> CompleteBuildJobInternal(
|
||||
string tenantId,
|
||||
GraphBuildJob current,
|
||||
GraphJobStatus requestedStatus,
|
||||
DateTimeOffset occurredAt,
|
||||
string? graphSnapshotId,
|
||||
string? correlationId,
|
||||
string? resultUri,
|
||||
string? error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var latest = current;
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
var transition = PrepareBuildTransition(latest, requestedStatus, occurredAt, graphSnapshotId, correlationId, resultUri, error);
|
||||
if (!transition.HasChanges)
|
||||
{
|
||||
return GraphJobResponse.From(latest);
|
||||
}
|
||||
|
||||
var updateResult = await _store.UpdateAsync(transition.Job, transition.ExpectedStatus, cancellationToken).ConfigureAwait(false);
|
||||
if (updateResult.Updated)
|
||||
{
|
||||
var stored = updateResult.Job;
|
||||
var response = GraphJobResponse.From(stored);
|
||||
|
||||
if (transition.ShouldPublish)
|
||||
{
|
||||
await PublishCompletionAsync(
|
||||
tenantId,
|
||||
GraphJobQueryType.Build,
|
||||
stored.Status,
|
||||
occurredAt,
|
||||
response,
|
||||
ExtractResultUri(response),
|
||||
stored.CorrelationId,
|
||||
stored.Error,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
latest = updateResult.Job;
|
||||
}
|
||||
|
||||
return GraphJobResponse.From(latest);
|
||||
}
|
||||
|
||||
private async Task<GraphJobResponse> CompleteOverlayJobInternal(
|
||||
string tenantId,
|
||||
GraphOverlayJob current,
|
||||
GraphJobStatus requestedStatus,
|
||||
DateTimeOffset occurredAt,
|
||||
string? graphSnapshotId,
|
||||
string? correlationId,
|
||||
string? resultUri,
|
||||
string? error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var latest = current;
|
||||
|
||||
for (var attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
var transition = PrepareOverlayTransition(latest, requestedStatus, occurredAt, graphSnapshotId, correlationId, resultUri, error);
|
||||
if (!transition.HasChanges)
|
||||
{
|
||||
return GraphJobResponse.From(latest);
|
||||
}
|
||||
|
||||
var updateResult = await _store.UpdateAsync(transition.Job, transition.ExpectedStatus, cancellationToken).ConfigureAwait(false);
|
||||
if (updateResult.Updated)
|
||||
{
|
||||
var stored = updateResult.Job;
|
||||
var response = GraphJobResponse.From(stored);
|
||||
|
||||
if (transition.ShouldPublish)
|
||||
{
|
||||
await PublishCompletionAsync(
|
||||
tenantId,
|
||||
GraphJobQueryType.Overlay,
|
||||
stored.Status,
|
||||
occurredAt,
|
||||
response,
|
||||
ExtractResultUri(response),
|
||||
stored.CorrelationId,
|
||||
stored.Error,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
latest = updateResult.Job;
|
||||
}
|
||||
|
||||
return GraphJobResponse.From(latest);
|
||||
}
|
||||
|
||||
private static CompletionTransition<GraphBuildJob> PrepareBuildTransition(
|
||||
GraphBuildJob current,
|
||||
GraphJobStatus requestedStatus,
|
||||
DateTimeOffset occurredAt,
|
||||
string? graphSnapshotId,
|
||||
string? correlationId,
|
||||
string? resultUri,
|
||||
string? error)
|
||||
{
|
||||
var transitional = current;
|
||||
if (transitional.Status is GraphJobStatus.Pending or GraphJobStatus.Queued)
|
||||
{
|
||||
transitional = GraphJobStateMachine.EnsureTransition(transitional, GraphJobStatus.Running, occurredAt, attempts: transitional.Attempts);
|
||||
}
|
||||
|
||||
var desiredAttempts = transitional.Status == requestedStatus ? transitional.Attempts : transitional.Attempts + 1;
|
||||
var updated = GraphJobStateMachine.EnsureTransition(transitional, requestedStatus, occurredAt, attempts: desiredAttempts, errorMessage: error);
|
||||
|
||||
var metadata = updated.Metadata;
|
||||
if (resultUri is { Length: > 0 })
|
||||
{
|
||||
if (!metadata.TryGetValue("resultUri", out var existingValue) || !string.Equals(existingValue, resultUri, StringComparison.Ordinal))
|
||||
{
|
||||
metadata = MergeMetadata(metadata, resultUri);
|
||||
}
|
||||
}
|
||||
|
||||
var normalized = new GraphBuildJob(
|
||||
id: updated.Id,
|
||||
tenantId: updated.TenantId,
|
||||
sbomId: updated.SbomId,
|
||||
sbomVersionId: updated.SbomVersionId,
|
||||
sbomDigest: updated.SbomDigest,
|
||||
graphSnapshotId: graphSnapshotId ?? updated.GraphSnapshotId,
|
||||
status: updated.Status,
|
||||
trigger: updated.Trigger,
|
||||
attempts: updated.Attempts,
|
||||
cartographerJobId: updated.CartographerJobId,
|
||||
correlationId: correlationId ?? updated.CorrelationId,
|
||||
createdAt: updated.CreatedAt,
|
||||
startedAt: updated.StartedAt,
|
||||
completedAt: updated.CompletedAt,
|
||||
error: updated.Error,
|
||||
metadata: metadata,
|
||||
schemaVersion: updated.SchemaVersion);
|
||||
|
||||
var hasChanges = !normalized.Equals(current);
|
||||
var shouldPublish = hasChanges && current.Status != normalized.Status;
|
||||
return new CompletionTransition<GraphBuildJob>(normalized, current.Status, hasChanges, shouldPublish);
|
||||
}
|
||||
|
||||
private static CompletionTransition<GraphOverlayJob> PrepareOverlayTransition(
|
||||
GraphOverlayJob current,
|
||||
GraphJobStatus requestedStatus,
|
||||
DateTimeOffset occurredAt,
|
||||
string? graphSnapshotId,
|
||||
string? correlationId,
|
||||
string? resultUri,
|
||||
string? error)
|
||||
{
|
||||
var transitional = current;
|
||||
if (transitional.Status is GraphJobStatus.Pending or GraphJobStatus.Queued)
|
||||
{
|
||||
transitional = GraphJobStateMachine.EnsureTransition(transitional, GraphJobStatus.Running, occurredAt, attempts: transitional.Attempts);
|
||||
}
|
||||
|
||||
var desiredAttempts = transitional.Status == requestedStatus ? transitional.Attempts : transitional.Attempts + 1;
|
||||
var updated = GraphJobStateMachine.EnsureTransition(transitional, requestedStatus, occurredAt, attempts: desiredAttempts, errorMessage: error);
|
||||
|
||||
var metadata = updated.Metadata;
|
||||
if (resultUri is { Length: > 0 })
|
||||
{
|
||||
if (!metadata.TryGetValue("resultUri", out var existingValue) || !string.Equals(existingValue, resultUri, StringComparison.Ordinal))
|
||||
{
|
||||
metadata = MergeMetadata(metadata, resultUri);
|
||||
}
|
||||
}
|
||||
|
||||
var normalized = new GraphOverlayJob(
|
||||
id: updated.Id,
|
||||
tenantId: updated.TenantId,
|
||||
graphSnapshotId: 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: correlationId ?? updated.CorrelationId,
|
||||
createdAt: updated.CreatedAt,
|
||||
startedAt: updated.StartedAt,
|
||||
completedAt: updated.CompletedAt,
|
||||
error: updated.Error,
|
||||
metadata: metadata,
|
||||
schemaVersion: updated.SchemaVersion);
|
||||
|
||||
var hasChanges = !normalized.Equals(current);
|
||||
var shouldPublish = hasChanges && current.Status != normalized.Status;
|
||||
return new CompletionTransition<GraphOverlayJob>(normalized, current.Status, hasChanges, shouldPublish);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? ExtractResultUri(GraphJobResponse response)
|
||||
=> response.Payload switch
|
||||
{
|
||||
GraphBuildJob build when build.Metadata.TryGetValue("resultUri", out var value) => value,
|
||||
GraphOverlayJob overlay when overlay.Metadata.TryGetValue("resultUri", out var value) => value,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private sealed record CompletionTransition<TJob>(TJob Job, GraphJobStatus ExpectedStatus, bool HasChanges, bool ShouldPublish)
|
||||
where TJob : class;
|
||||
|
||||
public async Task<OverlayLagMetricsResponse> GetOverlayLagMetricsAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Sha256Digest.Normalize(value, requirePrefix: true, parameterName: "sbomDigest");
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException)
|
||||
{
|
||||
throw new ValidationException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
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,8 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public readonly record struct GraphJobUpdateResult<TJob>(bool Updated, TJob Job) where TJob : class
|
||||
{
|
||||
public static GraphJobUpdateResult<TJob> UpdatedResult(TJob job) => new(true, job);
|
||||
|
||||
public static GraphJobUpdateResult<TJob> NotUpdated(TJob job) => new(false, job);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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<GraphJobUpdateResult<GraphBuildJob>> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<GraphJobUpdateResult<GraphOverlayJob>> UpdateAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
|
||||
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<GraphJobUpdateResult<GraphBuildJob>> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_buildJobs.TryGetValue(job.Id, out var existing) && string.Equals(existing.TenantId, job.TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
if (existing.Status == expectedStatus)
|
||||
{
|
||||
_buildJobs[job.Id] = job;
|
||||
return ValueTask.FromResult(GraphJobUpdateResult<GraphBuildJob>.UpdatedResult(job));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(GraphJobUpdateResult<GraphBuildJob>.NotUpdated(existing));
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"Graph build job '{job.Id}' not found.");
|
||||
}
|
||||
|
||||
public ValueTask<GraphJobUpdateResult<GraphOverlayJob>> UpdateAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_overlayJobs.TryGetValue(job.Id, out var existing) && string.Equals(existing.TenantId, job.TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
if (existing.Status == expectedStatus)
|
||||
{
|
||||
_overlayJobs[job.Id] = job;
|
||||
return ValueTask.FromResult(GraphJobUpdateResult<GraphOverlayJob>.UpdatedResult(job));
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(GraphJobUpdateResult<GraphOverlayJob>.NotUpdated(existing));
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"Graph overlay job '{job.Id}' not found.");
|
||||
}
|
||||
|
||||
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,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,83 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class PostgresGraphJobStore : IGraphJobStore
|
||||
{
|
||||
private readonly IGraphJobRepository _repository;
|
||||
|
||||
public PostgresGraphJobStore(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<GraphJobUpdateResult<GraphBuildJob>> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _repository.TryReplaceAsync(job, expectedStatus, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return GraphJobUpdateResult<GraphBuildJob>.UpdatedResult(job);
|
||||
}
|
||||
|
||||
var existing = await _repository.GetBuildJobAsync(job.TenantId, job.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Graph build job '{job.Id}' not found.");
|
||||
}
|
||||
|
||||
return GraphJobUpdateResult<GraphBuildJob>.NotUpdated(existing);
|
||||
}
|
||||
|
||||
public async ValueTask<GraphJobUpdateResult<GraphOverlayJob>> UpdateAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _repository.TryReplaceOverlayAsync(job, expectedStatus, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return GraphJobUpdateResult<GraphOverlayJob>.UpdatedResult(job);
|
||||
}
|
||||
|
||||
var existing = await _repository.GetOverlayJobAsync(job.TenantId, job.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Graph overlay job '{job.Id}' not found.");
|
||||
}
|
||||
|
||||
return GraphJobUpdateResult<GraphOverlayJob>.NotUpdated(existing);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
|
||||
=> await _repository.ListOverlayJobsAsync(tenantId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
26
src/JobEngine/StellaOps.Scheduler.WebService/ISystemClock.cs
Normal file
26
src/JobEngine/StellaOps.Scheduler.WebService/ISystemClock.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.Scheduler.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy system clock interface. Prefer using TimeProvider instead.
|
||||
/// </summary>
|
||||
[Obsolete("Use TimeProvider instead. This interface is retained for backward compatibility.")]
|
||||
public interface ISystemClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy system clock implementation. Prefer using TimeProvider instead.
|
||||
/// </summary>
|
||||
[Obsolete("Use TimeProvider instead. This class is retained for backward compatibility.")]
|
||||
public sealed class SystemClock : ISystemClock
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SystemClock(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public DateTimeOffset UtcNow => _timeProvider.GetUtcNow();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Observability;
|
||||
|
||||
internal sealed class SchedulerTelemetryMiddleware
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Scheduler.WebService");
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public SchedulerTelemetryMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var operationName = $"{context.Request.Method} {context.Request.Path}";
|
||||
using var activity = ActivitySource.StartActivity(operationName, ActivityKind.Server);
|
||||
|
||||
if (activity != null)
|
||||
{
|
||||
activity.SetTag("http.method", context.Request.Method);
|
||||
activity.SetTag("http.route", context.GetEndpoint()?.DisplayName ?? context.Request.Path.ToString());
|
||||
|
||||
var tenantId = TryGetTenantId(context);
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
activity.SetTag("tenant_id", tenantId);
|
||||
}
|
||||
|
||||
if (context.Request.RouteValues.TryGetValue("scheduleId", out var scheduleId) && scheduleId is not null)
|
||||
{
|
||||
activity.SetTag("schedule_id", scheduleId.ToString());
|
||||
}
|
||||
|
||||
if (context.Request.RouteValues.TryGetValue("runId", out var runId) && runId is not null)
|
||||
{
|
||||
activity.SetTag("run_id", runId.ToString());
|
||||
activity.SetTag("job_id", runId.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (activity != null && context.Response.StatusCode >= 400)
|
||||
{
|
||||
activity.SetStatus(ActivityStatusCode.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetTenantId(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return context.User?.Claims?.FirstOrDefault(c => c.Type == "tenant_id")?.Value;
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler WebService event options (outbound + inbound).
|
||||
/// </summary>
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Event transport driver (defaults to <c>redis</c>).
|
||||
/// </summary>
|
||||
public string Driver { get; set; } = "redis";
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the event transport.
|
||||
/// </summary>
|
||||
public string Dsn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Stream/topic identifier for published events.
|
||||
/// </summary>
|
||||
public string Stream { get; set; } = "stella.events";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in seconds to wait for the transport to accept an event.
|
||||
/// </summary>
|
||||
public double PublishTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of events to retain in the stream.
|
||||
/// </summary>
|
||||
public long MaxStreamLength { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Additional transport-specific settings (e.g., <c>clientName</c>, <c>ssl</c>).
|
||||
/// </summary>
|
||||
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class SchedulerInboundWebhooksOptions
|
||||
{
|
||||
public SchedulerWebhookOptions Conselier { get; set; } = SchedulerWebhookOptions.CreateDefault("conselier");
|
||||
|
||||
public SchedulerWebhookOptions Excitor { get; set; } = SchedulerWebhookOptions.CreateDefault("excitor");
|
||||
}
|
||||
|
||||
public sealed class SchedulerWebhookOptions
|
||||
{
|
||||
private const string DefaultSignatureHeader = "X-Scheduler-Signature";
|
||||
|
||||
public SchedulerWebhookOptions()
|
||||
{
|
||||
SignatureHeader = DefaultSignatureHeader;
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require a client certificate to be presented (mTLS). Optional when HMAC is configured.
|
||||
/// </summary>
|
||||
public bool RequireClientCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret (Base64 or raw text) for HMAC-SHA256 signatures. Required if <see cref="RequireClientCertificate"/> is false.
|
||||
/// </summary>
|
||||
public string? HmacSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name carrying the webhook signature (defaults to <c>X-Scheduler-Signature</c>).
|
||||
/// </summary>
|
||||
public string SignatureHeader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of accepted requests per sliding window.
|
||||
/// </summary>
|
||||
public int RateLimitRequests { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Sliding window duration in seconds for the rate limiter.
|
||||
/// </summary>
|
||||
public int RateLimitWindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Optional label used for logging/diagnostics; populated via <see cref="CreateDefault"/>.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public static SchedulerWebhookOptions CreateDefault(string name)
|
||||
=> new()
|
||||
{
|
||||
Name = name,
|
||||
SignatureHeader = DefaultSignatureHeader,
|
||||
RateLimitRequests = 120,
|
||||
RateLimitWindowSeconds = 60
|
||||
};
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SignatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must specify a signature header when enabled.");
|
||||
}
|
||||
|
||||
if (!RequireClientCertificate && string.IsNullOrWhiteSpace(HmacSecret))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure either HMAC secret or mTLS enforcement.");
|
||||
}
|
||||
|
||||
if (RateLimitRequests <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a positive rate limit.");
|
||||
}
|
||||
|
||||
if (RateLimitWindowSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a rate limit window greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetRateLimitWindow() => TimeSpan.FromSeconds(RateLimitWindowSeconds <= 0 ? 60 : RateLimitWindowSeconds);
|
||||
}
|
||||
@@ -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,16 @@
|
||||
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);
|
||||
|
||||
Task<PolicyRunStatus?> RequestCancellationAsync(string tenantId, string runId, string? reason, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRunStatus> RetryAsync(string tenantId, string runId, string? requestedBy, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
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();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryPolicyRunService(TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public 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, request.QueuedAt ?? now)
|
||||
: request.RunId;
|
||||
|
||||
var queuedAt = request.QueuedAt ?? now;
|
||||
|
||||
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,
|
||||
cancellationRequested: false,
|
||||
cancellationRequestedAt: null,
|
||||
cancellationReason: null,
|
||||
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);
|
||||
}
|
||||
|
||||
public Task<PolicyRunStatus?> RequestCancellationAsync(string tenantId, string runId, string? reason, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PolicyRunStatus? updated;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_runs.TryGetValue(runId, out var existing) || !string.Equals(existing.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<PolicyRunStatus?>(null);
|
||||
}
|
||||
|
||||
if (IsTerminal(existing.Status))
|
||||
{
|
||||
return Task.FromResult<PolicyRunStatus?>(existing);
|
||||
}
|
||||
|
||||
var cancellationReason = NormalizeCancellationReason(reason);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
updated = existing with
|
||||
{
|
||||
Status = PolicyRunExecutionStatus.Cancelled,
|
||||
FinishedAt = now,
|
||||
CancellationRequested = true,
|
||||
CancellationRequestedAt = now,
|
||||
CancellationReason = cancellationReason
|
||||
};
|
||||
|
||||
_runs[runId] = updated;
|
||||
var index = _orderedRuns.FindIndex(status => string.Equals(status.RunId, runId, StringComparison.Ordinal));
|
||||
if (index >= 0)
|
||||
{
|
||||
_orderedRuns[index] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<PolicyRunStatus?>(updated);
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus> RetryAsync(string tenantId, string runId, string? requestedBy, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PolicyRunStatus existing;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_runs.TryGetValue(runId, out var status) || !string.Equals(status.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new KeyNotFoundException($"Policy simulation {runId} was not found for tenant {tenantId}.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(status.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Simulation is still in progress and cannot be retried.");
|
||||
}
|
||||
|
||||
existing = status;
|
||||
}
|
||||
|
||||
var metadataBuilder = (existing.Metadata ?? ImmutableSortedDictionary<string, string>.Empty).ToBuilder();
|
||||
metadataBuilder["retry-of"] = runId;
|
||||
var request = new PolicyRunRequest(
|
||||
tenantId,
|
||||
existing.PolicyId,
|
||||
PolicyRunMode.Simulate,
|
||||
existing.Inputs,
|
||||
existing.Priority,
|
||||
runId: null,
|
||||
policyVersion: existing.PolicyVersion,
|
||||
requestedBy: NormalizeActor(requestedBy),
|
||||
queuedAt: _timeProvider.GetUtcNow(),
|
||||
correlationId: null,
|
||||
metadata: metadataBuilder.ToImmutable());
|
||||
|
||||
return await EnqueueAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string GenerateRunId(string policyId, DateTimeOffset timestamp)
|
||||
{
|
||||
var normalizedPolicyId = string.IsNullOrWhiteSpace(policyId) ? "policy" : policyId.Trim();
|
||||
var suffix = _guidProvider.NewGuid().ToString("N")[..8];
|
||||
return $"run:{normalizedPolicyId}:{timestamp:yyyyMMddTHHmmssZ}:{suffix}";
|
||||
}
|
||||
|
||||
private static bool IsTerminal(PolicyRunExecutionStatus status)
|
||||
=> status is PolicyRunExecutionStatus.Succeeded or PolicyRunExecutionStatus.Failed or PolicyRunExecutionStatus.Cancelled;
|
||||
|
||||
private static string? NormalizeCancellationReason(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
const int maxLength = 512;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeActor(string? actor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = actor.Trim();
|
||||
const int maxLength = 256;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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")
|
||||
.RequireAuthorization(SchedulerPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListPolicyRunsAsync)
|
||||
.WithName("ListPolicyRuns")
|
||||
.WithDescription("Lists policy run records for the tenant with optional filters by status, mode, and time range. Returns a paginated collection ordered by queue time. Requires policy.run scope.");
|
||||
group.MapGet("/{runId}", GetPolicyRunAsync)
|
||||
.WithName("GetPolicyRun")
|
||||
.WithDescription("Returns the full policy run record for a specific run ID including status, policy reference, inputs, and verdict counts. Returns 404 if the run ID is not found. Requires policy.run scope.");
|
||||
group.MapPost("/", CreatePolicyRunAsync)
|
||||
.WithName("CreatePolicyRun")
|
||||
.WithDescription("Enqueues a new policy evaluation run for the specified policy ID and version. Returns 201 Created with the run ID and initial queued status. Requires policy.run scope.")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
}
|
||||
|
||||
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,127 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
|
||||
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 PolicyRunQueryOptions ForceMode(PolicyRunMode mode)
|
||||
{
|
||||
Mode = mode;
|
||||
return this;
|
||||
}
|
||||
|
||||
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,301 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
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 PolicyRunStatusFactory.Create(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 PolicyRunStatusFactory.Create(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 => PolicyRunStatusFactory.Create(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 PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus?> RequestCancellationAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason,
|
||||
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();
|
||||
if (IsTerminal(job.Status))
|
||||
{
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
if (job.CancellationRequested && string.Equals(job.CancellationReason, reason, StringComparison.Ordinal))
|
||||
{
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
var updated = job with
|
||||
{
|
||||
CancellationRequested = true,
|
||||
CancellationRequestedAt = now,
|
||||
CancellationReason = NormalizeCancellationReason(reason),
|
||||
UpdatedAt = now,
|
||||
AvailableAt = now
|
||||
};
|
||||
|
||||
var replaced = await _repository
|
||||
.ReplaceAsync(updated, expectedLeaseOwner: job.LeaseOwner, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!replaced)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to persist cancellation request for policy run job {JobId} (runId={RunId}).",
|
||||
job.Id,
|
||||
job.RunId ?? "(pending)");
|
||||
return PolicyRunStatusFactory.Create(job, now);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cancellation requested for policy run job {JobId} (runId={RunId}, reason={Reason}).",
|
||||
updated.Id,
|
||||
updated.RunId ?? "(pending)",
|
||||
updated.CancellationReason ?? "none");
|
||||
|
||||
return PolicyRunStatusFactory.Create(updated, now);
|
||||
}
|
||||
|
||||
public async Task<PolicyRunStatus> RetryAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? requestedBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var job = await _repository
|
||||
.GetByRunIdAsync(tenantId, runId, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Policy simulation {runId} was not found for tenant {tenantId}.");
|
||||
|
||||
if (job.Mode != PolicyRunMode.Simulate)
|
||||
{
|
||||
throw new InvalidOperationException("Only simulation runs can be retried through this endpoint.");
|
||||
}
|
||||
|
||||
if (!IsTerminal(job.Status))
|
||||
{
|
||||
throw new InvalidOperationException("Simulation is still in progress and cannot be retried.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var metadataBuilder = (job.Metadata ?? ImmutableSortedDictionary<string, string>.Empty).ToBuilder();
|
||||
metadataBuilder["retry-of"] = runId;
|
||||
|
||||
var request = new PolicyRunRequest(
|
||||
tenantId,
|
||||
job.PolicyId,
|
||||
PolicyRunMode.Simulate,
|
||||
job.Inputs ?? PolicyRunInputs.Empty,
|
||||
job.Priority,
|
||||
runId: null,
|
||||
policyVersion: job.PolicyVersion,
|
||||
requestedBy: NormalizeActor(requestedBy),
|
||||
queuedAt: now,
|
||||
correlationId: job.CorrelationId,
|
||||
metadata: metadataBuilder.ToImmutable());
|
||||
|
||||
return await EnqueueAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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}";
|
||||
}
|
||||
|
||||
private static bool IsTerminal(PolicyRunJobStatus status)
|
||||
=> status is PolicyRunJobStatus.Completed or PolicyRunJobStatus.Failed or PolicyRunJobStatus.Cancelled;
|
||||
|
||||
private static string? NormalizeCancellationReason(string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = reason.Trim();
|
||||
const int maxLength = 512;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeActor(string? actor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = actor.Trim();
|
||||
const int maxLength = 256;
|
||||
return trimmed.Length > maxLength ? trimmed[..maxLength] : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicySimulations;
|
||||
|
||||
internal static class PolicySimulationEndpointExtensions
|
||||
{
|
||||
private const string Scope = StellaOpsScopes.PolicySimulate;
|
||||
|
||||
public static void MapPolicySimulationEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/api/v1/scheduler/policies/simulations")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListSimulationsAsync)
|
||||
.WithName("ListPolicySimulations")
|
||||
.WithDescription("Lists policy simulation runs for the tenant with optional filters by status and time range. Simulations are policy evaluations run in dry-run mode without committing verdicts. Requires policy.simulate scope.");
|
||||
group.MapGet("/{simulationId}", GetSimulationAsync)
|
||||
.WithName("GetPolicySimulation")
|
||||
.WithDescription("Returns the full simulation run record for a specific simulation ID including status, policy reference, inputs, and projected verdict counts. Returns 404 if the simulation ID is not found. Requires policy.simulate scope.");
|
||||
group.MapGet("/{simulationId}/stream", StreamSimulationAsync)
|
||||
.WithName("StreamSimulationEvents")
|
||||
.WithDescription("Server-Sent Events stream of real-time simulation progress events for a specific simulation ID. Clients should use the Last-Event-ID header for reconnect. Requires policy.simulate scope.");
|
||||
group.MapGet("/metrics", GetMetricsAsync)
|
||||
.WithName("GetSimulationMetrics")
|
||||
.WithDescription("Returns aggregated simulation throughput metrics for the tenant including queue depth, processing rates, and median latency. Returns 501 if the metrics provider is not configured. Requires policy.simulate scope.");
|
||||
group.MapPost("/", CreateSimulationAsync)
|
||||
.WithName("CreatePolicySimulation")
|
||||
.WithDescription("Enqueues a new policy simulation for the specified policy ID and version with the given SBOM input set. Returns 201 Created with the simulation ID and initial queued status. Requires policy.simulate scope.");
|
||||
group.MapPost("/preview", PreviewSimulationAsync)
|
||||
.WithName("PreviewPolicySimulation")
|
||||
.WithDescription("Enqueues a simulation and returns an immediate preview of the candidate count and estimated run scope before results are computed. Returns 201 Created with the simulation reference and preview payload. Requires policy.simulate scope.");
|
||||
group.MapPost("/{simulationId}/cancel", CancelSimulationAsync)
|
||||
.WithName("CancelPolicySimulation")
|
||||
.WithDescription("Requests cancellation of a queued or running simulation. Returns 200 with the updated simulation record, or 404 if the simulation ID is not found. Requires policy.simulate scope.");
|
||||
group.MapPost("/{simulationId}/retry", RetrySimulationAsync)
|
||||
.WithName("RetryPolicySimulation")
|
||||
.WithDescription("Retries a failed simulation, creating a new simulation record that re-uses the same policy and input configuration. Returns 201 Created with the new simulation ID. Requires policy.simulate scope.");
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSimulationsAsync(
|
||||
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)
|
||||
.ForceMode(PolicyRunMode.Simulate);
|
||||
|
||||
var simulations = await policyRunService
|
||||
.ListAsync(tenant.TenantId, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PolicySimulationCollectionResponse(simulations));
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSimulationAsync(
|
||||
HttpContext httpContext,
|
||||
string simulationId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var simulation = await policyRunService
|
||||
.GetAsync(tenant.TenantId, simulationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return simulation is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new PolicySimulationResponse(simulation));
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetMetricsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicySimulationMetricsProvider? metricsProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
if (metricsProvider is null)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status501NotImplemented);
|
||||
}
|
||||
|
||||
var metrics = await metricsProvider
|
||||
.CaptureAsync(tenant.TenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(metrics);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateSimulationAsync(
|
||||
HttpContext httpContext,
|
||||
PolicySimulationCreateRequest request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var actor = SchedulerEndpointHelpers.ResolveActorId(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
throw new ValidationException("policyId must be provided.");
|
||||
}
|
||||
|
||||
if (request.PolicyVersion is null || request.PolicyVersion <= 0)
|
||||
{
|
||||
throw new ValidationException("policyVersion must be provided and greater than zero.");
|
||||
}
|
||||
|
||||
var normalizedMetadata = NormalizeMetadata(request.Metadata);
|
||||
var inputs = request.Inputs ?? PolicyRunInputs.Empty;
|
||||
|
||||
var policyRequest = new PolicyRunRequest(
|
||||
tenant.TenantId,
|
||||
request.PolicyId,
|
||||
PolicyRunMode.Simulate,
|
||||
inputs,
|
||||
request.Priority,
|
||||
runId: null,
|
||||
policyVersion: request.PolicyVersion,
|
||||
requestedBy: actor,
|
||||
queuedAt: null,
|
||||
correlationId: request.CorrelationId,
|
||||
metadata: normalizedMetadata);
|
||||
|
||||
var status = await policyRunService
|
||||
.EnqueueAsync(tenant.TenantId, policyRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/scheduler/policies/simulations/{status.RunId}",
|
||||
new PolicySimulationResponse(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 });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewSimulationAsync(
|
||||
HttpContext httpContext,
|
||||
PolicySimulationCreateRequest request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var actor = SchedulerEndpointHelpers.ResolveActorId(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
throw new ValidationException("policyId must be provided.");
|
||||
}
|
||||
|
||||
if (request.PolicyVersion is null || request.PolicyVersion <= 0)
|
||||
{
|
||||
throw new ValidationException("policyVersion must be provided and greater than zero.");
|
||||
}
|
||||
|
||||
var normalizedMetadata = NormalizeMetadata(request.Metadata);
|
||||
var inputs = request.Inputs ?? PolicyRunInputs.Empty;
|
||||
|
||||
var policyRequest = new PolicyRunRequest(
|
||||
tenant.TenantId,
|
||||
request.PolicyId,
|
||||
PolicyRunMode.Simulate,
|
||||
inputs,
|
||||
request.Priority,
|
||||
runId: null,
|
||||
policyVersion: request.PolicyVersion,
|
||||
requestedBy: actor,
|
||||
queuedAt: null,
|
||||
correlationId: request.CorrelationId,
|
||||
metadata: normalizedMetadata);
|
||||
|
||||
var status = await policyRunService
|
||||
.EnqueueAsync(tenant.TenantId, policyRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var preview = new
|
||||
{
|
||||
candidates = inputs.SbomSet.Length,
|
||||
estimatedRuns = inputs.SbomSet.Length,
|
||||
message = "preview pending execution; actual diff will be available once job starts"
|
||||
};
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/scheduler/policies/simulations/{status.RunId}",
|
||||
new { simulation = new PolicySimulationResponse(status), preview });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelSimulationAsync(
|
||||
HttpContext httpContext,
|
||||
string simulationId,
|
||||
PolicySimulationCancelRequest? request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var cancellation = await policyRunService
|
||||
.RequestCancellationAsync(tenant.TenantId, simulationId, request?.Reason, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return cancellation is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new PolicySimulationResponse(cancellation));
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RetrySimulationAsync(
|
||||
HttpContext httpContext,
|
||||
string simulationId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var actor = SchedulerEndpointHelpers.ResolveActorId(httpContext);
|
||||
|
||||
var status = await policyRunService
|
||||
.RetryAsync(tenant.TenantId, simulationId, actor, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Created(
|
||||
$"/api/v1/scheduler/policies/simulations/{status.RunId}",
|
||||
new PolicySimulationResponse(status));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
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.Status409Conflict);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StreamSimulationAsync(
|
||||
HttpContext httpContext,
|
||||
string simulationId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IPolicyRunService policyRunService,
|
||||
[FromServices] IPolicySimulationStreamCoordinator streamCoordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, Scope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var simulation = await policyRunService
|
||||
.GetAsync(tenant.TenantId, simulationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (simulation is null)
|
||||
{
|
||||
await Results.NotFound().ExecuteAsync(httpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
await streamCoordinator
|
||||
.StreamAsync(httpContext, tenant.TenantId, simulation, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized)
|
||||
.ExecuteAsync(httpContext);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden)
|
||||
.ExecuteAsync(httpContext);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
await Results.BadRequest(new { error = ex.Message }).ExecuteAsync(httpContext);
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableSortedDictionary<string, string>? NormalizeMetadata(IReadOnlyDictionary<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 = key?.Trim();
|
||||
var normalizedValue = value?.Trim();
|
||||
if (string.IsNullOrEmpty(normalizedKey) || string.IsNullOrEmpty(normalizedValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var lowerKey = normalizedKey.ToLowerInvariant();
|
||||
if (!builder.ContainsKey(lowerKey))
|
||||
{
|
||||
builder[lowerKey] = normalizedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? null : builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicySimulationCreateRequest(
|
||||
[property: JsonPropertyName("policyId")] string PolicyId,
|
||||
[property: JsonPropertyName("policyVersion")] int? PolicyVersion,
|
||||
[property: JsonPropertyName("priority")] PolicyRunPriority Priority = PolicyRunPriority.Normal,
|
||||
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata = null,
|
||||
[property: JsonPropertyName("inputs")] PolicyRunInputs? Inputs = null);
|
||||
|
||||
internal sealed record PolicySimulationCancelRequest(
|
||||
[property: JsonPropertyName("reason")] string? Reason);
|
||||
|
||||
internal sealed record PolicySimulationCollectionResponse(
|
||||
[property: JsonPropertyName("simulations")] IReadOnlyList<PolicyRunStatus> Simulations);
|
||||
|
||||
internal sealed record PolicySimulationResponse(
|
||||
[property: JsonPropertyName("simulation")] PolicyRunStatus Simulation);
|
||||
@@ -0,0 +1,244 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicySimulations;
|
||||
|
||||
internal interface IPolicySimulationMetricsProvider
|
||||
{
|
||||
Task<PolicySimulationMetricsResponse> CaptureAsync(string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal interface IPolicySimulationMetricsRecorder
|
||||
{
|
||||
void RecordLatency(PolicyRunStatus status, DateTimeOffset observedAt);
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationMetricsProvider : IPolicySimulationMetricsProvider, IPolicySimulationMetricsRecorder, IDisposable
|
||||
{
|
||||
private static readonly PolicyRunJobStatus[] QueueStatuses =
|
||||
{
|
||||
PolicyRunJobStatus.Pending,
|
||||
PolicyRunJobStatus.Dispatching,
|
||||
PolicyRunJobStatus.Submitted,
|
||||
};
|
||||
|
||||
private static readonly PolicyRunJobStatus[] TerminalStatuses =
|
||||
{
|
||||
PolicyRunJobStatus.Completed,
|
||||
PolicyRunJobStatus.Failed,
|
||||
PolicyRunJobStatus.Cancelled,
|
||||
};
|
||||
|
||||
private readonly IPolicyRunJobRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Meter _meter;
|
||||
private readonly ObservableGauge<long> _queueGauge;
|
||||
private readonly Histogram<double> _latencyHistogram;
|
||||
private readonly object _snapshotLock = new();
|
||||
private IReadOnlyDictionary<string, long> _latestQueueSnapshot = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
private string _latestTenantId = string.Empty;
|
||||
private bool _disposed;
|
||||
|
||||
public PolicySimulationMetricsProvider(IPolicyRunJobRepository repository, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_meter = new Meter("StellaOps.Scheduler.WebService.PolicySimulations");
|
||||
_queueGauge = _meter.CreateObservableGauge<long>(
|
||||
"policy_simulation_queue_depth",
|
||||
ObserveQueueDepth,
|
||||
unit: "runs",
|
||||
description: "Queued policy simulation jobs grouped by status.");
|
||||
_latencyHistogram = _meter.CreateHistogram<double>(
|
||||
"policy_simulation_latency_seconds",
|
||||
unit: "s",
|
||||
description: "End-to-end policy simulation latency (seconds).");
|
||||
}
|
||||
|
||||
public async Task<PolicySimulationMetricsResponse> CaptureAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new ArgumentException("Tenant id must be provided.", nameof(tenantId));
|
||||
}
|
||||
|
||||
var queueCounts = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalQueueDepth = 0;
|
||||
|
||||
foreach (var status in QueueStatuses)
|
||||
{
|
||||
var count = await _repository.CountAsync(
|
||||
tenantId,
|
||||
PolicyRunMode.Simulate,
|
||||
new[] { status },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
queueCounts[status.ToString().ToLowerInvariant()] = count;
|
||||
totalQueueDepth += count;
|
||||
}
|
||||
|
||||
var snapshot = new Dictionary<string, long>(queueCounts, StringComparer.Ordinal);
|
||||
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
_latestQueueSnapshot = snapshot;
|
||||
_latestTenantId = tenantId;
|
||||
}
|
||||
|
||||
var sampleSize = 200;
|
||||
var recentJobs = await _repository.ListAsync(
|
||||
tenantId,
|
||||
policyId: null,
|
||||
mode: PolicyRunMode.Simulate,
|
||||
statuses: TerminalStatuses,
|
||||
queuedAfter: null,
|
||||
limit: sampleSize,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var durations = recentJobs
|
||||
.Select(job => CalculateLatencySeconds(job, _timeProvider.GetUtcNow()))
|
||||
.Where(duration => duration >= 0)
|
||||
.OrderBy(duration => duration)
|
||||
.ToArray();
|
||||
|
||||
var latencyMetrics = new PolicySimulationLatencyMetrics(
|
||||
durations.Length,
|
||||
Percentile(durations, 0.50),
|
||||
Percentile(durations, 0.90),
|
||||
Percentile(durations, 0.95),
|
||||
Percentile(durations, 0.99),
|
||||
Average(durations));
|
||||
|
||||
return new PolicySimulationMetricsResponse(
|
||||
new PolicySimulationQueueDepth(totalQueueDepth, snapshot),
|
||||
latencyMetrics);
|
||||
}
|
||||
|
||||
public void RecordLatency(PolicyRunStatus status, DateTimeOffset observedAt)
|
||||
{
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(status));
|
||||
}
|
||||
|
||||
var latencySeconds = CalculateLatencySeconds(status, observedAt);
|
||||
if (latencySeconds >= 0)
|
||||
{
|
||||
_latencyHistogram.Record(latencySeconds);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<long>> ObserveQueueDepth()
|
||||
{
|
||||
IReadOnlyDictionary<string, long> snapshot;
|
||||
string tenantId;
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
snapshot = _latestQueueSnapshot;
|
||||
tenantId = _latestTenantId;
|
||||
}
|
||||
|
||||
tenantId = string.IsNullOrWhiteSpace(tenantId) ? "unknown" : tenantId;
|
||||
|
||||
foreach (var pair in snapshot)
|
||||
{
|
||||
yield return new Measurement<long>(
|
||||
pair.Value,
|
||||
new KeyValuePair<string, object?>("status", pair.Key),
|
||||
new KeyValuePair<string, object?>("tenantId", tenantId));
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateLatencySeconds(PolicyRunJob job, DateTimeOffset now)
|
||||
{
|
||||
var started = job.QueuedAt ?? job.CreatedAt;
|
||||
var finished = job.CompletedAt ?? job.CancelledAt ?? job.UpdatedAt;
|
||||
if (started == default)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var duration = (finished - started).TotalSeconds;
|
||||
return duration < 0 ? 0 : duration;
|
||||
}
|
||||
|
||||
private static double CalculateLatencySeconds(PolicyRunStatus status, DateTimeOffset now)
|
||||
{
|
||||
var started = status.QueuedAt;
|
||||
var finished = status.FinishedAt ?? now;
|
||||
if (started == default)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var duration = (finished - started).TotalSeconds;
|
||||
return duration < 0 ? 0 : duration;
|
||||
}
|
||||
|
||||
private static double? Percentile(IReadOnlyList<double> values, double percentile)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var position = percentile * (values.Count - 1);
|
||||
var lowerIndex = (int)Math.Floor(position);
|
||||
var upperIndex = (int)Math.Ceiling(position);
|
||||
|
||||
if (lowerIndex == upperIndex)
|
||||
{
|
||||
return Math.Round(values[lowerIndex], 4);
|
||||
}
|
||||
|
||||
var fraction = position - lowerIndex;
|
||||
var interpolated = values[lowerIndex] + (values[upperIndex] - values[lowerIndex]) * fraction;
|
||||
return Math.Round(interpolated, 4);
|
||||
}
|
||||
|
||||
private static double? Average(IReadOnlyList<double> values)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sum = values.Sum();
|
||||
return Math.Round(sum / values.Count, 4);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_meter.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicySimulationMetricsResponse(
|
||||
[property: JsonPropertyName("policy_simulation_queue_depth")] PolicySimulationQueueDepth QueueDepth,
|
||||
[property: JsonPropertyName("policy_simulation_latency")] PolicySimulationLatencyMetrics Latency);
|
||||
|
||||
internal sealed record PolicySimulationQueueDepth(
|
||||
[property: JsonPropertyName("total")] long Total,
|
||||
[property: JsonPropertyName("by_status")] IReadOnlyDictionary<string, long> ByStatus);
|
||||
|
||||
internal sealed record PolicySimulationLatencyMetrics(
|
||||
[property: JsonPropertyName("samples")] int Samples,
|
||||
[property: JsonPropertyName("p50_seconds")] double? P50,
|
||||
[property: JsonPropertyName("p90_seconds")] double? P90,
|
||||
[property: JsonPropertyName("p95_seconds")] double? P95,
|
||||
[property: JsonPropertyName("p99_seconds")] double? P99,
|
||||
[property: JsonPropertyName("mean_seconds")] double? Mean);
|
||||
@@ -0,0 +1,199 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.PolicySimulations;
|
||||
|
||||
internal interface IPolicySimulationStreamCoordinator
|
||||
{
|
||||
Task StreamAsync(HttpContext context, string tenantId, PolicyRunStatus initialStatus, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationStreamCoordinator : IPolicySimulationStreamCoordinator
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IPolicyRunService _policyRunService;
|
||||
private readonly IQueueLagSummaryProvider _queueLagProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly RunStreamOptions _options;
|
||||
private readonly IPolicySimulationMetricsRecorder? _metricsRecorder;
|
||||
private readonly ILogger<PolicySimulationStreamCoordinator> _logger;
|
||||
|
||||
public PolicySimulationStreamCoordinator(
|
||||
IPolicyRunService policyRunService,
|
||||
IQueueLagSummaryProvider queueLagProvider,
|
||||
IOptions<RunStreamOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<PolicySimulationStreamCoordinator> logger,
|
||||
IPolicySimulationMetricsRecorder? metricsRecorder = null)
|
||||
{
|
||||
_policyRunService = policyRunService ?? throw new ArgumentNullException(nameof(policyRunService));
|
||||
_queueLagProvider = queueLagProvider ?? throw new ArgumentNullException(nameof(queueLagProvider));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Validate();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_metricsRecorder = metricsRecorder;
|
||||
}
|
||||
|
||||
public async Task StreamAsync(HttpContext context, string tenantId, PolicyRunStatus initialStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(initialStatus);
|
||||
|
||||
ConfigureSseHeaders(context.Response);
|
||||
await SseWriter.WriteRetryAsync(context.Response, _options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var last = initialStatus;
|
||||
await SseWriter.WriteEventAsync(context.Response, "initial", PolicySimulationPayload.From(last), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
await SseWriter.WriteEventAsync(context.Response, "queueLag", _queueLagProvider.Capture(), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
await SseWriter.WriteEventAsync(context.Response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (IsTerminal(last.Status))
|
||||
{
|
||||
_metricsRecorder?.RecordLatency(last, _timeProvider.GetUtcNow());
|
||||
await SseWriter.WriteEventAsync(context.Response, "completed", PolicySimulationPayload.From(last), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var pollTimer = new PeriodicTimer(_options.PollInterval);
|
||||
using var queueTimer = new PeriodicTimer(_options.QueueLagInterval);
|
||||
using var heartbeatTimer = new PeriodicTimer(_options.HeartbeatInterval);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var pollTask = pollTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var queueTask = queueTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var heartbeatTask = heartbeatTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
|
||||
var completed = await Task.WhenAny(pollTask, queueTask, heartbeatTask).ConfigureAwait(false);
|
||||
|
||||
if (completed == pollTask && await pollTask.ConfigureAwait(false))
|
||||
{
|
||||
var current = await _policyRunService
|
||||
.GetAsync(tenantId, last.RunId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
_logger.LogWarning("Policy simulation {RunId} disappeared while streaming.", last.RunId);
|
||||
await SseWriter.WriteEventAsync(
|
||||
context.Response,
|
||||
"notFound",
|
||||
new PolicySimulationNotFoundPayload(last.RunId),
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
if (HasMeaningfulChange(last, current))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(context.Response, "status", PolicySimulationPayload.From(current), SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
last = current;
|
||||
|
||||
if (IsTerminal(last.Status))
|
||||
{
|
||||
_metricsRecorder?.RecordLatency(last, _timeProvider.GetUtcNow());
|
||||
await SseWriter.WriteEventAsync(context.Response, "completed", PolicySimulationPayload.From(last), SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (completed == queueTask && await queueTask.ConfigureAwait(false))
|
||||
{
|
||||
var summary = _queueLagProvider.Capture();
|
||||
await SseWriter.WriteEventAsync(context.Response, "queueLag", summary, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (completed == heartbeatTask && await heartbeatTask.ConfigureAwait(false))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(context.Response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Policy simulation stream cancelled for run {RunId}.", last.RunId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureSseHeaders(HttpResponse response)
|
||||
{
|
||||
response.StatusCode = StatusCodes.Status200OK;
|
||||
response.Headers.CacheControl = "no-store";
|
||||
response.Headers["X-Accel-Buffering"] = "no";
|
||||
response.Headers["Connection"] = "keep-alive";
|
||||
response.ContentType = "text/event-stream";
|
||||
}
|
||||
|
||||
private static bool HasMeaningfulChange(PolicyRunStatus previous, PolicyRunStatus current)
|
||||
{
|
||||
if (!EqualityComparer<PolicyRunExecutionStatus>.Default.Equals(previous.Status, current.Status))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Nullable.Equals(previous.StartedAt, current.StartedAt) || !Nullable.Equals(previous.FinishedAt, current.FinishedAt))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (previous.Attempts != current.Attempts)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(previous.Error, current.Error, StringComparison.Ordinal) ||
|
||||
!string.Equals(previous.ErrorCode, current.ErrorCode, StringComparison.Ordinal) ||
|
||||
!string.Equals(previous.DeterminismHash, current.DeterminismHash, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (previous.CancellationRequested != current.CancellationRequested ||
|
||||
!Nullable.Equals(previous.CancellationRequestedAt, current.CancellationRequestedAt) ||
|
||||
!string.Equals(previous.CancellationReason, current.CancellationReason, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!EqualityComparer<PolicyRunStats>.Default.Equals(previous.Stats, current.Stats))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsTerminal(PolicyRunExecutionStatus status)
|
||||
=> status is PolicyRunExecutionStatus.Succeeded or PolicyRunExecutionStatus.Failed or PolicyRunExecutionStatus.Cancelled;
|
||||
|
||||
private sealed record PolicySimulationPayload(
|
||||
[property: JsonPropertyName("simulation")] PolicyRunStatus Simulation)
|
||||
{
|
||||
public static PolicySimulationPayload From(PolicyRunStatus status) => new(status);
|
||||
}
|
||||
|
||||
private sealed record PolicySimulationNotFoundPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId);
|
||||
|
||||
private sealed record HeartbeatPayload(
|
||||
[property: JsonPropertyName("ts")] DateTimeOffset Timestamp)
|
||||
{
|
||||
public static HeartbeatPayload Create(DateTimeOffset timestamp) => new(timestamp);
|
||||
}
|
||||
}
|
||||
307
src/JobEngine/StellaOps.Scheduler.WebService/Program.cs
Normal file
307
src/JobEngine/StellaOps.Scheduler.WebService/Program.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Extensions;
|
||||
using StellaOps.Scheduler.Persistence.Postgres;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
using StellaOps.Scheduler.WebService.FailureSignatures;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
using StellaOps.Scheduler.WebService.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Observability;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
using StellaOps.Scheduler.WebService.PolicySimulations;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
using StellaOps.Scheduler.WebService.Exceptions;
|
||||
using StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
using StellaOps.Scheduler.Worker.Exceptions;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using System.Linq;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
// TimeProvider.System is registered here for deterministic time support
|
||||
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.Conselier ??= SchedulerWebhookOptions.CreateDefault("conselier");
|
||||
options.Webhooks.Excitor ??= SchedulerWebhookOptions.CreateDefault("excitor");
|
||||
|
||||
options.Webhooks.Conselier.Name = string.IsNullOrWhiteSpace(options.Webhooks.Conselier.Name)
|
||||
? "conselier"
|
||||
: options.Webhooks.Conselier.Name;
|
||||
options.Webhooks.Excitor.Name = string.IsNullOrWhiteSpace(options.Webhooks.Excitor.Name)
|
||||
? "excitor"
|
||||
: options.Webhooks.Excitor.Name;
|
||||
|
||||
options.Webhooks.Conselier.Validate();
|
||||
options.Webhooks.Excitor.Validate();
|
||||
});
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<IWebhookRateLimiter, InMemoryWebhookRateLimiter>();
|
||||
builder.Services.AddSingleton<IWebhookRequestAuthenticator, WebhookRequestAuthenticator>();
|
||||
builder.Services.AddSingleton<IInboundExportEventSink, LoggingExportEventSink>();
|
||||
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
|
||||
|
||||
var cartographerOptions = builder.Configuration.GetSection("Scheduler:Cartographer").Get<SchedulerCartographerOptions>() ?? new SchedulerCartographerOptions();
|
||||
builder.Services.AddSingleton(cartographerOptions);
|
||||
builder.Services.AddOptions<SchedulerCartographerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:Cartographer"));
|
||||
|
||||
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
|
||||
if (storageSection.Exists())
|
||||
{
|
||||
builder.Services.AddSchedulerPersistence(storageSection);
|
||||
builder.Services.AddScoped<IGraphJobRepository, GraphJobRepository>();
|
||||
builder.Services.AddSingleton<IGraphJobStore, PostgresGraphJobStore>();
|
||||
builder.Services.AddScoped<IScheduleRepository, ScheduleRepository>();
|
||||
builder.Services.AddScoped<IRunRepository, RunRepository>();
|
||||
builder.Services.AddSingleton<IRunSummaryService, RunSummaryService>();
|
||||
builder.Services.AddScoped<IImpactSnapshotRepository, ImpactSnapshotRepository>();
|
||||
builder.Services.AddScoped<IPolicyRunJobRepository, PolicyRunJobRepository>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
|
||||
builder.Services.AddSingleton<IPolicySimulationMetricsProvider, PolicySimulationMetricsProvider>();
|
||||
builder.Services.AddSingleton<IPolicySimulationMetricsRecorder>(static sp => (IPolicySimulationMetricsRecorder)sp.GetRequiredService<IPolicySimulationMetricsProvider>());
|
||||
}
|
||||
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>();
|
||||
builder.Services.AddSingleton<IResolverJobService, InMemoryResolverJobService>();
|
||||
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.AddImpactIndex();
|
||||
builder.Services.AddResolverJobServices();
|
||||
|
||||
// Exception lifecycle workers (SCHED-WORKER-25-101/25-102)
|
||||
var workerOptions = builder.Configuration.GetSection("Scheduler:Worker").Get<SchedulerWorkerOptions>() ?? new SchedulerWorkerOptions();
|
||||
workerOptions.Validate();
|
||||
builder.Services.AddSingleton(workerOptions);
|
||||
builder.Services.AddSingleton<SchedulerWorkerMetrics>();
|
||||
builder.Services.AddSingleton<IExceptionRepository, PostgresExceptionRepository>();
|
||||
builder.Services.AddSingleton<IExceptionEventPublisher>(NullExceptionEventPublisher.Instance);
|
||||
builder.Services.AddSingleton<IExpiringDigestService>(NullExpiringDigestService.Instance);
|
||||
builder.Services.AddSingleton<IExpiringAlertService>(NullExpiringAlertService.Instance);
|
||||
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
|
||||
builder.Services.AddHostedService<ExpiringNotificationWorker>();
|
||||
|
||||
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());
|
||||
|
||||
builder.Services.AddSingleton<IQueueLagSummaryProvider, QueueLagSummaryProvider>();
|
||||
builder.Services.AddSingleton<IRunStreamCoordinator, RunStreamCoordinator>();
|
||||
builder.Services.AddSingleton<IPolicySimulationStreamCoordinator, PolicySimulationStreamCoordinator>();
|
||||
builder.Services.AddOptions<RunStreamOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:RunStream"));
|
||||
|
||||
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);
|
||||
|
||||
// Read collections directly from IConfiguration to work around
|
||||
// .NET Configuration.Bind() not populating IList<string> in nested init objects.
|
||||
var authSection = builder.Configuration.GetSection("Scheduler:Authority");
|
||||
|
||||
var cfgAudiences = authSection.GetSection("Audiences").Get<string[]>() ?? [];
|
||||
foreach (var audience in cfgAudiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
var cfgScopes = authSection.GetSection("RequiredScopes").Get<string[]>() ?? [];
|
||||
foreach (var scope in cfgScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
var cfgTenants = authSection.GetSection("RequiredTenants").Get<string[]>() ?? [];
|
||||
foreach (var tenant in cfgTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
var cfgBypassNetworks = authSection.GetSection("BypassNetworks").Get<string[]>() ?? [];
|
||||
foreach (var network in cfgBypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(SchedulerPolicies.Read, StellaOpsScopes.SchedulerRead);
|
||||
options.AddStellaOpsScopePolicy(SchedulerPolicies.Operate, StellaOpsScopes.SchedulerOperate);
|
||||
options.AddStellaOpsScopePolicy(SchedulerPolicies.Admin, StellaOpsScopes.SchedulerAdmin);
|
||||
});
|
||||
builder.Services.AddScoped<ITenantContextAccessor, ClaimsTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, TokenScopeAuthorizer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "Anonymous";
|
||||
options.DefaultChallengeScheme = "Anonymous";
|
||||
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
|
||||
|
||||
// Register scope handler + dependencies so AddStellaOpsScopePolicy policies can be evaluated
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
|
||||
builder.Services.AddOptions<StellaOpsResourceServerOptions>();
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(SchedulerPolicies.Read, StellaOpsScopes.SchedulerRead);
|
||||
options.AddStellaOpsScopePolicy(SchedulerPolicies.Operate, StellaOpsScopes.SchedulerOperate);
|
||||
options.AddStellaOpsScopePolicy(SchedulerPolicies.Admin, StellaOpsScopes.SchedulerAdmin);
|
||||
});
|
||||
builder.Services.AddScoped<ITenantContextAccessor, HeaderTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, HeaderScopeAuthorizer>();
|
||||
}
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
builder.Services.AddStellaOpsLocalization(builder.Configuration);
|
||||
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "scheduler",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("scheduler");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("scheduler");
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
app.UseMiddleware<SchedulerTelemetryMiddleware>();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
await app.LoadTranslationsAsync();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Json(new { status = "ok" }))
|
||||
.WithName("SchedulerHealthz")
|
||||
.WithDescription("Liveness probe endpoint for the Scheduler service. Returns HTTP 200 with a JSON body indicating the process is running. No authentication required.")
|
||||
.AllowAnonymous();
|
||||
app.MapGet("/readyz", () => Results.Json(new { status = "ready" }))
|
||||
.WithName("SchedulerReadyz")
|
||||
.WithDescription("Readiness probe endpoint for the Scheduler service. Returns HTTP 200 when the service is ready to accept traffic. No authentication required.")
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapGraphJobEndpoints();
|
||||
ResolverJobEndpointExtensions.MapResolverJobEndpoints(app);
|
||||
app.MapScheduleEndpoints();
|
||||
app.MapRunEndpoints();
|
||||
app.MapFailureSignatureEndpoints();
|
||||
app.MapPolicyRunEndpoints();
|
||||
app.MapPolicySimulationEndpoints();
|
||||
app.MapSchedulerEventWebhookEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
|
||||
// Make Program class accessible to test projects using WebApplicationFactory
|
||||
public sealed partial class Program;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.WebService.Tests")]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.Scheduler.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"STELLAOPS_WEBSERVICES_CORS": "true",
|
||||
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
|
||||
},
|
||||
"applicationUrl": "https://localhost:10190;http://localhost:10191"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal sealed class InMemoryRunRepository : IRunRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Run> _runs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task InsertAsync(
|
||||
Run run,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
_runs[run.Id] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(
|
||||
Run run,
|
||||
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,
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
if (options.Cursor is { } cursor)
|
||||
{
|
||||
query = options.SortAscending
|
||||
? query.Where(run => run.CreatedAt > cursor.CreatedAt ||
|
||||
(run.CreatedAt == cursor.CreatedAt &&
|
||||
string.Compare(run.Id, cursor.RunId, StringComparison.Ordinal) > 0))
|
||||
: query.Where(run => run.CreatedAt < cursor.CreatedAt ||
|
||||
(run.CreatedAt == cursor.CreatedAt &&
|
||||
string.Compare(run.Id, cursor.RunId, StringComparison.Ordinal) < 0));
|
||||
}
|
||||
|
||||
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,
|
||||
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,61 @@
|
||||
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal interface IQueueLagSummaryProvider
|
||||
{
|
||||
QueueLagSummaryResponse Capture();
|
||||
}
|
||||
|
||||
internal sealed class QueueLagSummaryProvider : IQueueLagSummaryProvider
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public QueueLagSummaryProvider(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public QueueLagSummaryResponse Capture()
|
||||
{
|
||||
var samples = SchedulerQueueMetrics.CaptureDepthSamples();
|
||||
if (samples.Count == 0)
|
||||
{
|
||||
return new QueueLagSummaryResponse(
|
||||
_timeProvider.GetUtcNow(),
|
||||
0,
|
||||
0,
|
||||
ImmutableArray<QueueLagEntry>.Empty);
|
||||
}
|
||||
|
||||
var ordered = samples
|
||||
.OrderBy(static sample => sample.Transport, StringComparer.Ordinal)
|
||||
.ThenBy(static sample => sample.Queue, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<QueueLagEntry>(ordered.Length);
|
||||
long totalDepth = 0;
|
||||
long maxDepth = 0;
|
||||
|
||||
foreach (var sample in ordered)
|
||||
{
|
||||
totalDepth += sample.Depth;
|
||||
if (sample.Depth > maxDepth)
|
||||
{
|
||||
maxDepth = sample.Depth;
|
||||
}
|
||||
|
||||
builder.Add(new QueueLagEntry(sample.Transport, sample.Queue, sample.Depth));
|
||||
}
|
||||
|
||||
return new QueueLagSummaryResponse(
|
||||
_timeProvider.GetUtcNow(),
|
||||
totalDepth,
|
||||
maxDepth,
|
||||
builder.ToImmutable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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,
|
||||
[property: JsonPropertyName("nextCursor")] string? NextCursor = null);
|
||||
|
||||
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);
|
||||
|
||||
internal sealed record RunDeltaCollectionResponse(
|
||||
[property: JsonPropertyName("deltas")] ImmutableArray<DeltaSummary> Deltas);
|
||||
|
||||
internal sealed record QueueLagSummaryResponse(
|
||||
[property: JsonPropertyName("capturedAt")] DateTimeOffset CapturedAt,
|
||||
[property: JsonPropertyName("totalDepth")] long TotalDepth,
|
||||
[property: JsonPropertyName("maxDepth")] long MaxDepth,
|
||||
[property: JsonPropertyName("queues")] ImmutableArray<QueueLagEntry> Queues);
|
||||
|
||||
internal sealed record QueueLagEntry(
|
||||
[property: JsonPropertyName("transport")] string Transport,
|
||||
[property: JsonPropertyName("queue")] string Queue,
|
||||
[property: JsonPropertyName("depth")] long Depth);
|
||||
@@ -0,0 +1,718 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using static StellaOps.Localization.T;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal static class RunEndpoints
|
||||
{
|
||||
private const string ReadScope = "scheduler.runs.read";
|
||||
private const string WriteScope = "scheduler.runs.write";
|
||||
private const string PreviewScope = "scheduler.runs.preview";
|
||||
private const string ManageScope = "scheduler.runs.manage";
|
||||
private const int DefaultRunListLimit = 50;
|
||||
|
||||
public static IEndpointRouteBuilder MapRunEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/scheduler/runs")
|
||||
.RequireAuthorization(SchedulerPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListRunsAsync)
|
||||
.WithName("ListSchedulerRuns")
|
||||
.WithDescription(_t("scheduler.run.list_description"));
|
||||
group.MapGet("/queue/lag", GetQueueLagAsync)
|
||||
.WithName("GetSchedulerQueueLag")
|
||||
.WithDescription(_t("scheduler.run.get_queue_lag_description"));
|
||||
group.MapGet("/{runId}/deltas", GetRunDeltasAsync)
|
||||
.WithName("GetRunDeltas")
|
||||
.WithDescription(_t("scheduler.run.get_deltas_description"));
|
||||
group.MapGet("/{runId}/stream", StreamRunAsync)
|
||||
.WithName("StreamRunEvents")
|
||||
.WithDescription(_t("scheduler.run.stream_description"));
|
||||
group.MapGet("/{runId}", GetRunAsync)
|
||||
.WithName("GetSchedulerRun")
|
||||
.WithDescription(_t("scheduler.run.get_description"));
|
||||
group.MapPost("/", CreateRunAsync)
|
||||
.WithName("CreateSchedulerRun")
|
||||
.WithDescription(_t("scheduler.run.create_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
group.MapPost("/{runId}/cancel", CancelRunAsync)
|
||||
.WithName("CancelSchedulerRun")
|
||||
.WithDescription(_t("scheduler.run.cancel_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
group.MapPost("/{runId}/retry", RetryRunAsync)
|
||||
.WithName("RetrySchedulerRun")
|
||||
.WithDescription(_t("scheduler.run.retry_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
group.MapPost("/preview", PreviewImpactAsync)
|
||||
.WithName("PreviewRunImpact")
|
||||
.WithDescription(_t("scheduler.run.preview_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult GetQueueLagAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IQueueLagSummaryProvider queueLagProvider)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var summary = queueLagProvider.Capture();
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
catch (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 (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRunsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IRunRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var scheduleId = httpContext.Request.Query.TryGetValue("scheduleId", out var scheduleValues)
|
||||
? scheduleValues.ToString().Trim()
|
||||
: null;
|
||||
|
||||
var states = ParseRunStates(httpContext.Request.Query.TryGetValue("state", out var stateValues) ? stateValues : StringValues.Empty);
|
||||
var createdAfter = SchedulerEndpointHelpers.TryParseDateTimeOffset(httpContext.Request.Query.TryGetValue("createdAfter", out var createdAfterValues) ? createdAfterValues.ToString() : null);
|
||||
var limit = SchedulerEndpointHelpers.TryParsePositiveInt(httpContext.Request.Query.TryGetValue("limit", out var limitValues) ? limitValues.ToString() : null);
|
||||
var cursor = SchedulerEndpointHelpers.TryParseRunCursor(httpContext.Request.Query.TryGetValue("cursor", out var cursorValues) ? cursorValues.ToString() : null);
|
||||
|
||||
var sortAscending = httpContext.Request.Query.TryGetValue("sort", out var sortValues) &&
|
||||
sortValues.Any(value => string.Equals(value, "asc", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var appliedLimit = limit ?? DefaultRunListLimit;
|
||||
var options = new RunQueryOptions
|
||||
{
|
||||
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId,
|
||||
States = states,
|
||||
CreatedAfter = createdAfter,
|
||||
Cursor = cursor,
|
||||
Limit = appliedLimit,
|
||||
SortAscending = sortAscending,
|
||||
};
|
||||
|
||||
var runs = await repository.ListAsync(tenant.TenantId, options, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? nextCursor = null;
|
||||
if (runs.Count == appliedLimit && runs.Count > 0)
|
||||
{
|
||||
var last = runs[^1];
|
||||
nextCursor = SchedulerEndpointHelpers.CreateRunCursor(last);
|
||||
}
|
||||
|
||||
return Results.Ok(new RunCollectionResponse(runs, nextCursor));
|
||||
}
|
||||
catch (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 (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 (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 (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRunDeltasAsync(
|
||||
HttpContext httpContext,
|
||||
string runId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IRunRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var run = await repository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new RunDeltaCollectionResponse(run.Deltas));
|
||||
}
|
||||
catch (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 (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRunAsync(
|
||||
HttpContext httpContext,
|
||||
RunCreateRequest request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IScheduleRepository scheduleRepository,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IRunSummaryService runSummaryService,
|
||||
[FromServices] ISchedulerAuditService auditService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ManageScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ScheduleId))
|
||||
{
|
||||
throw new ValidationException("scheduleId must be provided when creating a run.");
|
||||
}
|
||||
|
||||
var scheduleId = request.ScheduleId!.Trim();
|
||||
if (scheduleId.Length == 0)
|
||||
{
|
||||
throw new ValidationException("scheduleId must contain a value.");
|
||||
}
|
||||
|
||||
var schedule = await scheduleRepository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (schedule is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (request.Trigger != RunTrigger.Manual)
|
||||
{
|
||||
throw new ValidationException("Only manual runs can be created via this endpoint.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var runId = SchedulerEndpointHelpers.GenerateIdentifier("run");
|
||||
var reason = request.Reason ?? RunReason.Empty;
|
||||
|
||||
var run = new Run(
|
||||
runId,
|
||||
tenant.TenantId,
|
||||
request.Trigger,
|
||||
RunState.Planning,
|
||||
RunStats.Empty,
|
||||
now,
|
||||
reason,
|
||||
schedule.Id);
|
||||
|
||||
await runRepository.InsertAsync(run, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(run.ScheduleId))
|
||||
{
|
||||
await runSummaryService.ProjectAsync(run, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await auditService.WriteAsync(
|
||||
new SchedulerAuditEvent(
|
||||
tenant.TenantId,
|
||||
"scheduler.run",
|
||||
"create",
|
||||
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
|
||||
RunId: run.Id,
|
||||
ScheduleId: schedule.Id,
|
||||
Metadata: BuildMetadata(
|
||||
("state", run.State.ToString().ToLowerInvariant()),
|
||||
("trigger", run.Trigger.ToString().ToLowerInvariant()),
|
||||
("correlationId", request.CorrelationId?.Trim()))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/scheduler/runs/{run.Id}", new RunResponse(run));
|
||||
}
|
||||
catch (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 (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 = _t("scheduler.error.run_already_terminal") });
|
||||
}
|
||||
|
||||
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 = _t("scheduler.error.run_concurrent_update") });
|
||||
}
|
||||
|
||||
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 (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 (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RetryRunAsync(
|
||||
HttpContext httpContext,
|
||||
string runId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IScheduleRepository scheduleRepository,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IRunSummaryService runSummaryService,
|
||||
[FromServices] ISchedulerAuditService auditService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ManageScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var existing = await runRepository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existing.ScheduleId))
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("scheduler.error.run_no_schedule") });
|
||||
}
|
||||
|
||||
if (!RunStateMachine.IsTerminal(existing.State))
|
||||
{
|
||||
return Results.Conflict(new { error = _t("scheduler.error.run_not_terminal") });
|
||||
}
|
||||
|
||||
var schedule = await scheduleRepository.GetAsync(tenant.TenantId, existing.ScheduleId!, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (schedule is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = _t("scheduler.error.schedule_not_found") });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var newRunId = SchedulerEndpointHelpers.GenerateIdentifier("run");
|
||||
var baselineReason = existing.Reason ?? RunReason.Empty;
|
||||
var manualReason = string.IsNullOrWhiteSpace(baselineReason.ManualReason)
|
||||
? $"retry-of:{existing.Id}"
|
||||
: $"{baselineReason.ManualReason};retry-of:{existing.Id}";
|
||||
|
||||
var newReason = new RunReason(
|
||||
manualReason,
|
||||
baselineReason.ConselierExportId,
|
||||
baselineReason.ExcitorExportId,
|
||||
baselineReason.Cursor)
|
||||
{
|
||||
ImpactWindowFrom = baselineReason.ImpactWindowFrom,
|
||||
ImpactWindowTo = baselineReason.ImpactWindowTo
|
||||
};
|
||||
|
||||
var retryRun = new Run(
|
||||
newRunId,
|
||||
tenant.TenantId,
|
||||
RunTrigger.Manual,
|
||||
RunState.Planning,
|
||||
RunStats.Empty,
|
||||
now,
|
||||
newReason,
|
||||
existing.ScheduleId,
|
||||
retryOf: existing.Id);
|
||||
|
||||
await runRepository.InsertAsync(retryRun, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(retryRun.ScheduleId))
|
||||
{
|
||||
await runSummaryService.ProjectAsync(retryRun, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await auditService.WriteAsync(
|
||||
new SchedulerAuditEvent(
|
||||
tenant.TenantId,
|
||||
"scheduler.run",
|
||||
"retry",
|
||||
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
|
||||
RunId: retryRun.Id,
|
||||
ScheduleId: retryRun.ScheduleId,
|
||||
Metadata: BuildMetadata(
|
||||
("state", retryRun.State.ToString().ToLowerInvariant()),
|
||||
("retryOf", existing.Id),
|
||||
("trigger", retryRun.Trigger.ToString().ToLowerInvariant()))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/scheduler/runs/{retryRun.Id}", new RunResponse(retryRun));
|
||||
}
|
||||
catch (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 (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StreamRunAsync(
|
||||
HttpContext httpContext,
|
||||
string runId,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IRunRepository runRepository,
|
||||
[FromServices] IRunStreamCoordinator runStreamCoordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var run = await runRepository.GetAsync(tenant.TenantId, runId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
await Results.NotFound().ExecuteAsync(httpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
await runStreamCoordinator.StreamAsync(httpContext, tenant.TenantId, run, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Client disconnected; nothing to do.
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
if (!httpContext.Response.HasStarted)
|
||||
{
|
||||
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized).ExecuteAsync(httpContext);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (!httpContext.Response.HasStarted)
|
||||
{
|
||||
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden).ExecuteAsync(httpContext);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
if (!httpContext.Response.HasStarted)
|
||||
{
|
||||
await Results.BadRequest(new { error = ex.Message }).ExecuteAsync(httpContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewImpactAsync(
|
||||
HttpContext httpContext,
|
||||
ImpactPreviewRequest request,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IScheduleRepository scheduleRepository,
|
||||
[FromServices] IImpactIndex impactIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, PreviewScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
var selector = await ResolveSelectorAsync(request, tenant.TenantId, scheduleRepository, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var normalizedProductKeys = NormalizeStringInputs(request.ProductKeys);
|
||||
var normalizedVulnerabilityIds = NormalizeStringInputs(request.VulnerabilityIds);
|
||||
|
||||
ImpactSet impactSet;
|
||||
if (!normalizedProductKeys.IsDefaultOrEmpty)
|
||||
{
|
||||
impactSet = await impactIndex.ResolveByPurlsAsync(normalizedProductKeys, request.UsageOnly, selector, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (!normalizedVulnerabilityIds.IsDefaultOrEmpty)
|
||||
{
|
||||
impactSet = await impactIndex.ResolveByVulnerabilitiesAsync(normalizedVulnerabilityIds, request.UsageOnly, selector, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
impactSet = await impactIndex.ResolveAllAsync(selector, request.UsageOnly, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var sampleSize = Math.Clamp(request.SampleSize, 1, 50);
|
||||
var sampleBuilder = ImmutableArray.CreateBuilder<ImpactPreviewSample>();
|
||||
foreach (var image in impactSet.Images.Take(sampleSize))
|
||||
{
|
||||
sampleBuilder.Add(new ImpactPreviewSample(
|
||||
image.ImageDigest,
|
||||
image.Registry,
|
||||
image.Repository,
|
||||
image.Namespaces.IsDefault ? ImmutableArray<string>.Empty : image.Namespaces,
|
||||
image.Tags.IsDefault ? ImmutableArray<string>.Empty : image.Tags,
|
||||
image.UsedByEntrypoint));
|
||||
}
|
||||
|
||||
var response = new ImpactPreviewResponse(
|
||||
impactSet.Total,
|
||||
impactSet.UsageOnly,
|
||||
impactSet.GeneratedAt,
|
||||
impactSet.SnapshotId,
|
||||
sampleBuilder.ToImmutable());
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (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 (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,226 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal interface IRunStreamCoordinator
|
||||
{
|
||||
Task StreamAsync(HttpContext context, string tenantId, Run initialRun, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RunStreamCoordinator : IRunStreamCoordinator
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IRunRepository _runRepository;
|
||||
private readonly IQueueLagSummaryProvider _queueLagProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RunStreamCoordinator> _logger;
|
||||
private readonly RunStreamOptions _options;
|
||||
|
||||
public RunStreamCoordinator(
|
||||
IRunRepository runRepository,
|
||||
IQueueLagSummaryProvider queueLagProvider,
|
||||
IOptions<RunStreamOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<RunStreamCoordinator> logger)
|
||||
{
|
||||
_runRepository = runRepository ?? throw new ArgumentNullException(nameof(runRepository));
|
||||
_queueLagProvider = queueLagProvider ?? throw new ArgumentNullException(nameof(queueLagProvider));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Validate();
|
||||
}
|
||||
|
||||
public async Task StreamAsync(HttpContext context, string tenantId, Run initialRun, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(initialRun);
|
||||
|
||||
var response = context.Response;
|
||||
ConfigureSseHeaders(response);
|
||||
await SseWriter.WriteRetryAsync(response, _options.ReconnectDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lastRun = initialRun;
|
||||
await SseWriter.WriteEventAsync(response, "initial", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
await SseWriter.WriteEventAsync(response, "queueLag", _queueLagProvider.Capture(), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
await SseWriter.WriteEventAsync(response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (RunStateMachine.IsTerminal(lastRun.State))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "completed", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var pollTimer = new PeriodicTimer(_options.PollInterval);
|
||||
using var queueTimer = new PeriodicTimer(_options.QueueLagInterval);
|
||||
using var heartbeatTimer = new PeriodicTimer(_options.HeartbeatInterval);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var pollTask = pollTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var queueTask = queueTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
var heartbeatTask = heartbeatTimer.WaitForNextTickAsync(cancellationToken).AsTask();
|
||||
|
||||
var completed = await Task.WhenAny(pollTask, queueTask, heartbeatTask).ConfigureAwait(false);
|
||||
|
||||
if (completed == pollTask && await pollTask.ConfigureAwait(false))
|
||||
{
|
||||
var current = await _runRepository.GetAsync(tenantId, lastRun.Id, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
_logger.LogWarning("Run {RunId} disappeared while streaming; signalling notFound event.", lastRun.Id);
|
||||
await SseWriter.WriteEventAsync(response, "notFound", new RunNotFoundPayload(lastRun.Id), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
await EmitRunDifferencesAsync(response, lastRun, current, cancellationToken).ConfigureAwait(false);
|
||||
lastRun = current;
|
||||
|
||||
if (RunStateMachine.IsTerminal(lastRun.State))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "completed", RunSnapshotPayload.From(lastRun), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (completed == queueTask && await queueTask.ConfigureAwait(false))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "queueLag", _queueLagProvider.Capture(), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (completed == heartbeatTask && await heartbeatTask.ConfigureAwait(false))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "heartbeat", HeartbeatPayload.Create(_timeProvider.GetUtcNow()), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Run stream cancelled for run {RunId}.", lastRun.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureSseHeaders(HttpResponse response)
|
||||
{
|
||||
response.StatusCode = StatusCodes.Status200OK;
|
||||
response.Headers.CacheControl = "no-store";
|
||||
response.Headers["X-Accel-Buffering"] = "no";
|
||||
response.Headers["Connection"] = "keep-alive";
|
||||
response.ContentType = "text/event-stream";
|
||||
}
|
||||
|
||||
private async Task EmitRunDifferencesAsync(HttpResponse response, Run previous, Run current, CancellationToken cancellationToken)
|
||||
{
|
||||
var stateChanged = current.State != previous.State || current.StartedAt != previous.StartedAt || current.FinishedAt != previous.FinishedAt || !string.Equals(current.Error, previous.Error, StringComparison.Ordinal);
|
||||
if (stateChanged)
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "stateChanged", RunStateChangedPayload.From(current), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(current.Stats, previous.Stats) && current.Stats != previous.Stats)
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "segmentProgress", RunStatsPayload.From(current), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!current.Deltas.SequenceEqual(previous.Deltas))
|
||||
{
|
||||
await SseWriter.WriteEventAsync(response, "deltaSummary", new RunDeltaPayload(current.Id, current.Deltas), SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RunSnapshotPayload(
|
||||
[property: JsonPropertyName("run")] Run Run)
|
||||
{
|
||||
public static RunSnapshotPayload From(Run run)
|
||||
=> new(run);
|
||||
}
|
||||
|
||||
private sealed record RunStateChangedPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("state")] string State,
|
||||
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||
[property: JsonPropertyName("finishedAt")] DateTimeOffset? FinishedAt,
|
||||
[property: JsonPropertyName("error")] string? Error)
|
||||
{
|
||||
public static RunStateChangedPayload From(Run run)
|
||||
=> new(
|
||||
run.Id,
|
||||
run.State.ToString().ToLowerInvariant(),
|
||||
run.StartedAt,
|
||||
run.FinishedAt,
|
||||
run.Error);
|
||||
}
|
||||
|
||||
private sealed record RunStatsPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("stats")] RunStats Stats)
|
||||
{
|
||||
public static RunStatsPayload From(Run run)
|
||||
=> new(run.Id, run.Stats);
|
||||
}
|
||||
|
||||
private sealed record RunDeltaPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("deltas")] ImmutableArray<DeltaSummary> Deltas);
|
||||
|
||||
private sealed record HeartbeatPayload(
|
||||
[property: JsonPropertyName("ts")] DateTimeOffset Timestamp)
|
||||
{
|
||||
public static HeartbeatPayload Create(DateTimeOffset timestamp)
|
||||
=> new(timestamp);
|
||||
}
|
||||
|
||||
private sealed record RunNotFoundPayload(
|
||||
[property: JsonPropertyName("runId")] string RunId);
|
||||
}
|
||||
|
||||
internal sealed class RunStreamOptions
|
||||
{
|
||||
private static readonly TimeSpan MinimumInterval = TimeSpan.FromMilliseconds(100);
|
||||
private static readonly TimeSpan MinimumReconnectDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
public TimeSpan QueueLagInterval { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public TimeSpan ReconnectDelay { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
public RunStreamOptions Validate()
|
||||
{
|
||||
if (PollInterval < MinimumInterval)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(PollInterval), PollInterval, "Poll interval must be at least 100ms.");
|
||||
}
|
||||
|
||||
if (QueueLagInterval < MinimumInterval)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(QueueLagInterval), QueueLagInterval, "Queue lag interval must be at least 100ms.");
|
||||
}
|
||||
|
||||
if (HeartbeatInterval < MinimumInterval)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(HeartbeatInterval), HeartbeatInterval, "Heartbeat interval must be at least 100ms.");
|
||||
}
|
||||
|
||||
if (ReconnectDelay < MinimumReconnectDelay)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ReconnectDelay), ReconnectDelay, "Reconnect delay must be at least 500ms.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
internal static class SseWriter
|
||||
{
|
||||
public static async Task WriteRetryAsync(HttpResponse response, TimeSpan reconnectDelay, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
|
||||
var milliseconds = (int)Math.Clamp(reconnectDelay.TotalMilliseconds, 1, int.MaxValue);
|
||||
await response.WriteAsync($"retry: {milliseconds}\r\n\r\n", cancellationToken).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task WriteEventAsync(HttpResponse response, string eventName, object payload, JsonSerializerOptions serializerOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(response);
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
ArgumentNullException.ThrowIfNull(serializerOptions);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(eventName))
|
||||
{
|
||||
throw new ArgumentException("Event name must be provided.", nameof(eventName));
|
||||
}
|
||||
|
||||
await response.WriteAsync($"event: {eventName}\r\n", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, serializerOptions);
|
||||
using var reader = new StringReader(json);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
await response.WriteAsync($"data: {line}\r\n", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await response.WriteAsync("\r\n", cancellationToken).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
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, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
throw new ArgumentException("Prefix must be provided.", nameof(prefix));
|
||||
}
|
||||
|
||||
var guid = (guidProvider ?? SystemGuidProvider.Instance).NewGuid();
|
||||
return $"{prefix.Trim()}_{guid: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);
|
||||
}
|
||||
|
||||
public static string CreateRunCursor(Run run)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
var payload = $"{run.CreatedAt.ToUniversalTime():O}|{run.Id}";
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
|
||||
}
|
||||
|
||||
public static RunListCursor? TryParseRunCursor(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(trimmed);
|
||||
var decoded = Encoding.UTF8.GetString(bytes);
|
||||
var parts = decoded.Split('|', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
throw new ValidationException($"Cursor '{value}' is not valid.");
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp))
|
||||
{
|
||||
throw new ValidationException($"Cursor '{value}' is not valid.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
throw new ValidationException($"Cursor '{value}' is not valid.");
|
||||
}
|
||||
|
||||
return new RunListCursor(timestamp.ToUniversalTime(), parts[1]);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ValidationException($"Cursor '{value}' is not valid.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Schedules;
|
||||
|
||||
public interface ISchedulerAuditService
|
||||
{
|
||||
Task<AuditRecord> WriteAsync(SchedulerAuditEvent auditEvent, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record SchedulerAuditEvent(
|
||||
string TenantId,
|
||||
string Category,
|
||||
string Action,
|
||||
AuditActor Actor,
|
||||
DateTimeOffset? OccurredAt = null,
|
||||
string? AuditId = null,
|
||||
string? EntityId = null,
|
||||
string? ScheduleId = null,
|
||||
string? RunId = null,
|
||||
string? CorrelationId = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null,
|
||||
string? Message = null);
|
||||
@@ -0,0 +1,158 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Schedules;
|
||||
|
||||
internal sealed class InMemoryScheduleRepository : IScheduleRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Schedule> _schedules = new(StringComparer.Ordinal);
|
||||
|
||||
public Task UpsertAsync(
|
||||
Schedule schedule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_schedules[schedule.Id] = schedule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Schedule?> GetAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
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,
|
||||
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,
|
||||
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
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly StellaOps.Determinism.IGuidProvider _guidProvider;
|
||||
|
||||
public InMemorySchedulerAuditService(
|
||||
TimeProvider? timeProvider = null,
|
||||
StellaOps.Determinism.IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? StellaOps.Determinism.SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<AuditRecord> WriteAsync(SchedulerAuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var occurredAt = auditEvent.OccurredAt ?? _timeProvider.GetUtcNow();
|
||||
var record = new AuditRecord(
|
||||
auditEvent.AuditId ?? $"audit_{_guidProvider.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,35 @@
|
||||
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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,467 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static StellaOps.Localization.T;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
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")
|
||||
.RequireAuthorization(SchedulerPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListSchedulesAsync)
|
||||
.WithName("ListSchedules")
|
||||
.WithDescription(_t("scheduler.schedule.list_description"));
|
||||
group.MapGet("/{scheduleId}", GetScheduleAsync)
|
||||
.WithName("GetSchedule")
|
||||
.WithDescription(_t("scheduler.schedule.get_description"));
|
||||
group.MapPost("/", CreateScheduleAsync)
|
||||
.WithName("CreateSchedule")
|
||||
.WithDescription(_t("scheduler.schedule.create_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
group.MapPatch("/{scheduleId}", UpdateScheduleAsync)
|
||||
.WithName("UpdateSchedule")
|
||||
.WithDescription(_t("scheduler.schedule.update_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
group.MapPost("/{scheduleId}/pause", PauseScheduleAsync)
|
||||
.WithName("PauseSchedule")
|
||||
.WithDescription(_t("scheduler.schedule.pause_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
group.MapPost("/{scheduleId}/resume", ResumeScheduleAsync)
|
||||
.WithName("ResumeSchedule")
|
||||
.WithDescription(_t("scheduler.schedule.resume_description"))
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
|
||||
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 (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 (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 (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 (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 (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 (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", CultureInfo.InvariantCulture)
|
||||
}),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new ScheduleResponse(updated, null));
|
||||
}
|
||||
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 (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 (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 (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 (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 (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,19 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for the Scheduler service.
|
||||
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
|
||||
/// </summary>
|
||||
internal static class SchedulerPolicies
|
||||
{
|
||||
/// <summary>Policy for read-only access to Scheduler job state and history. Requires scheduler:read scope.</summary>
|
||||
public const string Read = "scheduler.read";
|
||||
|
||||
/// <summary>Policy for operating Scheduler jobs (pause, resume, trigger). Requires scheduler:operate scope.</summary>
|
||||
public const string Operate = "scheduler.operate";
|
||||
|
||||
/// <summary>Policy for administrative control over Scheduler configuration. Requires scheduler:admin scope.</summary>
|
||||
public const string Admin = "scheduler.admin";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scheduler.WebService.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker/StellaOps.Scheduler.Worker.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.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" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
# Completed Tasks
|
||||
|
||||
| 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/Excitor exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
|
||||
| SCHED-WEB-20-001 | DONE (2025-10-29) | Scheduler WebService Guild, Policy Guild | SCHED-WEB-16-101, POLICY-ENGINE-20-000 | Expose policy run scheduling APIs (`POST /policy/runs`, `GET /policy/runs`) with tenant scoping and RBAC enforcement for `policy:run`. | Endpoints documented; integration tests cover run creation/status; unauthorized access blocked. |
|
||||
| SCHED-WEB-21-001 | DONE (2025-10-26) | Scheduler WebService Guild, Cartographer Guild | SCHED-WEB-16-101, SCHED-MODELS-21-001 | Expose graph build/overlay job APIs (`POST /graphs/build`, `GET /graphs/jobs`) with `graph:write`/`graph:read` enforcement and tenant scoping. | APIs documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover submission/status; unauthorized requests blocked; scope checks now reference `StellaOpsScopes`. |
|
||||
| SCHED-WEB-21-002 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-001, CARTO-GRAPH-21-007 | Provide overlay lag metrics endpoint and webhook to notify Cartographer of job completions; include correlation IDs. | `POST /graphs/hooks/completed` + `GET /graphs/overlays/lag` documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover completion + metrics. |
|
||||
| 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. |
|
||||
9
src/JobEngine/StellaOps.Scheduler.WebService/TASKS.md
Normal file
9
src/JobEngine/StellaOps.Scheduler.WebService/TASKS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# StellaOps.Scheduler.WebService Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| QA-SCHED-VERIFY-002 | DONE | `scheduler-graph-job-dtos` verified (run-001 Tier 0/1/2 pass); scheduler verification batch completed with `QA-SCHED-VERIFY-003` terminalized as `not_implemented` (run-001 Tier 0 evidence). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scheduler/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "scheduler", "version": "1.0" },
|
||||
|
||||
"scheduler.schedule.list_description": "Lists all schedules for the tenant with optional filters for enabled and deleted state. Returns a collection of schedule records including cron expression, timezone, mode, selection, and last run summary. Requires scheduler.schedules.read scope.",
|
||||
"scheduler.schedule.get_description": "Returns the full schedule record for a specific schedule ID including cron expression, timezone, selection, and last run summary. Returns 404 if the schedule is not found. Requires scheduler.schedules.read scope.",
|
||||
"scheduler.schedule.create_description": "Creates a new release schedule with the specified cron expression, timezone, scope selection, and run mode. Returns 201 Created with the new schedule ID. Requires scheduler.schedules.write scope.",
|
||||
"scheduler.schedule.update_description": "Applies a partial update to an existing schedule, replacing only the provided fields. Returns 200 with the updated record, or 404 if the schedule is not found. Requires scheduler.schedules.write scope.",
|
||||
"scheduler.schedule.pause_description": "Disables an active schedule, preventing future runs from being enqueued. Idempotent: returns 200 if the schedule is already paused. Requires scheduler.schedules.write scope.",
|
||||
"scheduler.schedule.resume_description": "Re-enables a paused schedule, allowing future runs to be enqueued on the configured cron expression. Idempotent: returns 200 if the schedule is already active. Requires scheduler.schedules.write scope.",
|
||||
|
||||
"scheduler.run.list_description": "Lists scheduler runs for the tenant with optional filters by status, schedule ID, and time range. Returns a paginated result ordered by creation time. Requires scheduler.runs.read scope.",
|
||||
"scheduler.run.get_queue_lag_description": "Returns the current queue lag summary including the number of queued, running, and stuck runs per tenant. Used for SLO monitoring and alerting. Requires scheduler.runs.read scope.",
|
||||
"scheduler.run.get_deltas_description": "Returns the impact delta records for a specific run, showing which artifacts were added, removed, or changed relative to the previous run. Requires scheduler.runs.read scope.",
|
||||
"scheduler.run.stream_description": "Server-Sent Events stream of real-time run progress events for a specific run ID. Clients should use the Last-Event-ID header for reconnect. Requires scheduler.runs.read scope.",
|
||||
"scheduler.run.get_description": "Returns the full run record for a specific run ID including status, schedule reference, impact snapshot, and policy evaluation results. Requires scheduler.runs.read scope.",
|
||||
"scheduler.run.create_description": "Creates and enqueues a new scheduler run for the specified schedule ID. Returns 201 Created with the run ID and initial status. Requires scheduler.runs.write scope.",
|
||||
"scheduler.run.cancel_description": "Cancels a queued or running scheduler run. Returns 404 if the run is not found or 409 if the run is already in a terminal state. Requires scheduler.runs.manage scope.",
|
||||
"scheduler.run.retry_description": "Retries a failed scheduler run by creating a new run linked to the original failure. Returns 404 if the run is not found or 409 if the run is not in a failed state. Requires scheduler.runs.manage scope.",
|
||||
"scheduler.run.preview_description": "Computes a dry-run impact preview for the specified scope without persisting a run record. Returns the set of artifacts that would be evaluated and estimated policy gate results. Requires scheduler.runs.preview scope.",
|
||||
|
||||
"scheduler.failure_signature.best_match_description": "Returns the best-matching failure signature for the given scope type, scope ID, and optional toolchain hash. Used to predict the likely outcome and error category for a new run based on historical failure patterns. Requires scheduler.runs.read scope.",
|
||||
|
||||
"scheduler.error.run_already_terminal": "Run is already in a terminal state.",
|
||||
"scheduler.error.run_concurrent_update": "Run could not be updated because it changed concurrently.",
|
||||
"scheduler.error.run_no_schedule": "Run cannot be retried because it is not associated with a schedule.",
|
||||
"scheduler.error.run_not_terminal": "Run is not in a terminal state and cannot be retried.",
|
||||
"scheduler.error.schedule_not_found": "Associated schedule no longer exists.",
|
||||
"scheduler.error.failure_signature_storage_not_configured": "Failure signature storage is not configured."
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
|
||||
public interface IResolverJobService
|
||||
{
|
||||
Task<ResolverJobResponse> CreateAsync(string tenantId, ResolverJobRequest request, CancellationToken cancellationToken);
|
||||
Task<ResolverJobResponse?> GetAsync(string tenantId, string jobId, CancellationToken cancellationToken);
|
||||
ResolverBacklogMetricsResponse ComputeMetrics(string tenantId);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight in-memory resolver job service to satisfy API contract and rate-limit callers.
|
||||
/// Suitable for stub/air-gap scenarios; replace with PostgreSQL-backed implementation when ready.
|
||||
/// </summary>
|
||||
public sealed class InMemoryResolverJobService : IResolverJobService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ResolverJobResponse> _store = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, List<DateTimeOffset>> _tenantCreates = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private const int MaxJobsPerMinute = 60;
|
||||
|
||||
public InMemoryResolverJobService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ResolverJobResponse> CreateAsync(string tenantId, ResolverJobRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ValidateRequest(request);
|
||||
|
||||
EnforceRateLimit(tenantId);
|
||||
|
||||
var id = GenerateId(tenantId, request.ArtifactId, request.PolicyId);
|
||||
var created = _timeProvider.GetUtcNow();
|
||||
|
||||
var response = new ResolverJobResponse(
|
||||
id,
|
||||
request.ArtifactId.Trim(),
|
||||
request.PolicyId.Trim(),
|
||||
"queued",
|
||||
created,
|
||||
CompletedAt: null,
|
||||
request.CorrelationId,
|
||||
request.Metadata ?? new Dictionary<string, string>());
|
||||
|
||||
_store[id] = response;
|
||||
TrackCreate(tenantId, created);
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<ResolverJobResponse?> GetAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
|
||||
|
||||
_store.TryGetValue(jobId, out var response);
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public ResolverBacklogMetricsResponse ComputeMetrics(string tenantId)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var pending = new List<ResolverJobResponse>();
|
||||
var completed = new List<ResolverJobResponse>();
|
||||
|
||||
foreach (var job in _store.Values)
|
||||
{
|
||||
if (string.Equals(job.Status, "completed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
completed.Add(job);
|
||||
}
|
||||
else
|
||||
{
|
||||
pending.Add(job);
|
||||
}
|
||||
}
|
||||
|
||||
var lagEntries = completed
|
||||
.Where(j => j.CompletedAt is not null)
|
||||
.Select(j => new ResolverLagEntry(
|
||||
j.Id,
|
||||
j.CompletedAt!.Value,
|
||||
Math.Max((j.CompletedAt!.Value - j.CreatedAt).TotalSeconds, 0d),
|
||||
j.CorrelationId,
|
||||
j.ArtifactId,
|
||||
j.PolicyId))
|
||||
.OrderByDescending(e => e.CompletedAt)
|
||||
.ToList();
|
||||
|
||||
return new ResolverBacklogMetricsResponse(
|
||||
tenantId,
|
||||
Pending: pending.Count,
|
||||
Running: 0,
|
||||
Completed: completed.Count,
|
||||
Failed: 0,
|
||||
MinLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Min(e => (double?)e.LagSeconds),
|
||||
MaxLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Max(e => (double?)e.LagSeconds),
|
||||
AverageLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Average(e => e.LagSeconds),
|
||||
RecentCompleted: lagEntries.Take(5).ToList());
|
||||
}
|
||||
|
||||
private static void ValidateRequest(ResolverJobRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactId))
|
||||
{
|
||||
throw new ValidationException("artifactId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
throw new ValidationException("policyId is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateId(string tenantId, string artifactId, string policyId)
|
||||
{
|
||||
var raw = $"{tenantId}:{artifactId}:{policyId}:{Guid.NewGuid():N}";
|
||||
return "resolver-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void EnforceRateLimit(string tenantId)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cutoff = now.AddMinutes(-1);
|
||||
var list = _tenantCreates.GetOrAdd(tenantId, static _ => new List<DateTimeOffset>());
|
||||
lock (list)
|
||||
{
|
||||
list.RemoveAll(ts => ts < cutoff);
|
||||
if (list.Count >= MaxJobsPerMinute)
|
||||
{
|
||||
throw new InvalidOperationException("resolver job rate limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackCreate(string tenantId, DateTimeOffset timestamp)
|
||||
{
|
||||
var list = _tenantCreates.GetOrAdd(tenantId, static _ => new List<DateTimeOffset>());
|
||||
lock (list)
|
||||
{
|
||||
list.Add(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
|
||||
public interface IResolverBacklogNotifier
|
||||
{
|
||||
void NotifyIfBreached(ResolverBacklogMetricsResponse metrics);
|
||||
}
|
||||
|
||||
internal sealed class LoggingResolverBacklogNotifier : IResolverBacklogNotifier
|
||||
{
|
||||
private readonly ILogger<LoggingResolverBacklogNotifier> _logger;
|
||||
private readonly int _threshold;
|
||||
|
||||
public LoggingResolverBacklogNotifier(ILogger<LoggingResolverBacklogNotifier> logger, int threshold = 100)
|
||||
{
|
||||
_logger = logger;
|
||||
_threshold = threshold;
|
||||
}
|
||||
|
||||
public void NotifyIfBreached(ResolverBacklogMetricsResponse metrics)
|
||||
{
|
||||
if (metrics.Pending > _threshold)
|
||||
{
|
||||
_logger.LogWarning("resolver backlog threshold exceeded: {Pending} pending (threshold {Threshold})", metrics.Pending, _threshold);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
|
||||
internal interface IResolverBacklogService
|
||||
{
|
||||
ResolverBacklogSummary GetSummary();
|
||||
}
|
||||
|
||||
internal sealed class ResolverBacklogService : IResolverBacklogService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ResolverBacklogService(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public ResolverBacklogSummary GetSummary()
|
||||
{
|
||||
var samples = SchedulerQueueMetrics.CaptureDepthSamples();
|
||||
if (samples.Count == 0)
|
||||
{
|
||||
return new ResolverBacklogSummary(_timeProvider.GetUtcNow(), 0, 0, ImmutableArray<ResolverBacklogEntry>.Empty);
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
long max = 0;
|
||||
var builder = ImmutableArray.CreateBuilder<ResolverBacklogEntry>(samples.Count);
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
total += sample.Depth;
|
||||
if (sample.Depth > max)
|
||||
{
|
||||
max = sample.Depth;
|
||||
}
|
||||
builder.Add(new ResolverBacklogEntry(sample.Transport, sample.Queue, sample.Depth));
|
||||
}
|
||||
|
||||
return new ResolverBacklogSummary(_timeProvider.GetUtcNow(), total, max, builder.ToImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ResolverBacklogSummary(
|
||||
DateTimeOffset ObservedAt,
|
||||
long TotalDepth,
|
||||
long MaxDepth,
|
||||
IReadOnlyList<ResolverBacklogEntry> Queues);
|
||||
|
||||
public sealed record ResolverBacklogEntry(string Transport, string Queue, long Depth);
|
||||
@@ -0,0 +1,113 @@
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
|
||||
public static class ResolverJobEndpointExtensions
|
||||
{
|
||||
private const string ScopeWrite = StellaOpsScopes.EffectiveWrite;
|
||||
private const string ScopeRead = StellaOpsScopes.FindingsRead;
|
||||
|
||||
public static void MapResolverJobEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/api/v1/scheduler/vuln/resolver")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("/jobs", CreateJobAsync)
|
||||
.WithName("CreateResolverJob")
|
||||
.WithDescription("Enqueues a new vulnerability resolver job to fetch enriched vulnerability data for a given CVE or advisory set. Returns 201 Created with the job ID. Requires effective.write scope.");
|
||||
group.MapGet("/jobs/{jobId}", GetJobAsync)
|
||||
.WithName("GetResolverJob")
|
||||
.WithDescription("Returns the current status and result of a vulnerability resolver job by ID. Returns 404 if the job ID is not found. Requires findings.read scope.");
|
||||
group.MapGet("/metrics", GetLagMetricsAsync)
|
||||
.WithName("GetResolverLagMetrics")
|
||||
.WithDescription("Returns resolver job lag metrics including pending queue depth, processing rates, and backlog summary. Triggers a backlog breach notification if the depth exceeds the configured threshold. Requires findings.read scope.");
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CreateJobAsync(
|
||||
[FromBody] ResolverJobRequest request,
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IResolverJobService jobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, ScopeWrite);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var job = await jobService.CreateAsync(tenant.TenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/scheduler/vuln/resolver/jobs/{job.Id}", 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> GetJobAsync(
|
||||
string jobId,
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IResolverJobService jobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, ScopeRead);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var job = await jobService.GetAsync(tenant.TenantId, jobId, cancellationToken).ConfigureAwait(false);
|
||||
return job is null ? Results.NotFound() : Results.Ok(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);
|
||||
}
|
||||
}
|
||||
|
||||
internal static IResult GetLagMetricsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IResolverJobService jobService,
|
||||
[FromServices] IResolverBacklogService backlogService,
|
||||
[FromServices] IResolverBacklogNotifier backlogNotifier)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, ScopeRead);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var metrics = jobService.ComputeMetrics(tenant.TenantId);
|
||||
var backlog = backlogService.GetSummary();
|
||||
backlogNotifier.NotifyIfBreached(metrics with { Pending = (int)backlog.TotalDepth });
|
||||
return Results.Ok(new { jobs = metrics, backlog });
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
|
||||
public sealed record ResolverJobRequest(
|
||||
[property: Required]
|
||||
string ArtifactId,
|
||||
[property: Required]
|
||||
string PolicyId,
|
||||
string? CorrelationId = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null)
|
||||
{
|
||||
public ResolverJobRequest() : this(string.Empty, string.Empty, null, null) { }
|
||||
}
|
||||
|
||||
public sealed record ResolverJobResponse(
|
||||
string Id,
|
||||
string ArtifactId,
|
||||
string PolicyId,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? CorrelationId,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
public sealed record ResolverBacklogMetricsResponse(
|
||||
string TenantId,
|
||||
int Pending,
|
||||
int Running,
|
||||
int Completed,
|
||||
int Failed,
|
||||
double? MinLagSeconds,
|
||||
double? MaxLagSeconds,
|
||||
double? AverageLagSeconds,
|
||||
IReadOnlyList<ResolverLagEntry> RecentCompleted);
|
||||
|
||||
public sealed record ResolverLagEntry(
|
||||
string JobId,
|
||||
DateTimeOffset CompletedAt,
|
||||
double LagSeconds,
|
||||
string? CorrelationId,
|
||||
string? ArtifactId,
|
||||
string? PolicyId);
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
|
||||
public static class ResolverJobServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddResolverJobServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IResolverJobService, InMemoryResolverJobService>();
|
||||
services.AddSingleton<IResolverBacklogService, ResolverBacklogService>();
|
||||
services.AddSingleton<IResolverBacklogNotifier, LoggingResolverBacklogNotifier>();
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
# 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, cursor). | `scheduler.runs.read` |
|
||||
| `GET` | `/api/v1/scheduler/runs/{runId}` | Retrieve run details. | `scheduler.runs.read` |
|
||||
| `GET` | `/api/v1/scheduler/runs/{runId}/deltas` | Fetch deterministic delta metadata for the specified run. | `scheduler.runs.read` |
|
||||
| `GET` | `/api/v1/scheduler/runs/queue/lag` | Snapshot queue depth per transport/queue for console dashboards. | `scheduler.runs.read` |
|
||||
| `GET` | `/api/v1/scheduler/runs/{runId}/stream` | Server-sent events (SSE) stream for live progress, queue lag, and heartbeats. | `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.manage` |
|
||||
| `POST` | `/api/v1/scheduler/runs/{runId}/retry` | Clone a terminal run into a new manual retry, preserving provenance. | `scheduler.runs.manage` |
|
||||
| `POST` | `/api/v1/scheduler/runs/preview` | Resolve impacted images using the ImpactIndex without enqueuing work. | `scheduler.runs.preview` |
|
||||
| `GET` | `/api/v1/scheduler/policies/simulations` | List policy simulations for the current tenant (filters: policyId, status, since, limit). | `policy:simulate` |
|
||||
| `GET` | `/api/v1/scheduler/policies/simulations/{simulationId}` | Retrieve simulation status snapshot. | `policy:simulate` |
|
||||
| `GET` | `/api/v1/scheduler/policies/simulations/{simulationId}/stream` | SSE stream emitting simulation status, queue lag, and heartbeats. | `policy:simulate` |
|
||||
| `POST` | `/api/v1/scheduler/policies/simulations` | Enqueue a policy simulation (mode=`simulate`) with optional SBOM inputs and metadata. | `policy:simulate` |
|
||||
| `POST` | `/api/v1/scheduler/policies/simulations/{simulationId}/cancel` | Request cancellation for an in-flight simulation. | `policy:simulate` |
|
||||
| `POST` | `/api/v1/scheduler/policies/simulations/{simulationId}/retry` | Clone a terminal simulation into a new run preserving inputs/metadata. | `policy:simulate` |
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When additional pages are available the response includes `"nextCursor": "<base64>"`. Clients pass this cursor via `?cursor=` to fetch the next deterministic slice (ordering = `createdAt desc, id desc`).
|
||||
|
||||
## 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.
|
||||
|
||||
## Retry Run
|
||||
|
||||
`POST /api/v1/scheduler/runs/{runId}/retry` clones a terminal run into a new manual run with `retryOf` pointing to the original identifier. Retry is scope-gated with `scheduler.runs.manage`; the new run’s `reason.manualReason` gains a `retry-of:<runId>` suffix for provenance.
|
||||
|
||||
## Run deltas
|
||||
|
||||
`GET /api/v1/scheduler/runs/{runId}/deltas` returns an immutable, deterministically sorted array of delta summaries (`[imageDigest, severity slices, KEV hits, attestations]`).
|
||||
|
||||
## Queue lag snapshot
|
||||
|
||||
`GET /api/v1/scheduler/runs/queue/lag` exposes queue depth summaries for planner/runner transports. The payload includes `capturedAt`, `totalDepth`, `maxDepth`, and ordered queue entries (transport + queue + depth). Console uses this for backlog dashboards and alert thresholds.
|
||||
|
||||
## Live stream (SSE)
|
||||
|
||||
`GET /api/v1/scheduler/runs/{runId}/stream` emits server-sent events for:
|
||||
|
||||
- `initial` — full run snapshot
|
||||
- `stateChanged` — state/started/finished transitions
|
||||
- `segmentProgress` — stats updates
|
||||
- `deltaSummary` — deltas available
|
||||
- `queueLag` — periodic queue snapshots
|
||||
- `heartbeat` — uptime keep-alive (default 5s)
|
||||
- `completed` — terminal summary
|
||||
|
||||
The stream is tolerant to clients reconnecting (idempotent payloads, deterministic ordering) and honours tenant scope plus cancellation tokens.
|
||||
|
||||
```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.
|
||||
|
||||
## Policy simulations
|
||||
|
||||
The policy simulation APIs mirror the run endpoints but operate on policy-mode jobs (`mode=simulate`) scoped by tenant and RBAC (`policy:simulate`).
|
||||
|
||||
### Create simulation
|
||||
|
||||
```http
|
||||
POST /api/v1/scheduler/policies/simulations
|
||||
X-Tenant-Id: tenant-alpha
|
||||
Authorization: Bearer <OpTok>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"policyId": "P-7",
|
||||
"policyVersion": 4,
|
||||
"priority": "normal",
|
||||
"metadata": {
|
||||
"source": "console.review"
|
||||
},
|
||||
"inputs": {
|
||||
"sbomSet": ["sbom:S-318", "sbom:S-42"],
|
||||
"captureExplain": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
HTTP/1.1 201 Created
|
||||
Location: /api/v1/scheduler/policies/simulations/run:P-7:20251103T153000Z:e4d1a9b2
|
||||
{
|
||||
"simulation": {
|
||||
"schemaVersion": "scheduler.policy-run-status@1",
|
||||
"runId": "run:P-7:20251103T153000Z:e4d1a9b2",
|
||||
"tenantId": "tenant-alpha",
|
||||
"policyId": "P-7",
|
||||
"policyVersion": 4,
|
||||
"mode": "simulate",
|
||||
"status": "queued",
|
||||
"priority": "normal",
|
||||
"queuedAt": "2025-11-03T15:30:00Z",
|
||||
"stats": {
|
||||
"components": 0,
|
||||
"rulesFired": 0,
|
||||
"findingsWritten": 0,
|
||||
"vexOverrides": 0
|
||||
},
|
||||
"inputs": {
|
||||
"sbomSet": ["sbom:S-318", "sbom:S-42"],
|
||||
"captureExplain": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Canonical payload lives in `samples/api/scheduler/policy-simulation-status.json`.
|
||||
|
||||
### List and fetch simulations
|
||||
|
||||
- `GET /api/v1/scheduler/policies/simulations?policyId=P-7&status=queued&limit=25`
|
||||
- `GET /api/v1/scheduler/policies/simulations/{simulationId}`
|
||||
|
||||
The response envelope mirrors `policy-run-status` but uses `simulations` / `simulation` wrappers. All metadata keys are lower-case; retries append `retry-of=<priorRunId>` for provenance.
|
||||
|
||||
### Cancel and retry
|
||||
|
||||
- `POST /api/v1/scheduler/policies/simulations/{simulationId}/cancel`
|
||||
- Marks the job as `cancellationRequested` and surfaces the reason. Worker execution honours this flag before leasing.
|
||||
- `POST /api/v1/scheduler/policies/simulations/{simulationId}/retry`
|
||||
- Clones a terminal simulation, preserving inputs/metadata and adding `metadata.retry-of` pointing to the original run ID. Returns `409 Conflict` when the simulation is not terminal.
|
||||
|
||||
### Live stream (SSE)
|
||||
|
||||
`GET /api/v1/scheduler/policies/simulations/{simulationId}/stream` emits:
|
||||
|
||||
- `retry` — reconnection hint (milliseconds) emitted before events.
|
||||
- `initial` — current simulation snapshot.
|
||||
- `status` — status/attempt/stat updates.
|
||||
- `queueLag` — periodic queue depth summary (shares payload with run streams).
|
||||
- `heartbeat` — keep-alive ping (default 5s; configurable under `Scheduler:RunStream`).
|
||||
- `completed` — terminal summary (`succeeded`, `failed`, or `cancelled`).
|
||||
- `notFound` — emitted if the run record disappears while streaming.
|
||||
|
||||
Heartbeats, queue lag summaries, and the reconnection directive are sent immediately after connection so Console clients receive deterministic telemetry when loading a simulation workspace.
|
||||
|
||||
### Metrics
|
||||
|
||||
```
|
||||
GET /api/v1/scheduler/policies/simulations/metrics
|
||||
X-Tenant-Id: tenant-alpha
|
||||
Authorization: Bearer <OpTok>
|
||||
```
|
||||
|
||||
Returns queue depth and latency summaries tailored for simulation dashboards and alerting. Response properties align with the metric names exposed via OTEL (`policy_simulation_queue_depth`, `policy_simulation_latency_seconds`). Canonical payload lives at `samples/api/scheduler/policy-simulation-metrics.json`.
|
||||
|
||||
- `policy_simulation_queue_depth.total` — pending simulation jobs (aggregate of `pending`, `dispatching`, `submitted`).
|
||||
- `policy_simulation_latency.*` — latency percentiles (seconds) computed from the most recent terminal simulations.
|
||||
|
||||
> **Note:** When PostgreSQL storage is not configured the metrics provider is disabled and the endpoint responds with `501 Not Implemented`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# SCHED-WEB-16-104 · Conselier/Excitor Webhook Endpoints
|
||||
|
||||
## Overview
|
||||
|
||||
Scheduler.WebService exposes inbound webhooks that allow Conselier and Excitor to
|
||||
notify the planner when new exports are available. Each webhook validates the
|
||||
payload, enforces signature requirements, and applies a per-endpoint rate
|
||||
limit before queuing downstream processing.
|
||||
|
||||
| Endpoint | Description | AuthZ |
|
||||
|----------|-------------|-------|
|
||||
| `POST /events/conselier-export` | Ingest Conselier export metadata (`exportId`, `changedProductKeys`, optional KEV & window). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
| `POST /events/excitor-export` | Ingest Excitor export delta summary (`changedClaims`). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
|
||||
## Security
|
||||
|
||||
* Webhooks require either:
|
||||
* mTLS with trusted client certificates; **or**
|
||||
* an HMAC-SHA256 signature in the `X-Scheduler-Signature` header. The
|
||||
signature must be computed as `sha256=<hex>` over the raw request body.
|
||||
* Requests without the required signature/certificate return `401`.
|
||||
* Secrets are configured under `Scheduler:Events:Webhooks:{Conselier|Excitor}:HmacSecret`.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
* Each webhook enforces a sliding-window limit (`RateLimitRequests` over
|
||||
`RateLimitWindowSeconds`).
|
||||
* Requests over the limit return `429` and include a `Retry-After` header.
|
||||
* Defaults: 120 requests / 60 seconds. Adjust via configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
```
|
||||
Scheduler:
|
||||
Events:
|
||||
Webhooks:
|
||||
Conselier:
|
||||
Enabled: true
|
||||
HmacSecret: conselier-secret
|
||||
RequireClientCertificate: false
|
||||
RateLimitRequests: 120
|
||||
RateLimitWindowSeconds: 60
|
||||
Excitor:
|
||||
Enabled: true
|
||||
HmacSecret: excitor-secret
|
||||
RequireClientCertificate: false
|
||||
```
|
||||
|
||||
## Response envelope
|
||||
|
||||
On success the webhook returns `202 Accepted` and a JSON body:
|
||||
|
||||
```
|
||||
{ "status": "accepted" }
|
||||
```
|
||||
|
||||
Failures return problem JSON with `error` describing the violation.
|
||||
|
||||
@@ -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,136 @@
|
||||
# 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. Repeated notifications are idempotent: if the job already reached a terminal state, the response returns the stored snapshot without publishing another event. When a `resultUri` value changes, only the metadata is refreshed—events and webhooks are emitted once per successful status transition.
|
||||
|
||||
### `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
|
||||
|
||||
- Extend `GET /graphs/jobs` with pagination cursors shared with Cartographer/Console.
|
||||
@@ -0,0 +1,78 @@
|
||||
# SCHED-CONSOLE-27-002 · Policy Simulation Telemetry & Webhooks
|
||||
|
||||
> Owners: Scheduler WebService Guild, Observability Guild
|
||||
> Scope: Policy simulation metrics endpoint and completion webhooks feeding Registry/Console integrations.
|
||||
|
||||
## 1. Metrics endpoint refresher
|
||||
|
||||
- `GET /api/v1/scheduler/policies/simulations/metrics` (scope: `policy:simulate`)
|
||||
- Returns queue depth grouped by status plus latency percentiles derived from the most recent sample window (default 200 terminal runs).
|
||||
- Surface area is unchanged from the implementation in Sprint 27 week 1; consumers should continue to rely on the contract in `samples/api/scheduler/policy-simulation-metrics.json`.
|
||||
- When backing storage is not PostgreSQL the endpoint responds `501 Not Implemented`.
|
||||
|
||||
## 2. Completion webhooks
|
||||
|
||||
Scheduler Worker now emits policy simulation webhooks whenever a simulation reaches a terminal state (`succeeded`, `failed`, `cancelled`). Payloads are aligned with the SSE `completed` event shape and include idempotency headers so downstream systems can safely de-duplicate.
|
||||
|
||||
### 2.1 Configuration
|
||||
|
||||
```jsonc
|
||||
// scheduler-worker.appsettings.json
|
||||
{
|
||||
"Scheduler": {
|
||||
"Worker": {
|
||||
"Policy": {
|
||||
"Webhook": {
|
||||
"Enabled": true,
|
||||
"Endpoint": "https://registry.internal/hooks/policy-simulation",
|
||||
"ApiKeyHeader": "X-StellaOps-Webhook-Key",
|
||||
"ApiKey": "replace-me",
|
||||
"TimeoutSeconds": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `Enabled`: feature flag; disabled by default to preserve air-gap behaviour.
|
||||
- `Endpoint`: absolute HTTPS endpoint; requests use `POST`.
|
||||
- `ApiKeyHeader`/`ApiKey`: optional bearer for Registry verification.
|
||||
- `TimeoutSeconds`: per-request timeout (defaults to 10s).
|
||||
|
||||
### 2.2 Headers
|
||||
|
||||
| Header | Purpose |
|
||||
|------------------------|---------------------------------------|
|
||||
| `X-StellaOps-Tenant` | Tenant identifier for the simulation. |
|
||||
| `X-StellaOps-Run-Id` | Stable run id (use as idempotency key). |
|
||||
| `X-StellaOps-Webhook-Key` | Optional API key as configured. |
|
||||
|
||||
### 2.3 Payload
|
||||
|
||||
See `samples/api/scheduler/policy-simulation-webhook.json` for a canonical example.
|
||||
|
||||
```json
|
||||
{
|
||||
"tenantId": "tenant-alpha",
|
||||
"simulation": { /* PolicyRunStatus document */ },
|
||||
"result": "failed",
|
||||
"observedAt": "2025-11-03T20:05:12Z",
|
||||
"latencySeconds": 14.287,
|
||||
"reason": "policy engine timeout"
|
||||
}
|
||||
```
|
||||
|
||||
- `result`: `succeeded`, `failed`, `cancelled`, `running`, or `queued`. Terminal webhooks are emitted only for the first three.
|
||||
- `latencySeconds`: bounded to four decimal places; derived from `finishedAt - queuedAt` when timestamps exist, else falls back to observer timestamp.
|
||||
- `reason`: surfaced for failures (`error`) and cancellations (`cancellationReason`); omitted otherwise.
|
||||
|
||||
### 2.4 Delivery semantics
|
||||
|
||||
- Best effort with no retry from the worker — Registry should use `X-StellaOps-Run-Id` for idempotency.
|
||||
- Failures emit WARN logs (prefix `Policy run job {JobId}`).
|
||||
- Disabled configuration short-circuits without network calls (debug log only).
|
||||
|
||||
## 3. SSE compatibility
|
||||
|
||||
No changes were required on the streaming endpoint (`GET /api/v1/scheduler/policies/simulations/{id}/stream`); Console continues to receive `completed` events containing the same `PolicyRunStatus` payload that the webhook publishes.
|
||||
Reference in New Issue
Block a user