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