up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

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

@@ -1,27 +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());
}
}
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

@@ -1,31 +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}'.");
}
}
}
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

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

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

View File

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

View File

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

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

View File

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

@@ -1,63 +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();
}
}
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

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

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

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

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

View File

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

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

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

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

@@ -1,27 +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
}
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

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

@@ -1,91 +1,91 @@
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)
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);
}
@@ -367,150 +367,150 @@ internal sealed class GraphJobService : IGraphJobService
where TJob : class;
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);
}
}
{
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

@@ -1,8 +1,8 @@
namespace StellaOps.Scheduler.WebService.GraphJobs;
namespace StellaOps.Scheduler.WebService.GraphJobs;
public readonly record struct GraphJobUpdateResult<TJob>(bool Updated, TJob Job) where TJob : class
{
public static GraphJobUpdateResult<TJob> UpdatedResult(TJob job) => new(true, job);
public static GraphJobUpdateResult<TJob> NotUpdated(TJob job) => new(false, job);
}
{
public static GraphJobUpdateResult<TJob> UpdatedResult(TJob job) => new(true, job);
public static GraphJobUpdateResult<TJob> NotUpdated(TJob job) => new(false, job);
}

View File

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

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

View File

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

View File

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

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

View File

@@ -1,66 +1,66 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
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);
}
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<GraphJobUpdateResult<GraphBuildJob>> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken)
{
if (_buildJobs.TryGetValue(job.Id, out var existing) && string.Equals(existing.TenantId, job.TenantId, StringComparison.Ordinal))
@@ -92,13 +92,13 @@ internal sealed class InMemoryGraphJobStore : IGraphJobStore
throw new KeyNotFoundException($"Graph overlay job '{job.Id}' not found.");
}
public ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
{
var jobs = _overlayJobs.Values
.Where(job => string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
.ToArray();
return ValueTask.FromResult<IReadOnlyCollection<GraphOverlayJob>>(jobs);
}
}
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

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

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

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

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

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

View File

@@ -1,71 +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.");
}
}
}
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

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

@@ -1,70 +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.");
}
}
}
}
}
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

@@ -1,9 +1,9 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.PolicyRuns;
internal interface IPolicyRunService
{
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);

View File

@@ -1,28 +1,28 @@
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();
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;
{
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,
@@ -47,88 +47,88 @@ internal sealed class InMemoryPolicyRunService : IPolicyRunService
cancellationRequestedAt: null,
cancellationReason: null,
SchedulerSchemaVersions.PolicyRunStatus);
lock (_gate)
{
if (_runs.TryGetValue(runId, out var existing))
{
return Task.FromResult(existing);
}
_runs[runId] = status;
_orderedRuns.Add(status);
}
return Task.FromResult(status);
}
public Task<IReadOnlyList<PolicyRunStatus>> ListAsync(string tenantId, PolicyRunQueryOptions options, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
List<PolicyRunStatus> snapshot;
lock (_gate)
{
snapshot = _orderedRuns
.Where(run => string.Equals(run.TenantId, tenantId, StringComparison.Ordinal))
.ToList();
}
if (options.PolicyId is { Length: > 0 } policyId)
{
snapshot = snapshot
.Where(run => string.Equals(run.PolicyId, policyId, StringComparison.OrdinalIgnoreCase))
.ToList();
}
if (options.Mode is { } mode)
{
snapshot = snapshot
.Where(run => run.Mode == mode)
.ToList();
}
if (options.Status is { } status)
{
snapshot = snapshot
.Where(run => run.Status == status)
.ToList();
}
if (options.QueuedAfter is { } since)
{
snapshot = snapshot
.Where(run => run.QueuedAt >= since)
.ToList();
}
var result = snapshot
.OrderByDescending(run => run.QueuedAt)
.ThenBy(run => run.RunId, StringComparer.Ordinal)
.Take(options.Limit)
.ToList();
return Task.FromResult<IReadOnlyList<PolicyRunStatus>>(result);
}
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);
}
{
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);
}

View File

@@ -1,197 +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();
}
}
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

@@ -1,28 +1,28 @@
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; }
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 PolicyRunExecutionStatus? Status { get; private set; }
public DateTimeOffset? QueuedAfter { get; private set; }
public int Limit { get; private set; } = DefaultLimit;
public PolicyRunQueryOptions ForceMode(PolicyRunMode mode)
@@ -30,97 +30,97 @@ internal sealed class PolicyRunQueryOptions
Mode = mode;
return this;
}
public static PolicyRunQueryOptions FromRequest(HttpRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var options = new PolicyRunQueryOptions();
var query = request.Query;
if (query.TryGetValue("policyId", out var policyValues))
{
var policyId = policyValues.ToString().Trim();
if (!string.IsNullOrEmpty(policyId))
{
options.PolicyId = policyId;
}
}
options.Mode = ParseEnum<PolicyRunMode>(query, "mode");
options.Status = ParseEnum<PolicyRunExecutionStatus>(query, "status");
options.QueuedAfter = ParseTimestamp(query);
options.Limit = ParseLimit(query);
return options;
}
private static TEnum? ParseEnum<TEnum>(IQueryCollection query, string key)
where TEnum : struct, Enum
{
if (!query.TryGetValue(key, out var values) || values == StringValues.Empty)
{
return null;
}
var value = values.ToString().Trim();
if (string.IsNullOrEmpty(value))
{
return null;
}
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
throw new ValidationException($"Value '{value}' is not valid for parameter '{key}'.");
}
private static DateTimeOffset? ParseTimestamp(IQueryCollection query)
{
if (!query.TryGetValue("since", out var values) || values == StringValues.Empty)
{
return null;
}
var candidate = values.ToString().Trim();
if (string.IsNullOrEmpty(candidate))
{
return null;
}
if (DateTimeOffset.TryParse(candidate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp))
{
return timestamp.ToUniversalTime();
}
throw new ValidationException($"Value '{candidate}' is not a valid ISO-8601 timestamp.");
}
private static int ParseLimit(IQueryCollection query)
{
if (!query.TryGetValue("limit", out var values) || values == StringValues.Empty)
{
return DefaultLimit;
}
var candidate = values.ToString().Trim();
if (string.IsNullOrEmpty(candidate))
{
return DefaultLimit;
}
if (!int.TryParse(candidate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
{
throw new ValidationException("Parameter 'limit' must be a positive integer.");
}
if (parsed > MaxLimit)
{
throw new ValidationException($"Parameter 'limit' must not exceed {MaxLimit}.");
}
return parsed;
}
}
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

@@ -1,132 +1,132 @@
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 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.Postgres.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);
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 PolicyRunStatusFactory.Create(existing, now);
}
}
var jobId = SchedulerEndpointHelpers.GenerateIdentifier("policyjob");
var queuedAt = request.QueuedAt ?? now;
var metadata = request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
var job = new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: jobId,
TenantId: tenantId,
PolicyId: request.PolicyId,
PolicyVersion: request.PolicyVersion,
Mode: request.Mode,
Priority: request.Priority,
PriorityRank: -1,
RunId: runId,
RequestedBy: request.RequestedBy,
CorrelationId: request.CorrelationId,
Metadata: metadata,
Inputs: request.Inputs ?? PolicyRunInputs.Empty,
QueuedAt: queuedAt,
Status: PolicyRunJobStatus.Pending,
AttemptCount: 0,
LastAttemptAt: null,
LastError: null,
CreatedAt: now,
UpdatedAt: now,
AvailableAt: now,
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
await _repository.InsertAsync(job, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Enqueued policy run job {JobId} for tenant {TenantId} policy {PolicyId} (runId={RunId}, mode={Mode}).",
job.Id,
tenantId,
job.PolicyId,
job.RunId,
job.Mode);
}
}
var jobId = SchedulerEndpointHelpers.GenerateIdentifier("policyjob");
var queuedAt = request.QueuedAt ?? now;
var metadata = request.Metadata ?? ImmutableSortedDictionary<string, string>.Empty;
var job = new PolicyRunJob(
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
Id: jobId,
TenantId: tenantId,
PolicyId: request.PolicyId,
PolicyVersion: request.PolicyVersion,
Mode: request.Mode,
Priority: request.Priority,
PriorityRank: -1,
RunId: runId,
RequestedBy: request.RequestedBy,
CorrelationId: request.CorrelationId,
Metadata: metadata,
Inputs: request.Inputs ?? PolicyRunInputs.Empty,
QueuedAt: queuedAt,
Status: PolicyRunJobStatus.Pending,
AttemptCount: 0,
LastAttemptAt: null,
LastError: null,
CreatedAt: now,
UpdatedAt: now,
AvailableAt: now,
SubmittedAt: null,
CompletedAt: null,
LeaseOwner: null,
LeaseExpiresAt: null,
CancellationRequested: false,
CancellationRequestedAt: null,
CancellationReason: null,
CancelledAt: null);
await _repository.InsertAsync(job, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Enqueued policy run job {JobId} for tenant {TenantId} policy {PolicyId} (runId={RunId}, mode={Mode}).",
job.Id,
tenantId,
job.PolicyId,
job.RunId,
job.Mode);
return PolicyRunStatusFactory.Create(job, now);
}
public async Task<IReadOnlyList<PolicyRunStatus>> ListAsync(
string tenantId,
PolicyRunQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var statuses = options.Status is null
? null
: MapExecutionStatus(options.Status.Value);
var jobs = await _repository
.ListAsync(
tenantId,
options.PolicyId,
options.Mode,
statuses,
options.QueuedAfter,
options.Limit,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
}
public async Task<IReadOnlyList<PolicyRunStatus>> ListAsync(
string tenantId,
PolicyRunQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var statuses = options.Status is null
? null
: MapExecutionStatus(options.Status.Value);
var jobs = await _repository
.ListAsync(
tenantId,
options.PolicyId,
options.Mode,
statuses,
options.QueuedAfter,
options.Limit,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
return jobs
.Select(job => PolicyRunStatusFactory.Create(job, now))
.ToList();
}
}
public async Task<PolicyRunStatus?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
@@ -255,13 +255,13 @@ internal sealed class PolicyRunService : IPolicyRunService
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
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)

View File

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

View File

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

View File

@@ -1,37 +1,37 @@
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);
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,
[property: JsonPropertyName("nextCursor")] string? NextCursor = null);
internal sealed record RunResponse(
[property: JsonPropertyName("run")] Run Run);
internal sealed record ImpactPreviewRequest(
[property: JsonPropertyName("scheduleId")] string? ScheduleId,
[property: JsonPropertyName("selector")] Selector? Selector,
[property: JsonPropertyName("productKeys")] ImmutableArray<string>? ProductKeys,
[property: JsonPropertyName("vulnerabilityIds")] ImmutableArray<string>? VulnerabilityIds,
[property: JsonPropertyName("usageOnly")] bool UsageOnly = true,
[property: JsonPropertyName("sampleSize")] int SampleSize = 10);
internal sealed record ImpactPreviewResponse(
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("usageOnly")] bool UsageOnly,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("snapshotId")] string? SnapshotId,
[property: JsonPropertyName("sample")] ImmutableArray<ImpactPreviewSample> Sample);
internal sealed record 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,

View File

