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:
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class RegistrySecretStageExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithSecret_StoresCredentialsAndEmitsMetrics()
|
||||
{
|
||||
const string secretJson = """
|
||||
{
|
||||
"defaultRegistry": "registry.example.com",
|
||||
"entries": [
|
||||
{
|
||||
"registry": "registry.example.com",
|
||||
"username": "demo",
|
||||
"password": "s3cret",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var provider = new StubSecretProvider(secretJson);
|
||||
var environment = new StubSurfaceEnvironment("tenant-eu");
|
||||
var metrics = new ScannerWorkerMetrics();
|
||||
var timeProvider = TimeProvider.System;
|
||||
var executor = new RegistrySecretStageExecutor(
|
||||
provider,
|
||||
environment,
|
||||
metrics,
|
||||
timeProvider,
|
||||
NullLogger<RegistrySecretStageExecutor>.Instance);
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["surface.registry.secret"] = "primary"
|
||||
};
|
||||
var lease = new StubLease("job-1", "scan-1", metadata);
|
||||
using var contextCancellation = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None);
|
||||
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), contextCancellation.Token);
|
||||
|
||||
var measurements = new List<(long Value, KeyValuePair<string, object?>[] Tags)>();
|
||||
using var listener = CreateCounterListener("scanner_worker_registry_secret_requests_total", measurements);
|
||||
|
||||
await executor.ExecuteAsync(context, CancellationToken.None);
|
||||
listener.RecordObservableInstruments();
|
||||
|
||||
Assert.True(context.Analysis.TryGet<RegistryAccessSecret>(ScanAnalysisKeys.RegistryCredentials, out var secret));
|
||||
Assert.NotNull(secret);
|
||||
Assert.Single(secret!.Entries);
|
||||
|
||||
Assert.Contains(
|
||||
measurements,
|
||||
measurement => measurement.Value == 1 &&
|
||||
HasTagValue(measurement.Tags, "secret.result", "resolved") &&
|
||||
HasTagValue(measurement.Tags, "secret.name", "primary"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SecretMissing_RecordsMissingMetric()
|
||||
{
|
||||
var provider = new MissingSecretProvider();
|
||||
var environment = new StubSurfaceEnvironment("tenant-eu");
|
||||
var metrics = new ScannerWorkerMetrics();
|
||||
var executor = new RegistrySecretStageExecutor(
|
||||
provider,
|
||||
environment,
|
||||
metrics,
|
||||
TimeProvider.System,
|
||||
NullLogger<RegistrySecretStageExecutor>.Instance);
|
||||
|
||||
var lease = new StubLease("job-2", "scan-2", new Dictionary<string, string>());
|
||||
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None);
|
||||
|
||||
var measurements = new List<(long Value, KeyValuePair<string, object?>[] Tags)>();
|
||||
using var listener = CreateCounterListener("scanner_worker_registry_secret_requests_total", measurements);
|
||||
|
||||
await executor.ExecuteAsync(context, CancellationToken.None);
|
||||
listener.RecordObservableInstruments();
|
||||
|
||||
Assert.False(context.Analysis.TryGet<RegistryAccessSecret>(ScanAnalysisKeys.RegistryCredentials, out _));
|
||||
|
||||
Assert.Contains(
|
||||
measurements,
|
||||
measurement => measurement.Value == 1 &&
|
||||
HasTagValue(measurement.Tags, "secret.result", "missing") &&
|
||||
HasTagValue(measurement.Tags, "secret.name", "default"));
|
||||
}
|
||||
|
||||
private static MeterListener CreateCounterListener(
|
||||
string instrumentName,
|
||||
ICollection<(long Value, KeyValuePair<string, object?>[] Tags)> measurements)
|
||||
{
|
||||
var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, meterListener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName &&
|
||||
instrument.Name == instrumentName)
|
||||
{
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
var copy = tags.ToArray();
|
||||
measurements.Add((measurement, copy));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static bool HasTagValue(IEnumerable<KeyValuePair<string, object?>> tags, string key, string expected)
|
||||
=> tags.Any(tag => string.Equals(tag.Key, key, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(tag.Value?.ToString(), expected, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private sealed class StubSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly string _json;
|
||||
|
||||
public StubSecretProvider(string json)
|
||||
{
|
||||
_json = json;
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(_json);
|
||||
return ValueTask.FromResult(SurfaceSecretHandle.FromBytes(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MissingSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public StubSurfaceEnvironment(string tenant)
|
||||
{
|
||||
Settings = new SurfaceEnvironmentSettings(
|
||||
new Uri("https://surface.example"),
|
||||
"bucket",
|
||||
"region",
|
||||
new DirectoryInfo(Path.GetTempPath()),
|
||||
1024,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new SurfaceSecretsConfiguration("inline", tenant, null, null, null, AllowInline: true),
|
||||
tenant,
|
||||
new SurfaceTlsConfiguration(null, null, null))
|
||||
{
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
RawVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentSettings Settings { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; }
|
||||
}
|
||||
|
||||
private sealed class StubLease : IScanJobLease
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> _metadata;
|
||||
|
||||
public StubLease(string jobId, string scanId, IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
JobId = jobId;
|
||||
ScanId = scanId;
|
||||
_metadata = metadata;
|
||||
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
LeasedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public string JobId { get; }
|
||||
|
||||
public string ScanId { get; }
|
||||
|
||||
public int Attempt { get; } = 1;
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; } = 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using StellaOps.Scanner.Worker.Processing.Surface;
|
||||
using StellaOps.Scanner.Worker.Tests.TestInfrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class SurfaceManifestStageExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenNoPayloads_SkipsPublishAndRecordsSkipMetric()
|
||||
{
|
||||
var metrics = new ScannerWorkerMetrics();
|
||||
var publisher = new TestSurfaceManifestPublisher();
|
||||
var cache = new RecordingSurfaceCache();
|
||||
var environment = new TestSurfaceEnvironment("tenant-a");
|
||||
|
||||
using var listener = new WorkerMeterListener();
|
||||
listener.Start();
|
||||
|
||||
var executor = new SurfaceManifestStageExecutor(
|
||||
publisher,
|
||||
cache,
|
||||
environment,
|
||||
metrics,
|
||||
NullLogger<SurfaceManifestStageExecutor>.Instance);
|
||||
|
||||
var context = CreateContext();
|
||||
|
||||
await executor.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, publisher.PublishCalls);
|
||||
Assert.Empty(cache.Entries);
|
||||
|
||||
var skipMetrics = listener.Measurements
|
||||
.Where(m => m.InstrumentName == "scanner_worker_surface_manifests_skipped_total")
|
||||
.ToArray();
|
||||
|
||||
Assert.Single(skipMetrics);
|
||||
Assert.Equal(1, skipMetrics[0].Value);
|
||||
Assert.Equal("skipped", skipMetrics[0]["surface.result"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PublishesPayloads_CachesArtifacts_AndRecordsMetrics()
|
||||
{
|
||||
var metrics = new ScannerWorkerMetrics();
|
||||
var publisher = new TestSurfaceManifestPublisher("tenant-a");
|
||||
var cache = new RecordingSurfaceCache();
|
||||
var environment = new TestSurfaceEnvironment("tenant-a");
|
||||
|
||||
using var listener = new WorkerMeterListener();
|
||||
listener.Start();
|
||||
|
||||
var executor = new SurfaceManifestStageExecutor(
|
||||
publisher,
|
||||
cache,
|
||||
environment,
|
||||
metrics,
|
||||
NullLogger<SurfaceManifestStageExecutor>.Instance);
|
||||
|
||||
var context = CreateContext();
|
||||
PopulateAnalysis(context);
|
||||
|
||||
await executor.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, publisher.PublishCalls);
|
||||
Assert.True(context.Analysis.TryGet<SurfaceManifestPublishResult>(ScanAnalysisKeys.SurfaceManifest, out var result));
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(publisher.LastManifestDigest, result!.ManifestDigest);
|
||||
|
||||
Assert.Equal(4, cache.Entries.Count);
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.graph" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.entrytrace.ndjson" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.artifacts.layer.fragments" && key.Tenant == "tenant-a");
|
||||
Assert.Contains(cache.Entries.Keys, key => key.Namespace == "surface.manifests" && key.Tenant == "tenant-a");
|
||||
|
||||
var publishedMetrics = listener.Measurements
|
||||
.Where(m => m.InstrumentName == "scanner_worker_surface_manifests_published_total")
|
||||
.ToArray();
|
||||
Assert.Single(publishedMetrics);
|
||||
Assert.Equal(1, publishedMetrics[0].Value);
|
||||
Assert.Equal("published", publishedMetrics[0]["surface.result"]);
|
||||
Assert.Equal(3, Convert.ToInt32(publishedMetrics[0]["surface.payload_count"]));
|
||||
|
||||
var payloadMetrics = listener.Measurements
|
||||
.Where(m => m.InstrumentName == "scanner_worker_surface_payload_persisted_total")
|
||||
.ToArray();
|
||||
Assert.Equal(3, payloadMetrics.Length);
|
||||
Assert.Contains(payloadMetrics, m => Equals("entrytrace.graph", m["surface.kind"]));
|
||||
Assert.Contains(payloadMetrics, m => Equals("entrytrace.ndjson", m["surface.kind"]));
|
||||
Assert.Contains(payloadMetrics, m => Equals("layer.fragments", m["surface.kind"]));
|
||||
}
|
||||
|
||||
private static ScanJobContext CreateContext()
|
||||
{
|
||||
var lease = new FakeJobLease();
|
||||
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static void PopulateAnalysis(ScanJobContext context)
|
||||
{
|
||||
var node = new EntryTraceNode(
|
||||
Id: 1,
|
||||
Kind: EntryTraceNodeKind.Command,
|
||||
DisplayName: "/bin/entry",
|
||||
Arguments: ImmutableArray<string>.Empty,
|
||||
InterpreterKind: EntryTraceInterpreterKind.None,
|
||||
Evidence: null,
|
||||
Span: null,
|
||||
Metadata: null);
|
||||
|
||||
var graph = new EntryTraceGraph(
|
||||
Outcome: EntryTraceOutcome.Resolved,
|
||||
Nodes: ImmutableArray.Create(node),
|
||||
Edges: ImmutableArray<EntryTraceEdge>.Empty,
|
||||
Diagnostics: ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
Plans: ImmutableArray<EntryTracePlan>.Empty,
|
||||
Terminals: ImmutableArray<EntryTraceTerminal>.Empty);
|
||||
|
||||
context.Analysis.Set(ScanAnalysisKeys.EntryTraceGraph, graph);
|
||||
|
||||
var ndjson = ImmutableArray.Create("{\"entry\":\"/bin/entry\"}\n");
|
||||
context.Analysis.Set(ScanAnalysisKeys.EntryTraceNdjson, ndjson);
|
||||
|
||||
var component = new ComponentRecord
|
||||
{
|
||||
Identity = ComponentIdentity.Create("pkg:test", "test", "1.0.0"),
|
||||
LayerDigest = "sha256:layer-1",
|
||||
Evidence = ImmutableArray<ComponentEvidence>.Empty,
|
||||
Usage = ComponentUsage.Create(true, new[] { "/bin/entry" })
|
||||
};
|
||||
|
||||
var fragment = LayerComponentFragment.Create("sha256:layer-1", new[] { component });
|
||||
context.Analysis.Set(ScanAnalysisKeys.LayerComponentFragments, ImmutableArray.Create(fragment));
|
||||
}
|
||||
|
||||
private sealed class RecordingSurfaceCache : ISurfaceCache
|
||||
{
|
||||
private readonly Dictionary<SurfaceCacheKey, byte[]> _entries = new();
|
||||
|
||||
public IReadOnlyDictionary<SurfaceCacheKey, byte[]> Entries => _entries;
|
||||
|
||||
public Task<T> GetOrCreateAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
Func<T, ReadOnlyMemory<byte>> serializer,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var payload))
|
||||
{
|
||||
return Task.FromResult(deserializer(payload));
|
||||
}
|
||||
|
||||
return CreateAsync(key, factory, serializer, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<T?> TryGetAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var payload))
|
||||
{
|
||||
return Task.FromResult<T?>(deserializer(payload));
|
||||
}
|
||||
|
||||
return Task.FromResult<T?>(default);
|
||||
}
|
||||
|
||||
public Task SetAsync(
|
||||
SurfaceCacheKey key,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries[key] = payload.ToArray();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<T> CreateAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
Func<T, ReadOnlyMemory<byte>> serializer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
_entries[key] = serializer(value).ToArray();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSurfaceManifestPublisher : ISurfaceManifestPublisher
|
||||
{
|
||||
private readonly string _tenant;
|
||||
private readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public TestSurfaceManifestPublisher(string tenant = "tenant-a")
|
||||
{
|
||||
_tenant = tenant;
|
||||
}
|
||||
|
||||
public int PublishCalls { get; private set; }
|
||||
|
||||
public SurfaceManifestRequest? LastRequest { get; private set; }
|
||||
|
||||
public string? LastManifestDigest { get; private set; }
|
||||
|
||||
public Task<SurfaceManifestPublishResult> PublishAsync(SurfaceManifestRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
PublishCalls++;
|
||||
LastRequest = request;
|
||||
|
||||
var artifacts = request.Payloads.Select(payload =>
|
||||
{
|
||||
var digest = ComputeDigest(payload.Content.Span);
|
||||
return new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = payload.Kind,
|
||||
Uri = $"cas://test/{payload.Kind}/{digest}",
|
||||
Digest = digest,
|
||||
MediaType = payload.MediaType,
|
||||
Format = payload.ArtifactFormat.ToString().ToLowerInvariant(),
|
||||
SizeBytes = payload.Content.Length,
|
||||
View = payload.View,
|
||||
Metadata = payload.Metadata,
|
||||
Storage = new SurfaceManifestStorage
|
||||
{
|
||||
Bucket = "test-bucket",
|
||||
ObjectKey = $"objects/{digest}",
|
||||
SizeBytes = payload.Content.Length,
|
||||
ContentType = payload.MediaType
|
||||
}
|
||||
};
|
||||
}).ToImmutableArray();
|
||||
|
||||
var document = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = _tenant,
|
||||
ImageDigest = request.ImageDigest,
|
||||
ScanId = request.ScanId,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Source = new SurfaceManifestSource
|
||||
{
|
||||
Component = request.Component,
|
||||
Version = request.Version,
|
||||
WorkerInstance = request.WorkerInstance,
|
||||
Attempt = request.Attempt
|
||||
},
|
||||
Artifacts = artifacts
|
||||
};
|
||||
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(document, _options);
|
||||
var manifestDigest = ComputeDigest(manifestBytes);
|
||||
LastManifestDigest = manifestDigest;
|
||||
|
||||
var result = new SurfaceManifestPublishResult(
|
||||
ManifestDigest: manifestDigest,
|
||||
ManifestUri: $"cas://test/manifests/{manifestDigest}",
|
||||
ArtifactId: $"surface-manifest::{manifestDigest}",
|
||||
Document: document);
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(content, hash);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public TestSurfaceEnvironment(string tenant)
|
||||
{
|
||||
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "surface-cache-test"));
|
||||
Settings = new SurfaceEnvironmentSettings(
|
||||
SurfaceFsEndpoint: new Uri("https://surface.local"),
|
||||
SurfaceFsBucket: "test-bucket",
|
||||
SurfaceFsRegion: null,
|
||||
CacheRoot: cacheRoot,
|
||||
CacheQuotaMegabytes: 512,
|
||||
PrefetchEnabled: false,
|
||||
FeatureFlags: Array.Empty<string>(),
|
||||
Secrets: new SurfaceSecretsConfiguration("none", tenant, null, null, null, false),
|
||||
Tenant: tenant,
|
||||
Tls: new SurfaceTlsConfiguration(null, null, null));
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentSettings Settings { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
private sealed class FakeJobLease : IScanJobLease
|
||||
{
|
||||
private readonly Dictionary<string, string> _metadata = new()
|
||||
{
|
||||
["queue"] = "tests",
|
||||
["job.kind"] = "unit"
|
||||
};
|
||||
|
||||
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; } = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public TimeSpan LeaseDuration { get; } = 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
using StellaOps.Scanner.Worker.Diagnostics;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.TestInfrastructure;
|
||||
|
||||
public sealed class WorkerMeterListener : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
|
||||
public ConcurrentBag<Measurement> Measurements { get; } = new();
|
||||
|
||||
public WorkerMeterListener()
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName)
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
AddMeasurement(instrument, measurement, tags);
|
||||
});
|
||||
|
||||
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
AddMeasurement(instrument, measurement, tags);
|
||||
});
|
||||
}
|
||||
|
||||
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 void AddMeasurement<T>(Instrument instrument, T measurement, ReadOnlySpan<KeyValuePair<string, object?>> tags)
|
||||
where T : struct, IConvertible
|
||||
{
|
||||
var tagDictionary = new Dictionary<string, object?>(tags.Length, StringComparer.Ordinal);
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
tagDictionary[tag.Key] = tag.Value;
|
||||
}
|
||||
|
||||
var value = Convert.ToDouble(measurement, System.Globalization.CultureInfo.InvariantCulture);
|
||||
Measurements.Add(new Measurement(instrument.Name, value, tagDictionary));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
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;
|
||||
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 StellaOps.Scanner.Worker.Tests.TestInfrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
@@ -48,8 +48,8 @@ public sealed class WorkerBasicScanScenarioTests
|
||||
var scheduler = new ControlledDelayScheduler();
|
||||
var analyzer = new TestAnalyzerDispatcher(scheduler);
|
||||
|
||||
using var listener = new WorkerMetricsListener();
|
||||
listener.Start();
|
||||
using var listener = new WorkerMeterListener();
|
||||
listener.Start();
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddLogging(builder =>
|
||||
@@ -341,46 +341,6 @@ public sealed class WorkerBasicScanScenarioTests
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user