Restructure solution layout by module

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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