Complete Entrypoint Detection Re-Engineering Program (Sprints 0410-0415) and Sprint 3500.0002.0003 (Proof Replay + API)
Entrypoint Detection Program (100% complete): - Sprint 0411: Semantic Entrypoint Engine - all 25 tasks DONE - Sprint 0412: Temporal & Mesh Entrypoint - all 19 tasks DONE - Sprint 0413: Speculative Execution Engine - all 19 tasks DONE - Sprint 0414: Binary Intelligence - all 19 tasks DONE - Sprint 0415: Predictive Risk Scoring - all tasks DONE Key deliverables: - SemanticEntrypoint schema with ApplicationIntent/CapabilityClass - TemporalEntrypointGraph and MeshEntrypointGraph - ShellSymbolicExecutor with PathEnumerator and PathConfidenceScorer - CodeFingerprint index with symbol recovery - RiskScore with multi-dimensional risk assessment Sprint 3500.0002.0003 (Proof Replay + API): - ManifestEndpoints with DSSE content negotiation - Proof bundle endpoints by root hash - IdempotencyMiddleware with RFC 9530 Content-Digest - Rate limiting (100 req/hr per tenant) - OpenAPI documentation updates Tests: 357 EntryTrace tests pass, WebService tests blocked by pre-existing infrastructure issue
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Signals.Services;
|
||||
|
||||
namespace StellaOps.Signals.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ISchedulerJobClient"/> that enqueues jobs
|
||||
/// to the Scheduler planner queue.
|
||||
/// </summary>
|
||||
public sealed class SchedulerQueueJobClient : ISchedulerJobClient
|
||||
{
|
||||
private readonly ISchedulerPlannerQueue _plannerQueue;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SchedulerQueueJobClient> _logger;
|
||||
|
||||
public SchedulerQueueJobClient(
|
||||
ISchedulerPlannerQueue plannerQueue,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SchedulerQueueJobClient> logger)
|
||||
{
|
||||
_plannerQueue = plannerQueue ?? throw new ArgumentNullException(nameof(plannerQueue));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<SchedulerJobResult> CreateRescanJobAsync(
|
||||
RescanJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
var (run, impactSet) = CreateRunAndImpactSet(request);
|
||||
var message = new PlannerQueueMessage(
|
||||
run,
|
||||
impactSet,
|
||||
correlationId: request.CorrelationId ?? $"unknowns-rescan:{request.UnknownId}");
|
||||
|
||||
_logger.LogDebug(
|
||||
"Enqueueing rescan job for unknown {UnknownId}, runId={RunId}",
|
||||
request.UnknownId,
|
||||
run.Id);
|
||||
|
||||
var result = await _plannerQueue.EnqueueAsync(message, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// EnqueueAsync throws on failure; if we get here, it succeeded
|
||||
_logger.LogInformation(
|
||||
"Rescan job enqueued: runId={RunId}, messageId={MessageId}, deduplicated={Deduplicated}",
|
||||
run.Id,
|
||||
result.MessageId,
|
||||
result.Deduplicated);
|
||||
|
||||
return SchedulerJobResult.Succeeded(result.MessageId, run.Id);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Exception creating rescan job for unknown {UnknownId}", request.UnknownId);
|
||||
return SchedulerJobResult.Failed(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
|
||||
IReadOnlyList<RescanJobRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var results = new List<SchedulerJobResult>(requests.Count);
|
||||
var successCount = 0;
|
||||
var failureCount = 0;
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
var result = await CreateRescanJobAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
successCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return new BatchSchedulerJobResult(
|
||||
requests.Count,
|
||||
successCount,
|
||||
failureCount,
|
||||
results);
|
||||
}
|
||||
|
||||
private (Run Run, ImpactSet ImpactSet) CreateRunAndImpactSet(RescanJobRequest request)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var runId = $"rescan-{request.UnknownId}-{now:yyyyMMddHHmmss}";
|
||||
|
||||
var run = new Run(
|
||||
id: runId,
|
||||
tenantId: request.TenantId,
|
||||
trigger: RunTrigger.Manual, // Triggered by unknowns escalation
|
||||
state: RunState.Planning,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: now,
|
||||
reason: new RunReason(manualReason: $"Unknowns rescan for {request.PackageUrl}"));
|
||||
|
||||
// Create a selector targeting the specific package by purl
|
||||
// We use ByRepository scope with the purl as the repository identifier
|
||||
var selector = new Selector(
|
||||
scope: SelectorScope.ByRepository,
|
||||
tenantId: request.TenantId,
|
||||
repositories: new[] { ExtractRepositoryFromPurl(request.PackageUrl) });
|
||||
|
||||
var impactSet = new ImpactSet(
|
||||
selector: selector,
|
||||
images: ImmutableArray<ImpactImage>.Empty, // Will be resolved by planner
|
||||
usageOnly: false,
|
||||
generatedAt: now,
|
||||
total: 0);
|
||||
|
||||
return (run, impactSet);
|
||||
}
|
||||
|
||||
private static string ExtractRepositoryFromPurl(string purl)
|
||||
{
|
||||
// Parse purl to extract repository name
|
||||
// Format: pkg:type/namespace/name@version
|
||||
// We want: namespace/name
|
||||
|
||||
if (string.IsNullOrEmpty(purl))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Remove pkg: prefix
|
||||
var purlBody = purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)
|
||||
? purl[4..]
|
||||
: purl;
|
||||
|
||||
// Remove version suffix
|
||||
var atIndex = purlBody.IndexOf('@');
|
||||
if (atIndex > 0)
|
||||
{
|
||||
purlBody = purlBody[..atIndex];
|
||||
}
|
||||
|
||||
// Skip type prefix (e.g., "npm/", "maven/", "nuget/")
|
||||
var slashIndex = purlBody.IndexOf('/');
|
||||
if (slashIndex > 0)
|
||||
{
|
||||
return purlBody[(slashIndex + 1)..];
|
||||
}
|
||||
|
||||
return purlBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Signals.Services;
|
||||
|
||||
namespace StellaOps.Signals.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Scheduler integration services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the Scheduler-integrated rescan orchestrator.
|
||||
/// Requires <see cref="StellaOps.Scheduler.Queue.ISchedulerPlannerQueue"/> to be registered.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSchedulerRescanOrchestrator(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Register the Scheduler queue job client
|
||||
services.TryAddSingleton<ISchedulerJobClient, SchedulerQueueJobClient>();
|
||||
|
||||
// Register the orchestrator that uses the job client
|
||||
services.TryAddSingleton<IRescanOrchestrator, SchedulerRescanOrchestrator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||
<ProjectReference Include="..\..\Scheduler\__Libraries\StellaOps.Scheduler.Queue\StellaOps.Scheduler.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for creating rescan jobs in the scheduler.
|
||||
/// Allows Signals to integrate with the Scheduler module without tight coupling.
|
||||
/// </summary>
|
||||
public interface ISchedulerJobClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a targeted rescan job for a specific package.
|
||||
/// </summary>
|
||||
/// <param name="request">The rescan job request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result indicating success or failure with job ID.</returns>
|
||||
Task<SchedulerJobResult> CreateRescanJobAsync(
|
||||
RescanJobRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple rescan jobs in a batch.
|
||||
/// </summary>
|
||||
Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
|
||||
IReadOnlyList<RescanJobRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating a rescan job.
|
||||
/// </summary>
|
||||
/// <param name="TenantId">Tenant identifier.</param>
|
||||
/// <param name="UnknownId">ID of the unknown being rescanned.</param>
|
||||
/// <param name="PackageUrl">Package URL (purl) to rescan.</param>
|
||||
/// <param name="PackageVersion">Version to rescan (optional).</param>
|
||||
/// <param name="Priority">Job priority level.</param>
|
||||
/// <param name="CorrelationId">Correlation ID for tracing.</param>
|
||||
public sealed record RescanJobRequest(
|
||||
string TenantId,
|
||||
string UnknownId,
|
||||
string PackageUrl,
|
||||
string? PackageVersion,
|
||||
RescanJobPriority Priority,
|
||||
string? CorrelationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Priority level for rescan jobs.
|
||||
/// </summary>
|
||||
public enum RescanJobPriority
|
||||
{
|
||||
/// <summary>Immediate processing (HOT band).</summary>
|
||||
High,
|
||||
/// <summary>Normal processing (WARM band).</summary>
|
||||
Normal,
|
||||
/// <summary>Low priority batch processing (COLD band).</summary>
|
||||
Low
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from creating a scheduler job.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the job was created.</param>
|
||||
/// <param name="JobId">Scheduler job ID if successful.</param>
|
||||
/// <param name="RunId">Run ID in the scheduler.</param>
|
||||
/// <param name="ErrorMessage">Error message if failed.</param>
|
||||
public sealed record SchedulerJobResult(
|
||||
bool Success,
|
||||
string? JobId = null,
|
||||
string? RunId = null,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static SchedulerJobResult Succeeded(string jobId, string runId)
|
||||
=> new(true, jobId, runId);
|
||||
|
||||
public static SchedulerJobResult Failed(string error)
|
||||
=> new(false, ErrorMessage: error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from batch job creation.
|
||||
/// </summary>
|
||||
public sealed record BatchSchedulerJobResult(
|
||||
int TotalRequested,
|
||||
int SuccessCount,
|
||||
int FailureCount,
|
||||
IReadOnlyList<SchedulerJobResult> Results);
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of <see cref="ISchedulerJobClient"/> that logs requests
|
||||
/// but does not actually create jobs. Used when Scheduler integration is not configured.
|
||||
/// </summary>
|
||||
public sealed class NullSchedulerJobClient : ISchedulerJobClient
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NullSchedulerJobClient> _logger;
|
||||
|
||||
public NullSchedulerJobClient(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NullSchedulerJobClient> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<SchedulerJobResult> CreateRescanJobAsync(
|
||||
RescanJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Null scheduler client: Would create rescan job for unknown {UnknownId} (purl={Purl})",
|
||||
request.UnknownId,
|
||||
request.PackageUrl);
|
||||
|
||||
// Generate a fake job ID for testing/development
|
||||
var jobId = $"null-job-{Guid.NewGuid():N}";
|
||||
var runId = $"null-run-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}";
|
||||
|
||||
return Task.FromResult(SchedulerJobResult.Succeeded(jobId, runId));
|
||||
}
|
||||
|
||||
public Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
|
||||
IReadOnlyList<RescanJobRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Null scheduler client: Would create {Count} rescan jobs",
|
||||
requests.Count);
|
||||
|
||||
var results = requests
|
||||
.Select(r =>
|
||||
{
|
||||
var jobId = $"null-job-{Guid.NewGuid():N}";
|
||||
var runId = $"null-run-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}";
|
||||
return SchedulerJobResult.Succeeded(jobId, runId);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(new BatchSchedulerJobResult(
|
||||
requests.Count,
|
||||
requests.Count,
|
||||
0,
|
||||
results));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IRescanOrchestrator"/> that integrates with
|
||||
/// the Scheduler module via <see cref="ISchedulerJobClient"/>.
|
||||
/// </summary>
|
||||
public sealed class SchedulerRescanOrchestrator : IRescanOrchestrator
|
||||
{
|
||||
private readonly ISchedulerJobClient _schedulerClient;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SchedulerRescanOrchestrator> _logger;
|
||||
|
||||
public SchedulerRescanOrchestrator(
|
||||
ISchedulerJobClient schedulerClient,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SchedulerRescanOrchestrator> logger)
|
||||
{
|
||||
_schedulerClient = schedulerClient ?? throw new ArgumentNullException(nameof(schedulerClient));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RescanResult> TriggerRescanAsync(
|
||||
UnknownSymbolDocument unknown,
|
||||
RescanPriority priority,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(unknown);
|
||||
|
||||
var request = CreateJobRequest(unknown, priority);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating rescan job for unknown {UnknownId} (purl={Purl}, priority={Priority})",
|
||||
unknown.Id,
|
||||
unknown.Purl,
|
||||
priority);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _schedulerClient.CreateRescanJobAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Rescan job {JobId} created for unknown {UnknownId}",
|
||||
result.JobId,
|
||||
unknown.Id);
|
||||
|
||||
return new RescanResult(
|
||||
unknown.Id,
|
||||
Success: true,
|
||||
NextScheduledRescan: ComputeNextRescan(priority));
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Failed to create rescan job for unknown {UnknownId}: {Error}",
|
||||
unknown.Id,
|
||||
result.ErrorMessage);
|
||||
|
||||
return new RescanResult(
|
||||
unknown.Id,
|
||||
Success: false,
|
||||
ErrorMessage: result.ErrorMessage);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Exception creating rescan job for unknown {UnknownId}", unknown.Id);
|
||||
|
||||
return new RescanResult(
|
||||
unknown.Id,
|
||||
Success: false,
|
||||
ErrorMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BatchRescanResult> TriggerBatchRescanAsync(
|
||||
IReadOnlyList<UnknownSymbolDocument> unknowns,
|
||||
RescanPriority priority,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(unknowns);
|
||||
|
||||
if (unknowns.Count == 0)
|
||||
{
|
||||
return new BatchRescanResult(0, 0, 0, []);
|
||||
}
|
||||
|
||||
var requests = unknowns
|
||||
.Select(u => CreateJobRequest(u, priority))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating {Count} rescan jobs with priority {Priority}",
|
||||
requests.Count,
|
||||
priority);
|
||||
|
||||
try
|
||||
{
|
||||
var batchResult = await _schedulerClient.CreateRescanJobsAsync(requests, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var rescanResults = batchResult.Results
|
||||
.Zip(unknowns, (jobResult, unknown) => new RescanResult(
|
||||
unknown.Id,
|
||||
jobResult.Success,
|
||||
jobResult.ErrorMessage,
|
||||
jobResult.Success ? ComputeNextRescan(priority) : null))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Batch rescan complete: {Success}/{Total} succeeded",
|
||||
batchResult.SuccessCount,
|
||||
batchResult.TotalRequested);
|
||||
|
||||
return new BatchRescanResult(
|
||||
batchResult.TotalRequested,
|
||||
batchResult.SuccessCount,
|
||||
batchResult.FailureCount,
|
||||
rescanResults);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Exception in batch rescan for {Count} unknowns", unknowns.Count);
|
||||
|
||||
var failedResults = unknowns
|
||||
.Select(u => new RescanResult(u.Id, Success: false, ErrorMessage: ex.Message))
|
||||
.ToList();
|
||||
|
||||
return new BatchRescanResult(
|
||||
unknowns.Count,
|
||||
0,
|
||||
unknowns.Count,
|
||||
failedResults);
|
||||
}
|
||||
}
|
||||
|
||||
private RescanJobRequest CreateJobRequest(UnknownSymbolDocument unknown, RescanPriority priority)
|
||||
{
|
||||
var jobPriority = priority switch
|
||||
{
|
||||
RescanPriority.Immediate => RescanJobPriority.High,
|
||||
RescanPriority.Scheduled => RescanJobPriority.Normal,
|
||||
_ => RescanJobPriority.Low
|
||||
};
|
||||
|
||||
// Extract tenant from the unknown context
|
||||
// For now, use a default tenant if not available
|
||||
var tenantId = ExtractTenantId(unknown);
|
||||
|
||||
return new RescanJobRequest(
|
||||
TenantId: tenantId,
|
||||
UnknownId: unknown.Id,
|
||||
PackageUrl: unknown.Purl ?? unknown.SubjectKey,
|
||||
PackageVersion: unknown.PurlVersion,
|
||||
Priority: jobPriority,
|
||||
CorrelationId: unknown.CallgraphId);
|
||||
}
|
||||
|
||||
private static string ExtractTenantId(UnknownSymbolDocument unknown)
|
||||
{
|
||||
// The CallgraphId often follows pattern: {tenant}:{graph-id}
|
||||
// If not available, use a default
|
||||
if (string.IsNullOrEmpty(unknown.CallgraphId))
|
||||
{
|
||||
return "default";
|
||||
}
|
||||
|
||||
var colonIndex = unknown.CallgraphId.IndexOf(':', StringComparison.Ordinal);
|
||||
return colonIndex > 0
|
||||
? unknown.CallgraphId[..colonIndex]
|
||||
: "default";
|
||||
}
|
||||
|
||||
private DateTimeOffset ComputeNextRescan(RescanPriority priority)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return priority switch
|
||||
{
|
||||
RescanPriority.Immediate => now.AddMinutes(15), // Re-evaluate after 15 min
|
||||
RescanPriority.Scheduled => now.AddHours(24), // Next day for WARM
|
||||
_ => now.AddDays(7) // Weekly for COLD
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class SchedulerRescanOrchestratorTests
|
||||
{
|
||||
private readonly MockSchedulerJobClient _mockClient = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly ILogger<SchedulerRescanOrchestrator> _logger;
|
||||
private readonly SchedulerRescanOrchestrator _sut;
|
||||
|
||||
public SchedulerRescanOrchestratorTests()
|
||||
{
|
||||
_logger = LoggerFactory.Create(b => b.AddDebug()).CreateLogger<SchedulerRescanOrchestrator>();
|
||||
_sut = new SchedulerRescanOrchestrator(_mockClient, _timeProvider, _logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Immediate()
|
||||
{
|
||||
// Arrange
|
||||
var unknown = CreateUnknown("pkg:npm/lodash@4.17.21", UnknownsBand.Hot);
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
_mockClient.LastRequest.Should().NotBeNull();
|
||||
_mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.High);
|
||||
_mockClient.LastRequest.PackageUrl.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Scheduled()
|
||||
{
|
||||
// Arrange
|
||||
var unknown = CreateUnknown("pkg:maven/com.example/lib@1.0.0", UnknownsBand.Warm);
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Scheduled);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
_mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.Normal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Batch()
|
||||
{
|
||||
// Arrange
|
||||
var unknown = CreateUnknown("pkg:nuget/newtonsoft.json@13.0.1", UnknownsBand.Cold);
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Batch);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
_mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_PropagatesCorrelationId()
|
||||
{
|
||||
// Arrange
|
||||
var unknown = CreateUnknown("pkg:pypi/requests@2.28.0", UnknownsBand.Hot);
|
||||
unknown.CallgraphId = "tenant123:graph456";
|
||||
|
||||
// Act
|
||||
await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
_mockClient.LastRequest!.CorrelationId.Should().Be("tenant123:graph456");
|
||||
_mockClient.LastRequest.TenantId.Should().Be("tenant123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForImmediate()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
var unknown = CreateUnknown("pkg:npm/axios@1.0.0", UnknownsBand.Hot);
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
result.NextScheduledRescan.Should().Be(now.AddMinutes(15));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForScheduled()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
var unknown = CreateUnknown("pkg:npm/express@4.18.0", UnknownsBand.Warm);
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Scheduled);
|
||||
|
||||
// Assert
|
||||
result.NextScheduledRescan.Should().Be(now.AddHours(24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForBatch()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(now);
|
||||
var unknown = CreateUnknown("pkg:npm/mocha@10.0.0", UnknownsBand.Cold);
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Batch);
|
||||
|
||||
// Assert
|
||||
result.NextScheduledRescan.Should().Be(now.AddDays(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_ReturnsFailure_WhenClientFails()
|
||||
{
|
||||
// Arrange
|
||||
_mockClient.ShouldFail = true;
|
||||
_mockClient.FailureMessage = "Queue unavailable";
|
||||
var unknown = CreateUnknown("pkg:npm/fail@1.0.0", UnknownsBand.Hot);
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ErrorMessage.Should().Be("Queue unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerBatchRescanAsync_ProcessesAllItems()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new[]
|
||||
{
|
||||
CreateUnknown("pkg:npm/a@1.0.0", UnknownsBand.Hot),
|
||||
CreateUnknown("pkg:npm/b@1.0.0", UnknownsBand.Hot),
|
||||
CreateUnknown("pkg:npm/c@1.0.0", UnknownsBand.Hot)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerBatchRescanAsync(unknowns, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
result.TotalRequested.Should().Be(3);
|
||||
result.SuccessCount.Should().Be(3);
|
||||
result.FailureCount.Should().Be(0);
|
||||
result.Results.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerBatchRescanAsync_EmptyList_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = Array.Empty<UnknownSymbolDocument>();
|
||||
|
||||
// Act
|
||||
var result = await _sut.TriggerBatchRescanAsync(unknowns, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
result.TotalRequested.Should().Be(0);
|
||||
result.SuccessCount.Should().Be(0);
|
||||
result.FailureCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_ExtractsTenantFromCallgraphId()
|
||||
{
|
||||
// Arrange
|
||||
var unknown = CreateUnknown("pkg:npm/test@1.0.0", UnknownsBand.Hot);
|
||||
unknown.CallgraphId = "acme-corp:cg-12345";
|
||||
|
||||
// Act
|
||||
await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
_mockClient.LastRequest!.TenantId.Should().Be("acme-corp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerRescanAsync_UsesDefaultTenant_WhenNoCallgraphId()
|
||||
{
|
||||
// Arrange
|
||||
var unknown = CreateUnknown("pkg:npm/orphan@1.0.0", UnknownsBand.Hot);
|
||||
unknown.CallgraphId = null;
|
||||
|
||||
// Act
|
||||
await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
|
||||
|
||||
// Assert
|
||||
_mockClient.LastRequest!.TenantId.Should().Be("default");
|
||||
}
|
||||
|
||||
private static UnknownSymbolDocument CreateUnknown(string purl, UnknownsBand band)
|
||||
{
|
||||
return new UnknownSymbolDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
SubjectKey = purl,
|
||||
Purl = purl,
|
||||
Band = band,
|
||||
Score = band switch
|
||||
{
|
||||
UnknownsBand.Hot => 0.85,
|
||||
UnknownsBand.Warm => 0.55,
|
||||
UnknownsBand.Cold => 0.35,
|
||||
_ => 0.15
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class MockSchedulerJobClient : ISchedulerJobClient
|
||||
{
|
||||
public RescanJobRequest? LastRequest { get; private set; }
|
||||
public bool ShouldFail { get; set; }
|
||||
public string FailureMessage { get; set; } = "Mock failure";
|
||||
|
||||
public Task<SchedulerJobResult> CreateRescanJobAsync(
|
||||
RescanJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastRequest = request;
|
||||
|
||||
if (ShouldFail)
|
||||
{
|
||||
return Task.FromResult(SchedulerJobResult.Failed(FailureMessage));
|
||||
}
|
||||
|
||||
var jobId = $"mock-job-{Guid.NewGuid():N}";
|
||||
var runId = $"mock-run-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
return Task.FromResult(SchedulerJobResult.Succeeded(jobId, runId));
|
||||
}
|
||||
|
||||
public async Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
|
||||
IReadOnlyList<RescanJobRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<SchedulerJobResult>();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
var result = await CreateRescanJobAsync(request, cancellationToken);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return new BatchSchedulerJobResult(
|
||||
requests.Count,
|
||||
results.Count(r => r.Success),
|
||||
results.Count(r => !r.Success),
|
||||
results);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = DateTimeOffset.UtcNow;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user