Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class CartographerWebhookClient : ICartographerWebhookClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptionsMonitor<SchedulerCartographerOptions> _options;
|
||||
private readonly ILogger<CartographerWebhookClient> _logger;
|
||||
|
||||
public CartographerWebhookClient(
|
||||
HttpClient httpClient,
|
||||
IOptionsMonitor<SchedulerCartographerOptions> options,
|
||||
ILogger<CartographerWebhookClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = _options.CurrentValue;
|
||||
var webhook = snapshot.Webhook;
|
||||
|
||||
if (!webhook.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Cartographer webhook disabled; skipping notification for job {JobId}.", notification.Job.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(webhook.Endpoint))
|
||||
{
|
||||
_logger.LogWarning("Cartographer webhook endpoint not configured; unable to notify for job {JobId}.", notification.Job.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
Uri endpointUri;
|
||||
try
|
||||
{
|
||||
endpointUri = new Uri(webhook.Endpoint, UriKind.Absolute);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid Cartographer webhook endpoint '{Endpoint}'.", webhook.Endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
tenantId = notification.TenantId,
|
||||
jobId = notification.Job.Id,
|
||||
jobType = notification.Job.Kind,
|
||||
status = notification.Status.ToString().ToLowerInvariant(),
|
||||
occurredAt = notification.OccurredAt,
|
||||
resultUri = notification.ResultUri,
|
||||
correlationId = notification.CorrelationId,
|
||||
job = notification.Job,
|
||||
error = notification.Error
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpointUri)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, SerializerOptions), Encoding.UTF8, MediaTypeNames.Application.Json)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(webhook.ApiKey) && !string.IsNullOrWhiteSpace(webhook.ApiKeyHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(webhook.ApiKeyHeader!, webhook.ApiKey);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning("Cartographer webhook responded {StatusCode} for job {JobId}: {Body}", (int)response.StatusCode, notification.Job.Id, body);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to invoke Cartographer webhook for job {JobId}.", notification.Job.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal sealed record GraphJobCompletedEvent
|
||||
{
|
||||
[JsonPropertyName("eventId")]
|
||||
public required string EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public required string Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public required string Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("ts")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required GraphJobCompletedPayload Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("attributes")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record GraphJobCompletedPayload
|
||||
{
|
||||
[JsonPropertyName("jobType")]
|
||||
public required string JobType { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required GraphJobStatus Status { get; init; }
|
||||
|
||||
[JsonPropertyName("occurredAt")]
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
[JsonPropertyName("job")]
|
||||
public required GraphJobResponse Job { get; init; }
|
||||
|
||||
[JsonPropertyName("resultUri")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ResultUri { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal static class GraphJobEventFactory
|
||||
{
|
||||
public static GraphJobCompletedEvent Create(GraphJobCompletionNotification notification)
|
||||
{
|
||||
var eventId = Guid.CreateVersion7().ToString("n");
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.CorrelationId))
|
||||
{
|
||||
attributes["correlationId"] = notification.CorrelationId!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Error))
|
||||
{
|
||||
attributes["error"] = notification.Error!;
|
||||
}
|
||||
|
||||
var payload = new GraphJobCompletedPayload
|
||||
{
|
||||
JobType = notification.JobType.ToString().ToLowerInvariant(),
|
||||
Status = notification.Status,
|
||||
OccurredAt = notification.OccurredAt,
|
||||
Job = notification.Job,
|
||||
ResultUri = notification.ResultUri
|
||||
};
|
||||
|
||||
return new GraphJobCompletedEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
Kind = GraphJobEventKinds.GraphJobCompleted,
|
||||
Tenant = notification.TenantId,
|
||||
Timestamp = notification.OccurredAt,
|
||||
Payload = payload,
|
||||
Attributes = attributes.Count == 0 ? null : attributes
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal static class GraphJobEventKinds
|
||||
{
|
||||
public const string GraphJobCompleted = "scheduler.graph.job.completed";
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal sealed class GraphJobEventPublisher : IGraphJobCompletionPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IOptionsMonitor<SchedulerEventsOptions> _options;
|
||||
private readonly ILogger<GraphJobEventPublisher> _logger;
|
||||
|
||||
public GraphJobEventPublisher(
|
||||
IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
ILogger<GraphJobEventPublisher> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
if (!options.GraphJobs.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Graph job events disabled; skipping emission for {JobId}.", notification.Job.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
_logger.LogInformation("{EventJson}", json);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public sealed record GraphBuildJobRequest
|
||||
{
|
||||
[Required]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string SbomVersionId { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string SbomDigest { get; init; } = string.Empty;
|
||||
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public GraphBuildJobTrigger? Trigger { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public sealed record GraphJobCompletionNotification(
|
||||
string TenantId,
|
||||
GraphJobQueryType JobType,
|
||||
GraphJobStatus Status,
|
||||
DateTimeOffset OccurredAt,
|
||||
GraphJobResponse Job,
|
||||
string? ResultUri,
|
||||
string? CorrelationId,
|
||||
string? Error);
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public sealed record GraphJobCompletionRequest
|
||||
{
|
||||
[Required]
|
||||
public string JobId { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public GraphJobQueryType JobType { get; init; }
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public GraphJobStatus Status { get; init; }
|
||||
|
||||
[Required]
|
||||
public DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
public string? ResultUri { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public static class GraphJobEndpointExtensions
|
||||
{
|
||||
public static void MapGraphJobEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/graphs");
|
||||
|
||||
group.MapPost("/build", CreateGraphBuildJob);
|
||||
group.MapPost("/overlays", CreateGraphOverlayJob);
|
||||
group.MapGet("/jobs", GetGraphJobs);
|
||||
group.MapPost("/hooks/completed", CompleteGraphJob);
|
||||
group.MapGet("/overlays/lag", GetOverlayLagMetrics);
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CreateGraphBuildJob(
|
||||
[FromBody] GraphBuildJobRequest request,
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IGraphJobService jobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphWrite);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var job = await jobService.CreateBuildJobAsync(tenant.TenantId, request, cancellationToken);
|
||||
return Results.Created($"/graphs/jobs/{job.Id}", GraphJobResponse.From(job));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CreateGraphOverlayJob(
|
||||
[FromBody] GraphOverlayJobRequest request,
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IGraphJobService jobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphWrite);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var job = await jobService.CreateOverlayJobAsync(tenant.TenantId, request, cancellationToken);
|
||||
return Results.Created($"/graphs/jobs/{job.Id}", GraphJobResponse.From(job));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<IResult> GetGraphJobs(
|
||||
[AsParameters] GraphJobQuery query,
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IGraphJobService jobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphRead);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var jobs = await jobService.GetJobsAsync(tenant.TenantId, query, cancellationToken);
|
||||
return Results.Ok(jobs);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<IResult> CompleteGraphJob(
|
||||
[FromBody] GraphJobCompletionRequest request,
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IGraphJobService jobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphWrite);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var response = await jobService.CompleteJobAsync(tenant.TenantId, request, cancellationToken);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<IResult> GetOverlayLagMetrics(
|
||||
HttpContext httpContext,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer authorizer,
|
||||
[FromServices] IGraphJobService jobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
authorizer.EnsureScope(httpContext, StellaOpsScopes.GraphRead);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var metrics = await jobService.GetOverlayLagMetricsAsync(tenant.TenantId, cancellationToken);
|
||||
return Results.Ok(metrics);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public sealed record GraphJobQuery
|
||||
{
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public GraphJobQueryType? Type { get; init; }
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public GraphJobStatus? Status { get; init; }
|
||||
|
||||
public int? Limit { get; init; }
|
||||
|
||||
internal GraphJobQuery Normalize()
|
||||
=> this with
|
||||
{
|
||||
Limit = Limit is null or <= 0 or > 200 ? 50 : Limit
|
||||
};
|
||||
}
|
||||
|
||||
public enum GraphJobQueryType
|
||||
{
|
||||
Build,
|
||||
Overlay
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public sealed record GraphJobResponse
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required GraphJobStatus Status { get; init; }
|
||||
public required object Payload { get; init; }
|
||||
|
||||
public static GraphJobResponse From(GraphBuildJob job)
|
||||
=> new()
|
||||
{
|
||||
Id = job.Id,
|
||||
TenantId = job.TenantId,
|
||||
Kind = "build",
|
||||
Status = job.Status,
|
||||
Payload = job
|
||||
};
|
||||
|
||||
public static GraphJobResponse From(GraphOverlayJob job)
|
||||
=> new()
|
||||
{
|
||||
Id = job.Id,
|
||||
TenantId = job.TenantId,
|
||||
Kind = "overlay",
|
||||
Status = job.Status,
|
||||
Payload = job
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record GraphJobCollection(IReadOnlyList<GraphJobResponse> Jobs)
|
||||
{
|
||||
public static GraphJobCollection From(IEnumerable<GraphBuildJob> builds, IEnumerable<GraphOverlayJob> overlays)
|
||||
{
|
||||
var responses = builds.Select(GraphJobResponse.From)
|
||||
.Concat(overlays.Select(GraphJobResponse.From))
|
||||
.OrderBy(response => response.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new GraphJobCollection(responses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class GraphJobService : IGraphJobService
|
||||
{
|
||||
private readonly IGraphJobStore _store;
|
||||
private readonly ISystemClock _clock;
|
||||
private readonly IGraphJobCompletionPublisher _completionPublisher;
|
||||
private readonly ICartographerWebhookClient _cartographerWebhook;
|
||||
|
||||
public GraphJobService(
|
||||
IGraphJobStore store,
|
||||
ISystemClock clock,
|
||||
IGraphJobCompletionPublisher completionPublisher,
|
||||
ICartographerWebhookClient cartographerWebhook)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_completionPublisher = completionPublisher ?? throw new ArgumentNullException(nameof(completionPublisher));
|
||||
_cartographerWebhook = cartographerWebhook ?? throw new ArgumentNullException(nameof(cartographerWebhook));
|
||||
}
|
||||
|
||||
public async Task<GraphBuildJob> CreateBuildJobAsync(string tenantId, GraphBuildJobRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validate(request);
|
||||
|
||||
var trigger = request.Trigger ?? GraphBuildJobTrigger.SbomVersion;
|
||||
var metadata = request.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
var id = GenerateIdentifier("gbj");
|
||||
var job = new GraphBuildJob(
|
||||
id,
|
||||
tenantId,
|
||||
request.SbomId.Trim(),
|
||||
request.SbomVersionId.Trim(),
|
||||
NormalizeDigest(request.SbomDigest),
|
||||
GraphJobStatus.Pending,
|
||||
trigger,
|
||||
now,
|
||||
request.GraphSnapshotId,
|
||||
attempts: 0,
|
||||
cartographerJobId: null,
|
||||
correlationId: request.CorrelationId?.Trim(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
metadata: metadata.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value)));
|
||||
|
||||
return await _store.AddAsync(job, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<GraphOverlayJob> CreateOverlayJobAsync(string tenantId, GraphOverlayJobRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validate(request);
|
||||
|
||||
var subjects = (request.Subjects ?? Array.Empty<string>())
|
||||
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||
.Select(subject => subject.Trim())
|
||||
.ToArray();
|
||||
var metadata = request.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var trigger = request.Trigger ?? GraphOverlayJobTrigger.Policy;
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
var id = GenerateIdentifier("goj");
|
||||
|
||||
var job = new GraphOverlayJob(
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
graphSnapshotId: request.GraphSnapshotId.Trim(),
|
||||
overlayKind: request.OverlayKind,
|
||||
overlayKey: request.OverlayKey.Trim(),
|
||||
status: GraphJobStatus.Pending,
|
||||
trigger: trigger,
|
||||
createdAt: now,
|
||||
subjects: subjects,
|
||||
attempts: 0,
|
||||
buildJobId: request.BuildJobId?.Trim(),
|
||||
correlationId: request.CorrelationId?.Trim(),
|
||||
metadata: metadata.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value)));
|
||||
|
||||
return await _store.AddAsync(job, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _store.GetJobsAsync(tenantId, query, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<GraphJobResponse> CompleteJobAsync(string tenantId, GraphJobCompletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Status is not (GraphJobStatus.Completed or GraphJobStatus.Failed or GraphJobStatus.Cancelled))
|
||||
{
|
||||
throw new ValidationException("Completion requires status completed, failed, or cancelled.");
|
||||
}
|
||||
|
||||
var occurredAt = request.OccurredAt == default ? _clock.UtcNow : request.OccurredAt.ToUniversalTime();
|
||||
|
||||
switch (request.JobType)
|
||||
{
|
||||
case GraphJobQueryType.Build:
|
||||
{
|
||||
var existing = await _store.GetBuildJobAsync(tenantId, request.JobId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Graph build job '{request.JobId}' not found.");
|
||||
}
|
||||
|
||||
var current = existing;
|
||||
if (current.Status is GraphJobStatus.Pending or GraphJobStatus.Queued)
|
||||
{
|
||||
current = GraphJobStateMachine.EnsureTransition(current, GraphJobStatus.Running, occurredAt, attempts: current.Attempts);
|
||||
}
|
||||
|
||||
var updated = GraphJobStateMachine.EnsureTransition(current, request.Status, occurredAt, attempts: current.Attempts + 1, errorMessage: request.Error);
|
||||
var metadata = MergeMetadata(updated.Metadata, request.ResultUri);
|
||||
var normalized = new GraphBuildJob(
|
||||
id: updated.Id,
|
||||
tenantId: updated.TenantId,
|
||||
sbomId: updated.SbomId,
|
||||
sbomVersionId: updated.SbomVersionId,
|
||||
sbomDigest: updated.SbomDigest,
|
||||
graphSnapshotId: request.GraphSnapshotId?.Trim() ?? updated.GraphSnapshotId,
|
||||
status: updated.Status,
|
||||
trigger: updated.Trigger,
|
||||
attempts: updated.Attempts,
|
||||
cartographerJobId: updated.CartographerJobId,
|
||||
correlationId: request.CorrelationId?.Trim() ?? updated.CorrelationId,
|
||||
createdAt: updated.CreatedAt,
|
||||
startedAt: updated.StartedAt,
|
||||
completedAt: updated.CompletedAt,
|
||||
error: updated.Error,
|
||||
metadata: metadata,
|
||||
schemaVersion: updated.SchemaVersion);
|
||||
|
||||
var stored = await _store.UpdateAsync(normalized, cancellationToken);
|
||||
var response = GraphJobResponse.From(stored);
|
||||
await PublishCompletionAsync(tenantId, GraphJobQueryType.Build, request.Status, occurredAt, response, request.ResultUri, request.CorrelationId, request.Error, cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
case GraphJobQueryType.Overlay:
|
||||
{
|
||||
var existing = await _store.GetOverlayJobAsync(tenantId, request.JobId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Graph overlay job '{request.JobId}' not found.");
|
||||
}
|
||||
|
||||
var current = existing;
|
||||
if (current.Status is GraphJobStatus.Pending or GraphJobStatus.Queued)
|
||||
{
|
||||
current = GraphJobStateMachine.EnsureTransition(current, GraphJobStatus.Running, occurredAt, attempts: current.Attempts);
|
||||
}
|
||||
|
||||
var updated = GraphJobStateMachine.EnsureTransition(current, request.Status, occurredAt, attempts: current.Attempts + 1, errorMessage: request.Error);
|
||||
var metadata = MergeMetadata(updated.Metadata, request.ResultUri);
|
||||
var normalized = new GraphOverlayJob(
|
||||
id: updated.Id,
|
||||
tenantId: updated.TenantId,
|
||||
graphSnapshotId: updated.GraphSnapshotId,
|
||||
buildJobId: updated.BuildJobId,
|
||||
overlayKind: updated.OverlayKind,
|
||||
overlayKey: updated.OverlayKey,
|
||||
subjects: updated.Subjects,
|
||||
status: updated.Status,
|
||||
trigger: updated.Trigger,
|
||||
attempts: updated.Attempts,
|
||||
correlationId: request.CorrelationId?.Trim() ?? updated.CorrelationId,
|
||||
createdAt: updated.CreatedAt,
|
||||
startedAt: updated.StartedAt,
|
||||
completedAt: updated.CompletedAt,
|
||||
error: updated.Error,
|
||||
metadata: metadata,
|
||||
schemaVersion: updated.SchemaVersion);
|
||||
|
||||
var stored = await _store.UpdateAsync(normalized, cancellationToken);
|
||||
var response = GraphJobResponse.From(stored);
|
||||
await PublishCompletionAsync(tenantId, GraphJobQueryType.Overlay, request.Status, occurredAt, response, request.ResultUri, request.CorrelationId, request.Error, cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ValidationException("Unsupported job type.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<OverlayLagMetricsResponse> GetOverlayLagMetricsAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _clock.UtcNow;
|
||||
var overlayJobs = await _store.GetOverlayJobsAsync(tenantId, cancellationToken);
|
||||
|
||||
var pending = overlayJobs.Count(job => job.Status == GraphJobStatus.Pending);
|
||||
var running = overlayJobs.Count(job => job.Status == GraphJobStatus.Running || job.Status == GraphJobStatus.Queued);
|
||||
var completed = overlayJobs.Count(job => job.Status == GraphJobStatus.Completed);
|
||||
var failed = overlayJobs.Count(job => job.Status == GraphJobStatus.Failed);
|
||||
var cancelled = overlayJobs.Count(job => job.Status == GraphJobStatus.Cancelled);
|
||||
|
||||
var completedJobs = overlayJobs
|
||||
.Where(job => job.Status == GraphJobStatus.Completed && job.CompletedAt is not null)
|
||||
.OrderByDescending(job => job.CompletedAt)
|
||||
.ToArray();
|
||||
|
||||
double? minLag = null;
|
||||
double? maxLag = null;
|
||||
double? avgLag = null;
|
||||
List<OverlayLagEntry> recent = new();
|
||||
|
||||
if (completedJobs.Length > 0)
|
||||
{
|
||||
var lags = completedJobs
|
||||
.Select(job => (now - job.CompletedAt!.Value).TotalSeconds)
|
||||
.ToArray();
|
||||
|
||||
minLag = lags.Min();
|
||||
maxLag = lags.Max();
|
||||
avgLag = lags.Average();
|
||||
|
||||
recent = completedJobs
|
||||
.Take(5)
|
||||
.Select(job => new OverlayLagEntry(
|
||||
JobId: job.Id,
|
||||
CompletedAt: job.CompletedAt!.Value,
|
||||
LagSeconds: (now - job.CompletedAt!.Value).TotalSeconds,
|
||||
CorrelationId: job.CorrelationId,
|
||||
ResultUri: job.Metadata.TryGetValue("resultUri", out var value) ? value : null))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return new OverlayLagMetricsResponse(
|
||||
TenantId: tenantId,
|
||||
Pending: pending,
|
||||
Running: running,
|
||||
Completed: completed,
|
||||
Failed: failed,
|
||||
Cancelled: cancelled,
|
||||
MinLagSeconds: minLag,
|
||||
MaxLagSeconds: maxLag,
|
||||
AverageLagSeconds: avgLag,
|
||||
RecentCompleted: recent);
|
||||
}
|
||||
|
||||
private static void Validate(GraphBuildJobRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.SbomId))
|
||||
{
|
||||
throw new ValidationException("sbomId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SbomVersionId))
|
||||
{
|
||||
throw new ValidationException("sbomVersionId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SbomDigest))
|
||||
{
|
||||
throw new ValidationException("sbomDigest is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Validate(GraphOverlayJobRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.GraphSnapshotId))
|
||||
{
|
||||
throw new ValidationException("graphSnapshotId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.OverlayKey))
|
||||
{
|
||||
throw new ValidationException("overlayKey is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateIdentifier(string prefix)
|
||||
=> $"{prefix}_{Guid.CreateVersion7().ToString("n")}";
|
||||
|
||||
private static string NormalizeDigest(string value)
|
||||
{
|
||||
var text = value.Trim();
|
||||
if (!text.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ValidationException("sbomDigest must start with 'sha256:'.");
|
||||
}
|
||||
|
||||
var digest = text[7..];
|
||||
if (digest.Length != 64 || !digest.All(IsHex))
|
||||
{
|
||||
throw new ValidationException("sbomDigest must contain 64 hexadecimal characters.");
|
||||
}
|
||||
|
||||
return $"sha256:{digest.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static bool IsHex(char c)
|
||||
=> (c >= '0' && c <= '9') ||
|
||||
(c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F');
|
||||
|
||||
private static ImmutableSortedDictionary<string, string> MergeMetadata(ImmutableSortedDictionary<string, string> existing, string? resultUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resultUri))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var builder = existing.ToBuilder();
|
||||
builder["resultUri"] = resultUri.Trim();
|
||||
return builder.ToImmutableSortedDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private async Task PublishCompletionAsync(
|
||||
string tenantId,
|
||||
GraphJobQueryType jobType,
|
||||
GraphJobStatus status,
|
||||
DateTimeOffset occurredAt,
|
||||
GraphJobResponse response,
|
||||
string? resultUri,
|
||||
string? correlationId,
|
||||
string? error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var notification = new GraphJobCompletionNotification(
|
||||
tenantId,
|
||||
jobType,
|
||||
status,
|
||||
occurredAt,
|
||||
response,
|
||||
resultUri,
|
||||
correlationId,
|
||||
error);
|
||||
|
||||
await _completionPublisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
await _cartographerWebhook.NotifyAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public sealed record GraphOverlayJobRequest
|
||||
{
|
||||
[Required]
|
||||
public string GraphSnapshotId { get; init; } = string.Empty;
|
||||
|
||||
public string? BuildJobId { get; init; }
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public GraphOverlayKind OverlayKind { get; init; }
|
||||
|
||||
[Required]
|
||||
public string OverlayKey { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string>? Subjects { get; init; }
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public GraphOverlayJobTrigger? Trigger { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public interface ICartographerWebhookClient
|
||||
{
|
||||
Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public interface IGraphJobCompletionPublisher
|
||||
{
|
||||
Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public interface IGraphJobService
|
||||
{
|
||||
Task<GraphBuildJob> CreateBuildJobAsync(string tenantId, GraphBuildJobRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<GraphOverlayJob> CreateOverlayJobAsync(string tenantId, GraphOverlayJobRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken);
|
||||
|
||||
Task<GraphJobResponse> CompleteJobAsync(string tenantId, GraphJobCompletionRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<OverlayLagMetricsResponse> GetOverlayLagMetricsAsync(string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public interface IGraphJobStore
|
||||
{
|
||||
ValueTask<GraphBuildJob> AddAsync(GraphBuildJob job, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<GraphOverlayJob> AddAsync(GraphOverlayJob job, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<GraphBuildJob> UpdateAsync(GraphBuildJob job, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<GraphOverlayJob> UpdateAsync(GraphOverlayJob job, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class InMemoryGraphJobStore : IGraphJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, GraphBuildJob> _buildJobs = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, GraphOverlayJob> _overlayJobs = new(StringComparer.Ordinal);
|
||||
|
||||
public ValueTask<GraphBuildJob> AddAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
_buildJobs[job.Id] = job;
|
||||
return ValueTask.FromResult(job);
|
||||
}
|
||||
|
||||
public ValueTask<GraphOverlayJob> AddAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
_overlayJobs[job.Id] = job;
|
||||
return ValueTask.FromResult(job);
|
||||
}
|
||||
|
||||
public ValueTask<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = query.Normalize();
|
||||
var buildJobs = _buildJobs.Values
|
||||
.Where(job => string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
|
||||
.Where(job => normalized.Status is null || job.Status == normalized.Status)
|
||||
.OrderByDescending(job => job.CreatedAt)
|
||||
.Take(normalized.Limit ?? 50)
|
||||
.ToArray();
|
||||
|
||||
var overlayJobs = _overlayJobs.Values
|
||||
.Where(job => string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
|
||||
.Where(job => normalized.Status is null || job.Status == normalized.Status)
|
||||
.OrderByDescending(job => job.CreatedAt)
|
||||
.Take(normalized.Limit ?? 50)
|
||||
.ToArray();
|
||||
|
||||
return ValueTask.FromResult(GraphJobCollection.From(buildJobs, overlayJobs));
|
||||
}
|
||||
|
||||
public ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_buildJobs.TryGetValue(jobId, out var job) && string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<GraphBuildJob?>(job);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<GraphBuildJob?>(null);
|
||||
}
|
||||
|
||||
public ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_overlayJobs.TryGetValue(jobId, out var job) && string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<GraphOverlayJob?>(job);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<GraphOverlayJob?>(null);
|
||||
}
|
||||
|
||||
public ValueTask<GraphBuildJob> UpdateAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
_buildJobs[job.Id] = job;
|
||||
return ValueTask.FromResult(job);
|
||||
}
|
||||
|
||||
public ValueTask<GraphOverlayJob> UpdateAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
_overlayJobs[job.Id] = job;
|
||||
return ValueTask.FromResult(job);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var jobs = _overlayJobs.Values
|
||||
.Where(job => string.Equals(job.TenantId, tenantId, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyCollection<GraphOverlayJob>>(jobs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class MongoGraphJobStore : IGraphJobStore
|
||||
{
|
||||
private readonly IGraphJobRepository _repository;
|
||||
|
||||
public MongoGraphJobStore(IGraphJobRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async ValueTask<GraphBuildJob> AddAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
await _repository.InsertAsync(job, cancellationToken);
|
||||
return job;
|
||||
}
|
||||
|
||||
public async ValueTask<GraphOverlayJob> AddAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
await _repository.InsertAsync(job, cancellationToken);
|
||||
return job;
|
||||
}
|
||||
|
||||
public async ValueTask<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = query.Normalize();
|
||||
var builds = normalized.Type is null or GraphJobQueryType.Build
|
||||
? await _repository.ListBuildJobsAsync(tenantId, normalized.Status, normalized.Limit ?? 50, cancellationToken)
|
||||
: Array.Empty<GraphBuildJob>();
|
||||
|
||||
var overlays = normalized.Type is null or GraphJobQueryType.Overlay
|
||||
? await _repository.ListOverlayJobsAsync(tenantId, normalized.Status, normalized.Limit ?? 50, cancellationToken)
|
||||
: Array.Empty<GraphOverlayJob>();
|
||||
|
||||
return GraphJobCollection.From(builds, overlays);
|
||||
}
|
||||
|
||||
public async ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
||||
=> await _repository.GetBuildJobAsync(tenantId, jobId, cancellationToken);
|
||||
|
||||
public async ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
||||
=> await _repository.GetOverlayJobAsync(tenantId, jobId, cancellationToken);
|
||||
|
||||
public async ValueTask<GraphBuildJob> UpdateAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
=> await _repository.ReplaceAsync(job, cancellationToken);
|
||||
|
||||
public async ValueTask<GraphOverlayJob> UpdateAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
||||
=> await _repository.ReplaceAsync(job, cancellationToken);
|
||||
|
||||
public async ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
|
||||
=> await _repository.ListOverlayJobsAsync(tenantId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class NullCartographerWebhookClient : ICartographerWebhookClient
|
||||
{
|
||||
private readonly ILogger<NullCartographerWebhookClient> _logger;
|
||||
|
||||
public NullCartographerWebhookClient(ILogger<NullCartographerWebhookClient> logger)
|
||||
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Cartographer webhook suppressed for tenant {TenantId}, job {JobId} ({Status}).", notification.TenantId, notification.Job.Id, notification.Status);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
internal sealed class NullGraphJobCompletionPublisher : IGraphJobCompletionPublisher
|
||||
{
|
||||
private readonly ILogger<NullGraphJobCompletionPublisher> _logger;
|
||||
|
||||
public NullGraphJobCompletionPublisher(ILogger<NullGraphJobCompletionPublisher> logger)
|
||||
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Graph job completion notification suppressed for tenant {TenantId}, job {JobId} ({Status}).", notification.TenantId, notification.Job.Id, notification.Status);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
|
||||
public sealed record OverlayLagMetricsResponse(
|
||||
string TenantId,
|
||||
int Pending,
|
||||
int Running,
|
||||
int Completed,
|
||||
int Failed,
|
||||
int Cancelled,
|
||||
double? MinLagSeconds,
|
||||
double? MaxLagSeconds,
|
||||
double? AverageLagSeconds,
|
||||
IReadOnlyList<OverlayLagEntry> RecentCompleted);
|
||||
|
||||
public sealed record OverlayLagEntry(
|
||||
string JobId,
|
||||
DateTimeOffset CompletedAt,
|
||||
double LagSeconds,
|
||||
string? CorrelationId,
|
||||
string? ResultUri);
|
||||
Reference in New Issue
Block a user