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);
}
}