@@ -1,20 +1,20 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
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 Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Primitives;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.Runs;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.Runs;
internal static class RunEndpoints
{
private const string ReadScope = "scheduler.runs.read";
@@ -39,7 +39,7 @@ internal static class RunEndpoints
return routes;
}
private static IResult GetQueueLagAsync(
HttpContext httpContext,
[FromServices] ITenantContextAccessor tenantAccessor,
@@ -66,16 +66,16 @@ internal static class RunEndpoints
[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;
{
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);
@@ -105,13 +105,13 @@ internal static class RunEndpoints
}
return Results.Ok(new RunCollectionResponse(runs, nextCursor));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
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> GetRunAsync(
HttpContext httpContext,
string runId,
@@ -165,148 +165,148 @@ internal static class RunEndpoints
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
{
private static async Task<IResult> CreateRunAsync(
HttpContext httpContext,
RunCreateRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IScheduleRepository scheduleRepository,
[FromServices] IRunRepository runRepository,
[FromServices] IRunSummaryService runSummaryService,
[FromServices] ISchedulerAuditService auditService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ManageScope);
var tenant = tenantAccessor.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(request.ScheduleId))
{
throw new ValidationException("scheduleId must be provided when creating a run.");
}
var scheduleId = request.ScheduleId!.Trim();
if (scheduleId.Length == 0)
{
throw new ValidationException("scheduleId must contain a value.");
}
var schedule = await scheduleRepository.GetAsync(tenant.TenantId, scheduleId, cancellationToken: cancellationToken).ConfigureAwait(false);
if (schedule is null)
{
return Results.NotFound();
}
if (request.Trigger != RunTrigger.Manual)
{
throw new ValidationException("Only manual runs can be created via this endpoint.");
}
var now = timeProvider.GetUtcNow();
var runId = SchedulerEndpointHelpers.GenerateIdentifier("run");
var reason = request.Reason ?? RunReason.Empty;
var run = new Run(
runId,
tenant.TenantId,
request.Trigger,
RunState.Planning,
RunStats.Empty,
now,
reason,
schedule.Id);
await runRepository.InsertAsync(run, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(run.ScheduleId))
{
await runSummaryService.ProjectAsync(run, cancellationToken).ConfigureAwait(false);
}
await auditService.WriteAsync(
new SchedulerAuditEvent(
tenant.TenantId,
"scheduler.run",
"create",
SchedulerEndpointHelpers.ResolveAuditActor(httpContext),
RunId: run.Id,
ScheduleId: schedule.Id,
Metadata: BuildMetadata(
("state", run.State.ToString().ToLowerInvariant()),
("trigger", run.Trigger.ToString().ToLowerInvariant()),
("correlationId", request.CorrelationId?.Trim()))),
cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/scheduler/runs/{run.Id}", new RunResponse(run));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
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)
{
[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 });
}
}
@@ -446,174 +446,174 @@ internal static class RunEndpoints
}
}
}
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;
}
}
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

@@ -3,118 +3,118 @@ using System.Globalization;
using System.Text;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
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;
}
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));
}
}
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,

View File

