Add global using for Xunit in test project Enhance ImportValidatorTests with async validation and quarantine checks Implement FileSystemQuarantineServiceTests for quarantine functionality Add integration tests for ImportValidator to check monotonicity Create BundleVersionTests to validate version parsing and comparison logic Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
261 lines
9.5 KiB
C#
261 lines
9.5 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Metrics;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Scanner.WebService.Domain;
|
|
using StellaOps.Scanner.WebService.Options;
|
|
using StellaOps.Zastava.Core.Contracts;
|
|
|
|
namespace StellaOps.Scanner.WebService.Services;
|
|
|
|
/// <summary>
|
|
/// Handles delta scan requests triggered by runtime DRIFT events.
|
|
/// </summary>
|
|
internal interface IDeltaScanRequestHandler
|
|
{
|
|
/// <summary>
|
|
/// Processes a batch of runtime events and triggers scans for DRIFT events when enabled.
|
|
/// </summary>
|
|
Task<DeltaScanResult> ProcessAsync(
|
|
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of delta scan processing.
|
|
/// </summary>
|
|
internal readonly record struct DeltaScanResult(
|
|
int DriftEventsDetected,
|
|
int ScansTriggered,
|
|
int ScansSkipped,
|
|
int ScansDeduped);
|
|
|
|
internal sealed class DeltaScanRequestHandler : IDeltaScanRequestHandler
|
|
{
|
|
private static readonly Meter DeltaScanMeter = new("StellaOps.Scanner.DeltaScan", "1.0.0");
|
|
private static readonly Counter<long> DeltaScanTriggered = DeltaScanMeter.CreateCounter<long>(
|
|
"scanner_delta_scan_triggered_total",
|
|
unit: "1",
|
|
description: "Total delta scans triggered from runtime DRIFT events.");
|
|
private static readonly Counter<long> DeltaScanSkipped = DeltaScanMeter.CreateCounter<long>(
|
|
"scanner_delta_scan_skipped_total",
|
|
unit: "1",
|
|
description: "Total delta scans skipped (feature disabled, rate limited, or missing data).");
|
|
private static readonly Counter<long> DeltaScanDeduped = DeltaScanMeter.CreateCounter<long>(
|
|
"scanner_delta_scan_deduped_total",
|
|
unit: "1",
|
|
description: "Total delta scans deduplicated within cooldown window.");
|
|
private static readonly Histogram<double> DeltaScanLatencyMs = DeltaScanMeter.CreateHistogram<double>(
|
|
"scanner_delta_scan_latency_ms",
|
|
unit: "ms",
|
|
description: "Latency for delta scan trigger processing.");
|
|
|
|
// Deduplication cache: imageDigest -> last trigger time
|
|
private readonly ConcurrentDictionary<string, DateTimeOffset> _recentTriggers = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private readonly IScanCoordinator _scanCoordinator;
|
|
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<DeltaScanRequestHandler> _logger;
|
|
|
|
public DeltaScanRequestHandler(
|
|
IScanCoordinator scanCoordinator,
|
|
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
|
TimeProvider timeProvider,
|
|
ILogger<DeltaScanRequestHandler> logger)
|
|
{
|
|
_scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator));
|
|
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<DeltaScanResult> ProcessAsync(
|
|
IReadOnlyList<RuntimeEventEnvelope> envelopes,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(envelopes);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var options = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
|
|
|
// Check if autoscan is enabled
|
|
if (!options.AutoScanEnabled)
|
|
{
|
|
var driftCount = envelopes.Count(e => e.Event.Kind == RuntimeEventKind.Drift);
|
|
if (driftCount > 0)
|
|
{
|
|
DeltaScanSkipped.Add(driftCount);
|
|
_logger.LogDebug(
|
|
"Delta scan disabled, skipping {DriftCount} DRIFT events",
|
|
driftCount);
|
|
}
|
|
return new DeltaScanResult(driftCount, 0, driftCount, 0);
|
|
}
|
|
|
|
var driftEvents = envelopes
|
|
.Where(e => e.Event.Kind == RuntimeEventKind.Drift)
|
|
.ToList();
|
|
|
|
if (driftEvents.Count == 0)
|
|
{
|
|
return new DeltaScanResult(0, 0, 0, 0);
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
var cooldownWindow = TimeSpan.FromSeconds(options.AutoScanCooldownSeconds);
|
|
var triggered = 0;
|
|
var skipped = 0;
|
|
var deduped = 0;
|
|
|
|
// Cleanup old entries from dedup cache
|
|
CleanupDeduplicationCache(now, cooldownWindow);
|
|
|
|
foreach (var envelope in driftEvents)
|
|
{
|
|
var runtimeEvent = envelope.Event;
|
|
var imageDigest = ExtractImageDigest(runtimeEvent);
|
|
|
|
if (string.IsNullOrWhiteSpace(imageDigest))
|
|
{
|
|
_logger.LogWarning(
|
|
"DRIFT event {EventId} has no image digest, skipping auto-scan",
|
|
runtimeEvent.EventId);
|
|
DeltaScanSkipped.Add(1);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Check deduplication
|
|
if (_recentTriggers.TryGetValue(imageDigest, out var lastTrigger))
|
|
{
|
|
if (now - lastTrigger < cooldownWindow)
|
|
{
|
|
_logger.LogDebug(
|
|
"DRIFT event {EventId} for image {ImageDigest} within cooldown window, deduplicating",
|
|
runtimeEvent.EventId,
|
|
imageDigest);
|
|
DeltaScanDeduped.Add(1);
|
|
deduped++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Trigger scan
|
|
var scanTarget = new ScanTarget(
|
|
runtimeEvent.Workload.ImageRef,
|
|
imageDigest);
|
|
|
|
var metadata = new Dictionary<string, string>
|
|
{
|
|
["stellaops:trigger"] = "drift",
|
|
["stellaops:drift.eventId"] = runtimeEvent.EventId,
|
|
["stellaops:drift.tenant"] = runtimeEvent.Tenant,
|
|
["stellaops:drift.node"] = runtimeEvent.Node
|
|
};
|
|
|
|
if (runtimeEvent.Delta?.BaselineImageDigest is { } baseline)
|
|
{
|
|
metadata["stellaops:drift.baselineDigest"] = baseline;
|
|
}
|
|
|
|
if (runtimeEvent.Delta?.ChangedFiles is { Count: > 0 } changedFiles)
|
|
{
|
|
metadata["stellaops:drift.changedFilesCount"] = changedFiles.Count.ToString();
|
|
}
|
|
|
|
if (runtimeEvent.Delta?.NewBinaries is { Count: > 0 } newBinaries)
|
|
{
|
|
metadata["stellaops:drift.newBinariesCount"] = newBinaries.Count.ToString();
|
|
}
|
|
|
|
var submission = new ScanSubmission(
|
|
scanTarget.Normalize(),
|
|
Force: false,
|
|
ClientRequestId: $"drift:{runtimeEvent.EventId}",
|
|
Metadata: metadata);
|
|
|
|
try
|
|
{
|
|
var result = await _scanCoordinator.SubmitAsync(submission, cancellationToken).ConfigureAwait(false);
|
|
|
|
_recentTriggers[imageDigest] = now;
|
|
DeltaScanTriggered.Add(1);
|
|
triggered++;
|
|
|
|
_logger.LogInformation(
|
|
"Delta scan triggered for DRIFT event {EventId}: scanId={ScanId}, created={Created}",
|
|
runtimeEvent.EventId,
|
|
result.Snapshot.ScanId,
|
|
result.Created);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(
|
|
ex,
|
|
"Failed to trigger delta scan for DRIFT event {EventId}, image {ImageDigest}",
|
|
runtimeEvent.EventId,
|
|
imageDigest);
|
|
DeltaScanSkipped.Add(1);
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
stopwatch.Stop();
|
|
DeltaScanLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds);
|
|
|
|
_logger.LogInformation(
|
|
"Delta scan processing complete: {DriftCount} DRIFT events, {Triggered} triggered, {Skipped} skipped, {Deduped} deduped",
|
|
driftEvents.Count,
|
|
triggered,
|
|
skipped,
|
|
deduped);
|
|
|
|
return new DeltaScanResult(driftEvents.Count, triggered, skipped, deduped);
|
|
}
|
|
|
|
private void CleanupDeduplicationCache(DateTimeOffset now, TimeSpan cooldownWindow)
|
|
{
|
|
var expiredKeys = _recentTriggers
|
|
.Where(kvp => now - kvp.Value > cooldownWindow * 2)
|
|
.Select(kvp => kvp.Key)
|
|
.ToList();
|
|
|
|
foreach (var key in expiredKeys)
|
|
{
|
|
_recentTriggers.TryRemove(key, out _);
|
|
}
|
|
}
|
|
|
|
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
|
|
{
|
|
// Prefer baseline digest from Delta for DRIFT events
|
|
var digest = runtimeEvent.Delta?.BaselineImageDigest?.Trim().ToLowerInvariant();
|
|
if (!string.IsNullOrWhiteSpace(digest))
|
|
{
|
|
return digest;
|
|
}
|
|
|
|
// Fall back to extracting from ImageRef
|
|
var imageRef = runtimeEvent.Workload.ImageRef;
|
|
if (string.IsNullOrWhiteSpace(imageRef))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var trimmed = imageRef.Trim();
|
|
var atIndex = trimmed.LastIndexOf('@');
|
|
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
|
|
{
|
|
var candidate = trimmed[(atIndex + 1)..].Trim().ToLowerInvariant();
|
|
if (candidate.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|