Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies. - Documented roles and guidelines in AGENTS.md for Scheduler module. - Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs. - Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics. - Developed API endpoints for managing resolver jobs and retrieving metrics. - Defined models for resolver job requests and responses. - Integrated dependency injection for resolver job services. - Implemented ImpactIndexSnapshot for persisting impact index data. - Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring. - Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService. - Created dotnet-filter.sh script to handle command-line arguments for dotnet. - Established nuget-prime project for managing package downloads.
142 lines
5.0 KiB
C#
142 lines
5.0 KiB
C#
using System.Collections.Concurrent;
|
|
using System.ComponentModel.DataAnnotations;
|
|
|
|
namespace StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
|
|
|
/// <summary>
|
|
/// Lightweight in-memory resolver job service to satisfy API contract and rate-limit callers.
|
|
/// Suitable for stub/air-gap scenarios; replace with Mongo-backed implementation when ready.
|
|
/// </summary>
|
|
public sealed class InMemoryResolverJobService : IResolverJobService
|
|
{
|
|
private readonly ConcurrentDictionary<string, ResolverJobResponse> _store = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, List<DateTimeOffset>> _tenantCreates = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly TimeProvider _timeProvider;
|
|
private const int MaxJobsPerMinute = 60;
|
|
|
|
public InMemoryResolverJobService(TimeProvider? timeProvider = null)
|
|
{
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
public Task<ResolverJobResponse> CreateAsync(string tenantId, ResolverJobRequest request, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ValidateRequest(request);
|
|
|
|
EnforceRateLimit(tenantId);
|
|
|
|
var id = GenerateId(tenantId, request.ArtifactId, request.PolicyId);
|
|
var created = _timeProvider.GetUtcNow();
|
|
|
|
var response = new ResolverJobResponse(
|
|
id,
|
|
request.ArtifactId.Trim(),
|
|
request.PolicyId.Trim(),
|
|
"queued",
|
|
created,
|
|
CompletedAt: null,
|
|
request.CorrelationId,
|
|
request.Metadata ?? new Dictionary<string, string>());
|
|
|
|
_store[id] = response;
|
|
TrackCreate(tenantId, created);
|
|
return Task.FromResult(response);
|
|
}
|
|
|
|
public Task<ResolverJobResponse?> GetAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
|
|
|
|
_store.TryGetValue(jobId, out var response);
|
|
return Task.FromResult(response);
|
|
}
|
|
|
|
public ResolverBacklogMetricsResponse ComputeMetrics(string tenantId)
|
|
{
|
|
var now = _timeProvider.GetUtcNow();
|
|
var pending = new List<ResolverJobResponse>();
|
|
var completed = new List<ResolverJobResponse>();
|
|
|
|
foreach (var job in _store.Values)
|
|
{
|
|
if (string.Equals(job.Status, "completed", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
completed.Add(job);
|
|
}
|
|
else
|
|
{
|
|
pending.Add(job);
|
|
}
|
|
}
|
|
|
|
var lagEntries = completed
|
|
.Where(j => j.CompletedAt is not null)
|
|
.Select(j => new ResolverLagEntry(
|
|
j.Id,
|
|
j.CompletedAt!.Value,
|
|
Math.Max((j.CompletedAt!.Value - j.CreatedAt).TotalSeconds, 0d),
|
|
j.CorrelationId,
|
|
j.ArtifactId,
|
|
j.PolicyId))
|
|
.OrderByDescending(e => e.CompletedAt)
|
|
.ToList();
|
|
|
|
return new ResolverBacklogMetricsResponse(
|
|
tenantId,
|
|
Pending: pending.Count,
|
|
Running: 0,
|
|
Completed: completed.Count,
|
|
Failed: 0,
|
|
MinLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Min(e => (double?)e.LagSeconds),
|
|
MaxLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Max(e => (double?)e.LagSeconds),
|
|
AverageLagSeconds: lagEntries.Count == 0 ? null : lagEntries.Average(e => e.LagSeconds),
|
|
RecentCompleted: lagEntries.Take(5).ToList());
|
|
}
|
|
|
|
private static void ValidateRequest(ResolverJobRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.ArtifactId))
|
|
{
|
|
throw new ValidationException("artifactId is required.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
|
{
|
|
throw new ValidationException("policyId is required.");
|
|
}
|
|
}
|
|
|
|
private static string GenerateId(string tenantId, string artifactId, string policyId)
|
|
{
|
|
var raw = $"{tenantId}:{artifactId}:{policyId}:{Guid.NewGuid():N}";
|
|
return "resolver-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
|
|
}
|
|
|
|
private void EnforceRateLimit(string tenantId)
|
|
{
|
|
var now = _timeProvider.GetUtcNow();
|
|
var cutoff = now.AddMinutes(-1);
|
|
var list = _tenantCreates.GetOrAdd(tenantId, static _ => new List<DateTimeOffset>());
|
|
lock (list)
|
|
{
|
|
list.RemoveAll(ts => ts < cutoff);
|
|
if (list.Count >= MaxJobsPerMinute)
|
|
{
|
|
throw new InvalidOperationException("resolver job rate limit exceeded");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void TrackCreate(string tenantId, DateTimeOffset timestamp)
|
|
{
|
|
var list = _tenantCreates.GetOrAdd(tenantId, static _ => new List<DateTimeOffset>());
|
|
lock (list)
|
|
{
|
|
list.Add(timestamp);
|
|
}
|
|
}
|
|
}
|