@@ -1,151 +1,146 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using MongoDB.Driver;
using StellaOps.Scheduler.Models;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
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);
}
}
namespace StellaOps.Scheduler.WebService.Schedules;
internal sealed class InMemoryScheduleRepository : IScheduleRepository
{
private readonly ConcurrentDictionary<string, Schedule> _schedules = new(StringComparer.Ordinal);
public Task UpsertAsync(
Schedule schedule,
CancellationToken cancellationToken = default)
{
_schedules[schedule.Id] = schedule;
return Task.CompletedTask;
}
public Task<Schedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
return Task.FromResult<Schedule?>(schedule);
}
return Task.FromResult<Schedule?>(null);
}
public Task<IReadOnlyList<Schedule>> ListAsync(
string tenantId,
ScheduleQueryOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new ScheduleQueryOptions();
var query = _schedules.Values
.Where(schedule => string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal));
if (!options.IncludeDisabled)
{
query = query.Where(schedule => schedule.Enabled);
}
var result = query
.OrderBy(schedule => schedule.Name, StringComparer.Ordinal)
.Take(options.Limit ?? int.MaxValue)
.ToArray();
return Task.FromResult<IReadOnlyList<Schedule>>(result);
}
public Task<bool> SoftDeleteAsync(
string tenantId,
string scheduleId,
string deletedBy,
DateTimeOffset deletedAt,
CancellationToken cancellationToken = default)
{
if (_schedules.TryGetValue(scheduleId, out var schedule) &&
string.Equals(schedule.TenantId, tenantId, StringComparison.Ordinal))
{
_schedules.TryRemove(scheduleId, out _);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
internal sealed class InMemoryRunSummaryService : IRunSummaryService
{
private readonly ConcurrentDictionary<(string TenantId, string ScheduleId), RunSummaryProjection> _summaries = new();
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
{
var scheduleId = run.ScheduleId ?? string.Empty;
var updatedAt = run.FinishedAt ?? run.StartedAt ?? run.CreatedAt;
var counters = new RunSummaryCounters(
Total: 0,
Planning: 0,
Queued: 0,
Running: 0,
Completed: 0,
Error: 0,
Cancelled: 0,
TotalDeltas: 0,
TotalNewCriticals: 0,
TotalNewHigh: 0,
TotalNewMedium: 0,
TotalNewLow: 0);
var projection = new RunSummaryProjection(
run.TenantId,
scheduleId,
updatedAt,
null,
ImmutableArray<RunSummarySnapshot>.Empty,
counters);
_summaries[(run.TenantId, scheduleId)] = projection;
return Task.FromResult(projection);
}
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
{
_summaries.TryGetValue((tenantId, scheduleId), out var projection);
return Task.FromResult<RunSummaryProjection?>(projection);
}
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var projections = _summaries.Values
.Where(summary => string.Equals(summary.TenantId, tenantId, StringComparison.Ordinal))
.ToArray();
return Task.FromResult<IReadOnlyList<RunSummaryProjection>>(projections);
}
}
internal sealed class InMemorySchedulerAuditService : ISchedulerAuditService
{
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

@@ -1,34 +1,34 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
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);
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

@@ -1,396 +1,396 @@
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 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.Postgres.Repositories;
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);
}
}
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,48 +1,48 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
internal sealed class ScheduleModeConverter : HyphenatedEnumConverter<ScheduleMode>
{
protected override IReadOnlyDictionary<ScheduleMode, string> Map { get; } = new Dictionary<ScheduleMode, string>
{
[ScheduleMode.AnalysisOnly] = "analysis-only",
[ScheduleMode.ContentRefresh] = "content-refresh",
};
}
internal sealed class SelectorScopeConverter : HyphenatedEnumConverter<SelectorScope>
{
protected override IReadOnlyDictionary<SelectorScope, string> Map { get; } = new Dictionary<SelectorScope, string>
{
[SelectorScope.AllImages] = "all-images",
[SelectorScope.ByNamespace] = "by-namespace",
[SelectorScope.ByRepository] = "by-repo",
[SelectorScope.ByDigest] = "by-digest",
[SelectorScope.ByLabels] = "by-labels",
};
}
internal sealed class RunTriggerConverter : LowerCaseEnumConverter<RunTrigger>
{
}
internal sealed class RunStateConverter : LowerCaseEnumConverter<RunState>
{
}
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scheduler.Models;
internal sealed class ScheduleModeConverter : HyphenatedEnumConverter<ScheduleMode>
{
protected override IReadOnlyDictionary<ScheduleMode, string> Map { get; } = new Dictionary<ScheduleMode, string>
{
[ScheduleMode.AnalysisOnly] = "analysis-only",
[ScheduleMode.ContentRefresh] = "content-refresh",
};
}
internal sealed class SelectorScopeConverter : HyphenatedEnumConverter<SelectorScope>
{
protected override IReadOnlyDictionary<SelectorScope, string> Map { get; } = new Dictionary<SelectorScope, string>
{
[SelectorScope.AllImages] = "all-images",
[SelectorScope.ByNamespace] = "by-namespace",
[SelectorScope.ByRepository] = "by-repo",
[SelectorScope.ByDigest] = "by-digest",
[SelectorScope.ByLabels] = "by-labels",
};
}
internal sealed class RunTriggerConverter : LowerCaseEnumConverter<RunTrigger>
{
}
internal sealed class RunStateConverter : LowerCaseEnumConverter<RunState>
{
}
internal sealed class SeverityRankConverter : LowerCaseEnumConverter<SeverityRank>
{
protected override string ConvertToString(SeverityRank value)
=> value switch
{
SeverityRank.None => "none",
SeverityRank.Info => "info",
SeverityRank.Low => "low",
SeverityRank.Medium => "medium",
SeverityRank.High => "high",
SeverityRank.Critical => "critical",
SeverityRank.None => "none",
SeverityRank.Info => "info",
SeverityRank.Low => "low",
SeverityRank.Medium => "medium",
SeverityRank.High => "high",
SeverityRank.Critical => "critical",
SeverityRank.Unknown => "unknown",
_ => throw new ArgumentOutOfRangeException(nameof(value), value, null),
};
@@ -144,58 +144,58 @@ internal abstract class HyphenatedEnumConverter<TEnum> : JsonConverter<TEnum>
where TEnum : struct, Enum
{
private readonly Dictionary<string, TEnum> _reverse;
protected HyphenatedEnumConverter()
{
_reverse = Map.ToDictionary(static pair => pair.Value, static pair => pair.Key, StringComparer.OrdinalIgnoreCase);
}
protected abstract IReadOnlyDictionary<TEnum, string> Map { get; }
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && _reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
{
if (Map.TryGetValue(value, out var text))
{
writer.WriteStringValue(text);
return;
}
throw new JsonException($"Unable to serialize {typeof(TEnum).Name} value '{value}'.");
}
}
internal class LowerCaseEnumConverter<TEnum> : JsonConverter<TEnum>
where TEnum : struct, Enum
{
private static readonly Dictionary<string, TEnum> Reverse = Enum
.GetValues<TEnum>()
.ToDictionary(static value => value.ToString().ToLowerInvariant(), static value => value, StringComparer.OrdinalIgnoreCase);
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && Reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
=> writer.WriteStringValue(ConvertToString(value));
protected virtual string ConvertToString(TEnum value)
=> value.ToString().ToLowerInvariant();
}
protected HyphenatedEnumConverter()
{
_reverse = Map.ToDictionary(static pair => pair.Value, static pair => pair.Key, StringComparer.OrdinalIgnoreCase);
}
protected abstract IReadOnlyDictionary<TEnum, string> Map { get; }
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && _reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
{
if (Map.TryGetValue(value, out var text))
{
writer.WriteStringValue(text);
return;
}
throw new JsonException($"Unable to serialize {typeof(TEnum).Name} value '{value}'.");
}
}
internal class LowerCaseEnumConverter<TEnum> : JsonConverter<TEnum>
where TEnum : struct, Enum
{
private static readonly Dictionary<string, TEnum> Reverse = Enum
.GetValues<TEnum>()
.ToDictionary(static value => value.ToString().ToLowerInvariant(), static value => value, StringComparer.OrdinalIgnoreCase);
public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
if (value is not null && Reverse.TryGetValue(value, out var parsed))
{
return parsed;
}
throw new JsonException($"Value '{value}' is not a valid {typeof(TEnum).Name}.");
}
public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
=> writer.WriteStringValue(ConvertToString(value));
protected virtual string ConvertToString(TEnum value)
=> value.ToString().ToLowerInvariant();
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
// Temporary compatibility stub to allow transition away from MongoDB driver.
namespace MongoDB.Driver
{
public interface IClientSessionHandle { }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,58 +5,58 @@ using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Upgrades scheduler documents emitted by earlier schema revisions to the latest DTOs.
/// </summary>
public static class SchedulerSchemaMigration
{
private static readonly ImmutableHashSet<string> ScheduleProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy");
private static readonly ImmutableHashSet<string> RunProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas");
private static readonly ImmutableHashSet<string> ImpactSetProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"snapshotId");
/// <summary>
/// Upgrades scheduler documents emitted by earlier schema revisions to the latest DTOs.
/// </summary>
public static class SchedulerSchemaMigration
{
private static readonly ImmutableHashSet<string> ScheduleProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy");
private static readonly ImmutableHashSet<string> RunProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas");
private static readonly ImmutableHashSet<string> ImpactSetProperties = ImmutableHashSet.Create(
StringComparer.Ordinal,
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"snapshotId");
public static SchedulerSchemaMigrationResult<Schedule> UpgradeSchedule(JsonNode document, bool strict = false)
=> Upgrade(
document,
@@ -124,43 +124,43 @@ public static class SchedulerSchemaMigration
var value = deserialize(canonicalJson);
return new SchedulerSchemaMigrationResult<T>(
value,
fromVersion,
latestVersion,
warnings.ToImmutable());
}
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, Func<string?, string> ensureVersion)
{
if (node is not JsonObject obj)
{
throw new ArgumentException("Document must be a JSON object.", nameof(node));
}
if (obj.DeepClone() is not JsonObject clone)
{
throw new InvalidOperationException("Unable to clone scheduler document.");
}
string schemaVersion;
if (clone.TryGetPropertyValue("schemaVersion", out var value) &&
value is JsonValue jsonValue &&
jsonValue.TryGetValue(out string? rawVersion))
{
schemaVersion = ensureVersion(rawVersion);
}
else
{
schemaVersion = ensureVersion(null);
clone["schemaVersion"] = schemaVersion;
}
// Ensure schemaVersion is normalized in the clone.
clone["schemaVersion"] = schemaVersion;
return (clone, schemaVersion);
}
value,
fromVersion,
latestVersion,
warnings.ToImmutable());
}
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, Func<string?, string> ensureVersion)
{
if (node is not JsonObject obj)
{
throw new ArgumentException("Document must be a JSON object.", nameof(node));
}
if (obj.DeepClone() is not JsonObject clone)
{
throw new InvalidOperationException("Unable to clone scheduler document.");
}
string schemaVersion;
if (clone.TryGetPropertyValue("schemaVersion", out var value) &&
value is JsonValue jsonValue &&
jsonValue.TryGetValue(out string? rawVersion))
{
schemaVersion = ensureVersion(rawVersion);
}
else
{
schemaVersion = ensureVersion(null);
clone["schemaVersion"] = schemaVersion;
}
// Ensure schemaVersion is normalized in the clone.
clone["schemaVersion"] = schemaVersion;
return (clone, schemaVersion);
}
private static void RemoveUnknownMembers(
JsonObject json,
ImmutableHashSet<string> knownProperties,

View File

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

View File

@@ -1,8 +1,8 @@
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Canonical schema version identifiers for scheduler documents.
/// </summary>
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Canonical schema version identifiers for scheduler documents.
/// </summary>
public static class SchedulerSchemaVersions
{
public const string Schedule = "scheduler.schedule@1";
@@ -21,7 +21,7 @@ public static class SchedulerSchemaVersions
public static string EnsureSchedule(string? value)
=> Normalize(value, Schedule);
public static string EnsureRun(string? value)
=> Normalize(value, Run);

View File

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

View File

@@ -1,247 +1,247 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Lightweight validation helpers for scheduler DTO constructors.
/// </summary>
internal static partial class Validation
{
private const int MaxIdentifierLength = 256;
private const int MaxNameLength = 200;
public static string EnsureId(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > MaxIdentifierLength)
{
throw new ArgumentException($"Value exceeds {MaxIdentifierLength} characters.", paramName);
}
return normalized;
}
public static string EnsureName(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > MaxNameLength)
{
throw new ArgumentException($"Value exceeds {MaxNameLength} characters.", paramName);
}
return normalized;
}
public static string EnsureTenantId(string value, string paramName)
{
var normalized = EnsureId(value, paramName);
if (!TenantRegex().IsMatch(normalized))
{
throw new ArgumentException("Tenant id must be alphanumeric with '-', '_' separators.", paramName);
}
return normalized;
}
public static string EnsureCronExpression(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > 128 || normalized.Contains('\n', StringComparison.Ordinal) || normalized.Contains('\r', StringComparison.Ordinal))
{
throw new ArgumentException("Cron expression too long or contains invalid characters.", paramName);
}
if (!CronSegmentRegex().IsMatch(normalized))
{
throw new ArgumentException("Cron expression contains unsupported characters.", paramName);
}
return normalized;
}
public static string EnsureTimezone(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
try
{
_ = TimeZoneInfo.FindSystemTimeZoneById(normalized);
}
catch (TimeZoneNotFoundException ex)
{
throw new ArgumentException($"Timezone '{normalized}' is not recognized on this host.", paramName, ex);
}
catch (InvalidTimeZoneException ex)
{
throw new ArgumentException($"Timezone '{normalized}' is invalid.", paramName, ex);
}
return normalized;
}
public static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value)
? null
: value.Trim();
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values, string paramName, bool allowWildcards = false)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(value => allowWildcards ? value! : EnsureSimpleIdentifier(value!, paramName))
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
return result;
}
public static ImmutableArray<string> NormalizeTagPatterns(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(static value => value!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return result;
}
public static ImmutableArray<string> NormalizeDigests(IEnumerable<string>? values, string paramName)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(value => EnsureDigestFormat(value!, paramName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return result;
}
public static int? EnsurePositiveOrNull(int? value, string paramName)
{
if (value is null)
{
return null;
}
if (value <= 0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Value must be greater than zero.");
}
return value;
}
public static int EnsureNonNegative(int value, string paramName)
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Value must be zero or greater.");
}
return value;
}
public static ImmutableSortedDictionary<string, string> NormalizeMetadata(IEnumerable<KeyValuePair<string, string>>? metadata)
{
if (metadata is null)
{
return ImmutableSortedDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = TrimToNull(pair.Key);
var value = TrimToNull(pair.Value);
if (key is null || value is null)
{
continue;
}
var normalizedKey = key.ToLowerInvariant();
if (!builder.ContainsKey(normalizedKey))
{
builder[normalizedKey] = value;
}
}
return builder.ToImmutable();
}
public static string EnsureSimpleIdentifier(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (!SimpleIdentifierRegex().IsMatch(normalized))
{
throw new ArgumentException("Value must contain letters, digits, '-', '_', '.', or '/'.", paramName);
}
return normalized;
}
public static string EnsureDigestFormat(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName).ToLowerInvariant();
if (!normalized.StartsWith("sha256:", StringComparison.Ordinal) || normalized.Length <= 7)
{
throw new ArgumentException("Digest must start with 'sha256:' and contain a hex payload.", paramName);
}
if (!HexRegex().IsMatch(normalized.AsSpan(7)))
{
throw new ArgumentException("Digest must be hexadecimal.", paramName);
}
return normalized;
}
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
}
return value.Trim();
}
public static DateTimeOffset NormalizeTimestamp(DateTimeOffset value)
=> value.ToUniversalTime();
public static DateTimeOffset? NormalizeTimestamp(DateTimeOffset? value)
=> value?.ToUniversalTime();
[GeneratedRegex("^[A-Za-z0-9_-]+$")]
private static partial Regex TenantRegex();
[GeneratedRegex("^[A-Za-z0-9_./:@+\\-]+$")]
private static partial Regex SimpleIdentifierRegex();
[GeneratedRegex("^[A-Za-z0-9:*?/_.,\\- ]+$")]
private static partial Regex CronSegmentRegex();
[GeneratedRegex("^[a-f0-9]+$", RegexOptions.IgnoreCase)]
private static partial Regex HexRegex();
}
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Lightweight validation helpers for scheduler DTO constructors.
/// </summary>
internal static partial class Validation
{
private const int MaxIdentifierLength = 256;
private const int MaxNameLength = 200;
public static string EnsureId(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > MaxIdentifierLength)
{
throw new ArgumentException($"Value exceeds {MaxIdentifierLength} characters.", paramName);
}
return normalized;
}
public static string EnsureName(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > MaxNameLength)
{
throw new ArgumentException($"Value exceeds {MaxNameLength} characters.", paramName);
}
return normalized;
}
public static string EnsureTenantId(string value, string paramName)
{
var normalized = EnsureId(value, paramName);
if (!TenantRegex().IsMatch(normalized))
{
throw new ArgumentException("Tenant id must be alphanumeric with '-', '_' separators.", paramName);
}
return normalized;
}
public static string EnsureCronExpression(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (normalized.Length > 128 || normalized.Contains('\n', StringComparison.Ordinal) || normalized.Contains('\r', StringComparison.Ordinal))
{
throw new ArgumentException("Cron expression too long or contains invalid characters.", paramName);
}
if (!CronSegmentRegex().IsMatch(normalized))
{
throw new ArgumentException("Cron expression contains unsupported characters.", paramName);
}
return normalized;
}
public static string EnsureTimezone(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
try
{
_ = TimeZoneInfo.FindSystemTimeZoneById(normalized);
}
catch (TimeZoneNotFoundException ex)
{
throw new ArgumentException($"Timezone '{normalized}' is not recognized on this host.", paramName, ex);
}
catch (InvalidTimeZoneException ex)
{
throw new ArgumentException($"Timezone '{normalized}' is invalid.", paramName, ex);
}
return normalized;
}
public static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value)
? null
: value.Trim();
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values, string paramName, bool allowWildcards = false)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(value => allowWildcards ? value! : EnsureSimpleIdentifier(value!, paramName))
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
return result;
}
public static ImmutableArray<string> NormalizeTagPatterns(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(static value => value!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return result;
}
public static ImmutableArray<string> NormalizeDigests(IEnumerable<string>? values, string paramName)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
var result = values
.Select(static value => TrimToNull(value))
.Where(static value => value is not null)
.Select(value => EnsureDigestFormat(value!, paramName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return result;
}
public static int? EnsurePositiveOrNull(int? value, string paramName)
{
if (value is null)
{
return null;
}
if (value <= 0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Value must be greater than zero.");
}
return value;
}
public static int EnsureNonNegative(int value, string paramName)
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Value must be zero or greater.");
}
return value;
}
public static ImmutableSortedDictionary<string, string> NormalizeMetadata(IEnumerable<KeyValuePair<string, string>>? metadata)
{
if (metadata is null)
{
return ImmutableSortedDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = TrimToNull(pair.Key);
var value = TrimToNull(pair.Value);
if (key is null || value is null)
{
continue;
}
var normalizedKey = key.ToLowerInvariant();
if (!builder.ContainsKey(normalizedKey))
{
builder[normalizedKey] = value;
}
}
return builder.ToImmutable();
}
public static string EnsureSimpleIdentifier(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName);
if (!SimpleIdentifierRegex().IsMatch(normalized))
{
throw new ArgumentException("Value must contain letters, digits, '-', '_', '.', or '/'.", paramName);
}
return normalized;
}
public static string EnsureDigestFormat(string value, string paramName)
{
var normalized = EnsureNotNullOrWhiteSpace(value, paramName).ToLowerInvariant();
if (!normalized.StartsWith("sha256:", StringComparison.Ordinal) || normalized.Length <= 7)
{
throw new ArgumentException("Digest must start with 'sha256:' and contain a hex payload.", paramName);
}
if (!HexRegex().IsMatch(normalized.AsSpan(7)))
{
throw new ArgumentException("Digest must be hexadecimal.", paramName);
}
return normalized;
}
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
}
return value.Trim();
}
public static DateTimeOffset NormalizeTimestamp(DateTimeOffset value)
=> value.ToUniversalTime();
public static DateTimeOffset? NormalizeTimestamp(DateTimeOffset? value)
=> value?.ToUniversalTime();
[GeneratedRegex("^[A-Za-z0-9_-]+$")]
private static partial Regex TenantRegex();
[GeneratedRegex("^[A-Za-z0-9_./:@+\\-]+$")]
private static partial Regex SimpleIdentifierRegex();
[GeneratedRegex("^[A-Za-z0-9:*?/_.,\\- ]+$")]
private static partial Regex CronSegmentRegex();
[GeneratedRegex("^[a-f0-9]+$", RegexOptions.IgnoreCase)]
private static partial Regex HexRegex();
}

View File

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

View File

