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,173 @@
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.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
using WorkerOptions = StellaOps.Scanner.Worker.Options.ScannerWorkerOptions;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class CompositeScanAnalyzerDispatcherTests
{
[Fact]
public async Task ExecuteAsync_RunsLanguageAnalyzers_StoresResults()
{
using var workspace = new TempDirectory();
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
{ ScanMetadataKeys.RootFilesystemPath, workspace.Path },
{ ScanMetadataKeys.WorkspacePath, workspace.Path },
};
var osCatalog = new FakeOsCatalog();
var languageCatalog = new FakeLanguageCatalog(new FakeLanguageAnalyzer());
var services = new ServiceCollection()
.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug))
.BuildServiceProvider();
var scopeFactory = services.GetRequiredService<IServiceScopeFactory>();
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var options = Microsoft.Extensions.Options.Options.Create(new WorkerOptions());
var dispatcher = new CompositeScanAnalyzerDispatcher(
scopeFactory,
osCatalog,
languageCatalog,
options,
loggerFactory.CreateLogger<CompositeScanAnalyzerDispatcher>());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
await dispatcher.ExecuteAsync(context, CancellationToken.None);
Assert.True(context.Analysis.TryGet<ReadOnlyDictionary<string, LanguageAnalyzerResult>>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results));
Assert.Single(results);
Assert.True(context.Analysis.TryGet<ImmutableArray<LayerComponentFragment>>(ScanAnalysisKeys.LanguageComponentFragments, out var fragments));
Assert.False(fragments.IsDefaultOrEmpty);
Assert.True(context.Analysis.GetLayerFragments().Any(fragment => fragment.Components.Any(component => component.Identity.Name == "demo-package")));
}
private sealed class FakeOsCatalog : IOSAnalyzerPluginCatalog
{
public IReadOnlyCollection<IOSAnalyzerPlugin> Plugins => Array.Empty<IOSAnalyzerPlugin>();
public void LoadFromDirectory(string directory, bool seal = true)
{
}
public IReadOnlyList<IOSPackageAnalyzer> CreateAnalyzers(IServiceProvider services) => Array.Empty<IOSPackageAnalyzer>();
}
private sealed class FakeLanguageCatalog : ILanguageAnalyzerPluginCatalog
{
private readonly IReadOnlyList<ILanguageAnalyzer> _analyzers;
public FakeLanguageCatalog(params ILanguageAnalyzer[] analyzers)
{
_analyzers = analyzers ?? Array.Empty<ILanguageAnalyzer>();
}
public IReadOnlyCollection<ILanguageAnalyzerPlugin> Plugins => Array.Empty<ILanguageAnalyzerPlugin>();
public void LoadFromDirectory(string directory, bool seal = true)
{
}
public IReadOnlyList<ILanguageAnalyzer> CreateAnalyzers(IServiceProvider services) => _analyzers;
}
private sealed class FakeLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "lang.fake";
public string DisplayName => "Fake Language Analyzer";
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
writer.AddFromPurl(
analyzerId: Id,
purl: "pkg:npm/demo-package@1.0.0",
name: "demo-package",
version: "1.0.0",
type: "npm");
return ValueTask.CompletedTask;
}
}
private sealed class TestJobLease : IScanJobLease
{
private readonly Dictionary<string, string> _metadata;
public TestJobLease(Dictionary<string, string> metadata)
{
_metadata = metadata;
JobId = Guid.NewGuid().ToString("n");
ScanId = $"scan-{Guid.NewGuid():n}";
Attempt = 1;
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-1);
LeasedAtUtc = DateTimeOffset.UtcNow;
LeaseDuration = TimeSpan.FromMinutes(5);
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; }
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():n}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
}
}
}
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
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 StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class EntryTraceExecutionServiceTests : IDisposable
{
private readonly string _tempRoot;
public EntryTraceExecutionServiceTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
Directory.CreateDirectory(_tempRoot);
}
[Fact]
public async Task ExecuteAsync_Skips_When_ConfigMetadataMissing()
{
var analyzer = new CapturingEntryTraceAnalyzer();
var service = CreateService(analyzer);
var context = CreateContext(new Dictionary<string, string>());
await service.ExecuteAsync(context, CancellationToken.None);
Assert.False(analyzer.Invoked);
Assert.False(context.Analysis.TryGet<EntryTraceGraph>(ScanAnalysisKeys.EntryTraceGraph, out _));
}
[Fact]
public async Task ExecuteAsync_BuildsContext_AndStoresGraph()
{
var configPath = Path.Combine(_tempRoot, "config.json");
File.WriteAllText(configPath, """
{
"config": {
"Env": ["PATH=/bin:/usr/bin"],
"Entrypoint": ["/entrypoint.sh"],
"WorkingDir": "/workspace",
"User": "scanner"
}
}
""");
var layerDirectory = Path.Combine(_tempRoot, "layer-1");
Directory.CreateDirectory(layerDirectory);
File.WriteAllText(Path.Combine(layerDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
var metadata = new Dictionary<string, string>
{
[ScanMetadataKeys.ImageConfigPath] = configPath,
[ScanMetadataKeys.LayerDirectories] = layerDirectory,
["image.digest"] = "sha256:test-digest"
};
var analyzer = new CapturingEntryTraceAnalyzer();
var service = CreateService(analyzer);
var context = CreateContext(metadata);
await service.ExecuteAsync(context, CancellationToken.None);
Assert.True(analyzer.Invoked);
Assert.NotNull(analyzer.LastEntrypoint);
Assert.Equal("/entrypoint.sh", analyzer.LastEntrypoint!.Entrypoint[0]);
Assert.NotNull(analyzer.LastContext);
Assert.Equal("scanner", analyzer.LastContext!.User);
Assert.Equal("/workspace", analyzer.LastContext.WorkingDirectory);
Assert.Contains("/bin", analyzer.LastContext.Path);
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out EntryTraceGraph stored));
Assert.Same(analyzer.Graph, stored);
}
private EntryTraceExecutionService CreateService(IEntryTraceAnalyzer analyzer)
{
var workerOptions = new ScannerWorkerOptions();
var entryTraceOptions = new EntryTraceAnalyzerOptions();
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace));
return new EntryTraceExecutionService(
analyzer,
Options.Create(entryTraceOptions),
Options.Create(workerOptions),
loggerFactory.CreateLogger<EntryTraceExecutionService>(),
loggerFactory);
}
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
{
var lease = new TestLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
// ignore cleanup failures
}
}
private sealed class CapturingEntryTraceAnalyzer : IEntryTraceAnalyzer
{
public bool Invoked { get; private set; }
public EntrypointSpecification? LastEntrypoint { get; private set; }
public EntryTraceContext? LastContext { get; private set; }
public EntryTraceGraph Graph { get; } = new(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty);
public ValueTask<EntryTraceGraph> ResolveAsync(EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default)
{
Invoked = true;
LastEntrypoint = entrypoint;
LastContext = context;
return ValueTask.FromResult(Graph);
}
}
private sealed class TestLease : IScanJobLease
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public TestLease(IReadOnlyDictionary<string, string> metadata)
{
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow;
LeasedAtUtc = EnqueuedAtUtc;
}
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class LeaseHeartbeatServiceTests
{
[Fact]
public async Task RunAsync_RespectsSafetyFactorBudget()
{
var options = new ScannerWorkerOptions
{
MaxConcurrentJobs = 1,
};
options.Queue.HeartbeatSafetyFactor = 3.0;
options.Queue.MinHeartbeatInterval = TimeSpan.FromSeconds(5);
options.Queue.MaxHeartbeatInterval = TimeSpan.FromSeconds(60);
options.Queue.SetHeartbeatRetryDelays(Array.Empty<TimeSpan>());
options.Queue.MaxHeartbeatJitterMilliseconds = 750;
var optionsMonitor = new StaticOptionsMonitor<ScannerWorkerOptions>(options);
using var cts = new CancellationTokenSource();
var scheduler = new RecordingDelayScheduler(cts);
var lease = new TestJobLease(TimeSpan.FromSeconds(90));
var service = new LeaseHeartbeatService(TimeProvider.System, scheduler, optionsMonitor, NullLogger<LeaseHeartbeatService>.Instance);
await service.RunAsync(lease, cts.Token);
var delay = Assert.Single(scheduler.Delays);
var expectedMax = TimeSpan.FromTicks((long)(lease.LeaseDuration.Ticks / Math.Max(3.0, options.Queue.HeartbeatSafetyFactor)));
Assert.True(delay <= expectedMax, $"Heartbeat delay {delay} should stay within safety factor budget {expectedMax}.");
Assert.True(delay >= options.Queue.MinHeartbeatInterval, $"Heartbeat delay {delay} should respect minimum interval {options.Queue.MinHeartbeatInterval}.");
}
private sealed class RecordingDelayScheduler : IDelayScheduler
{
private readonly CancellationTokenSource _cts;
public RecordingDelayScheduler(CancellationTokenSource cts)
{
_cts = cts ?? throw new ArgumentNullException(nameof(cts));
}
public List<TimeSpan> Delays { get; } = new();
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
Delays.Add(delay);
_cts.Cancel();
return Task.CompletedTask;
}
}
private sealed class TestJobLease : IScanJobLease
{
public TestJobLease(TimeSpan leaseDuration)
{
LeaseDuration = leaseDuration;
EnqueuedAtUtc = DateTimeOffset.UtcNow - leaseDuration;
LeasedAtUtc = DateTimeOffset.UtcNow;
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; }
public IReadOnlyDictionary<string, string> Metadata { get; } = new Dictionary<string, string>();
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class StaticOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions _value;
public StaticOptionsMonitor(TOptions value)
{
_value = value ?? throw new ArgumentNullException(nameof(value));
}
public TOptions CurrentValue => _value;
public TOptions Get(string? name) => _value;
public IDisposable OnChange(Action<TOptions, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Queue;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class RedisWorkerSmokeTests
{
[Fact]
public async Task Worker_CompletesJob_ViaRedisQueue()
{
var flag = Environment.GetEnvironmentVariable("STELLAOPS_REDIS_SMOKE");
if (string.IsNullOrWhiteSpace(flag))
{
return;
}
var redisConnection = Environment.GetEnvironmentVariable("STELLAOPS_REDIS_CONNECTION") ?? "localhost:6379";
var streamName = $"scanner:jobs:{Guid.NewGuid():n}";
var consumerGroup = $"worker-smoke-{Guid.NewGuid():n}";
var configuration = BuildQueueConfiguration(redisConnection, streamName, consumerGroup);
var queueOptions = new ScannerQueueOptions();
configuration.GetSection("scanner:queue").Bind(queueOptions);
var workerOptions = new ScannerWorkerOptions
{
MaxConcurrentJobs = 1,
};
workerOptions.Queue.HeartbeatSafetyFactor = 3.0;
workerOptions.Queue.MinHeartbeatInterval = TimeSpan.FromSeconds(2);
workerOptions.Queue.MaxHeartbeatInterval = TimeSpan.FromSeconds(8);
workerOptions.Queue.SetHeartbeatRetryDelays(new[]
{
TimeSpan.FromMilliseconds(200),
TimeSpan.FromMilliseconds(500),
TimeSpan.FromSeconds(1),
});
var services = new ServiceCollection();
services.AddLogging(builder =>
{
builder.SetMinimumLevel(LogLevel.Debug);
builder.AddConsole();
});
services.AddSingleton(TimeProvider.System);
services.AddScannerQueue(configuration, "scanner:queue");
services.AddSingleton<IScanJobSource, QueueBackedScanJobSource>();
services.AddSingleton<QueueBackedScanJobSourceDependencies>();
services.AddSingleton(queueOptions);
services.AddSingleton<IScanAnalyzerDispatcher, SmokeAnalyzerDispatcher>();
services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();
services.AddSingleton<ScannerWorkerMetrics>();
services.AddSingleton<ScanProgressReporter>();
services.AddSingleton<ScanJobProcessor>();
services.AddSingleton<LeaseHeartbeatService>();
services.AddSingleton<IDelayScheduler, SystemDelayScheduler>();
services.AddSingleton<IOptionsMonitor<ScannerWorkerOptions>>(new StaticOptionsMonitor<ScannerWorkerOptions>(workerOptions));
services.AddSingleton<ScannerWorkerHostedService>();
using var provider = services.BuildServiceProvider();
var queue = provider.GetRequiredService<IScanQueue>();
var jobId = $"job-{Guid.NewGuid():n}";
var scanId = $"scan-{Guid.NewGuid():n}";
await queue.EnqueueAsync(new ScanQueueMessage(jobId, Encoding.UTF8.GetBytes("smoke"))
{
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["scanId"] = scanId,
["queue"] = "redis",
}
});
var hostedService = provider.GetRequiredService<ScannerWorkerHostedService>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await hostedService.StartAsync(cts.Token);
var smokeObserver = provider.GetRequiredService<QueueBackedScanJobSourceDependencies>();
await smokeObserver.JobCompleted.Task.WaitAsync(TimeSpan.FromSeconds(20));
await hostedService.StopAsync(CancellationToken.None);
}
private static IConfiguration BuildQueueConfiguration(string connection, string stream, string consumerGroup)
{
return new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["scanner:queue:kind"] = "redis",
["scanner:queue:defaultLeaseDuration"] = "00:00:30",
["scanner:queue:redis:connectionString"] = connection,
["scanner:queue:redis:streamName"] = stream,
["scanner:queue:redis:consumerGroup"] = consumerGroup,
["scanner:queue:redis:idempotencyKeyPrefix"] = $"{stream}:idemp:",
["scanner:queue:redis:initializationTimeout"] = "00:00:10",
})
.Build();
}
private sealed class SmokeAnalyzerDispatcher : IScanAnalyzerDispatcher
{
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
return ValueTask.CompletedTask;
}
}
private sealed class QueueBackedScanJobSourceDependencies
{
public QueueBackedScanJobSourceDependencies()
{
JobCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
public TaskCompletionSource JobCompleted { get; }
}
private sealed class QueueBackedScanJobSource : IScanJobSource
{
private readonly IScanQueue _queue;
private readonly ScannerQueueOptions _queueOptions;
private readonly QueueBackedScanJobSourceDependencies _deps;
private readonly TimeProvider _timeProvider;
private readonly string _consumerName = $"worker-smoke-{Guid.NewGuid():n}";
public QueueBackedScanJobSource(
IScanQueue queue,
ScannerQueueOptions queueOptions,
QueueBackedScanJobSourceDependencies deps,
TimeProvider timeProvider)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_deps = deps ?? throw new ArgumentNullException(nameof(deps));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
{
var request = new QueueLeaseRequest(_consumerName, 1, _queueOptions.DefaultLeaseDuration);
var leases = await _queue.LeaseAsync(request, cancellationToken).ConfigureAwait(false);
if (leases.Count == 0)
{
return null;
}
return new QueueBackedScanJobLease(
leases[0],
_queueOptions,
_deps,
_timeProvider.GetUtcNow());
}
}
private sealed class QueueBackedScanJobLease : IScanJobLease
{
private readonly IScanQueueLease _lease;
private readonly ScannerQueueOptions _options;
private readonly QueueBackedScanJobSourceDependencies _deps;
private readonly DateTimeOffset _leasedAt;
private readonly IReadOnlyDictionary<string, string> _metadata;
public QueueBackedScanJobLease(
IScanQueueLease lease,
ScannerQueueOptions options,
QueueBackedScanJobSourceDependencies deps,
DateTimeOffset leasedAt)
{
_lease = lease ?? throw new ArgumentNullException(nameof(lease));
_options = options ?? throw new ArgumentNullException(nameof(options));
_deps = deps ?? throw new ArgumentNullException(nameof(deps));
_leasedAt = leasedAt;
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["queue"] = _options.Kind.ToString(),
["queue.consumer"] = lease.Consumer,
};
if (!string.IsNullOrWhiteSpace(lease.IdempotencyKey))
{
metadata["job.idempotency"] = lease.IdempotencyKey;
}
foreach (var attribute in lease.Attributes)
{
metadata[attribute.Key] = attribute.Value;
}
_metadata = metadata;
}
public string JobId => _lease.JobId;
public string ScanId => _metadata.TryGetValue("scanId", out var scanId) ? scanId : _lease.JobId;
public int Attempt => _lease.Attempt;
public DateTimeOffset EnqueuedAtUtc => _lease.EnqueuedAt;
public DateTimeOffset LeasedAtUtc => _leasedAt;
public TimeSpan LeaseDuration => _lease.LeaseExpiresAt - _leasedAt;
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public async ValueTask RenewAsync(CancellationToken cancellationToken)
{
await _lease.RenewAsync(_options.DefaultLeaseDuration, cancellationToken).ConfigureAwait(false);
}
public async ValueTask CompleteAsync(CancellationToken cancellationToken)
{
await _lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
_deps.JobCompleted.TrySetResult();
}
public async ValueTask AbandonAsync(string reason, CancellationToken cancellationToken)
{
await _lease.ReleaseAsync(QueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
}
public async ValueTask PoisonAsync(string reason, CancellationToken cancellationToken)
{
await _lease.DeadLetterAsync(reason, cancellationToken).ConfigureAwait(false);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Linq;
using StellaOps.Scanner.Worker.Options;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class ScannerWorkerOptionsValidatorTests
{
[Fact]
public void Validate_Fails_WhenHeartbeatSafetyFactorBelowThree()
{
var options = new ScannerWorkerOptions();
options.Queue.HeartbeatSafetyFactor = 2.5;
var validator = new ScannerWorkerOptionsValidator();
var result = validator.Validate(string.Empty, options);
Assert.True(result.Failed, "Validation should fail when HeartbeatSafetyFactor < 3.");
Assert.Contains(result.Failures, failure => failure.Contains("HeartbeatSafetyFactor", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Validate_Succeeds_WhenHeartbeatSafetyFactorAtLeastThree()
{
var options = new ScannerWorkerOptions();
options.Queue.HeartbeatSafetyFactor = 3.5;
var validator = new ScannerWorkerOptionsValidator();
var result = validator.Validate(string.Empty, options);
Assert.True(result.Succeeded, "Validation should succeed when HeartbeatSafetyFactor >= 3.");
}
}

View File

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Worker.Tests.TestInfrastructure;
public sealed class StaticOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions _value;
public StaticOptionsMonitor(TOptions value)
{
_value = value ?? throw new ArgumentNullException(nameof(value));
}
public TOptions CurrentValue => _value;
public TOptions Get(string? name) => _value;
public IDisposable OnChange(Action<TOptions, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}

View File

@@ -0,0 +1,457 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class WorkerBasicScanScenarioTests
{
[Fact]
public async Task DelayAsync_CompletesAfterTimeAdvance()
{
var scheduler = new ControlledDelayScheduler();
var delayTask = scheduler.DelayAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
scheduler.AdvanceBy(TimeSpan.FromSeconds(5));
await delayTask.WaitAsync(TimeSpan.FromSeconds(1));
}
[Fact]
public async Task Worker_CompletesJob_RecordsTelemetry_And_Heartbeats()
{
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(DateTimeOffset.UtcNow);
var options = new ScannerWorkerOptions
{
MaxConcurrentJobs = 1,
};
options.Telemetry.EnableTelemetry = false;
options.Telemetry.EnableMetrics = true;
var optionsMonitor = new StaticOptionsMonitor<ScannerWorkerOptions>(options);
var testLoggerProvider = new TestLoggerProvider();
var lease = new TestJobLease(fakeTime);
var jobSource = new TestJobSource(lease);
var scheduler = new ControlledDelayScheduler();
var analyzer = new TestAnalyzerDispatcher(scheduler);
using var listener = new WorkerMetricsListener();
listener.Start();
using var services = new ServiceCollection()
.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddProvider(testLoggerProvider);
builder.SetMinimumLevel(LogLevel.Debug);
})
.AddSingleton(fakeTime)
.AddSingleton<TimeProvider>(fakeTime)
.AddSingleton<IOptionsMonitor<ScannerWorkerOptions>>(optionsMonitor)
.AddSingleton<ScannerWorkerMetrics>()
.AddSingleton<ScanProgressReporter>()
.AddSingleton<ScanJobProcessor>()
.AddSingleton<LeaseHeartbeatService>()
.AddSingleton<IDelayScheduler>(scheduler)
.AddSingleton<IScanJobSource>(_ => jobSource)
.AddSingleton<IScanAnalyzerDispatcher>(analyzer)
.AddSingleton<IEntryTraceExecutionService, NullEntryTraceExecutionService>()
.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>()
.AddSingleton<ScannerWorkerHostedService>()
.BuildServiceProvider();
var worker = services.GetRequiredService<ScannerWorkerHostedService>();
await worker.StartAsync(CancellationToken.None);
await jobSource.LeaseIssued.Task.WaitAsync(TimeSpan.FromSeconds(5));
await Task.Yield();
var spin = 0;
while (!lease.Completed.Task.IsCompleted && spin++ < 24)
{
fakeTime.Advance(TimeSpan.FromSeconds(15));
scheduler.AdvanceBy(TimeSpan.FromSeconds(15));
await Task.Delay(1);
}
try
{
await lease.Completed.Task.WaitAsync(TimeSpan.FromSeconds(30));
}
catch (TimeoutException ex)
{
var stageLogs = string.Join(Environment.NewLine, testLoggerProvider
.GetEntriesForCategory(typeof(ScanProgressReporter).FullName!)
.Select(entry => entry.ToFormattedString()));
throw new TimeoutException($"Worker did not complete within timeout. Logs:{Environment.NewLine}{stageLogs}", ex);
}
await worker.StopAsync(CancellationToken.None);
Assert.True(lease.Completed.Task.IsCompletedSuccessfully, "Job should complete successfully.");
Assert.Single(analyzer.Executions);
Assert.True(lease.RenewalCount >= 1, "Lease should have been renewed at least once.");
var stageOrder = testLoggerProvider
.GetEntriesForCategory(typeof(ScanProgressReporter).FullName!)
.Where(entry => entry.EventId.Id == 1000)
.Select(entry => entry.GetScopeProperty<string>("Stage"))
.Where(stage => stage is not null)
.Cast<string>()
.ToArray();
Assert.Equal(ScanStageNames.Ordered, stageOrder);
var queueLatency = listener.Measurements.Where(m => m.InstrumentName == "scanner_worker_queue_latency_ms").ToArray();
Assert.Single(queueLatency);
Assert.True(queueLatency[0].Value > 0, "Queue latency should be positive.");
var jobDuration = listener.Measurements.Where(m => m.InstrumentName == "scanner_worker_job_duration_ms").ToArray();
Assert.Single(jobDuration);
Assert.True(jobDuration[0].Value > 0, "Job duration should be positive.");
var stageDurations = listener.Measurements.Where(m => m.InstrumentName == "scanner_worker_stage_duration_ms").ToArray();
Assert.Contains(stageDurations, m => m.Tags.TryGetValue("stage", out var stage) && Equals(stage, ScanStageNames.ExecuteAnalyzers));
}
private sealed class TestJobSource : IScanJobSource
{
private readonly TestJobLease _lease;
private int _delivered;
public TestJobSource(TestJobLease lease)
{
_lease = lease;
}
public TaskCompletionSource LeaseIssued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _delivered, 1) == 0)
{
LeaseIssued.TrySetResult();
return Task.FromResult<IScanJobLease?>(_lease);
}
return Task.FromResult<IScanJobLease?>(null);
}
}
private sealed class TestJobLease : IScanJobLease
{
private readonly FakeTimeProvider _timeProvider;
private readonly Dictionary<string, string> _metadata = new()
{
{ "queue", "tests" },
{ "job.kind", "basic" },
};
public TestJobLease(FakeTimeProvider timeProvider)
{
_timeProvider = timeProvider;
EnqueuedAtUtc = _timeProvider.GetUtcNow() - TimeSpan.FromSeconds(5);
LeasedAtUtc = _timeProvider.GetUtcNow();
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; } = TimeSpan.FromSeconds(90);
public IReadOnlyDictionary<string, string> Metadata => _metadata;
public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int RenewalCount => _renewalCount;
public ValueTask RenewAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref _renewalCount);
return ValueTask.CompletedTask;
}
public ValueTask CompleteAsync(CancellationToken cancellationToken)
{
Completed.TrySetResult();
return ValueTask.CompletedTask;
}
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken)
{
Completed.TrySetException(new InvalidOperationException($"Abandoned: {reason}"));
return ValueTask.CompletedTask;
}
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken)
{
Completed.TrySetException(new InvalidOperationException($"Poisoned: {reason}"));
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private int _renewalCount;
}
private sealed class NullEntryTraceExecutionService : IEntryTraceExecutionService
{
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class TestAnalyzerDispatcher : IScanAnalyzerDispatcher
{
private readonly IDelayScheduler _scheduler;
public TestAnalyzerDispatcher(IDelayScheduler scheduler)
{
_scheduler = scheduler;
}
public List<string> Executions { get; } = new();
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
Executions.Add(context.JobId);
await _scheduler.DelayAsync(TimeSpan.FromSeconds(45), cancellationToken);
}
}
private sealed class ControlledDelayScheduler : IDelayScheduler
{
private readonly object _lock = new();
private readonly SortedDictionary<double, List<ScheduledDelay>> _scheduled = new();
private double _currentMilliseconds;
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
if (delay <= TimeSpan.Zero)
{
return Task.CompletedTask;
}
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
var scheduled = new ScheduledDelay(tcs, cancellationToken);
lock (_lock)
{
var due = _currentMilliseconds + delay.TotalMilliseconds;
if (!_scheduled.TryGetValue(due, out var list))
{
list = new List<ScheduledDelay>();
_scheduled.Add(due, list);
}
list.Add(scheduled);
}
return scheduled.Task;
}
public void AdvanceBy(TimeSpan delta)
{
lock (_lock)
{
_currentMilliseconds += delta.TotalMilliseconds;
var dueKeys = _scheduled.Keys.Where(key => key <= _currentMilliseconds).ToList();
foreach (var due in dueKeys)
{
foreach (var scheduled in _scheduled[due])
{
scheduled.Complete();
}
_scheduled.Remove(due);
}
}
}
private sealed class ScheduledDelay
{
private readonly TaskCompletionSource<object?> _tcs;
private readonly CancellationTokenRegistration _registration;
public ScheduledDelay(TaskCompletionSource<object?> tcs, CancellationToken cancellationToken)
{
_tcs = tcs;
if (cancellationToken.CanBeCanceled)
{
_registration = cancellationToken.Register(state =>
{
var source = (TaskCompletionSource<object?>)state!;
source.TrySetCanceled(cancellationToken);
}, tcs);
}
}
public Task Task => _tcs.Task;
public void Complete()
{
_registration.Dispose();
_tcs.TrySetResult(null);
}
}
}
private sealed class StaticOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions _value;
public StaticOptionsMonitor(TOptions value)
{
_value = value;
}
public TOptions CurrentValue => _value;
public TOptions Get(string? name) => _value;
public IDisposable OnChange(Action<TOptions, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
private sealed class WorkerMetricsListener : IDisposable
{
private readonly MeterListener _listener;
public ConcurrentBag<Measurement> Measurements { get; } = new();
public WorkerMetricsListener()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{
var tagDictionary = new Dictionary<string, object?>(tags.Length, StringComparer.Ordinal);
foreach (var tag in tags)
{
tagDictionary[tag.Key] = tag.Value;
}
Measurements.Add(new Measurement(instrument.Name, measurement, tagDictionary));
});
}
public void Start() => _listener.Start();
public void Dispose() => _listener.Dispose();
}
public sealed record Measurement(string InstrumentName, double Value, IReadOnlyDictionary<string, object?> Tags)
{
public object? this[string name] => Tags.TryGetValue(name, out var value) ? value : null;
}
private sealed class TestLoggerProvider : ILoggerProvider
{
private readonly ConcurrentQueue<TestLogEntry> _entries = new();
public ILogger CreateLogger(string categoryName) => new TestLogger(categoryName, _entries);
public void Dispose()
{
}
public IEnumerable<TestLogEntry> GetEntriesForCategory(string categoryName)
=> _entries.Where(entry => entry.Category == categoryName);
private sealed class TestLogger : ILogger
{
private readonly string _category;
private readonly ConcurrentQueue<TestLogEntry> _entries;
public TestLogger(string category, ConcurrentQueue<TestLogEntry> entries)
{
_category = category;
_entries = entries;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
_entries.Enqueue(new TestLogEntry(_category, logLevel, eventId, state, exception));
}
}
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
public sealed record TestLogEntry(string Category, LogLevel Level, EventId EventId, object? State, Exception? Exception)
{
public T? GetScopeProperty<T>(string name)
{
if (State is not IEnumerable<KeyValuePair<string, object?>> state)
{
return default;
}
foreach (var kvp in state)
{
if (string.Equals(kvp.Key, name, StringComparison.OrdinalIgnoreCase) && kvp.Value is T value)
{
return value;
}
}
return default;
}
public string ToFormattedString()
{
var properties = State is IEnumerable<KeyValuePair<string, object?>> kvps
? string.Join(", ", kvps.Select(kvp => $"{kvp.Key}={kvp.Value}"))
: State?.ToString() ?? string.Empty;
var exceptionPart = Exception is null ? string.Empty : $" Exception={Exception.GetType().Name}: {Exception.Message}";
return $"[{Level}] {Category} ({EventId.Id}) {properties}{exceptionPart}";
}
}
}