save dev progress
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScanCompletedEventHandler.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Task: SBOM-8200-025
|
||||
// Description: Hosted service that subscribes to Scanner ScanCompleted events
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that subscribes to Scanner ScanCompleted events
|
||||
/// and triggers automatic SBOM learning.
|
||||
/// </summary>
|
||||
public sealed class ScanCompletedEventHandler : BackgroundService
|
||||
{
|
||||
private readonly IEventStream<ScanCompletedEvent>? _eventStream;
|
||||
private readonly ISbomRegistryService _sbomService;
|
||||
private readonly ILogger<ScanCompletedEventHandler> _logger;
|
||||
private readonly ScanCompletedHandlerOptions _options;
|
||||
|
||||
public ScanCompletedEventHandler(
|
||||
IEventStream<ScanCompletedEvent>? eventStream,
|
||||
ISbomRegistryService sbomService,
|
||||
IOptions<ScanCompletedHandlerOptions> options,
|
||||
ILogger<ScanCompletedEventHandler> logger)
|
||||
{
|
||||
_eventStream = eventStream;
|
||||
_sbomService = sbomService ?? throw new ArgumentNullException(nameof(sbomService));
|
||||
_options = options?.Value ?? new ScanCompletedHandlerOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (_eventStream is null)
|
||||
{
|
||||
_logger.LogWarning("Event stream not configured, ScanCompleted event handler disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogInformation("ScanCompleted event handler disabled by configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting ScanCompleted event handler, subscribing to stream {StreamName}",
|
||||
_eventStream.StreamName);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var streamEvent in _eventStream.SubscribeAsync(
|
||||
StreamPosition.End, // Start from latest events
|
||||
stoppingToken))
|
||||
{
|
||||
await ProcessEventAsync(streamEvent.Event, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("ScanCompleted event handler stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ScanCompleted event handler failed");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessEventAsync(ScanCompletedEvent @event, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(@event.SbomDigest))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan {ScanId} completed without SBOM digest, skipping SBOM learning",
|
||||
@event.ScanId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing ScanCompleted event: ScanId={ScanId}, Image={ImageDigest}, SBOM={SbomDigest}",
|
||||
@event.ScanId, @event.ImageDigest, @event.SbomDigest);
|
||||
|
||||
try
|
||||
{
|
||||
// Build PURL list from scan findings
|
||||
var purls = @event.Purls ?? [];
|
||||
if (purls.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan {ScanId} has no PURLs, skipping SBOM learning",
|
||||
@event.ScanId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build reachability map from findings
|
||||
var reachabilityMap = BuildReachabilityMap(@event);
|
||||
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = @event.SbomDigest,
|
||||
Format = ParseSbomFormat(@event.SbomFormat),
|
||||
SpecVersion = @event.SbomSpecVersion ?? "1.6",
|
||||
PrimaryName = @event.ImageName,
|
||||
PrimaryVersion = @event.ImageTag,
|
||||
Purls = purls,
|
||||
Source = "scanner",
|
||||
TenantId = @event.TenantId,
|
||||
ReachabilityMap = reachabilityMap
|
||||
};
|
||||
|
||||
var result = await _sbomService.LearnSbomAsync(input, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Auto-learned SBOM from scan {ScanId}: {MatchCount} matches, {ScoresUpdated} scores updated",
|
||||
@event.ScanId, result.Matches.Count, result.ScoresUpdated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to process ScanCompleted event for scan {ScanId}",
|
||||
@event.ScanId);
|
||||
|
||||
// Don't rethrow - continue processing other events
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, bool>? BuildReachabilityMap(ScanCompletedEvent @event)
|
||||
{
|
||||
if (@event.ReachabilityData is null || @event.ReachabilityData.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return @event.ReachabilityData.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value);
|
||||
}
|
||||
|
||||
private static SbomFormat ParseSbomFormat(string? format)
|
||||
{
|
||||
return format?.ToLowerInvariant() switch
|
||||
{
|
||||
"cyclonedx" => SbomFormat.CycloneDX,
|
||||
"spdx" => SbomFormat.SPDX,
|
||||
_ => SbomFormat.CycloneDX
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event published when a scan completes.
|
||||
/// </summary>
|
||||
public sealed record ScanCompletedEvent
|
||||
{
|
||||
/// <summary>Unique scan identifier.</summary>
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>Report identifier.</summary>
|
||||
public string? ReportId { get; init; }
|
||||
|
||||
/// <summary>Scanned image digest.</summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Image name (repository).</summary>
|
||||
public string? ImageName { get; init; }
|
||||
|
||||
/// <summary>Image tag.</summary>
|
||||
public string? ImageTag { get; init; }
|
||||
|
||||
/// <summary>SBOM content digest.</summary>
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>SBOM format.</summary>
|
||||
public string? SbomFormat { get; init; }
|
||||
|
||||
/// <summary>SBOM specification version.</summary>
|
||||
public string? SbomSpecVersion { get; init; }
|
||||
|
||||
/// <summary>Extracted PURLs from SBOM.</summary>
|
||||
public IReadOnlyList<string>? Purls { get; init; }
|
||||
|
||||
/// <summary>Reachability data per PURL.</summary>
|
||||
public IReadOnlyDictionary<string, bool>? ReachabilityData { get; init; }
|
||||
|
||||
/// <summary>Deployment data per PURL.</summary>
|
||||
public IReadOnlyDictionary<string, bool>? DeploymentData { get; init; }
|
||||
|
||||
/// <summary>Tenant identifier.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Scan verdict (pass/fail).</summary>
|
||||
public string? Verdict { get; init; }
|
||||
|
||||
/// <summary>When the scan completed.</summary>
|
||||
public DateTimeOffset CompletedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for ScanCompleted event handler.
|
||||
/// </summary>
|
||||
public sealed class ScanCompletedHandlerOptions
|
||||
{
|
||||
/// <summary>Whether the handler is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Stream name to subscribe to.</summary>
|
||||
public string StreamName { get; set; } = "scanner:events:scan-completed";
|
||||
|
||||
/// <summary>Maximum concurrent event processing.</summary>
|
||||
public int MaxConcurrency { get; set; } = 4;
|
||||
|
||||
/// <summary>Retry count for failed processing.</summary>
|
||||
public int RetryCount { get; set; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScannerEventHandler.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Task: SBOM-8200-025
|
||||
// Description: Subscribes to Scanner events for auto-learning SBOMs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
|
||||
namespace StellaOps.Concelier.SbomIntegration.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that subscribes to Scanner SBOM events for auto-learning.
|
||||
/// </summary>
|
||||
public sealed class ScannerEventHandler : BackgroundService
|
||||
{
|
||||
/// <summary>
|
||||
/// Stream name for orchestrator events.
|
||||
/// </summary>
|
||||
public const string OrchestratorStreamName = "orchestrator:events";
|
||||
|
||||
/// <summary>
|
||||
/// Event kind for SBOM generated.
|
||||
/// </summary>
|
||||
public const string SbomGeneratedKind = "scanner.event.sbom.generated";
|
||||
|
||||
/// <summary>
|
||||
/// Event kind for scan completed.
|
||||
/// </summary>
|
||||
public const string ScanCompletedKind = "scanner.event.scan.completed";
|
||||
|
||||
private readonly IEventStream<OrchestratorEventEnvelope>? _eventStream;
|
||||
private readonly ISbomRegistryService _registryService;
|
||||
private readonly IScannerSbomFetcher? _sbomFetcher;
|
||||
private readonly ILogger<ScannerEventHandler> _logger;
|
||||
|
||||
private long _eventsProcessed;
|
||||
private long _sbomsLearned;
|
||||
private long _errors;
|
||||
|
||||
public ScannerEventHandler(
|
||||
ISbomRegistryService registryService,
|
||||
ILogger<ScannerEventHandler> logger,
|
||||
IEventStream<OrchestratorEventEnvelope>? eventStream = null,
|
||||
IScannerSbomFetcher? sbomFetcher = null)
|
||||
{
|
||||
_registryService = registryService ?? throw new ArgumentNullException(nameof(registryService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventStream = eventStream;
|
||||
_sbomFetcher = sbomFetcher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of events processed.
|
||||
/// </summary>
|
||||
public long EventsProcessed => Interlocked.Read(ref _eventsProcessed);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of SBOMs learned.
|
||||
/// </summary>
|
||||
public long SbomsLearned => Interlocked.Read(ref _sbomsLearned);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of errors.
|
||||
/// </summary>
|
||||
public long Errors => Interlocked.Read(ref _errors);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (_eventStream is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ScannerEventHandler disabled: no IEventStream<OrchestratorEventEnvelope> configured");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"ScannerEventHandler started, subscribing to {StreamName}",
|
||||
_eventStream.StreamName);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var streamEvent in _eventStream.SubscribeAsync(StreamPosition.End, stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandleEventAsync(streamEvent.Event, stoppingToken).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref _eventsProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Interlocked.Increment(ref _errors);
|
||||
_logger.LogError(ex,
|
||||
"Error processing orchestrator event {EventId} kind {Kind}",
|
||||
streamEvent.Event.EventId,
|
||||
streamEvent.Event.Kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Normal shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Fatal error in ScannerEventHandler event processing loop");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleEventAsync(OrchestratorEventEnvelope envelope, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (envelope.Kind)
|
||||
{
|
||||
case SbomGeneratedKind:
|
||||
await HandleSbomGeneratedAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case ScanCompletedKind:
|
||||
// ScanCompleted events contain findings but not the full SBOM
|
||||
// We could use this to enrich reachability data
|
||||
_logger.LogDebug(
|
||||
"Received ScanCompleted event {EventId} for digest {Digest}",
|
||||
envelope.EventId,
|
||||
envelope.Scope?.Digest);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore other event types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSbomGeneratedAsync(
|
||||
OrchestratorEventEnvelope envelope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (envelope.Payload is null)
|
||||
{
|
||||
_logger.LogWarning("SbomGenerated event {EventId} has no payload", envelope.EventId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the SBOM generated payload
|
||||
var payload = ParseSbomGeneratedPayload(envelope.Payload.Value);
|
||||
if (payload is null || string.IsNullOrEmpty(payload.Digest))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SbomGenerated event {EventId} has invalid payload",
|
||||
envelope.EventId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing SbomGenerated event {EventId}: SBOM {SbomId} with {ComponentCount} components",
|
||||
envelope.EventId,
|
||||
payload.SbomId,
|
||||
payload.ComponentCount);
|
||||
|
||||
// Fetch SBOM content if we have a fetcher
|
||||
IReadOnlyList<string> purls;
|
||||
if (_sbomFetcher is not null && !string.IsNullOrEmpty(payload.SbomRef))
|
||||
{
|
||||
purls = await _sbomFetcher.FetchPurlsAsync(payload.SbomRef, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cannot fetch SBOM content for {SbomId}: no fetcher configured or no SbomRef",
|
||||
payload.SbomId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (purls.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("SBOM {SbomId} has no PURLs", payload.SbomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create registration input
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = payload.Digest,
|
||||
Format = ParseSbomFormat(payload.Format),
|
||||
SpecVersion = payload.SpecVersion ?? "1.6",
|
||||
PrimaryName = envelope.Scope?.Repo,
|
||||
PrimaryVersion = envelope.Scope?.Digest,
|
||||
Purls = purls,
|
||||
Source = "scanner-event",
|
||||
TenantId = envelope.Tenant
|
||||
};
|
||||
|
||||
// Learn the SBOM
|
||||
try
|
||||
{
|
||||
var result = await _registryService.LearnSbomAsync(input, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Interlocked.Increment(ref _sbomsLearned);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Auto-learned SBOM {Digest} from scanner event: {MatchCount} advisories matched, {ScoresUpdated} scores updated",
|
||||
payload.Digest,
|
||||
result.Matches.Count,
|
||||
result.ScoresUpdated);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Interlocked.Increment(ref _errors);
|
||||
_logger.LogError(ex,
|
||||
"Failed to auto-learn SBOM {Digest} from scanner event",
|
||||
payload.Digest);
|
||||
}
|
||||
}
|
||||
|
||||
private static SbomGeneratedPayload? ParseSbomGeneratedPayload(JsonElement? payload)
|
||||
{
|
||||
if (payload is null || payload.Value.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return payload.Value.Deserialize<SbomGeneratedPayload>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SbomFormat ParseSbomFormat(string? format)
|
||||
{
|
||||
return format?.ToLowerInvariant() switch
|
||||
{
|
||||
"spdx" => SbomFormat.SPDX,
|
||||
_ => SbomFormat.CycloneDX
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Envelope for orchestrator events received from the event stream.
|
||||
/// </summary>
|
||||
public sealed record OrchestratorEventEnvelope
|
||||
{
|
||||
public Guid EventId { get; init; }
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public int Version { get; init; } = 1;
|
||||
public string? Tenant { get; init; }
|
||||
public DateTimeOffset OccurredAt { get; init; }
|
||||
public DateTimeOffset? RecordedAt { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? IdempotencyKey { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public OrchestratorEventScope? Scope { get; init; }
|
||||
public JsonElement? Payload { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope for orchestrator events.
|
||||
/// </summary>
|
||||
public sealed record OrchestratorEventScope
|
||||
{
|
||||
public string? Namespace { get; init; }
|
||||
public string? Repo { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for SBOM generated events.
|
||||
/// </summary>
|
||||
internal sealed record SbomGeneratedPayload
|
||||
{
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
public string Format { get; init; } = "cyclonedx";
|
||||
public string? SpecVersion { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public string? SbomRef { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for fetching SBOM content from Scanner service.
|
||||
/// </summary>
|
||||
public interface IScannerSbomFetcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches PURLs from an SBOM by reference.
|
||||
/// </summary>
|
||||
/// <param name="sbomRef">Reference to the SBOM (URL or ID).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of PURLs extracted from the SBOM.</returns>
|
||||
Task<IReadOnlyList<string>> FetchPurlsAsync(
|
||||
string sbomRef,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -108,5 +108,13 @@ public interface ISbomRegistryRepository
|
||||
DateTimeOffset lastMatched,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the PURL list for an SBOM.
|
||||
/// </summary>
|
||||
Task UpdatePurlsAsync(
|
||||
string digest,
|
||||
IReadOnlyList<string> purls,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Task: SBOM-8200-000
|
||||
// Tasks: SBOM-8200-000, SBOM-8200-025
|
||||
// Description: DI registration for SBOM integration services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Concelier.SbomIntegration.Events;
|
||||
using StellaOps.Concelier.SbomIntegration.Index;
|
||||
using StellaOps.Concelier.SbomIntegration.Matching;
|
||||
using StellaOps.Concelier.SbomIntegration.Parsing;
|
||||
@@ -61,4 +62,30 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the Scanner event handler for auto-learning SBOMs.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddConcelierSbomAutoLearning(this IServiceCollection services)
|
||||
{
|
||||
services.AddHostedService<ScanCompletedEventHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the Scanner event handler with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddConcelierSbomAutoLearning(
|
||||
this IServiceCollection services,
|
||||
Action<ScanCompletedHandlerOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.AddHostedService<ScanCompletedEventHandler>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user