@@ -1,9 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.Queue;
internal interface ISchedulerQueueTransportDiagnostics
{
ValueTask PingAsync(CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.Queue;
internal interface ISchedulerQueueTransportDiagnostics
{
ValueTask PingAsync(CancellationToken cancellationToken);
}

View File

@@ -1,26 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Scheduler.Queue.Nats;
internal interface INatsSchedulerQueuePayload<TMessage>
{
string QueueName { get; }
string GetIdempotencyKey(TMessage message);
byte[] Serialize(TMessage message);
TMessage Deserialize(byte[] payload);
string GetRunId(TMessage message);
string GetTenantId(TMessage message);
string? GetScheduleId(TMessage message);
string? GetSegmentId(TMessage message);
string? GetCorrelationId(TMessage message);
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
}
using System.Collections.Generic;
namespace StellaOps.Scheduler.Queue.Nats;
internal interface INatsSchedulerQueuePayload<TMessage>
{
string QueueName { get; }
string GetIdempotencyKey(TMessage message);
byte[] Serialize(TMessage message);
TMessage Deserialize(byte[] payload);
string GetRunId(TMessage message);
string GetTenantId(TMessage message);
string? GetScheduleId(TMessage message);
string? GetSegmentId(TMessage message);
string? GetCorrelationId(TMessage message);
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
}

View File

@@ -1,66 +1,66 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NATS.Client.Core;
using NATS.Client.JetStream;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Nats;
internal sealed class NatsSchedulerPlannerQueue
: NatsSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public NatsSchedulerPlannerQueue(
SchedulerQueueOptions queueOptions,
SchedulerNatsQueueOptions natsOptions,
ILogger<NatsSchedulerPlannerQueue> logger,
TimeProvider timeProvider,
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
: base(
queueOptions,
natsOptions,
natsOptions.Planner,
PlannerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class PlannerPayload : INatsSchedulerQueuePayload<PlannerQueueMessage>
{
public static PlannerPayload Instance { get; } = new();
public string QueueName => "planner";
public string GetIdempotencyKey(PlannerQueueMessage message)
=> message.IdempotencyKey;
public byte[] Serialize(PlannerQueueMessage message)
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
public PlannerQueueMessage Deserialize(byte[] payload)
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(Encoding.UTF8.GetString(payload));
public string GetRunId(PlannerQueueMessage message)
=> message.Run.Id;
public string GetTenantId(PlannerQueueMessage message)
=> message.Run.TenantId;
public string? GetScheduleId(PlannerQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(PlannerQueueMessage message)
=> null;
public string? GetCorrelationId(PlannerQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
=> null;
}
}
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NATS.Client.Core;
using NATS.Client.JetStream;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Nats;
internal sealed class NatsSchedulerPlannerQueue
: NatsSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public NatsSchedulerPlannerQueue(
SchedulerQueueOptions queueOptions,
SchedulerNatsQueueOptions natsOptions,
ILogger<NatsSchedulerPlannerQueue> logger,
TimeProvider timeProvider,
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
: base(
queueOptions,
natsOptions,
natsOptions.Planner,
PlannerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class PlannerPayload : INatsSchedulerQueuePayload<PlannerQueueMessage>
{
public static PlannerPayload Instance { get; } = new();
public string QueueName => "planner";
public string GetIdempotencyKey(PlannerQueueMessage message)
=> message.IdempotencyKey;
public byte[] Serialize(PlannerQueueMessage message)
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
public PlannerQueueMessage Deserialize(byte[] payload)
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(Encoding.UTF8.GetString(payload));
public string GetRunId(PlannerQueueMessage message)
=> message.Run.Id;
public string GetTenantId(PlannerQueueMessage message)
=> message.Run.TenantId;
public string? GetScheduleId(PlannerQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(PlannerQueueMessage message)
=> null;
public string? GetCorrelationId(PlannerQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
=> null;
}
}

View File

@@ -1,101 +1,101 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Scheduler.Queue.Nats;
internal sealed class NatsSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
{
private readonly NatsSchedulerQueueBase<TMessage> _queue;
private int _completed;
internal NatsSchedulerQueueLease(
NatsSchedulerQueueBase<TMessage> queue,
NatsJSMsg<byte[]> message,
byte[] payload,
string idempotencyKey,
string runId,
string tenantId,
string? scheduleId,
string? segmentId,
string? correlationId,
IReadOnlyDictionary<string, string> attributes,
TMessage deserialized,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer)
{
_queue = queue;
MessageId = message.Metadata?.Sequence.ToString() ?? idempotencyKey;
RunId = runId;
TenantId = tenantId;
ScheduleId = scheduleId;
SegmentId = segmentId;
CorrelationId = correlationId;
Attributes = attributes;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
IdempotencyKey = idempotencyKey;
Message = deserialized;
_message = message;
Payload = payload;
}
private readonly NatsJSMsg<byte[]> _message;
internal NatsJSMsg<byte[]> RawMessage => _message;
internal byte[] Payload { get; }
public string MessageId { get; }
public string IdempotencyKey { get; }
public string RunId { get; }
public string TenantId { get; }
public string? ScheduleId { get; }
public string? SegmentId { get; }
public string? CorrelationId { get; }
public IReadOnlyDictionary<string, string> Attributes { get; }
public TMessage Message { get; }
public int Attempt { get; private set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
internal void IncrementAttempt()
=> Attempt++;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Scheduler.Queue.Nats;
internal sealed class NatsSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
{
private readonly NatsSchedulerQueueBase<TMessage> _queue;
private int _completed;
internal NatsSchedulerQueueLease(
NatsSchedulerQueueBase<TMessage> queue,
NatsJSMsg<byte[]> message,
byte[] payload,
string idempotencyKey,
string runId,
string tenantId,
string? scheduleId,
string? segmentId,
string? correlationId,
IReadOnlyDictionary<string, string> attributes,
TMessage deserialized,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer)
{
_queue = queue;
MessageId = message.Metadata?.Sequence.ToString() ?? idempotencyKey;
RunId = runId;
TenantId = tenantId;
ScheduleId = scheduleId;
SegmentId = segmentId;
CorrelationId = correlationId;
Attributes = attributes;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
IdempotencyKey = idempotencyKey;
Message = deserialized;
_message = message;
Payload = payload;
}
private readonly NatsJSMsg<byte[]> _message;
internal NatsJSMsg<byte[]> RawMessage => _message;
internal byte[] Payload { get; }
public string MessageId { get; }
public string IdempotencyKey { get; }
public string RunId { get; }
public string TenantId { get; }
public string? ScheduleId { get; }
public string? SegmentId { get; }
public string? CorrelationId { get; }
public IReadOnlyDictionary<string, string> Attributes { get; }
public TMessage Message { get; }
public int Attempt { get; private set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
internal void IncrementAttempt()
=> Attempt++;
}

View File

@@ -1,74 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NATS.Client.Core;
using NATS.Client.JetStream;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Nats;
internal sealed class NatsSchedulerRunnerQueue
: NatsSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public NatsSchedulerRunnerQueue(
SchedulerQueueOptions queueOptions,
SchedulerNatsQueueOptions natsOptions,
ILogger<NatsSchedulerRunnerQueue> logger,
TimeProvider timeProvider,
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
: base(
queueOptions,
natsOptions,
natsOptions.Runner,
RunnerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class RunnerPayload : INatsSchedulerQueuePayload<RunnerSegmentQueueMessage>
{
public static RunnerPayload Instance { get; } = new();
public string QueueName => "runner";
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
=> message.IdempotencyKey;
public byte[] Serialize(RunnerSegmentQueueMessage message)
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
public RunnerSegmentQueueMessage Deserialize(byte[] payload)
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(Encoding.UTF8.GetString(payload));
public string GetRunId(RunnerSegmentQueueMessage message)
=> message.RunId;
public string GetTenantId(RunnerSegmentQueueMessage message)
=> message.TenantId;
public string? GetScheduleId(RunnerSegmentQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(RunnerSegmentQueueMessage message)
=> message.SegmentId;
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
{
if (message.Attributes is null || message.Attributes.Count == 0)
{
return null;
}
return message.Attributes.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NATS.Client.Core;
using NATS.Client.JetStream;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Nats;
internal sealed class NatsSchedulerRunnerQueue
: NatsSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public NatsSchedulerRunnerQueue(
SchedulerQueueOptions queueOptions,
SchedulerNatsQueueOptions natsOptions,
ILogger<NatsSchedulerRunnerQueue> logger,
TimeProvider timeProvider,
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
: base(
queueOptions,
natsOptions,
natsOptions.Runner,
RunnerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class RunnerPayload : INatsSchedulerQueuePayload<RunnerSegmentQueueMessage>
{
public static RunnerPayload Instance { get; } = new();
public string QueueName => "runner";
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
=> message.IdempotencyKey;
public byte[] Serialize(RunnerSegmentQueueMessage message)
=> Encoding.UTF8.GetBytes(CanonicalJsonSerializer.Serialize(message));
public RunnerSegmentQueueMessage Deserialize(byte[] payload)
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(Encoding.UTF8.GetString(payload));
public string GetRunId(RunnerSegmentQueueMessage message)
=> message.RunId;
public string GetTenantId(RunnerSegmentQueueMessage message)
=> message.TenantId;
public string? GetScheduleId(RunnerSegmentQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(RunnerSegmentQueueMessage message)
=> message.SegmentId;
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
{
if (message.Attributes is null || message.Attributes.Count == 0)
{
return null;
}
return message.Attributes.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
}
}
}

View File

@@ -1,26 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Scheduler.Queue.Redis;
internal interface IRedisSchedulerQueuePayload<TMessage>
{
string QueueName { get; }
string GetIdempotencyKey(TMessage message);
string Serialize(TMessage message);
TMessage Deserialize(string payload);
string GetRunId(TMessage message);
string GetTenantId(TMessage message);
string? GetScheduleId(TMessage message);
string? GetSegmentId(TMessage message);
string? GetCorrelationId(TMessage message);
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
}
using System.Collections.Generic;
namespace StellaOps.Scheduler.Queue.Redis;
internal interface IRedisSchedulerQueuePayload<TMessage>
{
string QueueName { get; }
string GetIdempotencyKey(TMessage message);
string Serialize(TMessage message);
TMessage Deserialize(string payload);
string GetRunId(TMessage message);
string GetTenantId(TMessage message);
string? GetScheduleId(TMessage message);
string? GetSegmentId(TMessage message);
string? GetCorrelationId(TMessage message);
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
}

View File

@@ -1,64 +1,64 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerPlannerQueue
: RedisSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public RedisSchedulerPlannerQueue(
SchedulerQueueOptions queueOptions,
SchedulerRedisQueueOptions redisOptions,
ILogger<RedisSchedulerPlannerQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
: base(
queueOptions,
redisOptions,
redisOptions.Planner,
PlannerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class PlannerPayload : IRedisSchedulerQueuePayload<PlannerQueueMessage>
{
public static PlannerPayload Instance { get; } = new();
public string QueueName => "planner";
public string GetIdempotencyKey(PlannerQueueMessage message)
=> message.IdempotencyKey;
public string Serialize(PlannerQueueMessage message)
=> CanonicalJsonSerializer.Serialize(message);
public PlannerQueueMessage Deserialize(string payload)
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(payload);
public string GetRunId(PlannerQueueMessage message)
=> message.Run.Id;
public string GetTenantId(PlannerQueueMessage message)
=> message.Run.TenantId;
public string? GetScheduleId(PlannerQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(PlannerQueueMessage message)
=> null;
public string? GetCorrelationId(PlannerQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
=> null;
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerPlannerQueue
: RedisSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public RedisSchedulerPlannerQueue(
SchedulerQueueOptions queueOptions,
SchedulerRedisQueueOptions redisOptions,
ILogger<RedisSchedulerPlannerQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
: base(
queueOptions,
redisOptions,
redisOptions.Planner,
PlannerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class PlannerPayload : IRedisSchedulerQueuePayload<PlannerQueueMessage>
{
public static PlannerPayload Instance { get; } = new();
public string QueueName => "planner";
public string GetIdempotencyKey(PlannerQueueMessage message)
=> message.IdempotencyKey;
public string Serialize(PlannerQueueMessage message)
=> CanonicalJsonSerializer.Serialize(message);
public PlannerQueueMessage Deserialize(string payload)
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(payload);
public string GetRunId(PlannerQueueMessage message)
=> message.Run.Id;
public string GetTenantId(PlannerQueueMessage message)
=> message.Run.TenantId;
public string? GetScheduleId(PlannerQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(PlannerQueueMessage message)
=> null;
public string? GetCorrelationId(PlannerQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
=> null;
}
}

View File

@@ -1,91 +1,91 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
{
private readonly RedisSchedulerQueueBase<TMessage> _queue;
private int _completed;
internal RedisSchedulerQueueLease(
RedisSchedulerQueueBase<TMessage> queue,
string messageId,
string idempotencyKey,
string runId,
string tenantId,
string? scheduleId,
string? segmentId,
string? correlationId,
IReadOnlyDictionary<string, string> attributes,
TMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer)
{
_queue = queue;
MessageId = messageId;
IdempotencyKey = idempotencyKey;
RunId = runId;
TenantId = tenantId;
ScheduleId = scheduleId;
SegmentId = segmentId;
CorrelationId = correlationId;
Attributes = attributes;
Message = message;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
}
public string MessageId { get; }
public string IdempotencyKey { get; }
public string RunId { get; }
public string TenantId { get; }
public string? ScheduleId { get; }
public string? SegmentId { get; }
public string? CorrelationId { get; }
public IReadOnlyDictionary<string, string> Attributes { get; }
public TMessage Message { get; }
public int Attempt { get; private set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
internal void IncrementAttempt()
=> Attempt++;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
{
private readonly RedisSchedulerQueueBase<TMessage> _queue;
private int _completed;
internal RedisSchedulerQueueLease(
RedisSchedulerQueueBase<TMessage> queue,
string messageId,
string idempotencyKey,
string runId,
string tenantId,
string? scheduleId,
string? segmentId,
string? correlationId,
IReadOnlyDictionary<string, string> attributes,
TMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer)
{
_queue = queue;
MessageId = messageId;
IdempotencyKey = idempotencyKey;
RunId = runId;
TenantId = tenantId;
ScheduleId = scheduleId;
SegmentId = segmentId;
CorrelationId = correlationId;
Attributes = attributes;
Message = message;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
}
public string MessageId { get; }
public string IdempotencyKey { get; }
public string RunId { get; }
public string TenantId { get; }
public string? ScheduleId { get; }
public string? SegmentId { get; }
public string? CorrelationId { get; }
public IReadOnlyDictionary<string, string> Attributes { get; }
public TMessage Message { get; }
public int Attempt { get; private set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
internal void IncrementAttempt()
=> Attempt++;
}

View File

@@ -1,90 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerRunnerQueue
: RedisSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public RedisSchedulerRunnerQueue(
SchedulerQueueOptions queueOptions,
SchedulerRedisQueueOptions redisOptions,
ILogger<RedisSchedulerRunnerQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
: base(
queueOptions,
redisOptions,
redisOptions.Runner,
RunnerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class RunnerPayload : IRedisSchedulerQueuePayload<RunnerSegmentQueueMessage>
{
public static RunnerPayload Instance { get; } = new();
public string QueueName => "runner";
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
=> message.IdempotencyKey;
public string Serialize(RunnerSegmentQueueMessage message)
=> CanonicalJsonSerializer.Serialize(message);
public RunnerSegmentQueueMessage Deserialize(string payload)
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(payload);
public string GetRunId(RunnerSegmentQueueMessage message)
=> message.RunId;
public string GetTenantId(RunnerSegmentQueueMessage message)
=> message.TenantId;
public string? GetScheduleId(RunnerSegmentQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(RunnerSegmentQueueMessage message)
=> message.SegmentId;
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
{
if (message.Attributes.Count == 0 && message.ImageDigests.Count == 0)
{
return null;
}
// Ensure digests remain accessible without deserializing the entire payload.
var map = new Dictionary<string, string>(message.Attributes, StringComparer.Ordinal);
map["imageDigestCount"] = message.ImageDigests.Count.ToString();
// populate first few digests for quick inspection (bounded)
var take = Math.Min(message.ImageDigests.Count, 5);
for (var i = 0; i < take; i++)
{
map[$"digest{i}"] = message.ImageDigests[i];
}
if (message.RatePerSecond.HasValue)
{
map["ratePerSecond"] = message.RatePerSecond.Value.ToString();
}
map["usageOnly"] = message.UsageOnly ? "true" : "false";
return map;
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerRunnerQueue
: RedisSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public RedisSchedulerRunnerQueue(
SchedulerQueueOptions queueOptions,
SchedulerRedisQueueOptions redisOptions,
ILogger<RedisSchedulerRunnerQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
: base(
queueOptions,
redisOptions,
redisOptions.Runner,
RunnerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class RunnerPayload : IRedisSchedulerQueuePayload<RunnerSegmentQueueMessage>
{
public static RunnerPayload Instance { get; } = new();
public string QueueName => "runner";
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
=> message.IdempotencyKey;
public string Serialize(RunnerSegmentQueueMessage message)
=> CanonicalJsonSerializer.Serialize(message);
public RunnerSegmentQueueMessage Deserialize(string payload)
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(payload);
public string GetRunId(RunnerSegmentQueueMessage message)
=> message.RunId;
public string GetTenantId(RunnerSegmentQueueMessage message)
=> message.TenantId;
public string? GetScheduleId(RunnerSegmentQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(RunnerSegmentQueueMessage message)
=> message.SegmentId;
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
{
if (message.Attributes.Count == 0 && message.ImageDigests.Count == 0)
{
return null;
}
// Ensure digests remain accessible without deserializing the entire payload.
var map = new Dictionary<string, string>(message.Attributes, StringComparer.Ordinal);
map["imageDigestCount"] = message.ImageDigests.Count.ToString();
// populate first few digests for quick inspection (bounded)
var take = Math.Min(message.ImageDigests.Count, 5);
for (var i = 0; i < take; i++)
{
map[$"digest{i}"] = message.ImageDigests[i];
}
if (message.RatePerSecond.HasValue)
{
map["ratePerSecond"] = message.RatePerSecond.Value.ToString();
}
map["usageOnly"] = message.UsageOnly ? "true" : "false";
return map;
}
}
}

View File

@@ -1,66 +1,66 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue;
public sealed class PlannerQueueMessage
{
[JsonConstructor]
public PlannerQueueMessage(
Run run,
ImpactSet impactSet,
Schedule? schedule = null,
string? correlationId = null)
{
Run = run ?? throw new ArgumentNullException(nameof(run));
ImpactSet = impactSet ?? throw new ArgumentNullException(nameof(impactSet));
if (schedule is not null && string.IsNullOrWhiteSpace(schedule.Id))
{
throw new ArgumentException("Schedule must have a valid identifier.", nameof(schedule));
}
if (!string.IsNullOrWhiteSpace(correlationId))
{
correlationId = correlationId!.Trim();
}
Schedule = schedule;
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
}
public Run Run { get; }
public ImpactSet ImpactSet { get; }
public Schedule? Schedule { get; }
public string? CorrelationId { get; }
public string IdempotencyKey => Run.Id;
public string TenantId => Run.TenantId;
public string? ScheduleId => Run.ScheduleId;
}
namespace StellaOps.Scheduler.Queue;
public sealed class PlannerQueueMessage
{
[JsonConstructor]
public PlannerQueueMessage(
Run run,
ImpactSet impactSet,
Schedule? schedule = null,
string? correlationId = null)
{
Run = run ?? throw new ArgumentNullException(nameof(run));
ImpactSet = impactSet ?? throw new ArgumentNullException(nameof(impactSet));
if (schedule is not null && string.IsNullOrWhiteSpace(schedule.Id))
{
throw new ArgumentException("Schedule must have a valid identifier.", nameof(schedule));
}
if (!string.IsNullOrWhiteSpace(correlationId))
{
correlationId = correlationId!.Trim();
}
Schedule = schedule;
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
}
public Run Run { get; }
public ImpactSet ImpactSet { get; }
public Schedule? Schedule { get; }
public string? CorrelationId { get; }
public string IdempotencyKey => Run.Id;
public string TenantId => Run.TenantId;
public string? ScheduleId => Run.ScheduleId;
}
public sealed class RunnerSegmentQueueMessage
{
private readonly ReadOnlyCollection<string> _imageDigests;
private readonly IReadOnlyDictionary<string, string> _attributes;
private readonly IReadOnlyDictionary<string, SurfaceManifestPointer> _surfaceManifests;
[JsonConstructor]
public RunnerSegmentQueueMessage(
string segmentId,
string runId,
string tenantId,
IReadOnlyList<string> imageDigests,
[JsonConstructor]
public RunnerSegmentQueueMessage(
string segmentId,
string runId,
string tenantId,
IReadOnlyList<string> imageDigests,
string? scheduleId = null,
int? ratePerSecond = null,
bool usageOnly = true,
@@ -68,26 +68,26 @@ public sealed class RunnerSegmentQueueMessage
string? correlationId = null,
IReadOnlyDictionary<string, SurfaceManifestPointer>? surfaceManifests = null)
{
if (string.IsNullOrWhiteSpace(segmentId))
{
throw new ArgumentException("Segment identifier must be provided.", nameof(segmentId));
}
if (string.IsNullOrWhiteSpace(runId))
{
throw new ArgumentException("Run identifier must be provided.", nameof(runId));
}
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant identifier must be provided.", nameof(tenantId));
}
SegmentId = segmentId;
RunId = runId;
TenantId = tenantId;
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId;
RatePerSecond = ratePerSecond;
if (string.IsNullOrWhiteSpace(segmentId))
{
throw new ArgumentException("Segment identifier must be provided.", nameof(segmentId));
}
if (string.IsNullOrWhiteSpace(runId))
{
throw new ArgumentException("Run identifier must be provided.", nameof(runId));
}
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant identifier must be provided.", nameof(tenantId));
}
SegmentId = segmentId;
RunId = runId;
TenantId = tenantId;
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId;
RatePerSecond = ratePerSecond;
UsageOnly = usageOnly;
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
@@ -99,121 +99,121 @@ public sealed class RunnerSegmentQueueMessage
? EmptyReadOnlyDictionary<string, SurfaceManifestPointer>.Instance
: new ReadOnlyDictionary<string, SurfaceManifestPointer>(new Dictionary<string, SurfaceManifestPointer>(surfaceManifests, StringComparer.Ordinal));
}
public string SegmentId { get; }
public string RunId { get; }
public string TenantId { get; }
public string? ScheduleId { get; }
public int? RatePerSecond { get; }
public bool UsageOnly { get; }
public string? CorrelationId { get; }
public IReadOnlyList<string> ImageDigests => _imageDigests;
public string SegmentId { get; }
public string RunId { get; }
public string TenantId { get; }
public string? ScheduleId { get; }
public int? RatePerSecond { get; }
public bool UsageOnly { get; }
public string? CorrelationId { get; }
public IReadOnlyList<string> ImageDigests => _imageDigests;
public IReadOnlyDictionary<string, string> Attributes => _attributes;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IReadOnlyDictionary<string, SurfaceManifestPointer> SurfaceManifests => _surfaceManifests;
public string IdempotencyKey => SegmentId;
private static List<string> NormalizeDigests(IReadOnlyList<string> digests)
{
if (digests is null)
{
throw new ArgumentNullException(nameof(digests));
}
var list = new List<string>();
foreach (var digest in digests)
{
if (string.IsNullOrWhiteSpace(digest))
{
continue;
}
list.Add(digest.Trim());
}
if (list.Count == 0)
{
throw new ArgumentException("At least one image digest must be provided.", nameof(digests));
}
return list;
}
private sealed class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}
public string IdempotencyKey => SegmentId;
private static List<string> NormalizeDigests(IReadOnlyList<string> digests)
{
if (digests is null)
{
throw new ArgumentNullException(nameof(digests));
}
var list = new List<string>();
foreach (var digest in digests)
{
if (string.IsNullOrWhiteSpace(digest))
{
continue;
}
list.Add(digest.Trim());
}
if (list.Count == 0)
{
throw new ArgumentException("At least one image digest must be provided.", nameof(digests));
}
return list;
}
private sealed class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}
}
public readonly record struct SchedulerQueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class SchedulerQueueLeaseRequest
{
public SchedulerQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer identifier must be provided.", nameof(consumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (leaseDuration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
}
Consumer = consumer;
BatchSize = batchSize;
LeaseDuration = leaseDuration;
}
public string Consumer { get; }
public int BatchSize { get; }
public TimeSpan LeaseDuration { get; }
}
public sealed class SchedulerQueueClaimOptions
{
public SchedulerQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
{
if (string.IsNullOrWhiteSpace(claimantConsumer))
{
throw new ArgumentException("Consumer identifier must be provided.", nameof(claimantConsumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (minIdleTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Idle time cannot be negative.");
}
ClaimantConsumer = claimantConsumer;
BatchSize = batchSize;
MinIdleTime = minIdleTime;
}
public sealed class SchedulerQueueLeaseRequest
{
public SchedulerQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer identifier must be provided.", nameof(consumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (leaseDuration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
}
Consumer = consumer;
BatchSize = batchSize;
LeaseDuration = leaseDuration;
}
public string Consumer { get; }
public int BatchSize { get; }
public TimeSpan LeaseDuration { get; }
}
public sealed class SchedulerQueueClaimOptions
{
public SchedulerQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
{
if (string.IsNullOrWhiteSpace(claimantConsumer))
{
throw new ArgumentException("Consumer identifier must be provided.", nameof(claimantConsumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (minIdleTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Idle time cannot be negative.");
}
ClaimantConsumer = claimantConsumer;
BatchSize = batchSize;
MinIdleTime = minIdleTime;
}
public string ClaimantConsumer { get; }
public int BatchSize { get; }
@@ -240,63 +240,63 @@ public sealed record SurfaceManifestPointer
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Tenant { get; init; }
}
public enum SchedulerQueueReleaseDisposition
{
Retry,
Abandon
}
public interface ISchedulerQueue<TMessage>
{
ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default);
}
public interface ISchedulerQueueLease<out TMessage>
{
string MessageId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string TenantId { get; }
string RunId { get; }
string? ScheduleId { get; }
string? SegmentId { get; }
string? CorrelationId { get; }
string IdempotencyKey { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
TMessage Message { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
public interface ISchedulerPlannerQueue : ISchedulerQueue<PlannerQueueMessage>
{
}
public interface ISchedulerRunnerQueue : ISchedulerQueue<RunnerSegmentQueueMessage>
{
}
public enum SchedulerQueueReleaseDisposition
{
Retry,
Abandon
}
public interface ISchedulerQueue<TMessage>
{
ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default);
}
public interface ISchedulerQueueLease<out TMessage>
{
string MessageId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string TenantId { get; }
string RunId { get; }
string? ScheduleId { get; }
string? SegmentId { get; }
string? CorrelationId { get; }
string IdempotencyKey { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
TMessage Message { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
public interface ISchedulerPlannerQueue : ISchedulerQueue<PlannerQueueMessage>
{
}
public interface ISchedulerRunnerQueue : ISchedulerQueue<RunnerSegmentQueueMessage>
{
}

View File

@@ -1,16 +1,16 @@
namespace StellaOps.Scheduler.Queue;
internal static class SchedulerQueueFields
{
public const string Payload = "payload";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string IdempotencyKey = "idempotency";
public const string RunId = "runId";
public const string TenantId = "tenantId";
public const string ScheduleId = "scheduleId";
public const string SegmentId = "segmentId";
public const string QueueKind = "queueKind";
public const string CorrelationId = "correlationId";
public const string AttributePrefix = "attr:";
}
namespace StellaOps.Scheduler.Queue;
internal static class SchedulerQueueFields
{
public const string Payload = "payload";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string IdempotencyKey = "idempotency";
public const string RunId = "runId";
public const string TenantId = "tenantId";
public const string ScheduleId = "scheduleId";
public const string SegmentId = "segmentId";
public const string QueueKind = "queueKind";
public const string CorrelationId = "correlationId";
public const string AttributePrefix = "attr:";
}

View File

@@ -1,72 +1,72 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.Queue;
public sealed class SchedulerQueueHealthCheck : IHealthCheck
{
private readonly ISchedulerPlannerQueue _plannerQueue;
private readonly ISchedulerRunnerQueue _runnerQueue;
private readonly ILogger<SchedulerQueueHealthCheck> _logger;
public SchedulerQueueHealthCheck(
ISchedulerPlannerQueue plannerQueue,
ISchedulerRunnerQueue runnerQueue,
ILogger<SchedulerQueueHealthCheck> logger)
{
_plannerQueue = plannerQueue ?? throw new ArgumentNullException(nameof(plannerQueue));
_runnerQueue = runnerQueue ?? throw new ArgumentNullException(nameof(runnerQueue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var failures = new List<string>();
if (!await ProbeAsync(_plannerQueue, "planner", cancellationToken).ConfigureAwait(false))
{
failures.Add("planner transport unreachable");
}
if (!await ProbeAsync(_runnerQueue, "runner", cancellationToken).ConfigureAwait(false))
{
failures.Add("runner transport unreachable");
}
if (failures.Count == 0)
{
return HealthCheckResult.Healthy("Scheduler queues reachable.");
}
var description = string.Join("; ", failures);
return new HealthCheckResult(
context.Registration.FailureStatus,
description);
}
private async Task<bool> ProbeAsync(object queue, string label, CancellationToken cancellationToken)
{
try
{
if (queue is ISchedulerQueueTransportDiagnostics diagnostics)
{
await diagnostics.PingAsync(cancellationToken).ConfigureAwait(false);
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Scheduler {Label} queue transport ping failed.", label);
return false;
}
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scheduler.Queue;
public sealed class SchedulerQueueHealthCheck : IHealthCheck
{
private readonly ISchedulerPlannerQueue _plannerQueue;
private readonly ISchedulerRunnerQueue _runnerQueue;
private readonly ILogger<SchedulerQueueHealthCheck> _logger;
public SchedulerQueueHealthCheck(
ISchedulerPlannerQueue plannerQueue,
ISchedulerRunnerQueue runnerQueue,
ILogger<SchedulerQueueHealthCheck> logger)
{
_plannerQueue = plannerQueue ?? throw new ArgumentNullException(nameof(plannerQueue));
_runnerQueue = runnerQueue ?? throw new ArgumentNullException(nameof(runnerQueue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var failures = new List<string>();
if (!await ProbeAsync(_plannerQueue, "planner", cancellationToken).ConfigureAwait(false))
{
failures.Add("planner transport unreachable");
}
if (!await ProbeAsync(_runnerQueue, "runner", cancellationToken).ConfigureAwait(false))
{
failures.Add("runner transport unreachable");
}
if (failures.Count == 0)
{
return HealthCheckResult.Healthy("Scheduler queues reachable.");
}
var description = string.Join("; ", failures);
return new HealthCheckResult(
context.Registration.FailureStatus,
description);
}
private async Task<bool> ProbeAsync(object queue, string label, CancellationToken cancellationToken)
{
try
{
if (queue is ISchedulerQueueTransportDiagnostics diagnostics)
{
await diagnostics.PingAsync(cancellationToken).ConfigureAwait(false);
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Scheduler {Label} queue transport ping failed.", label);
return false;
}
}
}

View File

@@ -8,10 +8,10 @@ namespace StellaOps.Scheduler.Queue;
public static class SchedulerQueueMetrics
{
private const string TransportTagName = "transport";
private const string QueueTagName = "queue";
private static readonly Meter Meter = new("StellaOps.Scheduler.Queue");
private const string TransportTagName = "transport";
private const string QueueTagName = "queue";
private static readonly Meter Meter = new("StellaOps.Scheduler.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("scheduler_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("scheduler_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("scheduler_queue_ack_total");
@@ -43,16 +43,16 @@ public static class SchedulerQueueMetrics
public static void RecordEnqueued(string transport, string queue)
=> EnqueuedCounter.Add(1, BuildTags(transport, queue));
public static void RecordDeduplicated(string transport, string queue)
=> DeduplicatedCounter.Add(1, BuildTags(transport, queue));
public static void RecordAck(string transport, string queue)
=> AckCounter.Add(1, BuildTags(transport, queue));
public static void RecordRetry(string transport, string queue)
=> RetryCounter.Add(1, BuildTags(transport, queue));
public static void RecordDeduplicated(string transport, string queue)
=> DeduplicatedCounter.Add(1, BuildTags(transport, queue));
public static void RecordAck(string transport, string queue)
=> AckCounter.Add(1, BuildTags(transport, queue));
public static void RecordRetry(string transport, string queue)
=> RetryCounter.Add(1, BuildTags(transport, queue));
public static void RecordDeadLetter(string transport, string queue)
=> DeadLetterCounter.Add(1, BuildTags(transport, queue));

View File

@@ -1,7 +1,7 @@
using System;
namespace StellaOps.Scheduler.Queue;
using System;
namespace StellaOps.Scheduler.Queue;
public sealed class SchedulerQueueOptions
{
public SchedulerQueueTransportKind Kind { get; set; } = SchedulerQueueTransportKind.Redis;
@@ -30,56 +30,56 @@ public sealed class SchedulerQueueOptions
/// Base retry delay used when a message is released for retry.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Cap applied to the retry delay when exponential backoff is used.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(1);
}
public sealed class SchedulerRedisQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public RedisSchedulerStreamOptions Planner { get; set; } = RedisSchedulerStreamOptions.ForPlanner();
public RedisSchedulerStreamOptions Runner { get; set; } = RedisSchedulerStreamOptions.ForRunner();
}
/// <summary>
/// Cap applied to the retry delay when exponential backoff is used.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(1);
}
public sealed class SchedulerRedisQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public RedisSchedulerStreamOptions Planner { get; set; } = RedisSchedulerStreamOptions.ForPlanner();
public RedisSchedulerStreamOptions Runner { get; set; } = RedisSchedulerStreamOptions.ForRunner();
}
public sealed class RedisSchedulerStreamOptions
{
public string Stream { get; set; } = string.Empty;
public string ConsumerGroup { get; set; } = string.Empty;
public string DeadLetterStream { get; set; } = string.Empty;
public string IdempotencyKeyPrefix { get; set; } = string.Empty;
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
public int? ApproximateMaxLength { get; set; }
public static RedisSchedulerStreamOptions ForPlanner()
=> new()
{
Stream = "scheduler:planner",
ConsumerGroup = "scheduler-planners",
DeadLetterStream = "scheduler:planner:dead",
IdempotencyKeyPrefix = "scheduler:planner:idemp:"
};
public static RedisSchedulerStreamOptions ForRunner()
=> new()
{
Stream = "scheduler:runner",
ConsumerGroup = "scheduler-runners",
DeadLetterStream = "scheduler:runner:dead",
IdempotencyKeyPrefix = "scheduler:runner:idemp:"
public string Stream { get; set; } = string.Empty;
public string ConsumerGroup { get; set; } = string.Empty;
public string DeadLetterStream { get; set; } = string.Empty;
public string IdempotencyKeyPrefix { get; set; } = string.Empty;
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
public int? ApproximateMaxLength { get; set; }
public static RedisSchedulerStreamOptions ForPlanner()
=> new()
{
Stream = "scheduler:planner",
ConsumerGroup = "scheduler-planners",
DeadLetterStream = "scheduler:planner:dead",
IdempotencyKeyPrefix = "scheduler:planner:idemp:"
};
public static RedisSchedulerStreamOptions ForRunner()
=> new()
{
Stream = "scheduler:runner",
ConsumerGroup = "scheduler-runners",
DeadLetterStream = "scheduler:runner:dead",
IdempotencyKeyPrefix = "scheduler:runner:idemp:"
};
}

View File

@@ -1,6 +1,6 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
@@ -10,26 +10,26 @@ using StellaOps.Scheduler.Queue.Redis;
namespace StellaOps.Scheduler.Queue;
public static class SchedulerQueueServiceCollectionExtensions
{
public static IServiceCollection AddSchedulerQueues(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "scheduler:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var options = new SchedulerQueueOptions();
configuration.GetSection(sectionName).Bind(options);
{
public static IServiceCollection AddSchedulerQueues(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "scheduler:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var options = new SchedulerQueueOptions();
configuration.GetSection(sectionName).Bind(options);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(options);
services.AddSingleton<ISchedulerPlannerQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
return options.Kind switch
{
SchedulerQueueTransportKind.Redis => new RedisSchedulerPlannerQueue(
@@ -47,10 +47,10 @@ public static class SchedulerQueueServiceCollectionExtensions
});
services.AddSingleton<ISchedulerRunnerQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
return options.Kind switch
{
SchedulerQueueTransportKind.Redis => new RedisSchedulerRunnerQueue(

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Scheduler.Queue;
/// <summary>
/// Transport backends supported by the scheduler queue abstraction.
/// </summary>
public enum SchedulerQueueTransportKind
{
Redis = 0,
Nats = 1,
}
namespace StellaOps.Scheduler.Queue;
/// <summary>
/// Transport backends supported by the scheduler queue abstraction.
/// </summary>
public enum SchedulerQueueTransportKind
{
Redis = 0,
Nats = 1,
}

View File

@@ -1,6 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Queue;
using StellaOps.Scheduler.Worker.Events;
@@ -14,85 +14,85 @@ using StellaOps.Scheduler.Worker.Graph.Cartographer;
using StellaOps.Scheduler.Worker.Graph.Scheduler;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.FS;
namespace StellaOps.Scheduler.Worker.DependencyInjection;
public static class SchedulerWorkerServiceCollectionExtensions
{
public static IServiceCollection AddSchedulerWorker(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services
.AddOptions<SchedulerWorkerOptions>()
.Bind(configuration)
.PostConfigure(options => options.Validate());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<SchedulerWorkerMetrics>();
services.AddSingleton<IImpactTargetingService, ImpactTargetingService>();
namespace StellaOps.Scheduler.Worker.DependencyInjection;
public static class SchedulerWorkerServiceCollectionExtensions
{
public static IServiceCollection AddSchedulerWorker(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services
.AddOptions<SchedulerWorkerOptions>()
.Bind(configuration)
.PostConfigure(options => options.Validate());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<SchedulerWorkerMetrics>();
services.AddSingleton<IImpactTargetingService, ImpactTargetingService>();
services.AddSingleton<IImpactShardPlanner, ImpactShardPlanner>();
services.AddSingleton<IPlannerQueueDispatchService, PlannerQueueDispatchService>();
services.AddSingleton<PlannerExecutionService>();
services.AddSingleton<IRunnerExecutionService, RunnerExecutionService>();
services.AddSingleton<IPolicyRunTargetingService, PolicyRunTargetingService>();
services.AddSingleton<PolicyRunExecutionService>();
services.AddSingleton<GraphBuildExecutionService>();
services.AddSingleton<GraphOverlayExecutionService>();
services.AddSingleton<ISchedulerEventPublisher>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var queue = sp.GetService<INotifyEventQueue>();
var queueOptions = sp.GetService<NotifyEventQueueOptions>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
if (queue is null || queueOptions is null)
{
return new NullSchedulerEventPublisher(loggerFactory.CreateLogger<NullSchedulerEventPublisher>());
}
return new SchedulerEventPublisher(
queue,
queueOptions,
timeProvider,
loggerFactory.CreateLogger<SchedulerEventPublisher>());
});
services.AddSingleton<IPolicyRunTargetingService, PolicyRunTargetingService>();
services.AddSingleton<PolicyRunExecutionService>();
services.AddSingleton<GraphBuildExecutionService>();
services.AddSingleton<GraphOverlayExecutionService>();
services.AddSingleton<ISchedulerEventPublisher>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var queue = sp.GetService<INotifyEventQueue>();
var queueOptions = sp.GetService<NotifyEventQueueOptions>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
if (queue is null || queueOptions is null)
{
return new NullSchedulerEventPublisher(loggerFactory.CreateLogger<NullSchedulerEventPublisher>());
}
return new SchedulerEventPublisher(
queue,
queueOptions,
timeProvider,
loggerFactory.CreateLogger<SchedulerEventPublisher>());
});
services.AddHttpClient<IScannerReportClient, HttpScannerReportClient>();
services.AddHttpClient<IPolicyRunClient, HttpPolicyRunClient>();
services.AddHttpClient<IPolicySimulationWebhookClient, HttpPolicySimulationWebhookClient>();
services.AddHttpClient<ICartographerBuildClient, HttpCartographerBuildClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
client.Timeout = options.CartographerTimeout;
if (options.Cartographer.BaseAddress is { } baseAddress)
{
client.BaseAddress = baseAddress;
}
});
services.AddHttpClient<ICartographerOverlayClient, HttpCartographerOverlayClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
client.Timeout = options.CartographerTimeout;
if (options.Cartographer.BaseAddress is { } baseAddress)
{
client.BaseAddress = baseAddress;
}
});
services.AddHttpClient<ICartographerBuildClient, HttpCartographerBuildClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
client.Timeout = options.CartographerTimeout;
if (options.Cartographer.BaseAddress is { } baseAddress)
{
client.BaseAddress = baseAddress;
}
});
services.AddHttpClient<ICartographerOverlayClient, HttpCartographerOverlayClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
client.Timeout = options.CartographerTimeout;
if (options.Cartographer.BaseAddress is { } baseAddress)
{
client.BaseAddress = baseAddress;
}
});
services.AddHttpClient<IGraphJobCompletionClient, HttpGraphJobCompletionClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
client.Timeout = options.CartographerTimeout;
if (options.SchedulerApi.BaseAddress is { } baseAddress)
{
client.BaseAddress = baseAddress;
}
});
if (options.SchedulerApi.BaseAddress is { } baseAddress)
{
client.BaseAddress = baseAddress;
}
});
services.AddHostedService<PlannerBackgroundService>();
services.AddHostedService<PlannerQueueDispatcherBackgroundService>();
services.AddHostedService<RunnerBackgroundService>();

View File

@@ -1,234 +1,234 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal sealed class HttpCartographerBuildClient : ICartographerBuildClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _httpClient;
private readonly IOptions<SchedulerWorkerOptions> _options;
private readonly ILogger<HttpCartographerBuildClient> _logger;
public HttpCartographerBuildClient(
HttpClient httpClient,
IOptions<SchedulerWorkerOptions> options,
ILogger<HttpCartographerBuildClient> 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<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (apiOptions.BaseAddress is null)
{
throw new InvalidOperationException("Cartographer base address must be configured before starting graph builds.");
}
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
{
_httpClient.BaseAddress = apiOptions.BaseAddress;
}
var payload = new CartographerBuildRequest
{
TenantId = job.TenantId,
SbomId = job.SbomId,
SbomVersionId = job.SbomVersionId,
SbomDigest = job.SbomDigest,
GraphSnapshotId = job.GraphSnapshotId,
CorrelationId = job.CorrelationId,
Metadata = job.Metadata
};
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.BuildPath)
{
Content = JsonContent.Create(payload, options: SerializerOptions)
};
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
{
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new CartographerBuildClientException($"Cartographer build submission failed with status {(int)response.StatusCode}: {body}");
}
CartographerBuildResponseModel? model = null;
try
{
if (response.Content.Headers.ContentLength is > 0)
{
model = await response.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse Cartographer build response for job {JobId}.", job.Id);
}
var status = ParseStatus(model?.Status);
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running) && !string.IsNullOrWhiteSpace(model?.CartographerJobId))
{
return await PollBuildStatusAsync(model.CartographerJobId!, cancellationToken).ConfigureAwait(false);
}
return new CartographerBuildResult(
status,
model?.CartographerJobId,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private static GraphJobStatus ParseStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return GraphJobStatus.Completed;
}
return value.Trim().ToLowerInvariant() switch
{
"pending" => GraphJobStatus.Pending,
"queued" => GraphJobStatus.Queued,
"running" => GraphJobStatus.Running,
"failed" => GraphJobStatus.Failed,
"cancelled" => GraphJobStatus.Cancelled,
_ => GraphJobStatus.Completed
};
}
private async Task<CartographerBuildResult> PollBuildStatusAsync(string cartographerJobId, CancellationToken cancellationToken)
{
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (string.IsNullOrWhiteSpace(apiOptions.StatusPath))
{
return new CartographerBuildResult(GraphJobStatus.Running, cartographerJobId, null, null, "status path not configured");
}
var statusPath = apiOptions.StatusPath.Replace("{jobId}", Uri.EscapeDataString(cartographerJobId), StringComparison.Ordinal);
var attempt = 0;
CartographerBuildResponseModel? model = null;
while (attempt < graphOptions.MaxAttempts)
{
cancellationToken.ThrowIfCancellationRequested();
attempt++;
try
{
using var statusResponse = await _httpClient.GetAsync(statusPath, cancellationToken).ConfigureAwait(false);
if (!statusResponse.IsSuccessStatusCode)
{
var body = await statusResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Cartographer build status request failed ({StatusCode}) for job {JobId}: {Body}", (int)statusResponse.StatusCode, cartographerJobId, body);
break;
}
model = await statusResponse.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
var status = ParseStatus(model?.Status);
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
{
return new CartographerBuildResult(
status,
cartographerJobId,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling Cartographer build status failed for job {JobId}.", cartographerJobId);
break;
}
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
}
var fallbackStatus = ParseStatus(model?.Status);
return new CartographerBuildResult(
fallbackStatus,
cartographerJobId,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private sealed record CartographerBuildRequest
{
public string TenantId { get; init; } = string.Empty;
public string SbomId { get; init; } = string.Empty;
public string SbomVersionId { get; init; } = string.Empty;
public string SbomDigest { get; init; } = string.Empty;
public string? GraphSnapshotId { get; init; }
public string? CorrelationId { get; init; }
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
private sealed record CartographerBuildResponseModel
{
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("cartographerJobId")]
public string? CartographerJobId { get; init; }
[JsonPropertyName("graphSnapshotId")]
public string? GraphSnapshotId { get; init; }
[JsonPropertyName("resultUri")]
public string? ResultUri { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
}
internal sealed class CartographerBuildClientException : Exception
{
public CartographerBuildClientException(string message)
: base(message)
{
}
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal sealed class HttpCartographerBuildClient : ICartographerBuildClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _httpClient;
private readonly IOptions<SchedulerWorkerOptions> _options;
private readonly ILogger<HttpCartographerBuildClient> _logger;
public HttpCartographerBuildClient(
HttpClient httpClient,
IOptions<SchedulerWorkerOptions> options,
ILogger<HttpCartographerBuildClient> 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<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (apiOptions.BaseAddress is null)
{
throw new InvalidOperationException("Cartographer base address must be configured before starting graph builds.");
}
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
{
_httpClient.BaseAddress = apiOptions.BaseAddress;
}
var payload = new CartographerBuildRequest
{
TenantId = job.TenantId,
SbomId = job.SbomId,
SbomVersionId = job.SbomVersionId,
SbomDigest = job.SbomDigest,
GraphSnapshotId = job.GraphSnapshotId,
CorrelationId = job.CorrelationId,
Metadata = job.Metadata
};
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.BuildPath)
{
Content = JsonContent.Create(payload, options: SerializerOptions)
};
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
{
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new CartographerBuildClientException($"Cartographer build submission failed with status {(int)response.StatusCode}: {body}");
}
CartographerBuildResponseModel? model = null;
try
{
if (response.Content.Headers.ContentLength is > 0)
{
model = await response.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse Cartographer build response for job {JobId}.", job.Id);
}
var status = ParseStatus(model?.Status);
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running) && !string.IsNullOrWhiteSpace(model?.CartographerJobId))
{
return await PollBuildStatusAsync(model.CartographerJobId!, cancellationToken).ConfigureAwait(false);
}
return new CartographerBuildResult(
status,
model?.CartographerJobId,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private static GraphJobStatus ParseStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return GraphJobStatus.Completed;
}
return value.Trim().ToLowerInvariant() switch
{
"pending" => GraphJobStatus.Pending,
"queued" => GraphJobStatus.Queued,
"running" => GraphJobStatus.Running,
"failed" => GraphJobStatus.Failed,
"cancelled" => GraphJobStatus.Cancelled,
_ => GraphJobStatus.Completed
};
}
private async Task<CartographerBuildResult> PollBuildStatusAsync(string cartographerJobId, CancellationToken cancellationToken)
{
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (string.IsNullOrWhiteSpace(apiOptions.StatusPath))
{
return new CartographerBuildResult(GraphJobStatus.Running, cartographerJobId, null, null, "status path not configured");
}
var statusPath = apiOptions.StatusPath.Replace("{jobId}", Uri.EscapeDataString(cartographerJobId), StringComparison.Ordinal);
var attempt = 0;
CartographerBuildResponseModel? model = null;
while (attempt < graphOptions.MaxAttempts)
{
cancellationToken.ThrowIfCancellationRequested();
attempt++;
try
{
using var statusResponse = await _httpClient.GetAsync(statusPath, cancellationToken).ConfigureAwait(false);
if (!statusResponse.IsSuccessStatusCode)
{
var body = await statusResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Cartographer build status request failed ({StatusCode}) for job {JobId}: {Body}", (int)statusResponse.StatusCode, cartographerJobId, body);
break;
}
model = await statusResponse.Content.ReadFromJsonAsync<CartographerBuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
var status = ParseStatus(model?.Status);
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
{
return new CartographerBuildResult(
status,
cartographerJobId,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling Cartographer build status failed for job {JobId}.", cartographerJobId);
break;
}
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
}
var fallbackStatus = ParseStatus(model?.Status);
return new CartographerBuildResult(
fallbackStatus,
cartographerJobId,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private sealed record CartographerBuildRequest
{
public string TenantId { get; init; } = string.Empty;
public string SbomId { get; init; } = string.Empty;
public string SbomVersionId { get; init; } = string.Empty;
public string SbomDigest { get; init; } = string.Empty;
public string? GraphSnapshotId { get; init; }
public string? CorrelationId { get; init; }
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
private sealed record CartographerBuildResponseModel
{
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("cartographerJobId")]
public string? CartographerJobId { get; init; }
[JsonPropertyName("graphSnapshotId")]
public string? GraphSnapshotId { get; init; }
[JsonPropertyName("resultUri")]
public string? ResultUri { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
}
internal sealed class CartographerBuildClientException : Exception
{
public CartographerBuildClientException(string message)
: base(message)
{
}
}

View File

@@ -1,227 +1,227 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal sealed class HttpCartographerOverlayClient : ICartographerOverlayClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _httpClient;
private readonly IOptions<SchedulerWorkerOptions> _options;
private readonly ILogger<HttpCartographerOverlayClient> _logger;
public HttpCartographerOverlayClient(
HttpClient httpClient,
IOptions<SchedulerWorkerOptions> options,
ILogger<HttpCartographerOverlayClient> 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<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (apiOptions.BaseAddress is null)
{
throw new InvalidOperationException("Cartographer base address must be configured before starting graph overlays.");
}
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
{
_httpClient.BaseAddress = apiOptions.BaseAddress;
}
var payload = new CartographerOverlayRequest
{
TenantId = job.TenantId,
GraphSnapshotId = job.GraphSnapshotId,
OverlayKind = job.OverlayKind.ToString().ToLowerInvariant(),
OverlayKey = job.OverlayKey,
Subjects = job.Subjects,
CorrelationId = job.CorrelationId,
Metadata = job.Metadata
};
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.OverlayPath)
{
Content = JsonContent.Create(payload, options: SerializerOptions)
};
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
{
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new CartographerOverlayClientException($"Cartographer overlay submission failed with status {(int)response.StatusCode}: {body}");
}
CartographerOverlayResponseModel? model = null;
try
{
if (response.Content.Headers.ContentLength is > 0)
{
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse Cartographer overlay response for job {JobId}.", job.Id);
}
var status = ParseStatus(model?.Status);
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running))
{
return await PollOverlayStatusAsync(job.Id, cancellationToken).ConfigureAwait(false);
}
return new CartographerOverlayResult(
status,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private static GraphJobStatus ParseStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return GraphJobStatus.Completed;
}
return value.Trim().ToLowerInvariant() switch
{
"pending" => GraphJobStatus.Pending,
"queued" => GraphJobStatus.Queued,
"running" => GraphJobStatus.Running,
"failed" => GraphJobStatus.Failed,
"cancelled" => GraphJobStatus.Cancelled,
_ => GraphJobStatus.Completed
};
}
private async Task<CartographerOverlayResult> PollOverlayStatusAsync(string overlayJobId, CancellationToken cancellationToken)
{
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (string.IsNullOrWhiteSpace(apiOptions.OverlayStatusPath))
{
return new CartographerOverlayResult(GraphJobStatus.Running, null, null, "overlay status path not configured");
}
var path = apiOptions.OverlayStatusPath.Replace("{jobId}", Uri.EscapeDataString(overlayJobId), StringComparison.Ordinal);
var attempt = 0;
CartographerOverlayResponseModel? model = null;
while (attempt < graphOptions.MaxAttempts)
{
cancellationToken.ThrowIfCancellationRequested();
attempt++;
try
{
using var response = await _httpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Cartographer overlay status request failed ({StatusCode}) for job {JobId}: {Body}", (int)response.StatusCode, overlayJobId, body);
break;
}
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
var status = ParseStatus(model?.Status);
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
{
return new CartographerOverlayResult(
status,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling Cartographer overlay status failed for job {JobId}.", overlayJobId);
break;
}
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
}
var fallbackStatus = ParseStatus(model?.Status);
return new CartographerOverlayResult(
fallbackStatus,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private sealed record CartographerOverlayRequest
{
public string TenantId { get; init; } = string.Empty;
public string? GraphSnapshotId { get; init; }
public string OverlayKind { get; init; } = string.Empty;
public string OverlayKey { get; init; } = string.Empty;
public IReadOnlyList<string> Subjects { get; init; } = Array.Empty<string>();
public string? CorrelationId { get; init; }
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
private sealed record CartographerOverlayResponseModel
{
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("graphSnapshotId")]
public string? GraphSnapshotId { get; init; }
[JsonPropertyName("resultUri")]
public string? ResultUri { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
}
internal sealed class CartographerOverlayClientException : Exception
{
public CartographerOverlayClientException(string message)
: base(message)
{
}
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal sealed class HttpCartographerOverlayClient : ICartographerOverlayClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _httpClient;
private readonly IOptions<SchedulerWorkerOptions> _options;
private readonly ILogger<HttpCartographerOverlayClient> _logger;
public HttpCartographerOverlayClient(
HttpClient httpClient,
IOptions<SchedulerWorkerOptions> options,
ILogger<HttpCartographerOverlayClient> 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<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (apiOptions.BaseAddress is null)
{
throw new InvalidOperationException("Cartographer base address must be configured before starting graph overlays.");
}
if (_httpClient.BaseAddress != apiOptions.BaseAddress)
{
_httpClient.BaseAddress = apiOptions.BaseAddress;
}
var payload = new CartographerOverlayRequest
{
TenantId = job.TenantId,
GraphSnapshotId = job.GraphSnapshotId,
OverlayKind = job.OverlayKind.ToString().ToLowerInvariant(),
OverlayKey = job.OverlayKey,
Subjects = job.Subjects,
CorrelationId = job.CorrelationId,
Metadata = job.Metadata
};
using var request = new HttpRequestMessage(HttpMethod.Post, apiOptions.OverlayPath)
{
Content = JsonContent.Create(payload, options: SerializerOptions)
};
if (!string.IsNullOrWhiteSpace(apiOptions.ApiKeyHeader) && !string.IsNullOrWhiteSpace(apiOptions.ApiKey))
{
request.Headers.TryAddWithoutValidation(apiOptions.ApiKeyHeader!, apiOptions.ApiKey);
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new CartographerOverlayClientException($"Cartographer overlay submission failed with status {(int)response.StatusCode}: {body}");
}
CartographerOverlayResponseModel? model = null;
try
{
if (response.Content.Headers.ContentLength is > 0)
{
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse Cartographer overlay response for job {JobId}.", job.Id);
}
var status = ParseStatus(model?.Status);
if ((status == GraphJobStatus.Pending || status == GraphJobStatus.Queued || status == GraphJobStatus.Running))
{
return await PollOverlayStatusAsync(job.Id, cancellationToken).ConfigureAwait(false);
}
return new CartographerOverlayResult(
status,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private static GraphJobStatus ParseStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return GraphJobStatus.Completed;
}
return value.Trim().ToLowerInvariant() switch
{
"pending" => GraphJobStatus.Pending,
"queued" => GraphJobStatus.Queued,
"running" => GraphJobStatus.Running,
"failed" => GraphJobStatus.Failed,
"cancelled" => GraphJobStatus.Cancelled,
_ => GraphJobStatus.Completed
};
}
private async Task<CartographerOverlayResult> PollOverlayStatusAsync(string overlayJobId, CancellationToken cancellationToken)
{
var graphOptions = _options.Value.Graph;
var apiOptions = graphOptions.Cartographer;
if (string.IsNullOrWhiteSpace(apiOptions.OverlayStatusPath))
{
return new CartographerOverlayResult(GraphJobStatus.Running, null, null, "overlay status path not configured");
}
var path = apiOptions.OverlayStatusPath.Replace("{jobId}", Uri.EscapeDataString(overlayJobId), StringComparison.Ordinal);
var attempt = 0;
CartographerOverlayResponseModel? model = null;
while (attempt < graphOptions.MaxAttempts)
{
cancellationToken.ThrowIfCancellationRequested();
attempt++;
try
{
using var response = await _httpClient.GetAsync(path, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning("Cartographer overlay status request failed ({StatusCode}) for job {JobId}: {Body}", (int)response.StatusCode, overlayJobId, body);
break;
}
model = await response.Content.ReadFromJsonAsync<CartographerOverlayResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
var status = ParseStatus(model?.Status);
if (status is GraphJobStatus.Completed or GraphJobStatus.Cancelled or GraphJobStatus.Failed)
{
return new CartographerOverlayResult(
status,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Polling Cartographer overlay status failed for job {JobId}.", overlayJobId);
break;
}
await Task.Delay(graphOptions.StatusPollInterval, cancellationToken).ConfigureAwait(false);
}
var fallbackStatus = ParseStatus(model?.Status);
return new CartographerOverlayResult(
fallbackStatus,
model?.GraphSnapshotId,
model?.ResultUri,
model?.Error);
}
private sealed record CartographerOverlayRequest
{
public string TenantId { get; init; } = string.Empty;
public string? GraphSnapshotId { get; init; }
public string OverlayKind { get; init; } = string.Empty;
public string OverlayKey { get; init; } = string.Empty;
public IReadOnlyList<string> Subjects { get; init; } = Array.Empty<string>();
public string? CorrelationId { get; init; }
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
}
private sealed record CartographerOverlayResponseModel
{
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("graphSnapshotId")]
public string? GraphSnapshotId { get; init; }
[JsonPropertyName("resultUri")]
public string? ResultUri { get; init; }
[JsonPropertyName("error")]
public string? Error { get; init; }
}
}
internal sealed class CartographerOverlayClientException : Exception
{
public CartographerOverlayClientException(string message)
: base(message)
{
}
}

View File

@@ -1,17 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal interface ICartographerBuildClient
{
Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken);
}
internal sealed record CartographerBuildResult(
GraphJobStatus Status,
string? CartographerJobId,
string? GraphSnapshotId,
string? ResultUri,
string? Error);
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal interface ICartographerBuildClient
{
Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken);
}
internal sealed record CartographerBuildResult(
GraphJobStatus Status,
string? CartographerJobId,
string? GraphSnapshotId,
string? ResultUri,
string? Error);

View File

@@ -1,16 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal interface ICartographerOverlayClient
{
Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken);
}
internal sealed record CartographerOverlayResult(
GraphJobStatus Status,
string? GraphSnapshotId,
string? ResultUri,
string? Error);
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Worker.Graph.Cartographer;
internal interface ICartographerOverlayClient
{
Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken);
}
internal sealed record CartographerOverlayResult(
GraphJobStatus Status,
string? GraphSnapshotId,
string? ResultUri,
string? Error);

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