Implement MongoDB-based storage for Pack Run approval, artifact, log, and state management
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added MongoPackRunApprovalStore for managing approval states with MongoDB. - Introduced MongoPackRunArtifactUploader for uploading and storing artifacts. - Created MongoPackRunLogStore to handle logging of pack run events. - Developed MongoPackRunStateStore for persisting and retrieving pack run states. - Implemented unit tests for MongoDB stores to ensure correct functionality. - Added MongoTaskRunnerTestContext for setting up MongoDB test environment. - Enhanced PackRunStateFactory to correctly initialize state with gate reasons.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
@@ -9,11 +10,18 @@ public sealed class ScannerWorkerMetrics
|
||||
{
|
||||
private readonly Histogram<double> _queueLatencyMs;
|
||||
private readonly Histogram<double> _jobDurationMs;
|
||||
private readonly Histogram<double> _stageDurationMs;
|
||||
private readonly Histogram<double> _stageDurationMs;
|
||||
private readonly Counter<long> _jobsCompleted;
|
||||
private readonly Counter<long> _jobsFailed;
|
||||
private readonly Counter<long> _languageCacheHits;
|
||||
private readonly Counter<long> _languageCacheMisses;
|
||||
private readonly Counter<long> _registrySecretRequests;
|
||||
private readonly Histogram<double> _registrySecretTtlSeconds;
|
||||
private readonly Counter<long> _surfaceManifestsPublished;
|
||||
private readonly Counter<long> _surfaceManifestSkipped;
|
||||
private readonly Counter<long> _surfaceManifestFailures;
|
||||
private readonly Counter<long> _surfacePayloadPersisted;
|
||||
private readonly Histogram<double> _surfaceManifestPublishDurationMs;
|
||||
|
||||
public ScannerWorkerMetrics()
|
||||
{
|
||||
@@ -41,6 +49,29 @@ public sealed class ScannerWorkerMetrics
|
||||
_languageCacheMisses = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_language_cache_misses_total",
|
||||
description: "Number of language analyzer cache misses encountered by the worker.");
|
||||
_registrySecretRequests = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_registry_secret_requests_total",
|
||||
description: "Number of registry secret resolution attempts performed by the worker.");
|
||||
_registrySecretTtlSeconds = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_registry_secret_ttl_seconds",
|
||||
unit: "s",
|
||||
description: "Time-to-live in seconds for resolved registry secrets (earliest expiration).");
|
||||
_surfaceManifestsPublished = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_surface_manifests_published_total",
|
||||
description: "Number of surface manifests successfully published by the worker.");
|
||||
_surfaceManifestSkipped = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_surface_manifests_skipped_total",
|
||||
description: "Number of surface manifest publish attempts skipped due to missing payloads.");
|
||||
_surfaceManifestFailures = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_surface_manifests_failed_total",
|
||||
description: "Number of surface manifest publish attempts that failed.");
|
||||
_surfacePayloadPersisted = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
|
||||
"scanner_worker_surface_payload_persisted_total",
|
||||
description: "Number of surface payload artefacts persisted to the local cache.");
|
||||
_surfaceManifestPublishDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
|
||||
"scanner_worker_surface_manifest_publish_duration_ms",
|
||||
unit: "ms",
|
||||
description: "Duration in milliseconds to persist and publish surface manifests.");
|
||||
}
|
||||
|
||||
public void RecordQueueLatency(ScanJobContext context, TimeSpan latency)
|
||||
@@ -63,15 +94,15 @@ public sealed class ScannerWorkerMetrics
|
||||
_jobDurationMs.Record(duration.TotalMilliseconds, CreateTags(context));
|
||||
}
|
||||
|
||||
public void RecordStageDuration(ScanJobContext context, string stage, TimeSpan duration)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stageDurationMs.Record(duration.TotalMilliseconds, CreateTags(context, stage: stage));
|
||||
}
|
||||
public void RecordStageDuration(ScanJobContext context, string stage, TimeSpan duration)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stageDurationMs.Record(duration.TotalMilliseconds, CreateTags(context, stage: stage));
|
||||
}
|
||||
|
||||
public void IncrementJobCompleted(ScanJobContext context)
|
||||
{
|
||||
@@ -93,9 +124,130 @@ public sealed class ScannerWorkerMetrics
|
||||
_languageCacheMisses.Add(1, CreateTags(context, analyzerId: analyzerId));
|
||||
}
|
||||
|
||||
private static KeyValuePair<string, object?>[] CreateTags(ScanJobContext context, string? stage = null, string? failureReason = null, string? analyzerId = null)
|
||||
public void RecordRegistrySecretResolved(
|
||||
ScanJobContext context,
|
||||
string secretName,
|
||||
RegistryAccessSecret secret,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tags = new List<KeyValuePair<string, object?>>(stage is null ? 5 : 6)
|
||||
var tags = CreateTags(
|
||||
context,
|
||||
secretName: secretName,
|
||||
secretResult: "resolved",
|
||||
secretEntryCount: secret.Entries.Count);
|
||||
|
||||
_registrySecretRequests.Add(1, tags);
|
||||
|
||||
if (ComputeTtlSeconds(secret, timeProvider) is double ttlSeconds)
|
||||
{
|
||||
_registrySecretTtlSeconds.Record(ttlSeconds, tags);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordRegistrySecretMissing(ScanJobContext context, string secretName)
|
||||
{
|
||||
var tags = CreateTags(context, secretName: secretName, secretResult: "missing");
|
||||
_registrySecretRequests.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordRegistrySecretFailure(ScanJobContext context, string secretName)
|
||||
{
|
||||
var tags = CreateTags(context, secretName: secretName, secretResult: "failure");
|
||||
_registrySecretRequests.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordSurfaceManifestPublished(ScanJobContext context, int payloadCount, TimeSpan duration)
|
||||
{
|
||||
if (payloadCount < 0)
|
||||
{
|
||||
payloadCount = 0;
|
||||
}
|
||||
|
||||
var tags = CreateTags(
|
||||
context,
|
||||
surfaceAction: "manifest",
|
||||
surfaceResult: "published",
|
||||
surfacePayloadCount: payloadCount);
|
||||
|
||||
_surfaceManifestsPublished.Add(1, tags);
|
||||
|
||||
if (duration > TimeSpan.Zero)
|
||||
{
|
||||
_surfaceManifestPublishDurationMs.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordSurfaceManifestSkipped(ScanJobContext context)
|
||||
{
|
||||
var tags = CreateTags(context, surfaceAction: "manifest", surfaceResult: "skipped");
|
||||
_surfaceManifestSkipped.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordSurfaceManifestFailed(ScanJobContext context, string failureReason)
|
||||
{
|
||||
var tags = CreateTags(
|
||||
context,
|
||||
surfaceAction: "manifest",
|
||||
surfaceResult: "failed",
|
||||
failureReason: failureReason);
|
||||
_surfaceManifestFailures.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordSurfacePayloadPersisted(ScanJobContext context, string surfaceKind)
|
||||
{
|
||||
var normalizedKind = string.IsNullOrWhiteSpace(surfaceKind)
|
||||
? "unknown"
|
||||
: surfaceKind.Trim().ToLowerInvariant();
|
||||
|
||||
var tags = CreateTags(
|
||||
context,
|
||||
surfaceAction: "payload",
|
||||
surfaceKind: normalizedKind,
|
||||
surfaceResult: "cached");
|
||||
|
||||
_surfacePayloadPersisted.Add(1, tags);
|
||||
}
|
||||
|
||||
private static double? ComputeTtlSeconds(RegistryAccessSecret secret, TimeProvider timeProvider)
|
||||
{
|
||||
DateTimeOffset? earliest = null;
|
||||
foreach (var entry in secret.Entries)
|
||||
{
|
||||
if (entry.ExpiresAt is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (earliest is null || entry.ExpiresAt < earliest)
|
||||
{
|
||||
earliest = entry.ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (earliest is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var ttl = (earliest.Value - now).TotalSeconds;
|
||||
return ttl < 0 ? 0 : ttl;
|
||||
}
|
||||
|
||||
private static KeyValuePair<string, object?>[] CreateTags(
|
||||
ScanJobContext context,
|
||||
string? stage = null,
|
||||
string? failureReason = null,
|
||||
string? analyzerId = null,
|
||||
string? secretName = null,
|
||||
string? secretResult = null,
|
||||
int? secretEntryCount = null,
|
||||
string? surfaceAction = null,
|
||||
string? surfaceKind = null,
|
||||
string? surfaceResult = null,
|
||||
int? surfacePayloadCount = null)
|
||||
{
|
||||
var tags = new List<KeyValuePair<string, object?>>(8)
|
||||
{
|
||||
new("job.id", context.JobId),
|
||||
new("scan.id", context.ScanId),
|
||||
@@ -113,10 +265,10 @@ public sealed class ScannerWorkerMetrics
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stage))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("stage", stage));
|
||||
}
|
||||
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("stage", stage));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(failureReason))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("reason", failureReason));
|
||||
@@ -127,6 +279,41 @@ public sealed class ScannerWorkerMetrics
|
||||
tags.Add(new KeyValuePair<string, object?>("analyzer.id", analyzerId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(secretName))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("secret.name", secretName));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(secretResult))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("secret.result", secretResult));
|
||||
}
|
||||
|
||||
if (secretEntryCount is not null)
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("secret.entries", secretEntryCount.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(surfaceAction))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("surface.action", surfaceAction));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(surfaceKind))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("surface.kind", surfaceKind));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(surfaceResult))
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("surface.result", surfaceResult));
|
||||
}
|
||||
|
||||
if (surfacePayloadCount is not null)
|
||||
{
|
||||
tags.Add(new KeyValuePair<string, object?>("surface.payload_count", surfacePayloadCount.Value));
|
||||
}
|
||||
|
||||
return tags.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
internal sealed class RegistrySecretStageExecutor : IScanStageExecutor
|
||||
{
|
||||
private const string ComponentName = "Scanner.Worker.Registry";
|
||||
private const string SecretType = "registry";
|
||||
|
||||
private static readonly string[] SecretNameMetadataKeys =
|
||||
{
|
||||
"surface.registry.secret",
|
||||
"scanner.registry.secret",
|
||||
"registry.secret",
|
||||
};
|
||||
|
||||
private readonly ISurfaceSecretProvider _secretProvider;
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RegistrySecretStageExecutor> _logger;
|
||||
|
||||
public RegistrySecretStageExecutor(
|
||||
ISurfaceSecretProvider secretProvider,
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
ScannerWorkerMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RegistrySecretStageExecutor> logger)
|
||||
{
|
||||
_secretProvider = secretProvider ?? throw new ArgumentNullException(nameof(secretProvider));
|
||||
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string StageName => ScanStageNames.ResolveImage;
|
||||
|
||||
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var secretName = ResolveSecretName(context.Lease.Metadata);
|
||||
var request = new SurfaceSecretRequest(
|
||||
Tenant: _surfaceEnvironment.Settings.Secrets.Tenant,
|
||||
Component: ComponentName,
|
||||
SecretType: SecretType,
|
||||
Name: secretName);
|
||||
|
||||
try
|
||||
{
|
||||
using var handle = await _secretProvider.GetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var secret = SurfaceSecretParser.ParseRegistryAccessSecret(handle);
|
||||
|
||||
context.Analysis.Set(ScanAnalysisKeys.RegistryCredentials, secret);
|
||||
|
||||
_metrics.RecordRegistrySecretResolved(
|
||||
context,
|
||||
secretName ?? "default",
|
||||
secret,
|
||||
_timeProvider);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registry secret '{SecretName}' resolved with {EntryCount} entries for job {JobId}.",
|
||||
secretName ?? "default",
|
||||
secret.Entries.Count,
|
||||
context.JobId);
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
_metrics.RecordRegistrySecretMissing(context, secretName ?? "default");
|
||||
_logger.LogDebug(
|
||||
"Registry secret '{SecretName}' not found for job {JobId}; continuing without registry credentials.",
|
||||
secretName ?? "default",
|
||||
context.JobId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.RecordRegistrySecretFailure(context, secretName ?? "default");
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to resolve registry secret '{SecretName}' for job {JobId}; continuing without registry credentials.",
|
||||
secretName ?? "default",
|
||||
context.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveSecretName(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
foreach (var key in SecretNameMetadataKeys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
@@ -37,7 +38,12 @@ internal sealed record SurfaceManifestRequest(
|
||||
string? Version,
|
||||
string? WorkerInstance);
|
||||
|
||||
internal sealed class SurfaceManifestPublisher
|
||||
internal interface ISurfaceManifestPublisher
|
||||
{
|
||||
Task<SurfaceManifestPublishResult> PublishAsync(SurfaceManifestRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Serialization;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Surface;
|
||||
|
||||
@@ -20,15 +26,30 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly SurfaceManifestPublisher _publisher;
|
||||
private static readonly JsonSerializerOptions ManifestSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ISurfaceManifestPublisher _publisher;
|
||||
private readonly ISurfaceCache _surfaceCache;
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
private readonly ScannerWorkerMetrics _metrics;
|
||||
private readonly ILogger<SurfaceManifestStageExecutor> _logger;
|
||||
private readonly string _componentVersion;
|
||||
|
||||
public SurfaceManifestStageExecutor(
|
||||
SurfaceManifestPublisher publisher,
|
||||
ISurfaceManifestPublisher publisher,
|
||||
ISurfaceCache surfaceCache,
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
ScannerWorkerMetrics metrics,
|
||||
ILogger<SurfaceManifestStageExecutor> logger)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_surfaceCache = surfaceCache ?? throw new ArgumentNullException(nameof(surfaceCache));
|
||||
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
}
|
||||
@@ -42,23 +63,49 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
var payloads = CollectPayloads(context);
|
||||
if (payloads.Count == 0)
|
||||
{
|
||||
_metrics.RecordSurfaceManifestSkipped(context);
|
||||
_logger.LogDebug("No surface payloads available for job {JobId}; skipping manifest publish.", context.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new SurfaceManifestRequest(
|
||||
ScanId: context.ScanId,
|
||||
ImageDigest: ResolveImageDigest(context),
|
||||
Attempt: context.Lease.Attempt,
|
||||
Metadata: context.Lease.Metadata,
|
||||
Payloads: payloads,
|
||||
Component: "scanner.worker",
|
||||
Version: _componentVersion,
|
||||
WorkerInstance: Environment.MachineName);
|
||||
var tenant = _surfaceEnvironment.Settings?.Tenant ?? string.Empty;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var result = await _publisher.PublishAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
context.Analysis.Set(ScanAnalysisKeys.SurfaceManifest, result);
|
||||
_logger.LogInformation("Surface manifest stored for job {JobId} with digest {Digest}.", context.JobId, result.ManifestDigest);
|
||||
try
|
||||
{
|
||||
await PersistPayloadsToSurfaceCacheAsync(context, tenant, payloads, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var request = new SurfaceManifestRequest(
|
||||
ScanId: context.ScanId,
|
||||
ImageDigest: ResolveImageDigest(context),
|
||||
Attempt: context.Lease.Attempt,
|
||||
Metadata: context.Lease.Metadata,
|
||||
Payloads: payloads,
|
||||
Component: "scanner.worker",
|
||||
Version: _componentVersion,
|
||||
WorkerInstance: Environment.MachineName);
|
||||
|
||||
var result = await _publisher.PublishAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await PersistManifestToSurfaceCacheAsync(context, tenant, result, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
context.Analysis.Set(ScanAnalysisKeys.SurfaceManifest, result);
|
||||
stopwatch.Stop();
|
||||
_metrics.RecordSurfaceManifestPublished(context, payloads.Count, stopwatch.Elapsed);
|
||||
_logger.LogInformation("Surface manifest stored for job {JobId} with digest {Digest}.", context.JobId, result.ManifestDigest);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_metrics.RecordSurfaceManifestFailed(context, ex.GetType().Name);
|
||||
_logger.LogError(ex, "Failed to persist surface manifest for job {JobId}.", context.JobId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private List<SurfaceManifestPayload> CollectPayloads(ScanJobContext context)
|
||||
@@ -118,6 +165,56 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
return payloads;
|
||||
}
|
||||
|
||||
private async Task PersistPayloadsToSurfaceCacheAsync(
|
||||
ScanJobContext context,
|
||||
string tenant,
|
||||
IReadOnlyList<SurfaceManifestPayload> payloads,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (payloads.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var digest = ComputeDigest(payload.Content.Span);
|
||||
var normalizedKind = NormalizeKind(payload.Kind);
|
||||
var cacheKey = CreateArtifactCacheKey(tenant, normalizedKind, digest);
|
||||
|
||||
await _surfaceCache.SetAsync(cacheKey, payload.Content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Cached surface payload {Kind} for job {JobId} with digest {Digest}.",
|
||||
normalizedKind,
|
||||
context.JobId,
|
||||
digest);
|
||||
|
||||
_metrics.RecordSurfacePayloadPersisted(context, normalizedKind);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistManifestToSurfaceCacheAsync(
|
||||
ScanJobContext context,
|
||||
string tenant,
|
||||
SurfaceManifestPublishResult result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(result.Document, ManifestSerializerOptions);
|
||||
var cacheKey = CreateManifestCacheKey(tenant, result.ManifestDigest);
|
||||
|
||||
await _surfaceCache.SetAsync(cacheKey, manifestBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Cached surface manifest for job {JobId} with digest {Digest}.",
|
||||
context.JobId,
|
||||
result.ManifestDigest);
|
||||
}
|
||||
|
||||
private static string ResolveImageDigest(ScanJobContext context)
|
||||
{
|
||||
static bool TryGet(IReadOnlyDictionary<string, string> metadata, string key, out string value)
|
||||
@@ -143,5 +240,46 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
return context.ScanId;
|
||||
}
|
||||
|
||||
private static SurfaceCacheKey CreateArtifactCacheKey(string tenant, string kind, string digest)
|
||||
{
|
||||
var @namespace = $"surface.artifacts.{kind}";
|
||||
var contentKey = NormalizeDigestForKey(digest);
|
||||
return new SurfaceCacheKey(@namespace, tenant, contentKey);
|
||||
}
|
||||
|
||||
private static SurfaceCacheKey CreateManifestCacheKey(string tenant, string digest)
|
||||
{
|
||||
var contentKey = NormalizeDigestForKey(digest);
|
||||
return new SurfaceCacheKey("surface.manifests", tenant, contentKey);
|
||||
}
|
||||
|
||||
private static string NormalizeKind(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeDigestForKey(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return digest.Trim();
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(content, hash);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static readonly IFormatProvider CultureInfoInvariant = System.Globalization.CultureInfo.InvariantCulture;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ if (!string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
builder.Services.AddScannerStorage(storageSection);
|
||||
builder.Services.AddSingleton<IConfigureOptions<ScannerStorageOptions>, ScannerStorageSurfaceSecretConfigurator>();
|
||||
builder.Services.AddSingleton<SurfaceManifestPublisher>();
|
||||
builder.Services.AddSingleton<ISurfaceManifestPublisher, SurfaceManifestPublisher>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, SurfaceManifestStageExecutor>();
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ builder.Services.TryAddSingleton<IPluginCatalogGuard, RestartOnlyPluginGuard>();
|
||||
builder.Services.AddSingleton<IOSAnalyzerPluginCatalog, OsAnalyzerPluginCatalog>();
|
||||
builder.Services.AddSingleton<ILanguageAnalyzerPluginCatalog, LanguageAnalyzerPluginCatalog>();
|
||||
builder.Services.AddSingleton<IScanAnalyzerDispatcher, CompositeScanAnalyzerDispatcher>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, RegistrySecretStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();
|
||||
|
||||
builder.Services.AddSingleton<ScannerWorkerHostedService>();
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCAN-REPLAY-186-002 | TODO | Scanner Worker Guild | REPLAY-CORE-185-001 | Enforce deterministic analyzer execution when consuming replay input bundles, emit layer Merkle metadata, and author `docs/modules/scanner/deterministic-execution.md` summarising invariants from `docs/replay/DETERMINISTIC_REPLAY.md` Section 4. | Replay mode analyzers pass determinism tests; new doc merged; integration fixtures updated. |
|
||||
| SCANNER-SURFACE-01 | DOING (2025-11-06) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.<br>2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review.<br>2025-11-06: Resuming with manifest writer abstraction, rotation metadata, and telemetry counters for Surface.FS persistence. | Integration tests prove cache entries exist; telemetry counters exported. |
|
||||
| SCANNER-SURFACE-01 | DONE (2025-11-06) | Scanner Worker Guild | SURFACE-FS-02 | Persist Surface.FS manifests after analyzer stages, including layer CAS metadata and EntryTrace fragments.<br>2025-11-02: Draft Surface.FS manifests emitted for sample scans; telemetry counters under review.<br>2025-11-06: Resuming with manifest writer abstraction, rotation metadata, and telemetry counters for Surface.FS persistence.<br>2025-11-06 21:05Z: Stage now persists manifest/payload caches, exports metrics to Prometheus/Grafana, and WebService pointer tests validate consumption. | Integration tests prove cache entries exist; telemetry counters exported. |
|
||||
> 2025-11-05 19:18Z: Bound root directory to resolved Surface.Env settings and added unit coverage around the configurator.
|
||||
> 2025-11-06 18:45Z: Resuming manifest persistence—planning publisher abstraction refactor, CAS storage wiring, and telemetry/test coverage.
|
||||
> 2025-11-06 20:20Z: Hooked Surface metrics into Grafana (new dashboard JSON) and verified WebService consumption via end-to-end pointer test seeding manifest + payload entries.
|
||||
> 2025-11-06 21:05Z: Completed Surface manifest cache + metrics work; tests/docs updated and task ready to close.
|
||||
| SCANNER-ENV-01 | TODO (2025-11-06) | Scanner Worker Guild | SURFACE-ENV-02 | Replace ad-hoc environment reads with `StellaOps.Scanner.Surface.Env` helpers for cache roots and CAS endpoints.<br>2025-11-02: Worker bootstrap now resolves cache roots via helper; warning path documented; smoke tests running.<br>2025-11-05 14:55Z: Extending helper usage into cache/secrets configuration, updating worker validator wiring, and drafting docs/tests for new Surface.Env outputs.<br>2025-11-06 17:05Z: README/design docs updated with warning catalogue; startup logging guidance captured for ops runbooks.<br>2025-11-06 07:45Z: Helm/Compose env profiles (dev/stage/prod/airgap/mirror) now seed `SCANNER_SURFACE_*` defaults to keep worker cache roots aligned with Surface.Env helpers.<br>2025-11-06 07:55Z: Paused; pending automation tracked via `DEVOPS-OPENSSL-11-001/002` and Surface.Env test fixtures. | Worker boots with helper; misconfiguration warnings documented; smoke tests updated. |
|
||||
> 2025-11-05 19:18Z: Bound `SurfaceCacheOptions` root directory to resolved Surface.Env settings and added unit coverage around the configurator.
|
||||
| SCANNER-SECRETS-01 | DOING (2025-11-06) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.<br>2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added.<br>2025-11-06: Continuing to replace legacy registry credential plumbing and extend rotation metrics/fixtures.<br>2025-11-06 21:35Z: Introduced `ScannerStorageSurfaceSecretConfigurator` mapping `cas-access` secrets into storage options plus unit coverage. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. |
|
||||
| SCANNER-SECRETS-01 | DONE (2025-11-06) | Scanner Worker Guild, Security Guild | SURFACE-SECRETS-02 | Adopt `StellaOps.Scanner.Surface.Secrets` for registry/CAS credentials during scan execution.<br>2025-11-02: Surface.Secrets provider wired for CAS token retrieval; integration tests added.<br>2025-11-06: Replaced registry credential plumbing with shared provider, added registry secret stage + metrics, and installed .NET 10 RC2 to validate parser/stage suites via targeted `dotnet test`. | Secrets fetched via shared provider; legacy secret code removed; integration tests cover rotation. |
|
||||
|
||||
Reference in New Issue
Block a user