up
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Diagnostics;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Diagnostics;
|
||||
|
||||
public sealed class VulnExplorerTelemetryTests
|
||||
{
|
||||
private static readonly AdvisoryObservationSource DefaultSource = new("ghsa", "stream", "https://example.test/api");
|
||||
private static readonly AdvisoryObservationSignature DefaultSignature = new(false, null, null, null);
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_RecordsIdentifierCollisionMetric()
|
||||
{
|
||||
var (listener, measurements) = CreateListener(
|
||||
VulnExplorerTelemetry.MeterName,
|
||||
"vuln.identifier_collisions_total");
|
||||
|
||||
var observations = new[]
|
||||
{
|
||||
CreateObservation(
|
||||
"tenant-a:ghsa:1",
|
||||
"tenant-a",
|
||||
aliases: new[] { "CVE-2025-0001" }),
|
||||
CreateObservation(
|
||||
"tenant-a:osv:2",
|
||||
"tenant-a",
|
||||
aliases: new[] { "GHSA-aaaa-bbbb-cccc" })
|
||||
};
|
||||
|
||||
var service = new AdvisoryObservationQueryService(new TestObservationLookup(observations));
|
||||
|
||||
await service.QueryAsync(new AdvisoryObservationQueryOptions("tenant-a"), CancellationToken.None);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
var collision = measurements.Single(m => m.Instrument == "vuln.identifier_collisions_total");
|
||||
Assert.Equal(1, collision.Value);
|
||||
Assert.Equal("tenant-a", collision.Tags.Single(t => t.Key == "tenant").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordChunkRequest_EmitsCounterAndLatency()
|
||||
{
|
||||
var (listener, measurements) = CreateListener(
|
||||
VulnExplorerTelemetry.MeterName,
|
||||
"vuln.chunk_requests_total",
|
||||
"vuln.chunk_latency_ms");
|
||||
|
||||
VulnExplorerTelemetry.RecordChunkRequest("tenant-a", "ok", cacheHit: true, chunkCount: 3, latencyMs: 42.5);
|
||||
listener.Dispose();
|
||||
|
||||
Assert.Equal(1, measurements.Single(m => m.Instrument == "vuln.chunk_requests_total").Value);
|
||||
Assert.Equal(42.5, measurements.Single(m => m.Instrument == "vuln.chunk_latency_ms").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordWithdrawnStatement_EmitsCounter()
|
||||
{
|
||||
var (listener, measurements) = CreateListener(
|
||||
VulnExplorerTelemetry.MeterName,
|
||||
"vuln.withdrawn_statements_total");
|
||||
|
||||
VulnExplorerTelemetry.RecordWithdrawnStatement("tenant-a", "nvd");
|
||||
listener.Dispose();
|
||||
|
||||
var withdrawn = measurements.Single(m => m.Instrument == "vuln.withdrawn_statements_total");
|
||||
Assert.Equal(1, withdrawn.Value);
|
||||
Assert.Equal("tenant-a", withdrawn.Tags.Single(t => t.Key == "tenant").Value);
|
||||
Assert.Equal("nvd", withdrawn.Tags.Single(t => t.Key == "source").Value);
|
||||
}
|
||||
|
||||
private static AdvisoryObservation CreateObservation(
|
||||
string observationId,
|
||||
string tenant,
|
||||
IEnumerable<string>? aliases = null)
|
||||
{
|
||||
var upstream = new AdvisoryObservationUpstream(
|
||||
upstreamId: $"upstream-{observationId}",
|
||||
documentVersion: null,
|
||||
fetchedAt: DateTimeOffset.UtcNow,
|
||||
receivedAt: DateTimeOffset.UtcNow,
|
||||
contentHash: "sha256:d41d8cd98f00b204e9800998ecf8427e",
|
||||
signature: DefaultSignature);
|
||||
|
||||
var content = new AdvisoryObservationContent(
|
||||
"json",
|
||||
"1.0",
|
||||
new JsonObject());
|
||||
|
||||
var aliasArray = aliases?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
|
||||
var linkset = new AdvisoryObservationLinkset(
|
||||
aliasArray,
|
||||
Enumerable.Empty<string>(),
|
||||
Enumerable.Empty<string>(),
|
||||
Enumerable.Empty<AdvisoryObservationReference>());
|
||||
|
||||
var rawLinkset = new RawLinkset
|
||||
{
|
||||
Aliases = aliasArray
|
||||
};
|
||||
|
||||
return new AdvisoryObservation(
|
||||
observationId,
|
||||
tenant,
|
||||
DefaultSource,
|
||||
upstream,
|
||||
content,
|
||||
linkset,
|
||||
rawLinkset,
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static (MeterListener Listener, List<MeasurementRecord> Measurements) CreateListener(
|
||||
string meterName,
|
||||
params string[] instruments)
|
||||
{
|
||||
var measurements = new List<MeasurementRecord>();
|
||||
var instrumentSet = instruments.ToHashSet(StringComparer.Ordinal);
|
||||
var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, meterListener) =>
|
||||
{
|
||||
if (string.Equals(instrument.Meter.Name, meterName, StringComparison.Ordinal) &&
|
||||
instrumentSet.Contains(instrument.Name))
|
||||
{
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
if (instrumentSet.Contains(instrument.Name))
|
||||
{
|
||||
measurements.Add(new MeasurementRecord(instrument.Name, measurement, CopyTags(tags)));
|
||||
}
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
if (instrumentSet.Contains(instrument.Name))
|
||||
{
|
||||
measurements.Add(new MeasurementRecord(instrument.Name, measurement, CopyTags(tags)));
|
||||
}
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
return (listener, measurements);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KeyValuePair<string, object?>> CopyTags(ReadOnlySpan<KeyValuePair<string, object?>> tags)
|
||||
{
|
||||
var list = new List<KeyValuePair<string, object?>>(tags.Length);
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
list.Add(tag);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private sealed record MeasurementRecord(string Instrument, double Value, IReadOnlyList<KeyValuePair<string, object?>> Tags);
|
||||
|
||||
private sealed class TestObservationLookup : IAdvisoryObservationLookup
|
||||
{
|
||||
private readonly IReadOnlyList<AdvisoryObservation> _observations;
|
||||
|
||||
public TestObservationLookup(IReadOnlyList<AdvisoryObservation> observations)
|
||||
{
|
||||
_observations = observations;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(string tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
var matches = _observations
|
||||
.Where(o => string.Equals(o.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(matches);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
|
||||
string tenant,
|
||||
IReadOnlyCollection<string> observationIds,
|
||||
IReadOnlyCollection<string> aliases,
|
||||
IReadOnlyCollection<string> purls,
|
||||
IReadOnlyCollection<string> cpes,
|
||||
AdvisoryObservationCursor? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matches = _observations
|
||||
.Where(o => string.Equals(o.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Core.Diagnostics;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public sealed class VulnExplorerTelemetryTests : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<(string Name, double Value, KeyValuePair<string, object?>[] Tags)> _histogramMeasurements = new();
|
||||
private readonly List<(string Name, long Value, KeyValuePair<string, object?>[] Tags)> _counterMeasurements = new();
|
||||
|
||||
public VulnExplorerTelemetryTests()
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == VulnExplorerTelemetry.MeterName)
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
if (instrument.Meter.Name == VulnExplorerTelemetry.MeterName)
|
||||
{
|
||||
_counterMeasurements.Add((instrument.Name, measurement, tags.ToArray()));
|
||||
}
|
||||
});
|
||||
|
||||
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
if (instrument.Meter.Name == VulnExplorerTelemetry.MeterName)
|
||||
{
|
||||
_histogramMeasurements.Add((instrument.Name, measurement, tags.ToArray()));
|
||||
}
|
||||
});
|
||||
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountAliasCollisions_FiltersAliasConflicts()
|
||||
{
|
||||
var conflicts = new List<AdvisoryLinksetConflict>
|
||||
{
|
||||
new("aliases", "alias-inconsistency", Array.Empty<string>()),
|
||||
new("ranges", "range-divergence", Array.Empty<string>()),
|
||||
new("alias-field", "ALIAS-INCONSISTENCY", Array.Empty<string>())
|
||||
};
|
||||
|
||||
var count = VulnExplorerTelemetry.CountAliasCollisions(conflicts);
|
||||
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWithdrawn_DetectsWithdrawnFlagsAndTimestamps()
|
||||
{
|
||||
using var json = JsonDocument.Parse("{\"withdrawn\":true,\"withdrawn_at\":\"2024-10-10T00:00:00Z\"}");
|
||||
Assert.True(VulnExplorerTelemetry.IsWithdrawn(json.RootElement));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordChunkLatency_EmitsHistogramMeasurement()
|
||||
{
|
||||
VulnExplorerTelemetry.RecordChunkLatency("tenant-a", "vendor-a", TimeSpan.FromMilliseconds(42));
|
||||
|
||||
var measurement = Assert.Single(_histogramMeasurements);
|
||||
Assert.Equal("vuln.chunk_latency_ms", measurement.Name);
|
||||
Assert.Equal(42, measurement.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordWithdrawnStatement_EmitsCounter()
|
||||
{
|
||||
VulnExplorerTelemetry.RecordWithdrawnStatement("tenant-b", "vendor-b");
|
||||
|
||||
var measurement = Assert.Single(_counterMeasurements);
|
||||
Assert.Equal("vuln.withdrawn_statements_total", measurement.Name);
|
||||
Assert.Equal(1, measurement.Value);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_listener.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -75,16 +75,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
PrepareMongoEnvironment();
|
||||
if (TryStartExternalMongo(out var externalConnectionString) && !string.IsNullOrWhiteSpace(externalConnectionString))
|
||||
{
|
||||
_factory = new ConcelierApplicationFactory(externalConnectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
_factory = new ConcelierApplicationFactory(_runner.ConnectionString);
|
||||
}
|
||||
_factory = new ConcelierApplicationFactory(string.Empty);
|
||||
WarmupFactory(_factory);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -92,30 +83,6 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_factory.Dispose();
|
||||
if (_externalMongo is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_externalMongo.HasExited)
|
||||
{
|
||||
_externalMongo.Kill(true);
|
||||
_externalMongo.WaitForExit(2000);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup errors in tests
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_externalMongoDataPath) && Directory.Exists(_externalMongoDataPath))
|
||||
{
|
||||
try { Directory.Delete(_externalMongoDataPath, recursive: true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -141,12 +108,12 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
var healthPayload = await healthResponse.Content.ReadFromJsonAsync<HealthPayload>();
|
||||
Assert.NotNull(healthPayload);
|
||||
Assert.Equal("healthy", healthPayload!.Status);
|
||||
Assert.Equal("mongo", healthPayload.Storage.Driver);
|
||||
Assert.Equal("postgres", healthPayload.Storage.Backend);
|
||||
|
||||
var readyPayload = await readyResponse.Content.ReadFromJsonAsync<ReadyPayload>();
|
||||
Assert.NotNull(readyPayload);
|
||||
Assert.Equal("ready", readyPayload!.Status);
|
||||
Assert.Equal("ready", readyPayload.Mongo.Status);
|
||||
Assert.True(readyPayload!.Status is "ready" or "degraded");
|
||||
Assert.Equal("postgres", readyPayload.Storage.Backend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2019,9 +1986,10 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
private sealed class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string? _previousDsn;
|
||||
private readonly string? _previousDriver;
|
||||
private readonly string? _previousTimeout;
|
||||
private readonly string? _previousPgDsn;
|
||||
private readonly string? _previousPgEnabled;
|
||||
private readonly string? _previousPgTimeout;
|
||||
private readonly string? _previousPgSchema;
|
||||
private readonly string? _previousTelemetryEnabled;
|
||||
private readonly string? _previousTelemetryLogging;
|
||||
private readonly string? _previousTelemetryTracing;
|
||||
@@ -2035,11 +2003,15 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Action<ConcelierOptions.AuthorityOptions>? authorityConfigure = null,
|
||||
IDictionary<string, string?>? environmentOverrides = null)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
var defaultPostgresDsn = "Host=localhost;Port=5432;Database=concelier_test;Username=postgres;Password=postgres";
|
||||
_connectionString = string.IsNullOrWhiteSpace(connectionString) || connectionString.StartsWith("mongodb://", StringComparison.OrdinalIgnoreCase)
|
||||
? defaultPostgresDsn
|
||||
: connectionString;
|
||||
_authorityConfigure = authorityConfigure;
|
||||
_previousDsn = Environment.GetEnvironmentVariable("CONCELIER_STORAGE__DSN");
|
||||
_previousDriver = Environment.GetEnvironmentVariable("CONCELIER_STORAGE__DRIVER");
|
||||
_previousTimeout = Environment.GetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS");
|
||||
_previousPgDsn = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING");
|
||||
_previousPgEnabled = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED");
|
||||
_previousPgTimeout = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS");
|
||||
_previousPgSchema = Environment.GetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME");
|
||||
_previousTelemetryEnabled = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED");
|
||||
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
|
||||
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
|
||||
@@ -2055,13 +2027,15 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged);
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", connectionString);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", "mongo");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING", _connectionString);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME", "vuln");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
|
||||
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
|
||||
@@ -2176,9 +2150,11 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", _previousDsn);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", _previousDriver);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", _previousTimeout);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__CONNECTIONSTRING", _previousPgDsn);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__ENABLED", _previousPgEnabled);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", _previousPgTimeout);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_POSTGRESSTORAGE__SCHEMANAME", _previousPgSchema);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", null);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLED", _previousTelemetryEnabled);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", _previousTelemetryLogging);
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", _previousTelemetryTracing);
|
||||
@@ -2470,13 +2446,11 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
|
||||
private sealed record HealthPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage, TelemetryPayload Telemetry);
|
||||
|
||||
private sealed record StoragePayload(string Driver, bool Completed, DateTimeOffset? CompletedAt, double? DurationMs);
|
||||
private sealed record StoragePayload(string Backend, bool Ready, DateTimeOffset? CheckedAt, double? LatencyMs, string? Error);
|
||||
|
||||
private sealed record TelemetryPayload(bool Enabled, bool Tracing, bool Metrics, bool Logging);
|
||||
|
||||
private sealed record ReadyPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, ReadyMongoPayload Mongo);
|
||||
|
||||
private sealed record ReadyMongoPayload(string Status, double? LatencyMs, DateTimeOffset? CheckedAt, string? Error);
|
||||
private sealed record ReadyPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage);
|
||||
|
||||
private sealed record JobDefinitionPayload(string Kind, bool Enabled, string? CronExpression, TimeSpan Timeout, TimeSpan LeaseDuration, JobRunPayload? LastRun);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user