Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class AnalyzerStageExecutor : IScanStageExecutor
{
private readonly IScanAnalyzerDispatcher _dispatcher;
private readonly IEntryTraceExecutionService _entryTrace;
public AnalyzerStageExecutor(IScanAnalyzerDispatcher dispatcher, IEntryTraceExecutionService entryTrace)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_entryTrace = entryTrace ?? throw new ArgumentNullException(nameof(entryTrace));
}
public string StageName => ScanStageNames.ExecuteAnalyzers;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
await _entryTrace.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
await _dispatcher.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.Analyzers.OS;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Mapping;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOSAnalyzerPluginCatalog _osCatalog;
private readonly ILanguageAnalyzerPluginCatalog _languageCatalog;
private readonly ScannerWorkerOptions _options;
private readonly ILogger<CompositeScanAnalyzerDispatcher> _logger;
private IReadOnlyList<string> _osPluginDirectories = Array.Empty<string>();
private IReadOnlyList<string> _languagePluginDirectories = Array.Empty<string>();
public CompositeScanAnalyzerDispatcher(
IServiceScopeFactory scopeFactory,
IOSAnalyzerPluginCatalog osCatalog,
ILanguageAnalyzerPluginCatalog languageCatalog,
IOptions<ScannerWorkerOptions> options,
ILogger<CompositeScanAnalyzerDispatcher> logger)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_osCatalog = osCatalog ?? throw new ArgumentNullException(nameof(osCatalog));
_languageCatalog = languageCatalog ?? throw new ArgumentNullException(nameof(languageCatalog));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
LoadPlugins();
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
using var scope = _scopeFactory.CreateScope();
var services = scope.ServiceProvider;
var osAnalyzers = _osCatalog.CreateAnalyzers(services);
var languageAnalyzers = _languageCatalog.CreateAnalyzers(services);
if (osAnalyzers.Count == 0 && languageAnalyzers.Count == 0)
{
_logger.LogWarning("No analyzer plug-ins available; skipping analyzer stage for job {JobId}.", context.JobId);
return;
}
var metadata = new Dictionary<string, string>(context.Lease.Metadata, StringComparer.Ordinal);
var rootfsPath = ResolvePath(metadata, _options.Analyzers.RootFilesystemMetadataKey);
var workspacePath = ResolvePath(metadata, _options.Analyzers.WorkspaceMetadataKey) ?? rootfsPath;
if (osAnalyzers.Count > 0)
{
await ExecuteOsAnalyzersAsync(context, osAnalyzers, services, rootfsPath, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
if (languageAnalyzers.Count > 0)
{
await ExecuteLanguageAnalyzersAsync(context, languageAnalyzers, services, workspacePath, cancellationToken)
.ConfigureAwait(false);
}
}
private async Task ExecuteOsAnalyzersAsync(
ScanJobContext context,
IReadOnlyList<IOSPackageAnalyzer> analyzers,
IServiceProvider services,
string? rootfsPath,
string? workspacePath,
CancellationToken cancellationToken)
{
if (rootfsPath is null)
{
_logger.LogWarning(
"Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate root filesystem. OS analyzers skipped.",
_options.Analyzers.RootFilesystemMetadataKey,
context.JobId);
return;
}
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var results = new List<OSPackageAnalyzerResult>(analyzers.Count);
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
var analyzerLogger = loggerFactory.CreateLogger(analyzer.GetType());
var analyzerContext = new OSPackageAnalyzerContext(rootfsPath, workspacePath, context.TimeProvider, analyzerLogger, context.Lease.Metadata);
try
{
var result = await analyzer.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Analyzer {AnalyzerId} failed for job {JobId}.", analyzer.AnalyzerId, context.JobId);
}
}
if (results.Count == 0)
{
return;
}
var dictionary = results.ToDictionary(result => result.AnalyzerId, StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.OsPackageAnalyzers, dictionary);
var fragments = OsComponentMapper.ToLayerFragments(results);
if (!fragments.IsDefaultOrEmpty)
{
context.Analysis.AppendLayerFragments(fragments);
context.Analysis.Set(ScanAnalysisKeys.OsComponentFragments, fragments);
}
}
private async Task ExecuteLanguageAnalyzersAsync(
ScanJobContext context,
IReadOnlyList<ILanguageAnalyzer> analyzers,
IServiceProvider services,
string? workspacePath,
CancellationToken cancellationToken)
{
if (workspacePath is null)
{
_logger.LogWarning(
"Metadata key '{MetadataKey}' missing for job {JobId}; unable to locate workspace. Language analyzers skipped.",
_options.Analyzers.WorkspaceMetadataKey,
context.JobId);
return;
}
var usageHints = LanguageUsageHints.Empty;
var analyzerContext = new LanguageAnalyzerContext(workspacePath, context.TimeProvider, usageHints, services);
var results = new Dictionary<string, LanguageAnalyzerResult>(StringComparer.OrdinalIgnoreCase);
var fragments = new List<LayerComponentFragment>();
foreach (var analyzer in analyzers)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var engine = new LanguageAnalyzerEngine(new[] { analyzer });
var result = await engine.AnalyzeAsync(analyzerContext, cancellationToken).ConfigureAwait(false);
results[analyzer.Id] = result;
var components = result.Components
.Where(component => string.Equals(component.AnalyzerId, analyzer.Id, StringComparison.Ordinal))
.ToArray();
if (components.Length > 0)
{
var fragment = LanguageComponentMapper.ToLayerFragment(analyzer.Id, components);
fragments.Add(fragment);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Language analyzer {AnalyzerId} failed for job {JobId}.", analyzer.Id, context.JobId);
}
}
if (results.Count == 0 && fragments.Count == 0)
{
return;
}
if (results.Count > 0)
{
context.Analysis.Set(
ScanAnalysisKeys.LanguageAnalyzerResults,
new ReadOnlyDictionary<string, LanguageAnalyzerResult>(results));
}
if (fragments.Count > 0)
{
var immutableFragments = ImmutableArray.CreateRange(fragments);
context.Analysis.AppendLayerFragments(immutableFragments);
context.Analysis.Set(ScanAnalysisKeys.LanguageComponentFragments, immutableFragments);
}
}
private void LoadPlugins()
{
_osPluginDirectories = NormalizeDirectories(_options.Analyzers.PluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "os"));
for (var i = 0; i < _osPluginDirectories.Count; i++)
{
var directory = _osPluginDirectories[i];
var seal = i == _osPluginDirectories.Count - 1;
try
{
_osCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load OS analyzer plug-ins from {Directory}.", directory);
}
}
_languagePluginDirectories = NormalizeDirectories(_options.Analyzers.LanguagePluginDirectories, Path.Combine("plugins", "scanner", "analyzers", "lang"));
for (var i = 0; i < _languagePluginDirectories.Count; i++)
{
var directory = _languagePluginDirectories[i];
var seal = i == _languagePluginDirectories.Count - 1;
try
{
_languageCatalog.LoadFromDirectory(directory, seal);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load language analyzer plug-ins from {Directory}.", directory);
}
}
}
private static IReadOnlyList<string> NormalizeDirectories(IEnumerable<string> configured, string fallbackRelative)
{
var directories = new List<string>();
foreach (var configuredPath in configured ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
continue;
}
var path = configuredPath;
if (!Path.IsPathRooted(path))
{
path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path));
}
directories.Add(path);
}
if (directories.Count == 0)
{
var fallback = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, fallbackRelative));
directories.Add(fallback);
}
return new ReadOnlyCollection<string>(directories);
}
private static string? ResolvePath(IReadOnlyDictionary<string, string> metadata, string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return Path.IsPathRooted(trimmed)
? trimmed
: Path.GetFullPath(trimmed);
}
}

View File

@@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Worker.Options;
using IOPath = System.IO.Path;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class EntryTraceExecutionService : IEntryTraceExecutionService
{
private readonly IEntryTraceAnalyzer _analyzer;
private readonly EntryTraceAnalyzerOptions _entryTraceOptions;
private readonly ScannerWorkerOptions _workerOptions;
private readonly ILogger<EntryTraceExecutionService> _logger;
private readonly ILoggerFactory _loggerFactory;
public EntryTraceExecutionService(
IEntryTraceAnalyzer analyzer,
IOptions<EntryTraceAnalyzerOptions> entryTraceOptions,
IOptions<ScannerWorkerOptions> workerOptions,
ILogger<EntryTraceExecutionService> logger,
ILoggerFactory loggerFactory)
{
_analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
_entryTraceOptions = (entryTraceOptions ?? throw new ArgumentNullException(nameof(entryTraceOptions))).Value ?? new EntryTraceAnalyzerOptions();
_workerOptions = (workerOptions ?? throw new ArgumentNullException(nameof(workerOptions))).Value ?? new ScannerWorkerOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var metadata = context.Lease.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
var configPath = ResolvePath(metadata, _workerOptions.Analyzers.EntryTraceConfigMetadataKey, ScanMetadataKeys.ImageConfigPath);
if (configPath is null)
{
_logger.LogDebug("EntryTrace config metadata '{MetadataKey}' missing for job {JobId}; skipping entry trace.", _workerOptions.Analyzers.EntryTraceConfigMetadataKey, context.JobId);
return;
}
if (!File.Exists(configPath))
{
_logger.LogWarning("EntryTrace config file '{ConfigPath}' not found for job {JobId}; skipping entry trace.", configPath, context.JobId);
return;
}
OciImageConfig config;
try
{
using var stream = File.OpenRead(configPath);
config = OciImageConfigLoader.Load(stream);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse OCI image config at '{ConfigPath}' for job {JobId}; entry trace skipped.", configPath, context.JobId);
return;
}
var fileSystem = BuildFileSystem(context.JobId, metadata);
if (fileSystem is null)
{
return;
}
var imageDigest = ResolveImageDigest(metadata, context);
var entryTraceLogger = _loggerFactory.CreateLogger<EntryTraceExecutionService>();
EntryTraceImageContext imageContext;
try
{
imageContext = EntryTraceImageContextFactory.Create(
config,
fileSystem,
_entryTraceOptions,
imageDigest,
context.ScanId,
entryTraceLogger);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to build EntryTrace context for job {JobId}; skipping entry trace.", context.JobId);
return;
}
EntryTraceGraph graph;
try
{
graph = await _analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "EntryTrace analyzer failed for job {JobId}.", context.JobId);
return;
}
context.Analysis.Set(ScanAnalysisKeys.EntryTraceGraph, graph);
}
private LayeredRootFileSystem? BuildFileSystem(string jobId, IReadOnlyDictionary<string, string> metadata)
{
var directoryValues = ResolveList(metadata, _workerOptions.Analyzers.EntryTraceLayerDirectoriesMetadataKey, ScanMetadataKeys.LayerDirectories);
var archiveValues = ResolveList(metadata, _workerOptions.Analyzers.EntryTraceLayerArchivesMetadataKey, ScanMetadataKeys.LayerArchives);
var directoryLayers = new List<LayeredRootFileSystem.LayerDirectory>();
foreach (var value in directoryValues)
{
var fullPath = NormalizePath(value);
if (string.IsNullOrWhiteSpace(fullPath))
{
continue;
}
if (!Directory.Exists(fullPath))
{
_logger.LogWarning("EntryTrace layer directory '{Directory}' not found for job {JobId}; skipping layer.", fullPath, jobId);
continue;
}
directoryLayers.Add(new LayeredRootFileSystem.LayerDirectory(TryDeriveDigest(fullPath) ?? string.Empty, fullPath));
}
var archiveLayers = new List<LayeredRootFileSystem.LayerArchive>();
foreach (var value in archiveValues)
{
var fullPath = NormalizePath(value);
if (string.IsNullOrWhiteSpace(fullPath))
{
continue;
}
if (!File.Exists(fullPath))
{
_logger.LogWarning("EntryTrace layer archive '{Archive}' not found for job {JobId}; skipping layer.", fullPath, jobId);
continue;
}
archiveLayers.Add(new LayeredRootFileSystem.LayerArchive(TryDeriveDigest(fullPath) ?? string.Empty, fullPath));
}
try
{
if (archiveLayers.Count > 0)
{
return LayeredRootFileSystem.FromArchives(archiveLayers);
}
if (directoryLayers.Count > 0)
{
return LayeredRootFileSystem.FromDirectories(directoryLayers);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to construct layered root filesystem for job {JobId}; entry trace skipped.", jobId);
return null;
}
var rootFsPath = ResolvePath(metadata, _workerOptions.Analyzers.RootFilesystemMetadataKey, ScanMetadataKeys.RootFilesystemPath);
if (!string.IsNullOrWhiteSpace(rootFsPath) && Directory.Exists(rootFsPath))
{
try
{
return LayeredRootFileSystem.FromDirectories(new[]
{
new LayeredRootFileSystem.LayerDirectory(TryDeriveDigest(rootFsPath) ?? string.Empty, rootFsPath)
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create layered filesystem from root path '{RootPath}' for job {JobId}; entry trace skipped.", rootFsPath, jobId);
return null;
}
}
_logger.LogDebug("No EntryTrace layers or root filesystem metadata available for job {JobId}; skipping entry trace.", jobId);
return null;
}
private static string ResolveImageDigest(IReadOnlyDictionary<string, string> metadata, ScanJobContext context)
{
if (metadata.TryGetValue("image.digest", out var digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
if (metadata.TryGetValue("imageDigest", out var altDigest) && !string.IsNullOrWhiteSpace(altDigest))
{
return altDigest.Trim();
}
return context.Lease.Metadata.TryGetValue("scanner.image.digest", out var scopedDigest) && !string.IsNullOrWhiteSpace(scopedDigest)
? scopedDigest.Trim()
: $"sha256:{context.JobId}";
}
private static IReadOnlyCollection<string> ResolveList(IReadOnlyDictionary<string, string> metadata, string key, string fallbackKey)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return SplitList(value);
}
if (!string.Equals(key, fallbackKey, StringComparison.Ordinal) &&
metadata.TryGetValue(fallbackKey, out var fallbackValue) &&
!string.IsNullOrWhiteSpace(fallbackValue))
{
return SplitList(fallbackValue);
}
return Array.Empty<string>();
}
private static string? ResolvePath(IReadOnlyDictionary<string, string> metadata, string key, string fallbackKey)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return NormalizePath(value);
}
if (!string.Equals(key, fallbackKey, StringComparison.Ordinal) &&
metadata.TryGetValue(fallbackKey, out var fallbackValue) &&
!string.IsNullOrWhiteSpace(fallbackValue))
{
return NormalizePath(fallbackValue);
}
return null;
}
private static IReadOnlyCollection<string> SplitList(string value)
{
var segments = value.Split(new[] { ';', ',', '\n', '\r', IOPath.PathSeparator }, StringSplitOptions.RemoveEmptyEntries);
return segments
.Select(segment => NormalizePath(segment))
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.ToArray();
}
private static string NormalizePath(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var trimmed = value.Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) ? string.Empty : trimmed;
}
private static string? TryDeriveDigest(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var candidate = path.TrimEnd(IOPath.DirectorySeparatorChar, IOPath.AltDirectorySeparatorChar);
var name = IOPath.GetFileName(candidate);
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var normalized = name;
if (normalized.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[..^7];
}
else if (normalized.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[..^4];
}
else if (normalized.EndsWith(".tar", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[..^4];
}
if (normalized.Contains(':', StringComparison.Ordinal))
{
return normalized;
}
if (normalized.StartsWith("sha", StringComparison.OrdinalIgnoreCase))
{
return normalized.Contains('-')
? normalized.Replace('-', ':')
: $"sha256:{normalized}";
}
return null;
}
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IDelayScheduler
{
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IEntryTraceExecutionService
{
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanAnalyzerDispatcher
{
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
}
public sealed class NullScanAnalyzerDispatcher : IScanAnalyzerDispatcher
{
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanJobLease : IAsyncDisposable
{
string JobId { get; }
string ScanId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAtUtc { get; }
DateTimeOffset LeasedAtUtc { get; }
TimeSpan LeaseDuration { get; }
IReadOnlyDictionary<string, string> Metadata { get; }
ValueTask RenewAsync(CancellationToken cancellationToken);
ValueTask CompleteAsync(CancellationToken cancellationToken);
ValueTask AbandonAsync(string reason, CancellationToken cancellationToken);
ValueTask PoisonAsync(string reason, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanJobSource
{
Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public interface IScanStageExecutor
{
string StageName { get; }
ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,155 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class LeaseHeartbeatService
{
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
private readonly IDelayScheduler _delayScheduler;
private readonly ILogger<LeaseHeartbeatService> _logger;
public LeaseHeartbeatService(TimeProvider timeProvider, IDelayScheduler delayScheduler, IOptionsMonitor<ScannerWorkerOptions> options, ILogger<LeaseHeartbeatService> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_delayScheduler = delayScheduler ?? throw new ArgumentNullException(nameof(delayScheduler));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task RunAsync(IScanJobLease lease, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(lease);
await Task.Yield();
while (!cancellationToken.IsCancellationRequested)
{
var options = _options.CurrentValue;
var interval = ComputeInterval(options, lease);
var delay = ApplyJitter(interval, options.Queue);
try
{
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
if (await TryRenewAsync(options, lease, cancellationToken).ConfigureAwait(false))
{
continue;
}
_logger.LogError(
"Job {JobId} (scan {ScanId}) lease renewal exhausted retries; cancelling processing.",
lease.JobId,
lease.ScanId);
throw new InvalidOperationException("Lease renewal retries exhausted.");
}
}
private static TimeSpan ComputeInterval(ScannerWorkerOptions options, IScanJobLease lease)
{
var divisor = options.Queue.HeartbeatSafetyFactor <= 0 ? 3.0 : options.Queue.HeartbeatSafetyFactor;
var safetyFactor = Math.Max(3.0, divisor);
var recommended = TimeSpan.FromTicks((long)(lease.LeaseDuration.Ticks / safetyFactor));
if (recommended < options.Queue.MinHeartbeatInterval)
{
recommended = options.Queue.MinHeartbeatInterval;
}
else if (recommended > options.Queue.MaxHeartbeatInterval)
{
recommended = options.Queue.MaxHeartbeatInterval;
}
return recommended;
}
private static TimeSpan ApplyJitter(TimeSpan duration, ScannerWorkerOptions.QueueOptions queueOptions)
{
if (queueOptions.MaxHeartbeatJitterMilliseconds <= 0)
{
return duration;
}
var offsetMs = Random.Shared.NextDouble() * queueOptions.MaxHeartbeatJitterMilliseconds;
var adjusted = duration - TimeSpan.FromMilliseconds(offsetMs);
if (adjusted < queueOptions.MinHeartbeatInterval)
{
return queueOptions.MinHeartbeatInterval;
}
return adjusted > TimeSpan.Zero ? adjusted : queueOptions.MinHeartbeatInterval;
}
private async Task<bool> TryRenewAsync(ScannerWorkerOptions options, IScanJobLease lease, CancellationToken cancellationToken)
{
try
{
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Job {JobId} (scan {ScanId}) heartbeat failed; retrying.",
lease.JobId,
lease.ScanId);
}
foreach (var delay in options.Queue.NormalizedHeartbeatRetryDelays)
{
if (cancellationToken.IsCancellationRequested)
{
return false;
}
try
{
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
try
{
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Job {JobId} (scan {ScanId}) heartbeat retry failed; will retry after {Delay}.",
lease.JobId,
lease.ScanId,
delay);
}
}
return false;
}
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class NoOpStageExecutor : IScanStageExecutor
{
public NoOpStageExecutor(string stageName)
{
StageName = stageName ?? throw new ArgumentNullException(nameof(stageName));
}
public string StageName { get; }
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class NullScanJobSource : IScanJobSource
{
private readonly ILogger<NullScanJobSource> _logger;
private int _logged;
public NullScanJobSource(ILogger<NullScanJobSource> logger)
{
_logger = logger;
}
public Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _logged, 1) == 0)
{
_logger.LogWarning("No queue provider registered. Scanner worker will idle until a queue adapter is configured.");
}
return Task.FromResult<IScanJobLease?>(null);
}
}

View File

@@ -0,0 +1,49 @@
using System;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class PollDelayStrategy
{
private readonly ScannerWorkerOptions.PollingOptions _options;
private TimeSpan _currentDelay;
public PollDelayStrategy(ScannerWorkerOptions.PollingOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public TimeSpan NextDelay()
{
if (_currentDelay == TimeSpan.Zero)
{
_currentDelay = _options.InitialDelay;
return ApplyJitter(_currentDelay);
}
var doubled = _currentDelay + _currentDelay;
_currentDelay = doubled < _options.MaxDelay ? doubled : _options.MaxDelay;
return ApplyJitter(_currentDelay);
}
public void Reset() => _currentDelay = TimeSpan.Zero;
private TimeSpan ApplyJitter(TimeSpan duration)
{
if (_options.JitterRatio <= 0)
{
return duration;
}
var maxOffset = duration.TotalMilliseconds * _options.JitterRatio;
if (maxOffset <= 0)
{
return duration;
}
var offset = (Random.Shared.NextDouble() * 2.0 - 1.0) * maxOffset;
var adjustedMs = Math.Max(0, duration.TotalMilliseconds + offset);
return TimeSpan.FromMilliseconds(adjustedMs);
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Threading;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class ScanJobContext
{
public ScanJobContext(IScanJobLease lease, TimeProvider timeProvider, DateTimeOffset startUtc, CancellationToken cancellationToken)
{
Lease = lease ?? throw new ArgumentNullException(nameof(lease));
TimeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
StartUtc = startUtc;
CancellationToken = cancellationToken;
Analysis = new ScanAnalysisStore();
}
public IScanJobLease Lease { get; }
public TimeProvider TimeProvider { get; }
public DateTimeOffset StartUtc { get; }
public CancellationToken CancellationToken { get; }
public string JobId => Lease.JobId;
public string ScanId => Lease.ScanId;
public ScanAnalysisStore Analysis { get; }
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class ScanJobProcessor
{
private readonly IReadOnlyDictionary<string, IScanStageExecutor> _executors;
private readonly ScanProgressReporter _progressReporter;
private readonly ILogger<ScanJobProcessor> _logger;
public ScanJobProcessor(IEnumerable<IScanStageExecutor> executors, ScanProgressReporter progressReporter, ILogger<ScanJobProcessor> logger)
{
_progressReporter = progressReporter ?? throw new ArgumentNullException(nameof(progressReporter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var map = new Dictionary<string, IScanStageExecutor>(StringComparer.OrdinalIgnoreCase);
foreach (var executor in executors ?? Array.Empty<IScanStageExecutor>())
{
if (executor is null || string.IsNullOrWhiteSpace(executor.StageName))
{
continue;
}
map[executor.StageName] = executor;
}
foreach (var stage in ScanStageNames.Ordered)
{
if (map.ContainsKey(stage))
{
continue;
}
map[stage] = new NoOpStageExecutor(stage);
_logger.LogDebug("No executor registered for stage {Stage}; using no-op placeholder.", stage);
}
_executors = map;
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
foreach (var stage in ScanStageNames.Ordered)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_executors.TryGetValue(stage, out var executor))
{
continue;
}
await _progressReporter.ExecuteStageAsync(
context,
stage,
executor.ExecuteAsync,
cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Worker.Diagnostics;
namespace StellaOps.Scanner.Worker.Processing;
public sealed partial class ScanProgressReporter
{
private readonly ScannerWorkerMetrics _metrics;
private readonly ILogger<ScanProgressReporter> _logger;
public ScanProgressReporter(ScannerWorkerMetrics metrics, ILogger<ScanProgressReporter> logger)
{
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask ExecuteStageAsync(
ScanJobContext context,
string stageName,
Func<ScanJobContext, CancellationToken, ValueTask> stageWork,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(stageName);
ArgumentNullException.ThrowIfNull(stageWork);
StageStarting(_logger, context.JobId, context.ScanId, stageName, context.Lease.Attempt);
var start = context.TimeProvider.GetUtcNow();
using var activity = ScannerWorkerInstrumentation.ActivitySource.StartActivity(
$"scanner.worker.{stageName}",
ActivityKind.Internal);
activity?.SetTag("scanner.worker.job_id", context.JobId);
activity?.SetTag("scanner.worker.scan_id", context.ScanId);
activity?.SetTag("scanner.worker.stage", stageName);
try
{
await stageWork(context, cancellationToken).ConfigureAwait(false);
var duration = context.TimeProvider.GetUtcNow() - start;
_metrics.RecordStageDuration(context, stageName, duration);
StageCompleted(_logger, context.JobId, context.ScanId, stageName, duration.TotalMilliseconds);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
StageCancelled(_logger, context.JobId, context.ScanId, stageName);
throw;
}
catch (Exception ex)
{
var duration = context.TimeProvider.GetUtcNow() - start;
_metrics.RecordStageDuration(context, stageName, duration);
StageFailed(_logger, context.JobId, context.ScanId, stageName, ex);
throw;
}
}
[LoggerMessage(
EventId = 1000,
Level = LogLevel.Information,
Message = "Job {JobId} (scan {ScanId}) entering stage {Stage} (attempt {Attempt}).")]
private static partial void StageStarting(ILogger logger, string jobId, string scanId, string stage, int attempt);
[LoggerMessage(
EventId = 1001,
Level = LogLevel.Information,
Message = "Job {JobId} (scan {ScanId}) finished stage {Stage} in {ElapsedMs:F0} ms.")]
private static partial void StageCompleted(ILogger logger, string jobId, string scanId, string stage, double elapsedMs);
[LoggerMessage(
EventId = 1002,
Level = LogLevel.Warning,
Message = "Job {JobId} (scan {ScanId}) stage {Stage} cancelled by request.")]
private static partial void StageCancelled(ILogger logger, string jobId, string scanId, string stage);
[LoggerMessage(
EventId = 1003,
Level = LogLevel.Error,
Message = "Job {JobId} (scan {ScanId}) stage {Stage} failed.")]
private static partial void StageFailed(ILogger logger, string jobId, string scanId, string stage, Exception exception);
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.Worker.Processing;
public static class ScanStageNames
{
public const string ResolveImage = "resolve-image";
public const string PullLayers = "pull-layers";
public const string BuildFilesystem = "build-filesystem";
public const string ExecuteAnalyzers = "execute-analyzers";
public const string ComposeArtifacts = "compose-artifacts";
public const string EmitReports = "emit-reports";
public static readonly IReadOnlyList<string> Ordered = new[]
{
ResolveImage,
PullLayers,
BuildFilesystem,
ExecuteAnalyzers,
ComposeArtifacts,
EmitReports,
};
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class SystemDelayScheduler : IDelayScheduler
{
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
if (delay <= TimeSpan.Zero)
{
return Task.CompletedTask;
}
return Task.Delay(delay, cancellationToken);
}
}