Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex.Tests;
|
||||
|
||||
public sealed class FixtureImpactIndexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolveByPurls_UsesEmbeddedFixtures()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages);
|
||||
var (impactIndex, loggerFactory) = CreateImpactIndex();
|
||||
using var _ = loggerFactory;
|
||||
|
||||
var result = await impactIndex.ResolveByPurlsAsync(
|
||||
new[] { "pkg:apk/alpine/openssl@3.2.2-r0?arch=x86_64" },
|
||||
usageOnly: false,
|
||||
selector);
|
||||
|
||||
result.UsageOnly.Should().BeFalse();
|
||||
result.Images.Should().ContainSingle();
|
||||
|
||||
var image = result.Images.Single();
|
||||
image.ImageDigest.Should().Be("sha256:8f47d7c6b538c0d9533b78913cba3d5e671e7c4b4e7c6a2bb9a1a1c4d4f8e123");
|
||||
image.Registry.Should().Be("docker.io");
|
||||
image.Repository.Should().Be("library/nginx");
|
||||
image.Tags.Should().ContainSingle(tag => tag == "1.25.4");
|
||||
image.UsedByEntrypoint.Should().BeTrue();
|
||||
|
||||
result.GeneratedAt.Should().Be(DateTimeOffset.Parse("2025-10-19T00:00:00Z"));
|
||||
result.SchemaVersion.Should().Be(SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveByPurls_UsageOnlyFiltersInventoryOnlyComponents()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages);
|
||||
var (impactIndex, loggerFactory) = CreateImpactIndex();
|
||||
using var _ = loggerFactory;
|
||||
|
||||
var inventoryOnlyPurl = "pkg:apk/alpine/pcre2@10.42-r1?arch=x86_64";
|
||||
|
||||
var runtimeResult = await impactIndex.ResolveByPurlsAsync(
|
||||
new[] { inventoryOnlyPurl },
|
||||
usageOnly: true,
|
||||
selector);
|
||||
|
||||
runtimeResult.Images.Should().BeEmpty();
|
||||
|
||||
var inventoryResult = await impactIndex.ResolveByPurlsAsync(
|
||||
new[] { inventoryOnlyPurl },
|
||||
usageOnly: false,
|
||||
selector);
|
||||
|
||||
inventoryResult.Images.Should().ContainSingle();
|
||||
inventoryResult.Images.Single().UsedByEntrypoint.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAll_ReturnsDeterministicFixtureSet()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages);
|
||||
var (impactIndex, loggerFactory) = CreateImpactIndex();
|
||||
using var _ = loggerFactory;
|
||||
|
||||
var first = await impactIndex.ResolveAllAsync(selector, usageOnly: false);
|
||||
first.Images.Should().HaveCount(6);
|
||||
|
||||
var second = await impactIndex.ResolveAllAsync(selector, usageOnly: false);
|
||||
second.Images.Should().HaveCount(6);
|
||||
second.Images.Should().Equal(first.Images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveByVulnerabilities_ReturnsEmptySet()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages);
|
||||
var (impactIndex, loggerFactory) = CreateImpactIndex();
|
||||
using var _ = loggerFactory;
|
||||
|
||||
var result = await impactIndex.ResolveByVulnerabilitiesAsync(
|
||||
new[] { "CVE-2025-0001" },
|
||||
usageOnly: false,
|
||||
selector);
|
||||
|
||||
result.Images.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FixtureDirectoryOption_LoadsFromFileSystem()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages);
|
||||
var samplesDirectory = LocateSamplesDirectory();
|
||||
var (impactIndex, loggerFactory) = CreateImpactIndex(options =>
|
||||
{
|
||||
options.FixtureDirectory = samplesDirectory;
|
||||
});
|
||||
using var _ = loggerFactory;
|
||||
|
||||
var result = await impactIndex.ResolveAllAsync(selector, usageOnly: false);
|
||||
|
||||
result.Images.Should().HaveCount(6);
|
||||
}
|
||||
|
||||
private static (FixtureImpactIndex ImpactIndex, ILoggerFactory LoggerFactory) CreateImpactIndex(
|
||||
Action<ImpactIndexStubOptions>? configure = null)
|
||||
{
|
||||
var options = new ImpactIndexStubOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
var logger = loggerFactory.CreateLogger<FixtureImpactIndex>();
|
||||
|
||||
var impactIndex = new FixtureImpactIndex(options, TimeProvider.System, logger);
|
||||
return (impactIndex, loggerFactory);
|
||||
}
|
||||
|
||||
private static string LocateSamplesDirectory()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
|
||||
while (!string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
var candidate = Path.Combine(current, "samples", "scanner", "images");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = Directory.GetParent(current)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate 'samples/scanner/images'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Emit.Index;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.ImpactIndex.Tests;
|
||||
|
||||
public sealed class RoaringImpactIndexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IngestAsync_RegistersComponentsAndUsage()
|
||||
{
|
||||
var (stream, digest) = CreateBomIndex(
|
||||
ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0"),
|
||||
ComponentUsage.Create(true, new[] { "/app/start.sh" }));
|
||||
|
||||
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
|
||||
var request = new ImpactIndexIngestionRequest
|
||||
{
|
||||
TenantId = "tenant-alpha",
|
||||
ImageDigest = digest,
|
||||
Registry = "docker.io",
|
||||
Repository = "library/alpine",
|
||||
Namespaces = ImmutableArray.Create("team-a"),
|
||||
Tags = ImmutableArray.Create("3.20"),
|
||||
Labels = ImmutableSortedDictionary.CreateRange(StringComparer.OrdinalIgnoreCase, new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("env", "prod")
|
||||
}),
|
||||
BomIndexStream = stream,
|
||||
};
|
||||
|
||||
await index.IngestAsync(request, CancellationToken.None);
|
||||
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var impactSet = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: false, selector);
|
||||
|
||||
impactSet.Images.Should().HaveCount(1);
|
||||
impactSet.Images[0].ImageDigest.Should().Be(digest);
|
||||
impactSet.Images[0].Tags.Should().ContainSingle(tag => tag == "3.20");
|
||||
impactSet.Images[0].UsedByEntrypoint.Should().BeTrue();
|
||||
|
||||
var usageOnly = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: true, selector);
|
||||
usageOnly.Images.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ReplacesExistingImageData()
|
||||
{
|
||||
var component = ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0");
|
||||
var (initialStream, digest) = CreateBomIndex(component, ComponentUsage.Create(false));
|
||||
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
|
||||
|
||||
await index.IngestAsync(new ImpactIndexIngestionRequest
|
||||
{
|
||||
TenantId = "tenant-alpha",
|
||||
ImageDigest = digest,
|
||||
Registry = "docker.io",
|
||||
Repository = "library/alpine",
|
||||
Tags = ImmutableArray.Create("v1"),
|
||||
BomIndexStream = initialStream,
|
||||
});
|
||||
|
||||
var (updatedStream, _) = CreateBomIndex(component, ComponentUsage.Create(true, new[] { "/start.sh" }), digest);
|
||||
await index.IngestAsync(new ImpactIndexIngestionRequest
|
||||
{
|
||||
TenantId = "tenant-alpha",
|
||||
ImageDigest = digest,
|
||||
Registry = "docker.io",
|
||||
Repository = "library/alpine",
|
||||
Tags = ImmutableArray.Create("v2"),
|
||||
BomIndexStream = updatedStream,
|
||||
});
|
||||
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var impactSet = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: true, selector);
|
||||
|
||||
impactSet.Images.Should().HaveCount(1);
|
||||
impactSet.Images[0].Tags.Should().ContainSingle(tag => tag == "v2");
|
||||
impactSet.Images[0].UsedByEntrypoint.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveByPurlsAsync_RespectsTenantNamespaceAndTagFilters()
|
||||
{
|
||||
var component = ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0");
|
||||
var (tenantStream, tenantDigest) = CreateBomIndex(component, ComponentUsage.Create(true, new[] { "/start.sh" }));
|
||||
var (otherStream, otherDigest) = CreateBomIndex(component, ComponentUsage.Create(false));
|
||||
|
||||
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
|
||||
|
||||
await index.IngestAsync(new ImpactIndexIngestionRequest
|
||||
{
|
||||
TenantId = "tenant-alpha",
|
||||
ImageDigest = tenantDigest,
|
||||
Registry = "docker.io",
|
||||
Repository = "library/service",
|
||||
Namespaces = ImmutableArray.Create("team-alpha"),
|
||||
Tags = ImmutableArray.Create("prod-eu"),
|
||||
BomIndexStream = tenantStream,
|
||||
});
|
||||
|
||||
await index.IngestAsync(new ImpactIndexIngestionRequest
|
||||
{
|
||||
TenantId = "tenant-beta",
|
||||
ImageDigest = otherDigest,
|
||||
Registry = "docker.io",
|
||||
Repository = "library/service",
|
||||
Namespaces = ImmutableArray.Create("team-beta"),
|
||||
Tags = ImmutableArray.Create("staging-us"),
|
||||
BomIndexStream = otherStream,
|
||||
});
|
||||
|
||||
var selector = new Selector(
|
||||
SelectorScope.AllImages,
|
||||
tenantId: "tenant-alpha",
|
||||
namespaces: new[] { "team-alpha" },
|
||||
includeTags: new[] { "prod-*" });
|
||||
|
||||
var result = await index.ResolveByPurlsAsync(new[] { "pkg:npm/a@1.0.0" }, usageOnly: true, selector);
|
||||
|
||||
result.Images.Should().ContainSingle(image => image.ImageDigest == tenantDigest);
|
||||
result.Images[0].Tags.Should().Contain("prod-eu");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAllAsync_UsageOnlyFiltersEntrypointImages()
|
||||
{
|
||||
var component = ComponentIdentity.Create("pkg:npm/a@1.0.0", "a", "1.0.0", "pkg:npm/a@1.0.0");
|
||||
var (entryStream, entryDigest) = CreateBomIndex(component, ComponentUsage.Create(true, new[] { "/start.sh" }));
|
||||
var nonEntryDigestValue = "sha256:" + new string('1', 64);
|
||||
var (nonEntryStream, nonEntryDigest) = CreateBomIndex(component, ComponentUsage.Create(false), nonEntryDigestValue);
|
||||
|
||||
var index = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
|
||||
|
||||
await index.IngestAsync(new ImpactIndexIngestionRequest
|
||||
{
|
||||
TenantId = "tenant-alpha",
|
||||
ImageDigest = entryDigest,
|
||||
Registry = "docker.io",
|
||||
Repository = "library/service",
|
||||
BomIndexStream = entryStream,
|
||||
});
|
||||
|
||||
await index.IngestAsync(new ImpactIndexIngestionRequest
|
||||
{
|
||||
TenantId = "tenant-alpha",
|
||||
ImageDigest = nonEntryDigest,
|
||||
Registry = "docker.io",
|
||||
Repository = "library/service",
|
||||
BomIndexStream = nonEntryStream,
|
||||
});
|
||||
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
|
||||
var usageOnly = await index.ResolveAllAsync(selector, usageOnly: true);
|
||||
usageOnly.Images.Should().ContainSingle(image => image.ImageDigest == entryDigest);
|
||||
|
||||
var allImages = await index.ResolveAllAsync(selector, usageOnly: false);
|
||||
allImages.Images.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static (Stream Stream, string Digest) CreateBomIndex(ComponentIdentity identity, ComponentUsage usage, string? digest = null)
|
||||
{
|
||||
var layer = LayerComponentFragment.Create(
|
||||
"sha256:layer1",
|
||||
new[]
|
||||
{
|
||||
new ComponentRecord
|
||||
{
|
||||
Identity = identity,
|
||||
LayerDigest = "sha256:layer1",
|
||||
Usage = usage,
|
||||
}
|
||||
});
|
||||
|
||||
var graph = ComponentGraphBuilder.Build(new[] { layer });
|
||||
var effectiveDigest = digest ?? "sha256:" + Guid.NewGuid().ToString("N");
|
||||
var builder = new BomIndexBuilder();
|
||||
var artifact = builder.Build(new BomIndexBuildRequest
|
||||
{
|
||||
ImageDigest = effectiveDigest,
|
||||
Graph = graph,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
return (new MemoryStream(artifact.Bytes, writable: false), effectiveDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<RestoreIgnoreFailedSources>true</RestoreIgnoreFailedSources>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class AuditRecordTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuditRecordNormalizesMetadataAndIdentifiers()
|
||||
{
|
||||
var actor = new AuditActor(actorId: "user_admin", displayName: "Cluster Admin", kind: "user");
|
||||
var metadata = new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("details", "schedule paused"),
|
||||
new KeyValuePair<string, string>("Details", "should be overridden"), // duplicate with different casing
|
||||
new KeyValuePair<string, string>("reason", "maintenance"),
|
||||
};
|
||||
|
||||
var record = new AuditRecord(
|
||||
id: "audit_001",
|
||||
tenantId: "tenant-alpha",
|
||||
category: "scheduler",
|
||||
action: "pause",
|
||||
occurredAt: DateTimeOffset.Parse("2025-10-18T05:00:00Z"),
|
||||
actor: actor,
|
||||
scheduleId: "sch_001",
|
||||
runId: null,
|
||||
correlationId: "corr-123",
|
||||
metadata: metadata,
|
||||
message: "Paused via API");
|
||||
|
||||
Assert.Equal("tenant-alpha", record.TenantId);
|
||||
Assert.Equal("scheduler", record.Category);
|
||||
Assert.Equal(2, record.Metadata.Count);
|
||||
Assert.Equal("schedule paused", record.Metadata["details"]);
|
||||
Assert.Equal("maintenance", record.Metadata["reason"]);
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(record);
|
||||
Assert.Contains("\"category\":\"scheduler\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"metadata\":{\"details\":\"schedule paused\",\"reason\":\"maintenance\"}", json, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class GraphJobStateMachineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Pending, true)]
|
||||
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Queued, true)]
|
||||
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Running, true)]
|
||||
[InlineData(GraphJobStatus.Pending, GraphJobStatus.Completed, false)]
|
||||
[InlineData(GraphJobStatus.Queued, GraphJobStatus.Running, true)]
|
||||
[InlineData(GraphJobStatus.Queued, GraphJobStatus.Completed, false)]
|
||||
[InlineData(GraphJobStatus.Running, GraphJobStatus.Completed, true)]
|
||||
[InlineData(GraphJobStatus.Running, GraphJobStatus.Pending, false)]
|
||||
[InlineData(GraphJobStatus.Completed, GraphJobStatus.Failed, false)]
|
||||
public void CanTransition_ReturnsExpectedResult(GraphJobStatus from, GraphJobStatus to, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, GraphJobStateMachine.CanTransition(from, to));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureTransition_UpdatesBuildJobLifecycle()
|
||||
{
|
||||
var createdAt = new DateTimeOffset(2025, 10, 26, 12, 0, 0, TimeSpan.Zero);
|
||||
var job = new GraphBuildJob(
|
||||
id: "gbj_1",
|
||||
tenantId: "tenant-alpha",
|
||||
sbomId: "sbom_1",
|
||||
sbomVersionId: "sbom_ver_1",
|
||||
sbomDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
status: GraphJobStatus.Pending,
|
||||
trigger: GraphBuildJobTrigger.SbomVersion,
|
||||
createdAt: createdAt);
|
||||
|
||||
var queuedAt = createdAt.AddSeconds(5);
|
||||
job = GraphJobStateMachine.EnsureTransition(job, GraphJobStatus.Queued, queuedAt);
|
||||
Assert.Equal(GraphJobStatus.Queued, job.Status);
|
||||
Assert.Null(job.StartedAt);
|
||||
Assert.Null(job.CompletedAt);
|
||||
|
||||
var runningAt = queuedAt.AddSeconds(5);
|
||||
job = GraphJobStateMachine.EnsureTransition(job, GraphJobStatus.Running, runningAt, attempts: job.Attempts + 1);
|
||||
Assert.Equal(GraphJobStatus.Running, job.Status);
|
||||
Assert.Equal(runningAt, job.StartedAt);
|
||||
Assert.Null(job.CompletedAt);
|
||||
|
||||
var completedAt = runningAt.AddSeconds(30);
|
||||
job = GraphJobStateMachine.EnsureTransition(job, GraphJobStatus.Completed, completedAt);
|
||||
Assert.Equal(GraphJobStatus.Completed, job.Status);
|
||||
Assert.Equal(runningAt, job.StartedAt);
|
||||
Assert.Equal(completedAt, job.CompletedAt);
|
||||
Assert.Null(job.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureTransition_ToFailedRequiresError()
|
||||
{
|
||||
var job = new GraphBuildJob(
|
||||
id: "gbj_1",
|
||||
tenantId: "tenant-alpha",
|
||||
sbomId: "sbom_1",
|
||||
sbomVersionId: "sbom_ver_1",
|
||||
sbomDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
status: GraphJobStatus.Running,
|
||||
trigger: GraphBuildJobTrigger.SbomVersion,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
startedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => GraphJobStateMachine.EnsureTransition(
|
||||
job,
|
||||
GraphJobStatus.Failed,
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureTransition_ToFailedSetsError()
|
||||
{
|
||||
var job = new GraphOverlayJob(
|
||||
id: "goj_1",
|
||||
tenantId: "tenant-alpha",
|
||||
graphSnapshotId: "graph_snap_1",
|
||||
overlayKind: GraphOverlayKind.Policy,
|
||||
overlayKey: "policy@latest",
|
||||
status: GraphJobStatus.Running,
|
||||
trigger: GraphOverlayJobTrigger.Policy,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
startedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
var failed = GraphJobStateMachine.EnsureTransition(
|
||||
job,
|
||||
GraphJobStatus.Failed,
|
||||
DateTimeOffset.UtcNow,
|
||||
errorMessage: "cartographer timeout");
|
||||
|
||||
Assert.Equal(GraphJobStatus.Failed, failed.Status);
|
||||
Assert.NotNull(failed.CompletedAt);
|
||||
Assert.Equal("cartographer timeout", failed.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiresCompletedAtForTerminalState()
|
||||
{
|
||||
var job = new GraphOverlayJob(
|
||||
id: "goj_1",
|
||||
tenantId: "tenant-alpha",
|
||||
graphSnapshotId: "graph_snap_1",
|
||||
overlayKind: GraphOverlayKind.Policy,
|
||||
overlayKey: "policy@latest",
|
||||
status: GraphJobStatus.Completed,
|
||||
trigger: GraphOverlayJobTrigger.Policy,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => GraphJobStateMachine.Validate(job));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphOverlayJob_NormalizesSubjectsAndMetadata()
|
||||
{
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var job = new GraphOverlayJob(
|
||||
id: "goj_norm",
|
||||
tenantId: "tenant-alpha",
|
||||
graphSnapshotId: "graph_snap_norm",
|
||||
overlayKind: GraphOverlayKind.Policy,
|
||||
overlayKey: "policy@norm",
|
||||
status: GraphJobStatus.Pending,
|
||||
trigger: GraphOverlayJobTrigger.Policy,
|
||||
createdAt: createdAt,
|
||||
subjects: new[]
|
||||
{
|
||||
"artifact/service-api",
|
||||
"artifact/service-ui",
|
||||
"artifact/service-api"
|
||||
},
|
||||
metadata: new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("PolicyRunId", "run-123"),
|
||||
new KeyValuePair<string, string>("policyRunId", "run-123")
|
||||
});
|
||||
|
||||
Assert.Equal(2, job.Subjects.Length);
|
||||
Assert.Collection(
|
||||
job.Subjects,
|
||||
subject => Assert.Equal("artifact/service-api", subject),
|
||||
subject => Assert.Equal("artifact/service-ui", subject));
|
||||
|
||||
Assert.Single(job.Metadata);
|
||||
Assert.Equal("run-123", job.Metadata["policyrunid"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphBuildJob_NormalizesDigestAndMetadata()
|
||||
{
|
||||
var job = new GraphBuildJob(
|
||||
id: "gbj_norm",
|
||||
tenantId: "tenant-alpha",
|
||||
sbomId: "sbom_norm",
|
||||
sbomVersionId: "sbom_ver_norm",
|
||||
sbomDigest: "SHA256:ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890",
|
||||
status: GraphJobStatus.Pending,
|
||||
trigger: GraphBuildJobTrigger.Manual,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
metadata: new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("SBoMEventId", "evt-42")
|
||||
});
|
||||
|
||||
Assert.Equal("sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", job.SbomDigest);
|
||||
Assert.Single(job.Metadata);
|
||||
Assert.Equal("evt-42", job.Metadata["sbomeventid"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class ImpactSetTests
|
||||
{
|
||||
[Fact]
|
||||
public void ImpactSetSortsImagesByDigest()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var images = new[]
|
||||
{
|
||||
new ImpactImage(
|
||||
imageDigest: "sha256:bbbb",
|
||||
registry: "registry.internal",
|
||||
repository: "app/api",
|
||||
namespaces: new[] { "team-a" },
|
||||
tags: new[] { "prod", "latest" },
|
||||
usedByEntrypoint: true,
|
||||
labels: new Dictionary<string, string>
|
||||
{
|
||||
["env"] = "prod",
|
||||
}),
|
||||
new ImpactImage(
|
||||
imageDigest: "sha256:aaaa",
|
||||
registry: "registry.internal",
|
||||
repository: "app/api",
|
||||
namespaces: new[] { "team-a" },
|
||||
tags: new[] { "prod" },
|
||||
usedByEntrypoint: false),
|
||||
};
|
||||
|
||||
var impactSet = new ImpactSet(
|
||||
selector,
|
||||
images,
|
||||
usageOnly: true,
|
||||
generatedAt: DateTimeOffset.Parse("2025-10-18T05:04:03Z"),
|
||||
total: 2,
|
||||
snapshotId: "snap-001");
|
||||
|
||||
Assert.Equal(SchedulerSchemaVersions.ImpactSet, impactSet.SchemaVersion);
|
||||
Assert.Equal(new[] { "sha256:aaaa", "sha256:bbbb" }, impactSet.Images.Select(i => i.ImageDigest));
|
||||
Assert.True(impactSet.UsageOnly);
|
||||
Assert.Equal(2, impactSet.Total);
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(impactSet);
|
||||
Assert.Contains("\"snapshotId\":\"snap-001\"", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpactImageRejectsInvalidDigest()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new ImpactImage("sha1:not-supported", "registry", "repo"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class PolicyRunModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void PolicyRunInputs_NormalizesEnvironmentKeys()
|
||||
{
|
||||
var inputs = new PolicyRunInputs(
|
||||
sbomSet: new[] { "sbom:two", "sbom:one" },
|
||||
env: new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("Sealed", true),
|
||||
new KeyValuePair<string, object?>("Exposure", "internet"),
|
||||
new KeyValuePair<string, object?>("region", JsonSerializer.SerializeToElement("global"))
|
||||
},
|
||||
captureExplain: true);
|
||||
|
||||
Assert.Equal(new[] { "sbom:one", "sbom:two" }, inputs.SbomSet);
|
||||
Assert.True(inputs.CaptureExplain);
|
||||
Assert.Equal(3, inputs.Environment.Count);
|
||||
Assert.True(inputs.Environment.ContainsKey("sealed"));
|
||||
Assert.Equal(JsonValueKind.True, inputs.Environment["sealed"].ValueKind);
|
||||
Assert.Equal("internet", inputs.Environment["exposure"].GetString());
|
||||
Assert.Equal("global", inputs.Environment["region"].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyRunStatus_ThrowsOnNegativeAttempts()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new PolicyRunStatus(
|
||||
runId: "run:test",
|
||||
tenantId: "tenant-alpha",
|
||||
policyId: "P-1",
|
||||
policyVersion: 1,
|
||||
mode: PolicyRunMode.Full,
|
||||
status: PolicyRunExecutionStatus.Queued,
|
||||
priority: PolicyRunPriority.Normal,
|
||||
queuedAt: DateTimeOffset.UtcNow,
|
||||
attempts: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyDiffSummary_NormalizesSeverityKeys()
|
||||
{
|
||||
var summary = new PolicyDiffSummary(
|
||||
added: 1,
|
||||
removed: 2,
|
||||
unchanged: 3,
|
||||
bySeverity: new[]
|
||||
{
|
||||
new KeyValuePair<string, PolicyDiffSeverityDelta>("critical", new PolicyDiffSeverityDelta(1, 0)),
|
||||
new KeyValuePair<string, PolicyDiffSeverityDelta>("HIGH", new PolicyDiffSeverityDelta(0, 1))
|
||||
});
|
||||
|
||||
Assert.True(summary.BySeverity.ContainsKey("Critical"));
|
||||
Assert.True(summary.BySeverity.ContainsKey("High"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyExplainTrace_LowercasesMetadataKeys()
|
||||
{
|
||||
var trace = new PolicyExplainTrace(
|
||||
findingId: "finding:alpha",
|
||||
policyId: "P-1",
|
||||
policyVersion: 1,
|
||||
tenantId: "tenant-alpha",
|
||||
runId: "run:test",
|
||||
verdict: new PolicyExplainVerdict(PolicyVerdictStatus.Passed, SeverityRank.Low, quiet: false, score: 0, rationale: "ok"),
|
||||
evaluatedAt: DateTimeOffset.UtcNow,
|
||||
metadata: ImmutableSortedDictionary.CreateRange(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("TraceId", "trace-1"),
|
||||
new KeyValuePair<string, string>("ComponentPurl", "pkg:npm/a@1.0.0")
|
||||
}));
|
||||
|
||||
Assert.Equal("trace-1", trace.Metadata["traceid"]);
|
||||
Assert.Equal("pkg:npm/a@1.0.0", trace.Metadata["componentpurl"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class RescanDeltaEventSampleTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void RescanDeltaEventSampleAlignsWithContracts()
|
||||
{
|
||||
const string fileName = "scheduler.rescan.delta@1.sample.json";
|
||||
var json = LoadSample(fileName);
|
||||
var notifyEvent = JsonSerializer.Deserialize<NotifyEvent>(json, SerializerOptions);
|
||||
|
||||
Assert.NotNull(notifyEvent);
|
||||
Assert.Equal(NotifyEventKinds.SchedulerRescanDelta, notifyEvent!.Kind);
|
||||
Assert.NotEqual(Guid.Empty, notifyEvent.EventId);
|
||||
Assert.NotNull(notifyEvent.Payload);
|
||||
Assert.Null(notifyEvent.Scope);
|
||||
|
||||
var payload = Assert.IsType<JsonObject>(notifyEvent.Payload);
|
||||
var scheduleId = Assert.IsAssignableFrom<JsonValue>(payload["scheduleId"]).GetValue<string>();
|
||||
Assert.Equal("rescan-weekly-critical", scheduleId);
|
||||
|
||||
var digests = Assert.IsType<JsonArray>(payload["impactedDigests"]);
|
||||
Assert.Equal(2, digests.Count);
|
||||
foreach (var digestNode in digests)
|
||||
{
|
||||
var digest = Assert.IsAssignableFrom<JsonValue>(digestNode).GetValue<string>();
|
||||
Assert.StartsWith("sha256:", digest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var summary = Assert.IsType<JsonObject>(payload["summary"]);
|
||||
Assert.Equal(0, summary["newCritical"]!.GetValue<int>());
|
||||
Assert.Equal(1, summary["newHigh"]!.GetValue<int>());
|
||||
Assert.Equal(4, summary["total"]!.GetValue<int>());
|
||||
|
||||
var canonicalJson = NotifyCanonicalJsonSerializer.Serialize(notifyEvent);
|
||||
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON null.");
|
||||
var sampleNode = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
|
||||
Assert.True(JsonNode.DeepEquals(sampleNode, canonicalNode), "Rescan delta event sample must remain canonical.");
|
||||
}
|
||||
|
||||
private static string LoadSample(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Unable to locate sample '{fileName}'.", path);
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class RunStateMachineTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnsureTransition_FromQueuedToRunningSetsStartedAt()
|
||||
{
|
||||
var run = new Run(
|
||||
id: "run-queued",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Manual,
|
||||
state: RunState.Queued,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"));
|
||||
|
||||
var transitionTime = DateTimeOffset.Parse("2025-10-18T03:05:00Z");
|
||||
|
||||
var updated = RunStateMachine.EnsureTransition(
|
||||
run,
|
||||
RunState.Running,
|
||||
transitionTime,
|
||||
mutateStats: builder => builder.SetQueued(1));
|
||||
|
||||
Assert.Equal(RunState.Running, updated.State);
|
||||
Assert.Equal(transitionTime.ToUniversalTime(), updated.StartedAt);
|
||||
Assert.Equal(1, updated.Stats.Queued);
|
||||
Assert.Null(updated.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureTransition_ToCompletedPopulatesFinishedAt()
|
||||
{
|
||||
var run = new Run(
|
||||
id: "run-running",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Manual,
|
||||
state: RunState.Running,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"),
|
||||
startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z"));
|
||||
|
||||
var completedAt = DateTimeOffset.Parse("2025-10-18T03:10:00Z");
|
||||
|
||||
var updated = RunStateMachine.EnsureTransition(
|
||||
run,
|
||||
RunState.Completed,
|
||||
completedAt,
|
||||
mutateStats: builder =>
|
||||
{
|
||||
builder.SetQueued(1);
|
||||
builder.SetCompleted(1);
|
||||
});
|
||||
|
||||
Assert.Equal(RunState.Completed, updated.State);
|
||||
Assert.Equal(completedAt.ToUniversalTime(), updated.FinishedAt);
|
||||
Assert.Equal(1, updated.Stats.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureTransition_ErrorRequiresMessage()
|
||||
{
|
||||
var run = new Run(
|
||||
id: "run-running",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Manual,
|
||||
state: RunState.Running,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"),
|
||||
startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z"));
|
||||
|
||||
var timestamp = DateTimeOffset.Parse("2025-10-18T03:06:00Z");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => RunStateMachine.EnsureTransition(run, RunState.Error, timestamp));
|
||||
|
||||
Assert.Contains("requires a non-empty error message", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ThrowsWhenTerminalWithoutFinishedAt()
|
||||
{
|
||||
var run = new Run(
|
||||
id: "run-bad",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Manual,
|
||||
state: RunState.Completed,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T03:00:00Z"),
|
||||
startedAt: DateTimeOffset.Parse("2025-10-18T03:05:00Z"));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => RunStateMachine.Validate(run));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunReasonExtension_NormalizesImpactWindow()
|
||||
{
|
||||
var reason = new RunReason(manualReason: "delta");
|
||||
var from = DateTimeOffset.Parse("2025-10-18T01:00:00+02:00");
|
||||
var to = DateTimeOffset.Parse("2025-10-18T03:30:00+02:00");
|
||||
|
||||
var updated = reason.WithImpactWindow(from, to);
|
||||
|
||||
Assert.Equal(from.ToUniversalTime().ToString("O"), updated.ImpactWindowFrom);
|
||||
Assert.Equal(to.ToUniversalTime().ToString("O"), updated.ImpactWindowTo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class RunValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void RunStatsRejectsNegativeValues()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(candidates: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deduped: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(queued: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(completed: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(deltas: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newCriticals: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newHigh: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newMedium: -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new RunStats(newLow: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeltaSummarySortsTopFindingsBySeverityThenId()
|
||||
{
|
||||
var summary = new DeltaSummary(
|
||||
imageDigest: "sha256:0011",
|
||||
newFindings: 3,
|
||||
newCriticals: 1,
|
||||
newHigh: 1,
|
||||
newMedium: 1,
|
||||
newLow: 0,
|
||||
kevHits: new[] { "CVE-2025-0002", "CVE-2025-0001" },
|
||||
topFindings: new[]
|
||||
{
|
||||
new DeltaFinding("pkg:maven/b", "CVE-2025-0002", SeverityRank.High),
|
||||
new DeltaFinding("pkg:maven/a", "CVE-2024-0001", SeverityRank.Critical),
|
||||
new DeltaFinding("pkg:maven/c", "CVE-2025-0008", SeverityRank.Medium),
|
||||
},
|
||||
reportUrl: "https://ui.example/reports/sha256:0011",
|
||||
attestation: new DeltaAttestation(uuid: "rekor-1", verified: true),
|
||||
detectedAt: DateTimeOffset.Parse("2025-10-18T00:01:02Z"));
|
||||
|
||||
Assert.Equal(new[] { "pkg:maven/a", "pkg:maven/b", "pkg:maven/c" }, summary.TopFindings.Select(f => f.Purl));
|
||||
Assert.Equal(new[] { "CVE-2025-0001", "CVE-2025-0002" }, summary.KevHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunSerializationIncludesDeterministicOrdering()
|
||||
{
|
||||
var stats = new RunStats(candidates: 10, deduped: 8, queued: 8, completed: 5, deltas: 3, newCriticals: 2);
|
||||
var run = new Run(
|
||||
id: "run_001",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Feedser,
|
||||
state: RunState.Running,
|
||||
stats: stats,
|
||||
reason: new RunReason(feedserExportId: "exp-123"),
|
||||
scheduleId: "sch_001",
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T01:00:00Z"),
|
||||
startedAt: DateTimeOffset.Parse("2025-10-18T01:00:05Z"),
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
deltas: new[]
|
||||
{
|
||||
new DeltaSummary(
|
||||
imageDigest: "sha256:aaa",
|
||||
newFindings: 1,
|
||||
newCriticals: 1,
|
||||
newHigh: 0,
|
||||
newMedium: 0,
|
||||
newLow: 0)
|
||||
});
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(run);
|
||||
Assert.Equal(SchedulerSchemaVersions.Run, run.SchemaVersion);
|
||||
Assert.Contains("\"trigger\":\"feedser\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"stats\":{\"candidates\":10,\"deduped\":8,\"queued\":8,\"completed\":5,\"deltas\":3,\"newCriticals\":2,\"newHigh\":0,\"newMedium\":0,\"newLow\":0}", json, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class SamplePayloadTests
|
||||
{
|
||||
private static readonly string SamplesRoot = LocateSamplesRoot();
|
||||
|
||||
[Fact]
|
||||
public void ScheduleSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("schedule.json");
|
||||
var schedule = CanonicalJsonSerializer.Deserialize<Schedule>(json);
|
||||
|
||||
Assert.Equal("sch_20251018a", schedule.Id);
|
||||
Assert.Equal("tenant-alpha", schedule.TenantId);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(schedule);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("run.json");
|
||||
var run = CanonicalJsonSerializer.Deserialize<Run>(json);
|
||||
|
||||
Assert.Equal(RunState.Running, run.State);
|
||||
Assert.Equal(42, run.Stats.Deltas);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(run);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpactSetSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("impact-set.json");
|
||||
var impact = CanonicalJsonSerializer.Deserialize<ImpactSet>(json);
|
||||
|
||||
Assert.True(impact.UsageOnly);
|
||||
Assert.Single(impact.Images);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(impact);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("audit.json");
|
||||
var audit = CanonicalJsonSerializer.Deserialize<AuditRecord>(json);
|
||||
|
||||
Assert.Equal("scheduler", audit.Category);
|
||||
Assert.Equal("pause", audit.Action);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(audit);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphBuildJobSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("graph-build-job.json");
|
||||
var job = CanonicalJsonSerializer.Deserialize<GraphBuildJob>(json);
|
||||
|
||||
Assert.Equal(GraphJobStatus.Running, job.Status);
|
||||
Assert.Equal(GraphBuildJobTrigger.SbomVersion, job.Trigger);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(job);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphOverlayJobSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("graph-overlay-job.json");
|
||||
var job = CanonicalJsonSerializer.Deserialize<GraphOverlayJob>(json);
|
||||
|
||||
Assert.Equal(GraphJobStatus.Queued, job.Status);
|
||||
Assert.Equal(GraphOverlayJobTrigger.Policy, job.Trigger);
|
||||
Assert.Equal(GraphOverlayKind.Policy, job.OverlayKind);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(job);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyRunRequestSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("policy-run-request.json");
|
||||
var request = CanonicalJsonSerializer.Deserialize<PolicyRunRequest>(json);
|
||||
|
||||
Assert.Equal("default", request.TenantId);
|
||||
Assert.Equal(PolicyRunMode.Incremental, request.Mode);
|
||||
Assert.Equal("run:P-7:2025-10-26:auto", request.RunId);
|
||||
Assert.True(request.Inputs.CaptureExplain);
|
||||
Assert.Equal(2, request.Inputs.SbomSet.Length);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(request);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyRunStatusSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("policy-run-status.json");
|
||||
var status = CanonicalJsonSerializer.Deserialize<PolicyRunStatus>(json);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionStatus.Succeeded, status.Status);
|
||||
Assert.Equal(1742, status.Stats.Components);
|
||||
Assert.Equal("scheduler", status.Metadata["orchestrator"]);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(status);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyDiffSummarySample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("policy-diff-summary.json");
|
||||
var summary = CanonicalJsonSerializer.Deserialize<PolicyDiffSummary>(json);
|
||||
|
||||
Assert.Equal(12, summary.Added);
|
||||
Assert.Contains(summary.BySeverity.Keys, key => string.Equals(key, "critical", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(2, summary.RuleHits.Length);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(summary);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyExplainTraceSample_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var json = ReadSample("policy-explain-trace.json");
|
||||
var trace = CanonicalJsonSerializer.Deserialize<PolicyExplainTrace>(json);
|
||||
|
||||
Assert.Equal("finding:sbom:S-42/pkg:npm/lodash@4.17.21", trace.FindingId);
|
||||
Assert.Equal(PolicyVerdictStatus.Blocked, trace.Verdict.Status);
|
||||
Assert.Equal(2, trace.RuleChain.Length);
|
||||
Assert.Equal(2, trace.Evidence.Length);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(trace);
|
||||
AssertJsonEquivalent(json, canonical);
|
||||
}
|
||||
[Fact]
|
||||
public void PolicyRunJob_RoundtripsThroughCanonicalSerializer()
|
||||
{
|
||||
var metadata = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
metadata["source"] = "cli";
|
||||
metadata["trigger"] = "manual";
|
||||
|
||||
var job = new PolicyRunJob(
|
||||
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
|
||||
Id: "job_20251026T140500Z",
|
||||
TenantId: "tenant-alpha",
|
||||
PolicyId: "P-7",
|
||||
PolicyVersion: 4,
|
||||
Mode: PolicyRunMode.Incremental,
|
||||
Priority: PolicyRunPriority.High,
|
||||
PriorityRank: -1,
|
||||
RunId: "run:P-7:2025-10-26:auto",
|
||||
RequestedBy: "user:cli",
|
||||
CorrelationId: "req-20251026T140500Z",
|
||||
Metadata: metadata.ToImmutable(),
|
||||
Inputs: new PolicyRunInputs(
|
||||
sbomSet: new[] { "sbom:S-42", "sbom:S-318" },
|
||||
advisoryCursor: DateTimeOffset.Parse("2025-10-26T13:59:00Z"),
|
||||
captureExplain: true),
|
||||
QueuedAt: DateTimeOffset.Parse("2025-10-26T14:05:00Z"),
|
||||
Status: PolicyRunJobStatus.Pending,
|
||||
AttemptCount: 1,
|
||||
LastAttemptAt: DateTimeOffset.Parse("2025-10-26T14:05:30Z"),
|
||||
LastError: "transient network error",
|
||||
CreatedAt: DateTimeOffset.Parse("2025-10-26T14:04:50Z"),
|
||||
UpdatedAt: DateTimeOffset.Parse("2025-10-26T14:05:10Z"),
|
||||
AvailableAt: DateTimeOffset.Parse("2025-10-26T14:05:20Z"),
|
||||
SubmittedAt: null,
|
||||
CompletedAt: null,
|
||||
LeaseOwner: "worker-01",
|
||||
LeaseExpiresAt: DateTimeOffset.Parse("2025-10-26T14:06:30Z"),
|
||||
CancellationRequested: false,
|
||||
CancellationRequestedAt: null,
|
||||
CancellationReason: null,
|
||||
CancelledAt: null);
|
||||
|
||||
var canonical = CanonicalJsonSerializer.Serialize(job);
|
||||
var roundtrip = CanonicalJsonSerializer.Deserialize<PolicyRunJob>(canonical);
|
||||
|
||||
Assert.Equal(job.TenantId, roundtrip.TenantId);
|
||||
Assert.Equal(job.PolicyId, roundtrip.PolicyId);
|
||||
Assert.Equal(job.Priority, roundtrip.Priority);
|
||||
Assert.Equal(job.Status, roundtrip.Status);
|
||||
Assert.Equal(job.RunId, roundtrip.RunId);
|
||||
Assert.Equal("cli", roundtrip.Metadata!["source"]);
|
||||
}
|
||||
|
||||
|
||||
private static string ReadSample(string fileName)
|
||||
{
|
||||
var path = Path.Combine(SamplesRoot, fileName);
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
private static string LocateSamplesRoot()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var candidate = Path.Combine(current, "samples", "api", "scheduler");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var parent = Path.GetDirectoryName(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.Equals(parent, current, StringComparison.Ordinal))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate samples/api/scheduler in repository tree.");
|
||||
}
|
||||
|
||||
private static void AssertJsonEquivalent(string expected, string actual)
|
||||
{
|
||||
var normalizedExpected = NormalizeJson(expected);
|
||||
var normalizedActual = NormalizeJson(actual);
|
||||
Assert.Equal(normalizedExpected, normalizedActual);
|
||||
}
|
||||
|
||||
private static string NormalizeJson(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class ScheduleSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScheduleSerialization_IsDeterministicRegardlessOfInputOrdering()
|
||||
{
|
||||
var selectionA = new Selector(
|
||||
SelectorScope.ByNamespace,
|
||||
tenantId: "tenant-alpha",
|
||||
namespaces: new[] { "team-b", "team-a" },
|
||||
repositories: new[] { "app/service-api", "app/service-web" },
|
||||
digests: new[] { "sha256:bb", "sha256:aa" },
|
||||
includeTags: new[] { "prod", "canary" },
|
||||
labels: new[]
|
||||
{
|
||||
new LabelSelector("env", new[] { "prod", "staging" }),
|
||||
new LabelSelector("app", new[] { "web", "api" }),
|
||||
},
|
||||
resolvesTags: true);
|
||||
|
||||
var selectionB = new Selector(
|
||||
scope: SelectorScope.ByNamespace,
|
||||
tenantId: "tenant-alpha",
|
||||
namespaces: new[] { "team-a", "team-b" },
|
||||
repositories: new[] { "app/service-web", "app/service-api" },
|
||||
digests: new[] { "sha256:aa", "sha256:bb" },
|
||||
includeTags: new[] { "canary", "prod" },
|
||||
labels: new[]
|
||||
{
|
||||
new LabelSelector("app", new[] { "api", "web" }),
|
||||
new LabelSelector("env", new[] { "staging", "prod" }),
|
||||
},
|
||||
resolvesTags: true);
|
||||
|
||||
var scheduleA = new Schedule(
|
||||
id: "sch_001",
|
||||
tenantId: "tenant-alpha",
|
||||
name: "Nightly Prod",
|
||||
enabled: true,
|
||||
cronExpression: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: selectionA,
|
||||
onlyIf: new ScheduleOnlyIf(lastReportOlderThanDays: 7, policyRevision: "policy@42"),
|
||||
notify: new ScheduleNotify(onNewFindings: true, SeverityRank.High, includeKev: true),
|
||||
limits: new ScheduleLimits(maxJobs: 1000, ratePerSecond: 25, parallelism: 4),
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T23:00:00Z"),
|
||||
createdBy: "svc_scheduler",
|
||||
updatedAt: DateTimeOffset.Parse("2025-10-18T23:00:00Z"),
|
||||
updatedBy: "svc_scheduler");
|
||||
|
||||
var scheduleB = new Schedule(
|
||||
id: scheduleA.Id,
|
||||
tenantId: scheduleA.TenantId,
|
||||
name: scheduleA.Name,
|
||||
enabled: scheduleA.Enabled,
|
||||
cronExpression: scheduleA.CronExpression,
|
||||
timezone: scheduleA.Timezone,
|
||||
mode: scheduleA.Mode,
|
||||
selection: selectionB,
|
||||
onlyIf: scheduleA.OnlyIf,
|
||||
notify: scheduleA.Notify,
|
||||
limits: scheduleA.Limits,
|
||||
createdAt: scheduleA.CreatedAt,
|
||||
createdBy: scheduleA.CreatedBy,
|
||||
updatedAt: scheduleA.UpdatedAt,
|
||||
updatedBy: scheduleA.UpdatedBy,
|
||||
subscribers: scheduleA.Subscribers);
|
||||
|
||||
var jsonA = CanonicalJsonSerializer.Serialize(scheduleA);
|
||||
var jsonB = CanonicalJsonSerializer.Serialize(scheduleB);
|
||||
|
||||
Assert.Equal(jsonA, jsonB);
|
||||
|
||||
using var doc = JsonDocument.Parse(jsonA);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(SchedulerSchemaVersions.Schedule, root.GetProperty("schemaVersion").GetString());
|
||||
Assert.Equal("analysis-only", root.GetProperty("mode").GetString());
|
||||
Assert.Equal("tenant-alpha", root.GetProperty("tenantId").GetString());
|
||||
|
||||
var namespaces = root.GetProperty("selection").GetProperty("namespaces").EnumerateArray().Select(e => e.GetString()).ToArray();
|
||||
Assert.Equal(new[] { "team-a", "team-b" }, namespaces);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-timezone")]
|
||||
public void Schedule_ThrowsWhenTimezoneInvalid(string timezone)
|
||||
{
|
||||
var selection = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
|
||||
Assert.ThrowsAny<Exception>(() => new Schedule(
|
||||
id: "sch_002",
|
||||
tenantId: "tenant-alpha",
|
||||
name: "Invalid timezone",
|
||||
enabled: true,
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: timezone,
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: selection,
|
||||
onlyIf: null,
|
||||
notify: null,
|
||||
limits: null,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
createdBy: "svc",
|
||||
updatedAt: DateTimeOffset.UtcNow,
|
||||
updatedBy: "svc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.Models.Tests;
|
||||
|
||||
public sealed class SchedulerSchemaMigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpgradeSchedule_DefaultsSchemaVersionWhenMissing()
|
||||
{
|
||||
var schedule = new Schedule(
|
||||
id: "sch-01",
|
||||
tenantId: "tenant-alpha",
|
||||
name: "Nightly",
|
||||
enabled: true,
|
||||
cronExpression: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
|
||||
onlyIf: null,
|
||||
notify: null,
|
||||
limits: null,
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
|
||||
createdBy: "svc-scheduler",
|
||||
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
|
||||
updatedBy: "svc-scheduler");
|
||||
|
||||
var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(schedule))!.AsObject();
|
||||
json.Remove("schemaVersion");
|
||||
|
||||
var result = SchedulerSchemaMigration.UpgradeSchedule(json);
|
||||
|
||||
Assert.Equal(SchedulerSchemaVersions.Schedule, result.Value.SchemaVersion);
|
||||
Assert.Equal(SchedulerSchemaVersions.Schedule, result.ToVersion);
|
||||
Assert.Empty(result.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeRun_StrictModeRemovesUnknownProperties()
|
||||
{
|
||||
var run = new Run(
|
||||
id: "run-01",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Manual,
|
||||
state: RunState.Queued,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: DateTimeOffset.Parse("2025-10-18T01:10:00Z"));
|
||||
|
||||
var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(run))!.AsObject();
|
||||
json["extraField"] = "to-be-removed";
|
||||
|
||||
var result = SchedulerSchemaMigration.UpgradeRun(json, strict: true);
|
||||
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("extraField", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeImpactSet_ThrowsForUnsupportedVersion()
|
||||
{
|
||||
var impactSet = new ImpactSet(
|
||||
selector: new Selector(SelectorScope.AllImages, "tenant-alpha"),
|
||||
images: Array.Empty<ImpactImage>(),
|
||||
usageOnly: false,
|
||||
generatedAt: DateTimeOffset.Parse("2025-10-18T02:00:00Z"));
|
||||
|
||||
var json = JsonNode.Parse(CanonicalJsonSerializer.Serialize(impactSet))!.AsObject();
|
||||
json["schemaVersion"] = "scheduler.impact-set@99";
|
||||
|
||||
var ex = Assert.Throws<NotSupportedException>(() => SchedulerSchemaMigration.UpgradeImpactSet(json));
|
||||
Assert.Contains("Unsupported scheduler schema version", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeSchedule_Legacy0_UpgradesToLatestVersion()
|
||||
{
|
||||
var legacy = new JsonObject
|
||||
{
|
||||
["schemaVersion"] = SchedulerSchemaVersions.ScheduleLegacy0,
|
||||
["id"] = "sch-legacy",
|
||||
["tenantId"] = "tenant-alpha",
|
||||
["name"] = "Legacy Nightly",
|
||||
["enabled"] = true,
|
||||
["cronExpression"] = "0 2 * * *",
|
||||
["timezone"] = "UTC",
|
||||
["mode"] = "analysis-only",
|
||||
["selection"] = new JsonObject
|
||||
{
|
||||
["scope"] = "all-images",
|
||||
["tenantId"] = "tenant-alpha",
|
||||
},
|
||||
["notify"] = new JsonObject
|
||||
{
|
||||
["onNewFindings"] = "true",
|
||||
["minSeverity"] = "HIGH",
|
||||
},
|
||||
["limits"] = new JsonObject
|
||||
{
|
||||
["maxJobs"] = "5",
|
||||
["parallelism"] = -2,
|
||||
},
|
||||
["subscribers"] = "ops-team",
|
||||
["createdAt"] = "2025-10-10T00:00:00Z",
|
||||
["createdBy"] = "system",
|
||||
["updatedAt"] = "2025-10-10T01:00:00Z",
|
||||
["updatedBy"] = "system",
|
||||
};
|
||||
|
||||
var result = SchedulerSchemaMigration.UpgradeSchedule(legacy, strict: true);
|
||||
|
||||
Assert.Equal(SchedulerSchemaVersions.ScheduleLegacy0, result.FromVersion);
|
||||
Assert.Equal(SchedulerSchemaVersions.Schedule, result.ToVersion);
|
||||
Assert.Equal(SchedulerSchemaVersions.Schedule, result.Value.SchemaVersion);
|
||||
Assert.True(result.Value.Notify.IncludeKev);
|
||||
Assert.Empty(result.Value.Subscribers);
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("schedule.limits.parallelism", StringComparison.Ordinal));
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("schedule.subscribers", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeRun_Legacy0_BackfillsMissingStats()
|
||||
{
|
||||
var legacy = new JsonObject
|
||||
{
|
||||
["schemaVersion"] = SchedulerSchemaVersions.RunLegacy0,
|
||||
["id"] = "run-legacy",
|
||||
["tenantId"] = "tenant-alpha",
|
||||
["trigger"] = "manual",
|
||||
["state"] = "queued",
|
||||
["stats"] = new JsonObject
|
||||
{
|
||||
["candidates"] = "4",
|
||||
["queued"] = 2,
|
||||
},
|
||||
["createdAt"] = "2025-10-10T02:00:00Z",
|
||||
};
|
||||
|
||||
var result = SchedulerSchemaMigration.UpgradeRun(legacy, strict: true);
|
||||
|
||||
Assert.Equal(SchedulerSchemaVersions.RunLegacy0, result.FromVersion);
|
||||
Assert.Equal(SchedulerSchemaVersions.Run, result.ToVersion);
|
||||
Assert.Equal(SchedulerSchemaVersions.Run, result.Value.SchemaVersion);
|
||||
Assert.Equal(4, result.Value.Stats.Candidates);
|
||||
Assert.Equal(0, result.Value.Stats.NewMedium);
|
||||
Assert.Equal(RunState.Queued, result.Value.State);
|
||||
Assert.Empty(result.Value.Deltas);
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("run.stats.newMedium", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpgradeImpactSet_Legacy0_ComputesTotal()
|
||||
{
|
||||
var legacy = new JsonObject
|
||||
{
|
||||
["schemaVersion"] = SchedulerSchemaVersions.ImpactSetLegacy0,
|
||||
["selector"] = JsonNode.Parse("""{"scope":"all-images","tenantId":"tenant-alpha"}"""),
|
||||
["images"] = new JsonArray(
|
||||
JsonNode.Parse("""{"imageDigest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","registry":"docker.io","repository":"library/nginx"}"""),
|
||||
JsonNode.Parse("""{"imageDigest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","registry":"docker.io","repository":"library/httpd"}""")),
|
||||
["usageOnly"] = "false",
|
||||
["generatedAt"] = "2025-10-10T03:00:00Z",
|
||||
};
|
||||
|
||||
var result = SchedulerSchemaMigration.UpgradeImpactSet(legacy, strict: true);
|
||||
|
||||
Assert.Equal(SchedulerSchemaVersions.ImpactSetLegacy0, result.FromVersion);
|
||||
Assert.Equal(SchedulerSchemaVersions.ImpactSet, result.ToVersion);
|
||||
Assert.Equal(2, result.Value.Total);
|
||||
Assert.Equal(2, result.Value.Images.Length);
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("impact set total", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\docs\events\samples\scheduler.rescan.delta@1.sample.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Tests;
|
||||
|
||||
public sealed class PlannerAndRunnerMessageTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlannerMessage_CanonicalSerialization_RoundTrips()
|
||||
{
|
||||
var schedule = new Schedule(
|
||||
id: "sch-tenant-nightly",
|
||||
tenantId: "tenant-alpha",
|
||||
name: "Nightly Deltas",
|
||||
enabled: true,
|
||||
cronExpression: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
|
||||
onlyIf: new ScheduleOnlyIf(lastReportOlderThanDays: 3),
|
||||
notify: new ScheduleNotify(onNewFindings: true, SeverityRank.High, includeKev: true),
|
||||
limits: new ScheduleLimits(maxJobs: 10, ratePerSecond: 5, parallelism: 3),
|
||||
createdAt: DateTimeOffset.Parse("2025-10-01T02:00:00Z"),
|
||||
createdBy: "system",
|
||||
updatedAt: DateTimeOffset.Parse("2025-10-02T02:00:00Z"),
|
||||
updatedBy: "system",
|
||||
subscribers: ImmutableArray<string>.Empty,
|
||||
schemaVersion: "1.0.0");
|
||||
|
||||
var run = new Run(
|
||||
id: "run-123",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Cron,
|
||||
state: RunState.Planning,
|
||||
stats: new RunStats(candidates: 5, deduped: 4, queued: 0, completed: 0, deltas: 0),
|
||||
createdAt: DateTimeOffset.Parse("2025-10-02T02:05:00Z"),
|
||||
reason: new RunReason(manualReason: null, feedserExportId: null, vexerExportId: null, cursor: null)
|
||||
with { ImpactWindowFrom = "2025-10-01T00:00:00Z", ImpactWindowTo = "2025-10-02T00:00:00Z" },
|
||||
scheduleId: "sch-tenant-nightly");
|
||||
|
||||
var impactSet = new ImpactSet(
|
||||
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
|
||||
images: new[]
|
||||
{
|
||||
new ImpactImage(
|
||||
imageDigest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
registry: "registry",
|
||||
repository: "repo",
|
||||
namespaces: new[] { "prod" },
|
||||
tags: new[] { "latest" },
|
||||
usedByEntrypoint: true,
|
||||
labels: new[] { KeyValuePair.Create("team", "appsec") })
|
||||
},
|
||||
usageOnly: true,
|
||||
generatedAt: DateTimeOffset.Parse("2025-10-02T02:06:00Z"),
|
||||
total: 1,
|
||||
snapshotId: "snap-001");
|
||||
|
||||
var message = new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-1");
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(message);
|
||||
var roundTrip = CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(json);
|
||||
|
||||
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunnerSegmentMessage_RequiresAtLeastOneDigest()
|
||||
{
|
||||
var act = () => new RunnerSegmentQueueMessage(
|
||||
segmentId: "segment-empty",
|
||||
runId: "run-123",
|
||||
tenantId: "tenant-alpha",
|
||||
imageDigests: Array.Empty<string>());
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunnerSegmentMessage_CanonicalSerialization_RoundTrips()
|
||||
{
|
||||
var message = new RunnerSegmentQueueMessage(
|
||||
segmentId: "segment-01",
|
||||
runId: "run-123",
|
||||
tenantId: "tenant-alpha",
|
||||
imageDigests: new[]
|
||||
{
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
},
|
||||
scheduleId: "sch-tenant-nightly",
|
||||
ratePerSecond: 25,
|
||||
usageOnly: true,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["plannerShard"] = "0",
|
||||
["priority"] = "kev"
|
||||
},
|
||||
correlationId: "corr-2");
|
||||
|
||||
var json = CanonicalJsonSerializer.Serialize(message);
|
||||
var roundTrip = CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(json);
|
||||
|
||||
roundTrip.Should().BeEquivalentTo(message, options => options.WithStrictOrdering());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using DotNet.Testcontainers.Configurations;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue.Redis;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Tests;
|
||||
|
||||
public sealed class RedisSchedulerQueueTests : IAsyncLifetime
|
||||
{
|
||||
private readonly RedisTestcontainer _redis;
|
||||
private string? _skipReason;
|
||||
|
||||
public RedisSchedulerQueueTests()
|
||||
{
|
||||
var configuration = new RedisTestcontainerConfiguration();
|
||||
|
||||
_redis = new TestcontainersBuilder<RedisTestcontainer>()
|
||||
.WithDatabase(configuration)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _redis.StartAsync();
|
||||
}
|
||||
catch (Exception ex) when (IsDockerUnavailable(ex))
|
||||
{
|
||||
_skipReason = $"Docker engine is not available for Redis-backed tests: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _redis.DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlannerQueue_EnqueueLeaseAck_RemovesMessage()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
|
||||
await using var queue = new RedisSchedulerPlannerQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisSchedulerPlannerQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
|
||||
var message = TestData.CreatePlannerMessage();
|
||||
|
||||
var enqueue = await queue.EnqueueAsync(message);
|
||||
enqueue.Deduplicated.Should().BeFalse();
|
||||
|
||||
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", batchSize: 5, options.DefaultLeaseDuration));
|
||||
leases.Should().HaveCount(1);
|
||||
|
||||
var lease = leases[0];
|
||||
lease.Message.Run.Id.Should().Be(message.Run.Id);
|
||||
lease.TenantId.Should().Be(message.TenantId);
|
||||
lease.ScheduleId.Should().Be(message.ScheduleId);
|
||||
|
||||
await lease.AcknowledgeAsync();
|
||||
|
||||
var afterAck = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-1", 5, options.DefaultLeaseDuration));
|
||||
afterAck.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunnerQueue_Retry_IncrementsDeliveryAttempt()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
options.RetryInitialBackoff = TimeSpan.Zero;
|
||||
options.RetryMaxBackoff = TimeSpan.Zero;
|
||||
|
||||
await using var queue = new RedisSchedulerRunnerQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisSchedulerRunnerQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
|
||||
var message = TestData.CreateRunnerMessage();
|
||||
|
||||
await queue.EnqueueAsync(message);
|
||||
|
||||
var firstLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
|
||||
firstLease.Should().ContainSingle();
|
||||
|
||||
var lease = firstLease[0];
|
||||
lease.Attempt.Should().Be(1);
|
||||
|
||||
await lease.ReleaseAsync(SchedulerQueueReleaseDisposition.Retry);
|
||||
|
||||
var secondLease = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-1", batchSize: 1, options.DefaultLeaseDuration));
|
||||
secondLease.Should().ContainSingle();
|
||||
secondLease[0].Attempt.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlannerQueue_ClaimExpired_ReassignsLease()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
|
||||
await using var queue = new RedisSchedulerPlannerQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisSchedulerPlannerQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
|
||||
var message = TestData.CreatePlannerMessage();
|
||||
await queue.EnqueueAsync(message);
|
||||
|
||||
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-a", 1, options.DefaultLeaseDuration));
|
||||
leases.Should().ContainSingle();
|
||||
|
||||
await Task.Delay(50);
|
||||
|
||||
var reclaimed = await queue.ClaimExpiredAsync(new SchedulerQueueClaimOptions("planner-b", batchSize: 1, minIdleTime: TimeSpan.Zero));
|
||||
reclaimed.Should().ContainSingle();
|
||||
reclaimed[0].Consumer.Should().Be("planner-b");
|
||||
reclaimed[0].RunId.Should().Be(message.Run.Id);
|
||||
|
||||
await reclaimed[0].AcknowledgeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlannerQueue_RecordsDepthMetrics()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
|
||||
await using var queue = new RedisSchedulerPlannerQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisSchedulerPlannerQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
|
||||
var message = TestData.CreatePlannerMessage();
|
||||
|
||||
await queue.EnqueueAsync(message);
|
||||
|
||||
var depths = SchedulerQueueMetrics.SnapshotDepths();
|
||||
depths.TryGetValue(("redis", "planner"), out var plannerDepth)
|
||||
.Should().BeTrue();
|
||||
plannerDepth.Should().Be(1);
|
||||
|
||||
var leases = await queue.LeaseAsync(new SchedulerQueueLeaseRequest("planner-depth", 1, options.DefaultLeaseDuration));
|
||||
leases.Should().ContainSingle();
|
||||
await leases[0].AcknowledgeAsync();
|
||||
|
||||
depths = SchedulerQueueMetrics.SnapshotDepths();
|
||||
depths.TryGetValue(("redis", "planner"), out plannerDepth).Should().BeTrue();
|
||||
plannerDepth.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunnerQueue_DropWhenDeadLetterDisabled()
|
||||
{
|
||||
if (SkipIfUnavailable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = CreateOptions();
|
||||
options.MaxDeliveryAttempts = 1;
|
||||
options.DeadLetterEnabled = false;
|
||||
|
||||
await using var queue = new RedisSchedulerRunnerQueue(
|
||||
options,
|
||||
options.Redis,
|
||||
NullLogger<RedisSchedulerRunnerQueue>.Instance,
|
||||
TimeProvider.System,
|
||||
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
|
||||
|
||||
var message = TestData.CreateRunnerMessage();
|
||||
await queue.EnqueueAsync(message);
|
||||
|
||||
var lease = (await queue.LeaseAsync(new SchedulerQueueLeaseRequest("runner-drop", 1, options.DefaultLeaseDuration)))[0];
|
||||
|
||||
await lease.ReleaseAsync(SchedulerQueueReleaseDisposition.Retry);
|
||||
|
||||
var depths = SchedulerQueueMetrics.SnapshotDepths();
|
||||
depths.TryGetValue(("redis", "runner"), out var runnerDepth).Should().BeTrue();
|
||||
runnerDepth.Should().Be(0);
|
||||
}
|
||||
|
||||
private SchedulerQueueOptions CreateOptions()
|
||||
{
|
||||
var unique = Guid.NewGuid().ToString("N");
|
||||
|
||||
return new SchedulerQueueOptions
|
||||
{
|
||||
Kind = SchedulerQueueTransportKind.Redis,
|
||||
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
|
||||
MaxDeliveryAttempts = 5,
|
||||
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
|
||||
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
|
||||
Redis = new SchedulerRedisQueueOptions
|
||||
{
|
||||
ConnectionString = _redis.ConnectionString,
|
||||
Database = 0,
|
||||
InitializationTimeout = TimeSpan.FromSeconds(10),
|
||||
Planner = new RedisSchedulerStreamOptions
|
||||
{
|
||||
Stream = $"scheduler:test:planner:{unique}",
|
||||
ConsumerGroup = $"planner-consumers-{unique}",
|
||||
DeadLetterStream = $"scheduler:test:planner:{unique}:dead",
|
||||
IdempotencyKeyPrefix = $"scheduler:test:planner:{unique}:idemp:",
|
||||
IdempotencyWindow = TimeSpan.FromMinutes(5)
|
||||
},
|
||||
Runner = new RedisSchedulerStreamOptions
|
||||
{
|
||||
Stream = $"scheduler:test:runner:{unique}",
|
||||
ConsumerGroup = $"runner-consumers-{unique}",
|
||||
DeadLetterStream = $"scheduler:test:runner:{unique}:dead",
|
||||
IdempotencyKeyPrefix = $"scheduler:test:runner:{unique}:idemp:",
|
||||
IdempotencyWindow = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private bool SkipIfUnavailable()
|
||||
{
|
||||
if (_skipReason is not null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsDockerUnavailable(Exception exception)
|
||||
{
|
||||
while (exception is AggregateException aggregate && aggregate.InnerException is not null)
|
||||
{
|
||||
exception = aggregate.InnerException;
|
||||
}
|
||||
|
||||
return exception is TimeoutException
|
||||
|| exception.GetType().Name.Contains("Docker", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static class TestData
|
||||
{
|
||||
public static PlannerQueueMessage CreatePlannerMessage()
|
||||
{
|
||||
var schedule = new Schedule(
|
||||
id: "sch-test",
|
||||
tenantId: "tenant-alpha",
|
||||
name: "Test",
|
||||
enabled: true,
|
||||
cronExpression: "0 0 * * *",
|
||||
timezone: "UTC",
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
|
||||
onlyIf: ScheduleOnlyIf.Default,
|
||||
notify: ScheduleNotify.Default,
|
||||
limits: ScheduleLimits.Default,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
createdBy: "tests",
|
||||
updatedAt: DateTimeOffset.UtcNow,
|
||||
updatedBy: "tests");
|
||||
|
||||
var run = new Run(
|
||||
id: "run-test",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Manual,
|
||||
state: RunState.Planning,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
reason: RunReason.Empty,
|
||||
scheduleId: schedule.Id);
|
||||
|
||||
var impactSet = new ImpactSet(
|
||||
selector: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
|
||||
images: new[]
|
||||
{
|
||||
new ImpactImage(
|
||||
imageDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
registry: "registry",
|
||||
repository: "repo",
|
||||
namespaces: new[] { "prod" },
|
||||
tags: new[] { "latest" })
|
||||
},
|
||||
usageOnly: true,
|
||||
generatedAt: DateTimeOffset.UtcNow,
|
||||
total: 1);
|
||||
|
||||
return new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-test");
|
||||
}
|
||||
|
||||
public static RunnerSegmentQueueMessage CreateRunnerMessage()
|
||||
{
|
||||
return new RunnerSegmentQueueMessage(
|
||||
segmentId: "segment-test",
|
||||
runId: "run-test",
|
||||
tenantId: "tenant-alpha",
|
||||
imageDigests: new[]
|
||||
{
|
||||
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
},
|
||||
scheduleId: "sch-test",
|
||||
ratePerSecond: 10,
|
||||
usageOnly: true,
|
||||
attributes: new Dictionary<string, string> { ["priority"] = "kev" },
|
||||
correlationId: "corr-runner");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scheduler.Queue.Nats;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue.Tests;
|
||||
|
||||
public sealed class SchedulerQueueServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AddSchedulerQueues_RegistersNatsTransport()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ILoggerFactory>(_ => NullLoggerFactory.Instance);
|
||||
services.AddSchedulerQueues(new ConfigurationBuilder().Build());
|
||||
|
||||
var optionsDescriptor = services.First(descriptor => descriptor.ServiceType == typeof(SchedulerQueueOptions));
|
||||
var options = (SchedulerQueueOptions)optionsDescriptor.ImplementationInstance!;
|
||||
options.Kind = SchedulerQueueTransportKind.Nats;
|
||||
options.Nats.Url = "nats://localhost:4222";
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
var plannerQueue = provider.GetRequiredService<ISchedulerPlannerQueue>();
|
||||
var runnerQueue = provider.GetRequiredService<ISchedulerRunnerQueue>();
|
||||
|
||||
plannerQueue.Should().BeOfType<NatsSchedulerPlannerQueue>();
|
||||
runnerQueue.Should().BeOfType<NatsSchedulerRunnerQueue>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SchedulerQueueHealthCheck_ReturnsHealthy_WhenTransportsReachable()
|
||||
{
|
||||
var healthCheck = new SchedulerQueueHealthCheck(
|
||||
new FakePlannerQueue(failPing: false),
|
||||
new FakeRunnerQueue(failPing: false),
|
||||
NullLogger<SchedulerQueueHealthCheck>.Instance);
|
||||
|
||||
var context = new HealthCheckContext
|
||||
{
|
||||
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
|
||||
};
|
||||
|
||||
var result = await healthCheck.CheckHealthAsync(context);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SchedulerQueueHealthCheck_ReturnsUnhealthy_WhenRunnerPingFails()
|
||||
{
|
||||
var healthCheck = new SchedulerQueueHealthCheck(
|
||||
new FakePlannerQueue(failPing: false),
|
||||
new FakeRunnerQueue(failPing: true),
|
||||
NullLogger<SchedulerQueueHealthCheck>.Instance);
|
||||
|
||||
var context = new HealthCheckContext
|
||||
{
|
||||
Registration = new HealthCheckRegistration("scheduler-queue", healthCheck, HealthStatus.Unhealthy, Array.Empty<string>())
|
||||
};
|
||||
|
||||
var result = await healthCheck.CheckHealthAsync(context);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||
result.Description.Should().Contain("runner transport unreachable");
|
||||
}
|
||||
private abstract class FakeQueue<TMessage> : ISchedulerQueue<TMessage>, ISchedulerQueueTransportDiagnostics
|
||||
{
|
||||
private readonly bool _failPing;
|
||||
|
||||
protected FakeQueue(bool failPing)
|
||||
{
|
||||
_failPing = failPing;
|
||||
}
|
||||
|
||||
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(TMessage message, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(new SchedulerQueueEnqueueResult("stub", false));
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<TMessage>>>(Array.Empty<ISchedulerQueueLease<TMessage>>());
|
||||
|
||||
public ValueTask PingAsync(CancellationToken cancellationToken)
|
||||
=> _failPing
|
||||
? ValueTask.FromException(new InvalidOperationException("ping failed"))
|
||||
: ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakePlannerQueue : FakeQueue<PlannerQueueMessage>, ISchedulerPlannerQueue
|
||||
{
|
||||
public FakePlannerQueue(bool failPing) : base(failPing)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeRunnerQueue : FakeQueue<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
|
||||
{
|
||||
public FakeRunnerQueue(bool failPing) : base(failPing)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="DotNet.Testcontainers" Version="1.7.0-beta.2269" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Nodes;
|
||||
global using Microsoft.Extensions.Logging.Abstractions;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using Mongo2Go;
|
||||
global using MongoDB.Bson;
|
||||
global using MongoDB.Driver;
|
||||
global using StellaOps.Scheduler.Models;
|
||||
global using StellaOps.Scheduler.Storage.Mongo.Internal;
|
||||
global using StellaOps.Scheduler.Storage.Mongo.Migrations;
|
||||
global using StellaOps.Scheduler.Storage.Mongo.Options;
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Integration;
|
||||
|
||||
public sealed class SchedulerMongoRoundTripTests : IDisposable
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly SchedulerMongoContext _context;
|
||||
|
||||
public SchedulerMongoRoundTripTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
|
||||
var options = new SchedulerMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = $"scheduler_roundtrip_{Guid.NewGuid():N}"
|
||||
};
|
||||
|
||||
_context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
|
||||
var migrations = new ISchedulerMongoMigration[]
|
||||
{
|
||||
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
|
||||
new EnsureSchedulerIndexesMigration()
|
||||
};
|
||||
var runner = new SchedulerMongoMigrationRunner(_context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
|
||||
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SamplesRoundTripThroughMongoWithoutLosingCanonicalShape()
|
||||
{
|
||||
var samplesRoot = LocateSamplesRoot();
|
||||
|
||||
var scheduleJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "schedule.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
scheduleJson,
|
||||
_context.Options.SchedulesCollection,
|
||||
CanonicalJsonSerializer.Deserialize<Schedule>,
|
||||
schedule => schedule.Id);
|
||||
|
||||
var runJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "run.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
runJson,
|
||||
_context.Options.RunsCollection,
|
||||
CanonicalJsonSerializer.Deserialize<Run>,
|
||||
run => run.Id);
|
||||
|
||||
var impactJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "impact-set.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
impactJson,
|
||||
_context.Options.ImpactSnapshotsCollection,
|
||||
CanonicalJsonSerializer.Deserialize<ImpactSet>,
|
||||
_ => null);
|
||||
|
||||
var auditJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "audit.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
auditJson,
|
||||
_context.Options.AuditCollection,
|
||||
CanonicalJsonSerializer.Deserialize<AuditRecord>,
|
||||
audit => audit.Id);
|
||||
}
|
||||
|
||||
private async Task AssertRoundTripAsync<TModel>(
|
||||
string json,
|
||||
string collectionName,
|
||||
Func<string, TModel> deserialize,
|
||||
Func<TModel, string?> resolveId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deserialize);
|
||||
ArgumentNullException.ThrowIfNull(resolveId);
|
||||
|
||||
var model = deserialize(json);
|
||||
var canonical = CanonicalJsonSerializer.Serialize(model);
|
||||
|
||||
var document = BsonDocument.Parse(canonical);
|
||||
var identifier = resolveId(model);
|
||||
if (!string.IsNullOrEmpty(identifier))
|
||||
{
|
||||
document["_id"] = identifier;
|
||||
}
|
||||
|
||||
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
|
||||
await collection.InsertOneAsync(document, cancellationToken: CancellationToken.None);
|
||||
|
||||
var filter = identifier is null ? Builders<BsonDocument>.Filter.Empty : Builders<BsonDocument>.Filter.Eq("_id", identifier);
|
||||
var stored = await collection.Find(filter).FirstOrDefaultAsync();
|
||||
Assert.NotNull(stored);
|
||||
|
||||
var sanitized = stored!.DeepClone().AsBsonDocument;
|
||||
sanitized.Remove("_id");
|
||||
|
||||
var storedJson = sanitized.ToJson();
|
||||
|
||||
var parsedExpected = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical node null.");
|
||||
var parsedActual = JsonNode.Parse(storedJson) ?? throw new InvalidOperationException("Stored node null.");
|
||||
Assert.True(JsonNode.DeepEquals(parsedExpected, parsedActual), "Document changed shape after Mongo round-trip.");
|
||||
}
|
||||
|
||||
private static string LocateSamplesRoot()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var candidate = Path.Combine(current, "samples", "api", "scheduler");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var parent = Path.GetDirectoryName(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.Equals(parent, current, StringComparison.Ordinal))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate samples/api/scheduler in repository tree.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Migrations;
|
||||
|
||||
public sealed class SchedulerMongoMigrationTests : IDisposable
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
|
||||
public SchedulerMongoMigrationTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CreatesCollectionsAndIndexes()
|
||||
{
|
||||
var options = new SchedulerMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = $"scheduler_tests_{Guid.NewGuid():N}"
|
||||
};
|
||||
|
||||
var context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
|
||||
var migrations = new ISchedulerMongoMigration[]
|
||||
{
|
||||
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
|
||||
new EnsureSchedulerIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new SchedulerMongoMigrationRunner(context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
|
||||
await runner.RunAsync(CancellationToken.None);
|
||||
|
||||
var cursor = await context.Database.ListCollectionNamesAsync(cancellationToken: CancellationToken.None);
|
||||
var collections = await cursor.ToListAsync();
|
||||
|
||||
Assert.Contains(options.SchedulesCollection, collections);
|
||||
Assert.Contains(options.RunsCollection, collections);
|
||||
Assert.Contains(options.ImpactSnapshotsCollection, collections);
|
||||
Assert.Contains(options.AuditCollection, collections);
|
||||
Assert.Contains(options.LocksCollection, collections);
|
||||
Assert.Contains(options.MigrationsCollection, collections);
|
||||
|
||||
await AssertScheduleIndexesAsync(context, options);
|
||||
await AssertRunIndexesAsync(context, options);
|
||||
await AssertImpactSnapshotIndexesAsync(context, options);
|
||||
await AssertAuditIndexesAsync(context, options);
|
||||
await AssertLockIndexesAsync(context, options);
|
||||
}
|
||||
|
||||
private static async Task AssertScheduleIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.SchedulesCollection));
|
||||
Assert.Contains("tenant_enabled", names);
|
||||
Assert.Contains("cron_timezone", names);
|
||||
}
|
||||
|
||||
private static async Task AssertRunIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(options.RunsCollection);
|
||||
var indexes = await ListIndexesAsync(collection);
|
||||
|
||||
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "tenant_createdAt_desc", StringComparison.Ordinal));
|
||||
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "state_lookup", StringComparison.Ordinal));
|
||||
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "schedule_createdAt_desc", StringComparison.Ordinal));
|
||||
|
||||
var ttl = indexes.FirstOrDefault(doc => doc.TryGetValue("name", out var name) && name == "finishedAt_ttl");
|
||||
Assert.NotNull(ttl);
|
||||
Assert.Equal(options.CompletedRunRetention.TotalSeconds, ttl!["expireAfterSeconds"].ToDouble());
|
||||
}
|
||||
|
||||
private static async Task AssertImpactSnapshotIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.ImpactSnapshotsCollection));
|
||||
Assert.Contains("selector_tenant_scope", names);
|
||||
Assert.Contains("snapshotId_unique", names);
|
||||
}
|
||||
|
||||
private static async Task AssertAuditIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.AuditCollection));
|
||||
Assert.Contains("tenant_occurredAt_desc", names);
|
||||
Assert.Contains("correlation_lookup", names);
|
||||
}
|
||||
|
||||
private static async Task AssertLockIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.LocksCollection));
|
||||
Assert.Contains("tenant_resource_unique", names);
|
||||
Assert.Contains("expiresAt_ttl", names);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>> ListIndexNamesAsync(IMongoCollection<BsonDocument> collection)
|
||||
{
|
||||
var documents = await ListIndexesAsync(collection);
|
||||
return documents.Select(doc => doc["name"].AsString).ToArray();
|
||||
}
|
||||
|
||||
private static async Task<List<BsonDocument>> ListIndexesAsync(IMongoCollection<BsonDocument> collection)
|
||||
{
|
||||
using var cursor = await collection.Indexes.ListAsync();
|
||||
return await cursor.ToListAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class AuditRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InsertAndListAsync_ReturnsTenantScopedEntries()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new AuditRepository(harness.Context);
|
||||
|
||||
var record1 = TestDataFactory.CreateAuditRecord("tenant-alpha", "1");
|
||||
var record2 = TestDataFactory.CreateAuditRecord("tenant-alpha", "2");
|
||||
var otherTenant = TestDataFactory.CreateAuditRecord("tenant-beta", "3");
|
||||
|
||||
await repository.InsertAsync(record1);
|
||||
await repository.InsertAsync(record2);
|
||||
await repository.InsertAsync(otherTenant);
|
||||
|
||||
var results = await repository.ListAsync("tenant-alpha");
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.DoesNotContain(results, record => record.TenantId == "tenant-beta");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_AppliesFilters()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new AuditRepository(harness.Context);
|
||||
|
||||
var older = TestDataFactory.CreateAuditRecord(
|
||||
"tenant-alpha",
|
||||
"old",
|
||||
occurredAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
scheduleId: "sch-a");
|
||||
var newer = TestDataFactory.CreateAuditRecord(
|
||||
"tenant-alpha",
|
||||
"new",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
scheduleId: "sch-a");
|
||||
|
||||
await repository.InsertAsync(older);
|
||||
await repository.InsertAsync(newer);
|
||||
|
||||
var options = new AuditQueryOptions
|
||||
{
|
||||
Since = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
ScheduleId = "sch-a",
|
||||
Limit = 5
|
||||
};
|
||||
|
||||
var results = await repository.ListAsync("tenant-alpha", options);
|
||||
Assert.Single(results);
|
||||
Assert.Equal("audit_new", results.Single().Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class ImpactSnapshotRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAndGetAsync_RoundTripsSnapshot()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ImpactSnapshotRepository(harness.Context);
|
||||
|
||||
var snapshot = TestDataFactory.CreateImpactSet("tenant-alpha", "impact-1", DateTimeOffset.UtcNow.AddMinutes(-5));
|
||||
await repository.UpsertAsync(snapshot, cancellationToken: CancellationToken.None);
|
||||
|
||||
var stored = await repository.GetBySnapshotIdAsync("impact-1", cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(snapshot.SnapshotId, stored!.SnapshotId);
|
||||
Assert.Equal(snapshot.Images[0].ImageDigest, stored.Images[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestBySelectorAsync_ReturnsMostRecent()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ImpactSnapshotRepository(harness.Context);
|
||||
|
||||
var selectorTenant = "tenant-alpha";
|
||||
var first = TestDataFactory.CreateImpactSet(selectorTenant, "impact-old", DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
var latest = TestDataFactory.CreateImpactSet(selectorTenant, "impact-new", DateTimeOffset.UtcNow);
|
||||
|
||||
await repository.UpsertAsync(first);
|
||||
await repository.UpsertAsync(latest);
|
||||
|
||||
var resolved = await repository.GetLatestBySelectorAsync(latest.Selector);
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal("impact-new", resolved!.SnapshotId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class RunRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InsertAndGetAsync_RoundTripsRun()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new RunRepository(harness.Context);
|
||||
|
||||
var run = TestDataFactory.CreateRun("run_1", "tenant-alpha", RunState.Planning);
|
||||
await repository.InsertAsync(run, cancellationToken: CancellationToken.None);
|
||||
|
||||
var stored = await repository.GetAsync(run.TenantId, run.Id, cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(run.State, stored!.State);
|
||||
Assert.Equal(run.Trigger, stored.Trigger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ChangesStateAndStats()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new RunRepository(harness.Context);
|
||||
|
||||
var run = TestDataFactory.CreateRun("run_update", "tenant-alpha", RunState.Planning);
|
||||
await repository.InsertAsync(run);
|
||||
|
||||
var updated = run with
|
||||
{
|
||||
State = RunState.Completed,
|
||||
FinishedAt = DateTimeOffset.UtcNow,
|
||||
Stats = new RunStats(candidates: 10, deduped: 10, queued: 10, completed: 10, deltas: 2)
|
||||
};
|
||||
|
||||
var result = await repository.UpdateAsync(updated);
|
||||
Assert.True(result);
|
||||
|
||||
var stored = await repository.GetAsync(updated.TenantId, updated.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(RunState.Completed, stored!.State);
|
||||
Assert.Equal(10, stored.Stats.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_FiltersByStateAndSchedule()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new RunRepository(harness.Context);
|
||||
|
||||
var run1 = TestDataFactory.CreateRun("run_state_1", "tenant-alpha", RunState.Planning, scheduleId: "sch_a");
|
||||
var run2 = TestDataFactory.CreateRun("run_state_2", "tenant-alpha", RunState.Running, scheduleId: "sch_a");
|
||||
var run3 = TestDataFactory.CreateRun("run_state_3", "tenant-alpha", RunState.Completed, scheduleId: "sch_b");
|
||||
|
||||
await repository.InsertAsync(run1);
|
||||
await repository.InsertAsync(run2);
|
||||
await repository.InsertAsync(run3);
|
||||
|
||||
var options = new RunQueryOptions
|
||||
{
|
||||
ScheduleId = "sch_a",
|
||||
States = new[] { RunState.Running }.ToImmutableArray(),
|
||||
Limit = 10
|
||||
};
|
||||
|
||||
var results = await repository.ListAsync("tenant-alpha", options);
|
||||
Assert.Single(results);
|
||||
Assert.Equal("run_state_2", results.Single().Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Repositories;
|
||||
|
||||
public sealed class ScheduleRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsScheduleWithCanonicalShape()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ScheduleRepository(harness.Context);
|
||||
|
||||
var schedule = TestDataFactory.CreateSchedule("sch_unit_1", "tenant-alpha");
|
||||
await repository.UpsertAsync(schedule, cancellationToken: CancellationToken.None);
|
||||
|
||||
var stored = await repository.GetAsync(schedule.TenantId, schedule.Id, cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(schedule.Id, stored!.Id);
|
||||
Assert.Equal(schedule.Name, stored.Name);
|
||||
Assert.Equal(schedule.Selection.Scope, stored.Selection.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ExcludesDisabledAndDeletedByDefault()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ScheduleRepository(harness.Context);
|
||||
var tenantId = "tenant-alpha";
|
||||
|
||||
var enabled = TestDataFactory.CreateSchedule("sch_enabled", tenantId, enabled: true, name: "Enabled");
|
||||
var disabled = TestDataFactory.CreateSchedule("sch_disabled", tenantId, enabled: false, name: "Disabled");
|
||||
|
||||
await repository.UpsertAsync(enabled);
|
||||
await repository.UpsertAsync(disabled);
|
||||
await repository.SoftDeleteAsync(tenantId, enabled.Id, "svc_scheduler", DateTimeOffset.UtcNow);
|
||||
|
||||
var results = await repository.ListAsync(tenantId);
|
||||
Assert.Empty(results);
|
||||
|
||||
var includeDisabled = await repository.ListAsync(
|
||||
tenantId,
|
||||
new ScheduleQueryOptions { IncludeDisabled = true, IncludeDeleted = true });
|
||||
|
||||
Assert.Equal(2, includeDisabled.Count);
|
||||
Assert.Contains(includeDisabled, schedule => schedule.Id == enabled.Id);
|
||||
Assert.Contains(includeDisabled, schedule => schedule.Id == disabled.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SoftDeleteAsync_SetsMetadataAndExcludesFromQueries()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ScheduleRepository(harness.Context);
|
||||
|
||||
var schedule = TestDataFactory.CreateSchedule("sch_delete", "tenant-beta");
|
||||
await repository.UpsertAsync(schedule);
|
||||
|
||||
var deletedAt = DateTimeOffset.UtcNow;
|
||||
var deleted = await repository.SoftDeleteAsync(schedule.TenantId, schedule.Id, "svc_delete", deletedAt);
|
||||
Assert.True(deleted);
|
||||
|
||||
var retrieved = await repository.GetAsync(schedule.TenantId, schedule.Id);
|
||||
Assert.Null(retrieved);
|
||||
|
||||
var includeDeleted = await repository.ListAsync(
|
||||
schedule.TenantId,
|
||||
new ScheduleQueryOptions { IncludeDeleted = true, IncludeDisabled = true });
|
||||
|
||||
Assert.Single(includeDeleted);
|
||||
Assert.Equal("sch_delete", includeDeleted[0].Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests;
|
||||
|
||||
internal sealed class SchedulerMongoTestHarness : IDisposable
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
|
||||
public SchedulerMongoTestHarness()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
|
||||
var options = new SchedulerMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = $"scheduler_tests_{Guid.NewGuid():N}"
|
||||
};
|
||||
|
||||
Context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
|
||||
var migrations = new ISchedulerMongoMigration[]
|
||||
{
|
||||
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
|
||||
new EnsureSchedulerIndexesMigration()
|
||||
};
|
||||
var runner = new SchedulerMongoMigrationRunner(Context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
|
||||
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public SchedulerMongoContext Context { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Services;
|
||||
|
||||
public sealed class RunSummaryServiceTests : IDisposable
|
||||
{
|
||||
private readonly SchedulerMongoTestHarness _harness;
|
||||
private readonly RunSummaryRepository _repository;
|
||||
private readonly StubTimeProvider _timeProvider;
|
||||
private readonly RunSummaryService _service;
|
||||
|
||||
public RunSummaryServiceTests()
|
||||
{
|
||||
_harness = new SchedulerMongoTestHarness();
|
||||
_repository = new RunSummaryRepository(_harness.Context);
|
||||
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T10:00:00Z"));
|
||||
_service = new RunSummaryService(_repository, _timeProvider, NullLogger<RunSummaryService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProjectAsync_FirstRunCreatesProjection()
|
||||
{
|
||||
var run = TestDataFactory.CreateRun("run-1", "tenant-alpha", RunState.Planning, "sch-alpha");
|
||||
|
||||
var projection = await _service.ProjectAsync(run, CancellationToken.None);
|
||||
|
||||
Assert.Equal("tenant-alpha", projection.TenantId);
|
||||
Assert.Equal("sch-alpha", projection.ScheduleId);
|
||||
Assert.NotNull(projection.LastRun);
|
||||
Assert.Equal(RunState.Planning, projection.LastRun!.State);
|
||||
Assert.Equal(1, projection.Counters.Total);
|
||||
Assert.Equal(1, projection.Counters.Planning);
|
||||
Assert.Equal(0, projection.Counters.Completed);
|
||||
Assert.Single(projection.Recent);
|
||||
Assert.Equal(run.Id, projection.Recent[0].RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProjectAsync_UpdateRunReplacesExistingEntry()
|
||||
{
|
||||
var createdAt = DateTimeOffset.Parse("2025-10-26T09:55:00Z");
|
||||
var run = TestDataFactory.CreateRun(
|
||||
"run-update",
|
||||
"tenant-alpha",
|
||||
RunState.Planning,
|
||||
"sch-alpha",
|
||||
createdAt: createdAt,
|
||||
startedAt: createdAt.AddMinutes(1));
|
||||
await _service.ProjectAsync(run, CancellationToken.None);
|
||||
|
||||
var updated = run with
|
||||
{
|
||||
State = RunState.Completed,
|
||||
StartedAt = run.StartedAt,
|
||||
FinishedAt = run.CreatedAt.AddMinutes(5),
|
||||
Stats = new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 10, deltas: 2, newCriticals: 1)
|
||||
};
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var projection = await _service.ProjectAsync(updated, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(projection.LastRun);
|
||||
Assert.Equal(RunState.Completed, projection.LastRun!.State);
|
||||
Assert.Equal(1, projection.Counters.Completed);
|
||||
Assert.Equal(0, projection.Counters.Planning);
|
||||
Assert.Single(projection.Recent);
|
||||
Assert.Equal(updated.Stats.Completed, projection.LastRun!.Stats.Completed);
|
||||
Assert.True(projection.UpdatedAt > run.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProjectAsync_TrimsRecentEntriesBeyondLimit()
|
||||
{
|
||||
var baseTime = DateTimeOffset.Parse("2025-10-26T00:00:00Z");
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
var run = TestDataFactory.CreateRun(
|
||||
$"run-{i}",
|
||||
"tenant-alpha",
|
||||
RunState.Completed,
|
||||
"sch-alpha",
|
||||
stats: new RunStats(candidates: 5, deduped: 4, queued: 3, completed: 5, deltas: 1),
|
||||
createdAt: baseTime.AddMinutes(i));
|
||||
|
||||
await _service.ProjectAsync(run, CancellationToken.None);
|
||||
}
|
||||
|
||||
var projections = await _service.ListAsync("tenant-alpha", CancellationToken.None);
|
||||
Assert.Single(projections);
|
||||
var projection = projections[0];
|
||||
Assert.Equal(20, projection.Recent.Length);
|
||||
Assert.Equal(20, projection.Counters.Total);
|
||||
Assert.Equal("run-24", projection.Recent[0].RunId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_harness.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public StubTimeProvider(DateTimeOffset initial)
|
||||
=> _utcNow = initial;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Services;
|
||||
|
||||
public sealed class SchedulerAuditServiceTests : IDisposable
|
||||
{
|
||||
private readonly SchedulerMongoTestHarness _harness;
|
||||
private readonly AuditRepository _repository;
|
||||
private readonly StubTimeProvider _timeProvider;
|
||||
private readonly SchedulerAuditService _service;
|
||||
|
||||
public SchedulerAuditServiceTests()
|
||||
{
|
||||
_harness = new SchedulerMongoTestHarness();
|
||||
_repository = new AuditRepository(_harness.Context);
|
||||
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T11:30:00Z"));
|
||||
_service = new SchedulerAuditService(_repository, _timeProvider, NullLogger<SchedulerAuditService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsRecordWithGeneratedId()
|
||||
{
|
||||
var auditEvent = new SchedulerAuditEvent(
|
||||
TenantId: "tenant-alpha",
|
||||
Category: "scheduler",
|
||||
Action: "create",
|
||||
Actor: new AuditActor("user_admin", "Admin", "user"),
|
||||
ScheduleId: "sch-alpha",
|
||||
CorrelationId: "corr-1",
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["Reason"] = "initial",
|
||||
},
|
||||
Message: "created schedule");
|
||||
|
||||
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
|
||||
|
||||
Assert.StartsWith("audit_", record.Id, StringComparison.Ordinal);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), record.OccurredAt);
|
||||
|
||||
var stored = await _repository.ListAsync("tenant-alpha", new AuditQueryOptions { ScheduleId = "sch-alpha" }, session: null, CancellationToken.None);
|
||||
Assert.Single(stored);
|
||||
Assert.Equal(record.Id, stored[0].Id);
|
||||
Assert.Equal("created schedule", stored[0].Message);
|
||||
Assert.Contains(stored[0].Metadata, pair => pair.Key == "reason" && pair.Value == "initial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_HonoursProvidedAuditId()
|
||||
{
|
||||
var auditEvent = new SchedulerAuditEvent(
|
||||
TenantId: "tenant-alpha",
|
||||
Category: "scheduler",
|
||||
Action: "update",
|
||||
Actor: new AuditActor("user_admin", "Admin", "user"),
|
||||
ScheduleId: "sch-alpha",
|
||||
AuditId: "audit_custom_1",
|
||||
OccurredAt: DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
|
||||
|
||||
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
|
||||
Assert.Equal("audit_custom_1", record.Id);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-10-26T12:00:00Z"), record.OccurredAt);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_harness.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public StubTimeProvider(DateTimeOffset initial)
|
||||
=> _utcNow = initial;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Threading;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Sessions;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests.Sessions;
|
||||
|
||||
public sealed class SchedulerMongoSessionFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartSessionAsync_UsesCausalConsistencyByDefault()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var factory = new SchedulerMongoSessionFactory(harness.Context);
|
||||
|
||||
using var session = await factory.StartSessionAsync(cancellationToken: CancellationToken.None);
|
||||
Assert.True(session.Options.CausalConsistency.GetValueOrDefault());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartSessionAsync_AllowsOverridingOptions()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var factory = new SchedulerMongoSessionFactory(harness.Context);
|
||||
|
||||
var options = new SchedulerMongoSessionOptions
|
||||
{
|
||||
CausalConsistency = false,
|
||||
ReadPreference = ReadPreference.PrimaryPreferred
|
||||
};
|
||||
|
||||
using var session = await factory.StartSessionAsync(options);
|
||||
Assert.False(session.Options.CausalConsistency.GetValueOrDefault(true));
|
||||
Assert.Equal(ReadPreference.PrimaryPreferred, session.Options.DefaultTransactionOptions?.ReadPreference);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Storage.Mongo/StellaOps.Scheduler.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="../../samples/api/scheduler/*.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Mongo.Tests;
|
||||
|
||||
internal static class TestDataFactory
|
||||
{
|
||||
public static Schedule CreateSchedule(
|
||||
string id,
|
||||
string tenantId,
|
||||
bool enabled = true,
|
||||
string name = "Nightly Prod")
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new Schedule(
|
||||
id,
|
||||
tenantId,
|
||||
name,
|
||||
enabled,
|
||||
"0 2 * * *",
|
||||
"UTC",
|
||||
ScheduleMode.AnalysisOnly,
|
||||
new Selector(SelectorScope.AllImages, tenantId),
|
||||
ScheduleOnlyIf.Default,
|
||||
ScheduleNotify.Default,
|
||||
ScheduleLimits.Default,
|
||||
now,
|
||||
"svc_scheduler",
|
||||
now,
|
||||
"svc_scheduler",
|
||||
ImmutableArray<string>.Empty,
|
||||
SchedulerSchemaVersions.Schedule);
|
||||
}
|
||||
|
||||
public static Run CreateRun(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunState state,
|
||||
string? scheduleId = null,
|
||||
RunTrigger trigger = RunTrigger.Manual,
|
||||
RunStats? stats = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? startedAt = null)
|
||||
{
|
||||
var resolvedStats = stats ?? new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 0, deltas: 2);
|
||||
var created = createdAt ?? DateTimeOffset.UtcNow;
|
||||
return new Run(
|
||||
id,
|
||||
tenantId,
|
||||
trigger,
|
||||
state,
|
||||
resolvedStats,
|
||||
created,
|
||||
scheduleId: scheduleId,
|
||||
reason: new RunReason(manualReason: "test"),
|
||||
startedAt: startedAt ?? created);
|
||||
}
|
||||
|
||||
public static ImpactSet CreateImpactSet(string tenantId, string snapshotId, DateTimeOffset? generatedAt = null, bool usageOnly = true)
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId);
|
||||
var image = new ImpactImage(
|
||||
"sha256:" + Guid.NewGuid().ToString("N"),
|
||||
"registry",
|
||||
"repo/app",
|
||||
namespaces: new[] { "team-a" },
|
||||
tags: new[] { "prod" },
|
||||
usedByEntrypoint: true);
|
||||
|
||||
return new ImpactSet(
|
||||
selector,
|
||||
new[] { image },
|
||||
usageOnly: usageOnly,
|
||||
generatedAt ?? DateTimeOffset.UtcNow,
|
||||
total: 1,
|
||||
snapshotId: snapshotId,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
public static AuditRecord CreateAuditRecord(
|
||||
string tenantId,
|
||||
string idSuffix,
|
||||
DateTimeOffset? occurredAt = null,
|
||||
string? scheduleId = null,
|
||||
string? category = null,
|
||||
string? action = null)
|
||||
{
|
||||
return new AuditRecord(
|
||||
$"audit_{idSuffix}",
|
||||
tenantId,
|
||||
category ?? "scheduler",
|
||||
action ?? "create",
|
||||
occurredAt ?? DateTimeOffset.UtcNow,
|
||||
new AuditActor("user_admin", "Admin", "user"),
|
||||
scheduleId: scheduleId ?? $"sch_{idSuffix}",
|
||||
message: "created");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class CartographerWebhookClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NotifyAsync_PostsPayload_WhenEnabled()
|
||||
{
|
||||
var handler = new RecordingHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerCartographerOptions
|
||||
{
|
||||
Webhook =
|
||||
{
|
||||
Enabled = true,
|
||||
Endpoint = "https://cartographer.local/hooks/graph-completed",
|
||||
ApiKeyHeader = "X-Api-Key",
|
||||
ApiKey = "secret"
|
||||
}
|
||||
});
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug());
|
||||
var client = new CartographerWebhookClient(httpClient, new OptionsMonitorStub<SchedulerCartographerOptions>(options), loggerFactory.CreateLogger<CartographerWebhookClient>());
|
||||
|
||||
var job = new GraphBuildJob(
|
||||
id: "gbj_test",
|
||||
tenantId: "tenant-alpha",
|
||||
sbomId: "sbom",
|
||||
sbomVersionId: "sbom_v1",
|
||||
sbomDigest: "sha256:" + new string('a', 64),
|
||||
status: GraphJobStatus.Completed,
|
||||
trigger: GraphBuildJobTrigger.Backfill,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
graphSnapshotId: "snap",
|
||||
attempts: 1,
|
||||
cartographerJobId: "carto-123",
|
||||
correlationId: "corr-1",
|
||||
startedAt: null,
|
||||
completedAt: DateTimeOffset.UtcNow,
|
||||
error: null,
|
||||
metadata: Array.Empty<KeyValuePair<string, string>>());
|
||||
|
||||
var notification = new GraphJobCompletionNotification(
|
||||
job.TenantId,
|
||||
GraphJobQueryType.Build,
|
||||
GraphJobStatus.Completed,
|
||||
DateTimeOffset.UtcNow,
|
||||
GraphJobResponse.From(job),
|
||||
"oras://snap/result",
|
||||
"corr-1",
|
||||
null);
|
||||
|
||||
await client.NotifyAsync(notification, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal("https://cartographer.local/hooks/graph-completed", handler.LastRequest.RequestUri!.ToString());
|
||||
Assert.True(handler.LastRequest.Headers.TryGetValues("X-Api-Key", out var values) && values!.Single() == "secret");
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(handler.LastPayload!);
|
||||
Assert.Equal("gbj_test", json.GetProperty("jobId").GetString());
|
||||
Assert.Equal("tenant-alpha", json.GetProperty("tenantId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyAsync_Skips_WhenDisabled()
|
||||
{
|
||||
var handler = new RecordingHandler();
|
||||
var httpClient = new HttpClient(handler);
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerCartographerOptions());
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug());
|
||||
var client = new CartographerWebhookClient(httpClient, new OptionsMonitorStub<SchedulerCartographerOptions>(options), loggerFactory.CreateLogger<CartographerWebhookClient>());
|
||||
|
||||
var job = new GraphOverlayJob(
|
||||
id: "goj-test",
|
||||
tenantId: "tenant-alpha",
|
||||
graphSnapshotId: "snap",
|
||||
overlayKind: GraphOverlayKind.Policy,
|
||||
overlayKey: "policy@1",
|
||||
status: GraphJobStatus.Completed,
|
||||
trigger: GraphOverlayJobTrigger.Manual,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
subjects: Array.Empty<string>(),
|
||||
attempts: 1,
|
||||
correlationId: null,
|
||||
startedAt: null,
|
||||
completedAt: DateTimeOffset.UtcNow,
|
||||
error: null,
|
||||
metadata: Array.Empty<KeyValuePair<string, string>>());
|
||||
|
||||
var notification = new GraphJobCompletionNotification(
|
||||
job.TenantId,
|
||||
GraphJobQueryType.Overlay,
|
||||
GraphJobStatus.Completed,
|
||||
DateTimeOffset.UtcNow,
|
||||
GraphJobResponse.From(job),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
await client.NotifyAsync(notification, CancellationToken.None);
|
||||
|
||||
Assert.Null(handler.LastRequest);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastPayload { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
LastPayload = request.Content is null ? null : request.Content.ReadAsStringAsync(cancellationToken).Result;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OptionsMonitorStub<T> : IOptionsMonitor<T> where T : class
|
||||
{
|
||||
private readonly IOptions<T> _options;
|
||||
|
||||
public OptionsMonitorStub(IOptions<T> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public T CurrentValue => _options.Value;
|
||||
|
||||
public T Get(string? name) => _options.Value;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class EventWebhookEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
static EventWebhookEndpointTests()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Feedser__HmacSecret", FeedserSecret);
|
||||
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Feedser__Enabled", "true");
|
||||
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Vexer__HmacSecret", VexerSecret);
|
||||
Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Vexer__Enabled", "true");
|
||||
}
|
||||
|
||||
private const string FeedserSecret = "feedser-secret";
|
||||
private const string VexerSecret = "vexer-secret";
|
||||
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public EventWebhookEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeedserWebhook_AcceptsValidSignature()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var payload = new
|
||||
{
|
||||
exportId = "feedser-exp-1",
|
||||
changedProductKeys = new[] { "pkg:rpm/openssl", "pkg:deb/nginx" },
|
||||
kev = new[] { "CVE-2024-0001" },
|
||||
window = new { from = DateTimeOffset.UtcNow.AddHours(-1), to = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/feedser-export")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(FeedserSecret, json));
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeedserWebhook_RejectsInvalidSignature()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var payload = new
|
||||
{
|
||||
exportId = "feedser-exp-2",
|
||||
changedProductKeys = new[] { "pkg:nuget/log4net" }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/events/feedser-export")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", "sha256=invalid");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VexerWebhook_HonoursRateLimit()
|
||||
{
|
||||
using var restrictedFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Scheduler:Events:Webhooks:Vexer:RateLimitRequests"] = "1",
|
||||
["Scheduler:Events:Webhooks:Vexer:RateLimitWindowSeconds"] = "60"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
using var client = restrictedFactory.CreateClient();
|
||||
var payload = new
|
||||
{
|
||||
exportId = "vexer-exp-1",
|
||||
changedClaims = new[]
|
||||
{
|
||||
new { productKey = "pkg:deb/openssl", vulnerabilityId = "CVE-2024-1234", status = "affected" }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
using var first = new HttpRequestMessage(HttpMethod.Post, "/events/vexer-export")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
first.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(VexerSecret, json));
|
||||
var firstResponse = await client.SendAsync(first);
|
||||
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
|
||||
|
||||
using var second = new HttpRequestMessage(HttpMethod.Post, "/events/vexer-export")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
second.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(VexerSecret, json));
|
||||
var secondResponse = await client.SendAsync(second);
|
||||
Assert.Equal((HttpStatusCode)429, secondResponse.StatusCode);
|
||||
Assert.True(secondResponse.Headers.Contains("Retry-After"));
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string secret, string payload)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
global using System.Net.Http.Json;
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Serialization;
|
||||
global using System.Threading.Tasks;
|
||||
global using Microsoft.AspNetCore.Mvc.Testing;
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,110 @@
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class GraphJobEndpointTests : IClassFixture<SchedulerWebApplicationFactory>
|
||||
{
|
||||
private readonly SchedulerWebApplicationFactory _factory;
|
||||
|
||||
public GraphJobEndpointTests(SchedulerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGraphBuildJob_RequiresGraphWriteScope()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-alpha");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/graphs/build", new
|
||||
{
|
||||
sbomId = "sbom-test",
|
||||
sbomVersionId = "sbom-ver",
|
||||
sbomDigest = "sha256:" + new string('a', 64)
|
||||
});
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGraphBuildJob_AndList()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-alpha");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", string.Join(' ', StellaOpsScopes.GraphWrite, StellaOpsScopes.GraphRead));
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/graphs/build", new
|
||||
{
|
||||
sbomId = "sbom-alpha",
|
||||
sbomVersionId = "sbom-alpha-v1",
|
||||
sbomDigest = "sha256:" + new string('b', 64),
|
||||
metadata = new { source = "test" }
|
||||
});
|
||||
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var listResponse = await client.GetAsync("/graphs/jobs");
|
||||
listResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await listResponse.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.TryGetProperty("jobs", out var jobs));
|
||||
Assert.True(jobs.GetArrayLength() >= 1);
|
||||
var first = jobs[0];
|
||||
Assert.Equal("build", first.GetProperty("kind").GetString());
|
||||
Assert.Equal("tenant-alpha", first.GetProperty("tenantId").GetString());
|
||||
Assert.Equal("pending", first.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteOverlayJob_UpdatesStatusAndMetrics()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-bravo");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", string.Join(' ', StellaOpsScopes.GraphWrite, StellaOpsScopes.GraphRead));
|
||||
|
||||
var createOverlay = await client.PostAsJsonAsync("/graphs/overlays", new
|
||||
{
|
||||
graphSnapshotId = "graph_snap_20251026",
|
||||
overlayKind = "policy",
|
||||
overlayKey = "policy@2025-10-01",
|
||||
subjects = new[] { "artifact/service-api" }
|
||||
});
|
||||
|
||||
createOverlay.EnsureSuccessStatusCode();
|
||||
var createdJson = await createOverlay.Content.ReadAsStringAsync();
|
||||
using var createdDoc = JsonDocument.Parse(createdJson);
|
||||
var jobId = createdDoc.RootElement.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(jobId));
|
||||
|
||||
var completeResponse = await client.PostAsJsonAsync("/graphs/hooks/completed", new
|
||||
{
|
||||
jobId = jobId,
|
||||
jobType = "Overlay",
|
||||
status = "Completed",
|
||||
occurredAt = DateTimeOffset.UtcNow,
|
||||
correlationId = "corr-123",
|
||||
resultUri = "oras://cartographer/snapshots/graph_snap_20251026"
|
||||
});
|
||||
|
||||
completeResponse.EnsureSuccessStatusCode();
|
||||
var completedJson = await completeResponse.Content.ReadAsStringAsync();
|
||||
using var completedDoc = JsonDocument.Parse(completedJson);
|
||||
Assert.Equal("completed", completedDoc.RootElement.GetProperty("status").GetString());
|
||||
|
||||
var metricsResponse = await client.GetAsync("/graphs/overlays/lag");
|
||||
metricsResponse.EnsureSuccessStatusCode();
|
||||
var metricsJson = await metricsResponse.Content.ReadAsStringAsync();
|
||||
using var metricsDoc = JsonDocument.Parse(metricsJson);
|
||||
var metricsRoot = metricsDoc.RootElement;
|
||||
Assert.Equal("tenant-bravo", metricsRoot.GetProperty("tenantId").GetString());
|
||||
Assert.True(metricsRoot.GetProperty("completed").GetInt32() >= 1);
|
||||
var recent = metricsRoot.GetProperty("recentCompleted");
|
||||
Assert.True(recent.GetArrayLength() >= 1);
|
||||
var entry = recent[0];
|
||||
Assert.Equal(jobId, entry.GetProperty("jobId").GetString());
|
||||
Assert.Equal("corr-123", entry.GetProperty("correlationId").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class GraphJobEventPublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishAsync_WritesEventJson_WhenEnabled()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerEventsOptions
|
||||
{
|
||||
GraphJobs = { Enabled = true }
|
||||
});
|
||||
var loggerProvider = new ListLoggerProvider();
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(loggerProvider));
|
||||
var publisher = new GraphJobEventPublisher(new OptionsMonitorStub<SchedulerEventsOptions>(options), loggerFactory.CreateLogger<GraphJobEventPublisher>());
|
||||
|
||||
var buildJob = new GraphBuildJob(
|
||||
id: "gbj_test",
|
||||
tenantId: "tenant-alpha",
|
||||
sbomId: "sbom",
|
||||
sbomVersionId: "sbom_v1",
|
||||
sbomDigest: "sha256:" + new string('a', 64),
|
||||
status: GraphJobStatus.Completed,
|
||||
trigger: GraphBuildJobTrigger.SbomVersion,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
graphSnapshotId: "graph_snap",
|
||||
attempts: 1,
|
||||
cartographerJobId: "carto",
|
||||
correlationId: "corr",
|
||||
startedAt: DateTimeOffset.UtcNow.AddSeconds(-30),
|
||||
completedAt: DateTimeOffset.UtcNow,
|
||||
error: null,
|
||||
metadata: Array.Empty<KeyValuePair<string, string>>());
|
||||
|
||||
var notification = new GraphJobCompletionNotification(
|
||||
buildJob.TenantId,
|
||||
GraphJobQueryType.Build,
|
||||
GraphJobStatus.Completed,
|
||||
DateTimeOffset.UtcNow,
|
||||
GraphJobResponse.From(buildJob),
|
||||
"oras://result",
|
||||
"corr",
|
||||
null);
|
||||
|
||||
await publisher.PublishAsync(notification, CancellationToken.None);
|
||||
|
||||
var message = Assert.Single(loggerProvider.Messages);
|
||||
Assert.Contains("\"kind\":\"scheduler.graph.job.completed\"", message);
|
||||
Assert.Contains("\"tenant\":\"tenant-alpha\"", message);
|
||||
Assert.Contains("\"resultUri\":\"oras://result\"", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_Suppressed_WhenDisabled()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerEventsOptions());
|
||||
var loggerProvider = new ListLoggerProvider();
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(loggerProvider));
|
||||
var publisher = new GraphJobEventPublisher(new OptionsMonitorStub<SchedulerEventsOptions>(options), loggerFactory.CreateLogger<GraphJobEventPublisher>());
|
||||
|
||||
var overlayJob = new GraphOverlayJob(
|
||||
id: "goj_test",
|
||||
tenantId: "tenant-alpha",
|
||||
graphSnapshotId: "graph_snap",
|
||||
buildJobId: null,
|
||||
overlayKind: GraphOverlayKind.Policy,
|
||||
overlayKey: "policy@1",
|
||||
subjects: Array.Empty<string>(),
|
||||
status: GraphJobStatus.Completed,
|
||||
trigger: GraphOverlayJobTrigger.Policy,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
attempts: 1,
|
||||
correlationId: null,
|
||||
startedAt: DateTimeOffset.UtcNow.AddSeconds(-10),
|
||||
completedAt: DateTimeOffset.UtcNow,
|
||||
error: null,
|
||||
metadata: Array.Empty<KeyValuePair<string, string>>());
|
||||
|
||||
var notification = new GraphJobCompletionNotification(
|
||||
overlayJob.TenantId,
|
||||
GraphJobQueryType.Overlay,
|
||||
GraphJobStatus.Completed,
|
||||
DateTimeOffset.UtcNow,
|
||||
GraphJobResponse.From(overlayJob),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
await publisher.PublishAsync(notification, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain(loggerProvider.Messages, message => message.Contains(GraphJobEventKinds.GraphJobCompleted, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class OptionsMonitorStub<T> : IOptionsMonitor<T> where T : class
|
||||
{
|
||||
private readonly IOptions<T> _options;
|
||||
|
||||
public OptionsMonitorStub(IOptions<T> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public T CurrentValue => _options.Value;
|
||||
|
||||
public T Get(string? name) => _options.Value;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class ListLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly ListLogger _logger = new();
|
||||
|
||||
public IList<string> Messages => _logger.Messages;
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => _logger;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class ListLogger : ILogger
|
||||
{
|
||||
public IList<string> Messages { get; } = new List<string>();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullDisposable.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Messages.Add(formatter(state, exception));
|
||||
}
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class PolicyRunEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public PolicyRunEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateListGetPolicyRun()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-policy");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "policy:run");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/v1/scheduler/policy/runs", new
|
||||
{
|
||||
policyId = "P-7",
|
||||
policyVersion = 4,
|
||||
mode = "incremental",
|
||||
priority = "normal",
|
||||
metadata = new { source = "cli" },
|
||||
inputs = new
|
||||
{
|
||||
sbomSet = new[] { "sbom:S-42", "sbom:S-99" },
|
||||
advisoryCursor = "2025-10-26T13:59:00+00:00",
|
||||
vexCursor = "2025-10-26T13:58:30+00:00",
|
||||
environment = new { @sealed = false, exposure = "internet" },
|
||||
captureExplain = true
|
||||
}
|
||||
});
|
||||
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
Assert.Equal(System.Net.HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var runJson = created.GetProperty("run");
|
||||
var runId = runJson.GetProperty("runId").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(runId));
|
||||
Assert.Equal("queued", runJson.GetProperty("status").GetString());
|
||||
Assert.Equal("P-7", runJson.GetProperty("policyId").GetString());
|
||||
Assert.True(runJson.GetProperty("inputs").GetProperty("captureExplain").GetBoolean());
|
||||
|
||||
var listResponse = await client.GetAsync("/api/v1/scheduler/policy/runs?policyId=P-7");
|
||||
listResponse.EnsureSuccessStatusCode();
|
||||
var list = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var runsArray = list.GetProperty("runs");
|
||||
Assert.True(runsArray.GetArrayLength() >= 1);
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/v1/scheduler/policy/runs/{runId}");
|
||||
getResponse.EnsureSuccessStatusCode();
|
||||
var retrieved = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(runId, retrieved.GetProperty("run").GetProperty("runId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingScopeReturnsForbidden()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-policy");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scheduler/policy/runs");
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class RunEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public RunEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateListCancelRun()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-runs");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview");
|
||||
|
||||
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
|
||||
{
|
||||
name = "RunSchedule",
|
||||
cronExpression = "0 3 * * *",
|
||||
timezone = "UTC",
|
||||
mode = "analysis-only",
|
||||
selection = new
|
||||
{
|
||||
scope = "all-images"
|
||||
}
|
||||
});
|
||||
|
||||
scheduleResponse.EnsureSuccessStatusCode();
|
||||
var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(scheduleId));
|
||||
|
||||
var createRun = await client.PostAsJsonAsync("/api/v1/scheduler/runs", new
|
||||
{
|
||||
scheduleId,
|
||||
trigger = "manual"
|
||||
});
|
||||
|
||||
createRun.EnsureSuccessStatusCode();
|
||||
Assert.Equal(System.Net.HttpStatusCode.Created, createRun.StatusCode);
|
||||
var runJson = await createRun.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var runId = runJson.GetProperty("run").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(runId));
|
||||
Assert.Equal("planning", runJson.GetProperty("run").GetProperty("state").GetString());
|
||||
|
||||
var listResponse = await client.GetAsync("/api/v1/scheduler/runs");
|
||||
listResponse.EnsureSuccessStatusCode();
|
||||
var listJson = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(listJson.GetProperty("runs").EnumerateArray().Any());
|
||||
|
||||
var cancelResponse = await client.PostAsync($"/api/v1/scheduler/runs/{runId}/cancel", null);
|
||||
cancelResponse.EnsureSuccessStatusCode();
|
||||
var cancelled = await cancelResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("cancelled", cancelled.GetProperty("run").GetProperty("state").GetString());
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/v1/scheduler/runs/{runId}");
|
||||
getResponse.EnsureSuccessStatusCode();
|
||||
var runDetail = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("cancelled", runDetail.GetProperty("run").GetProperty("state").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewImpactForSchedule()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-preview");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read scheduler.runs.write scheduler.runs.read scheduler.runs.preview");
|
||||
|
||||
var scheduleResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
|
||||
{
|
||||
name = "PreviewSchedule",
|
||||
cronExpression = "0 5 * * *",
|
||||
timezone = "UTC",
|
||||
mode = "analysis-only",
|
||||
selection = new
|
||||
{
|
||||
scope = "all-images"
|
||||
}
|
||||
});
|
||||
|
||||
scheduleResponse.EnsureSuccessStatusCode();
|
||||
var scheduleJson = await scheduleResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var scheduleId = scheduleJson.GetProperty("schedule").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(scheduleId));
|
||||
|
||||
var previewResponse = await client.PostAsJsonAsync("/api/v1/scheduler/runs/preview", new
|
||||
{
|
||||
scheduleId,
|
||||
usageOnly = true,
|
||||
sampleSize = 3
|
||||
});
|
||||
|
||||
previewResponse.EnsureSuccessStatusCode();
|
||||
var preview = await previewResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(preview.GetProperty("total").GetInt32() >= 0);
|
||||
Assert.True(preview.GetProperty("sample").GetArrayLength() <= 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class ScheduleEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ScheduleEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateListAndRetrieveSchedule()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-schedules");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
|
||||
{
|
||||
name = "Nightly",
|
||||
cronExpression = "0 2 * * *",
|
||||
timezone = "UTC",
|
||||
mode = "analysis-only",
|
||||
selection = new
|
||||
{
|
||||
scope = "all-images"
|
||||
},
|
||||
notify = new
|
||||
{
|
||||
onNewFindings = true,
|
||||
minSeverity = "medium",
|
||||
includeKev = true
|
||||
}
|
||||
});
|
||||
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var scheduleId = created.GetProperty("schedule").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(scheduleId));
|
||||
|
||||
var listResponse = await client.GetAsync("/api/v1/scheduler/schedules");
|
||||
listResponse.EnsureSuccessStatusCode();
|
||||
var listJson = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var schedules = listJson.GetProperty("schedules");
|
||||
Assert.True(schedules.GetArrayLength() >= 1);
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/v1/scheduler/schedules/{scheduleId}");
|
||||
getResponse.EnsureSuccessStatusCode();
|
||||
var scheduleJson = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("Nightly", scheduleJson.GetProperty("schedule").GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PauseAndResumeSchedule()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-controls");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.schedules.write scheduler.schedules.read");
|
||||
|
||||
var create = await client.PostAsJsonAsync("/api/v1/scheduler/schedules", new
|
||||
{
|
||||
name = "PauseResume",
|
||||
cronExpression = "*/5 * * * *",
|
||||
timezone = "UTC",
|
||||
mode = "analysis-only",
|
||||
selection = new
|
||||
{
|
||||
scope = "all-images"
|
||||
}
|
||||
});
|
||||
|
||||
create.EnsureSuccessStatusCode();
|
||||
var created = await create.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var scheduleId = created.GetProperty("schedule").GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(scheduleId));
|
||||
|
||||
var pauseResponse = await client.PostAsync($"/api/v1/scheduler/schedules/{scheduleId}/pause", null);
|
||||
pauseResponse.EnsureSuccessStatusCode();
|
||||
var paused = await pauseResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.False(paused.GetProperty("schedule").GetProperty("enabled").GetBoolean());
|
||||
|
||||
var resumeResponse = await client.PostAsync($"/api/v1/scheduler/schedules/{scheduleId}/resume", null);
|
||||
resumeResponse.EnsureSuccessStatusCode();
|
||||
var resumed = await resumeResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(resumed.GetProperty("schedule").GetProperty("enabled").GetBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public class SchedulerPluginHostFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_usesDefaults_whenOptionsEmpty()
|
||||
{
|
||||
var options = new SchedulerOptions.PluginOptions();
|
||||
var contentRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(contentRoot);
|
||||
|
||||
try
|
||||
{
|
||||
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRoot);
|
||||
|
||||
var expectedBase = Path.GetFullPath(Path.Combine(contentRoot, ".."));
|
||||
var expectedPlugins = Path.Combine(expectedBase, "plugins", "scheduler");
|
||||
|
||||
Assert.Equal(expectedBase, hostOptions.BaseDirectory);
|
||||
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
|
||||
Assert.Single(hostOptions.SearchPatterns, "StellaOps.Scheduler.Plugin.*.dll");
|
||||
Assert.True(hostOptions.EnsureDirectoryExists);
|
||||
Assert.False(hostOptions.RecursiveSearch);
|
||||
Assert.Empty(hostOptions.PluginOrder);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(contentRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_respectsConfiguredValues()
|
||||
{
|
||||
var options = new SchedulerOptions.PluginOptions
|
||||
{
|
||||
BaseDirectory = Path.Combine(Path.GetTempPath(), "scheduler-options", Guid.NewGuid().ToString("N")),
|
||||
Directory = Path.Combine("custom", "plugins"),
|
||||
RecursiveSearch = true,
|
||||
EnsureDirectoryExists = false
|
||||
};
|
||||
|
||||
options.SearchPatterns.Add("Custom.Plugin.*.dll");
|
||||
options.OrderedPlugins.Add("StellaOps.Scheduler.Plugin.Alpha");
|
||||
|
||||
Directory.CreateDirectory(options.BaseDirectory!);
|
||||
|
||||
try
|
||||
{
|
||||
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRootPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
|
||||
|
||||
var expectedPlugins = Path.GetFullPath(Path.Combine(options.BaseDirectory!, options.Directory!));
|
||||
|
||||
Assert.Equal(options.BaseDirectory, hostOptions.BaseDirectory);
|
||||
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
|
||||
Assert.Single(hostOptions.SearchPatterns, "Custom.Plugin.*.dll");
|
||||
Assert.Single(hostOptions.PluginOrder, "StellaOps.Scheduler.Plugin.Alpha");
|
||||
Assert.True(hostOptions.RecursiveSearch);
|
||||
Assert.False(hostOptions.EnsureDirectoryExists);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(options.BaseDirectory!, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class SchedulerWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("Scheduler:Authority:Enabled", "false"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Cartographer:Webhook:Enabled", "false"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:GraphJobs:Enabled", "false"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:Enabled", "true"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:HmacSecret", "feedser-secret"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:RateLimitRequests", "20"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Feedser:RateLimitWindowSeconds", "60"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:Enabled", "true"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:HmacSecret", "vexer-secret"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:RateLimitRequests", "20"),
|
||||
new KeyValuePair<string, string?>("Scheduler:Events:Webhooks:Vexer:RateLimitWindowSeconds", "60")
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.Configure<SchedulerEventsOptions>(options =>
|
||||
{
|
||||
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
|
||||
options.Webhooks.Feedser ??= SchedulerWebhookOptions.CreateDefault("feedser");
|
||||
options.Webhooks.Vexer ??= SchedulerWebhookOptions.CreateDefault("vexer");
|
||||
options.Webhooks.Feedser.HmacSecret = "feedser-secret";
|
||||
options.Webhooks.Feedser.Enabled = true;
|
||||
options.Webhooks.Vexer.HmacSecret = "vexer-secret";
|
||||
options.Webhooks.Vexer.Enabled = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
global using System.Collections.Immutable;
|
||||
global using StellaOps.Scheduler.ImpactIndex;
|
||||
global using StellaOps.Scheduler.Models;
|
||||
global using StellaOps.Scheduler.Worker;
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class GraphBuildExecutionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository();
|
||||
var cartographer = new StubCartographerBuildClient();
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
|
||||
|
||||
var job = CreateGraphJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
|
||||
Assert.Equal("graph_processing_disabled", result.Reason);
|
||||
Assert.Equal(0, repository.ReplaceCalls);
|
||||
Assert.Equal(0, cartographer.CallCount);
|
||||
Assert.Empty(completion.Notifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_CompletesJob_OnSuccess()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository();
|
||||
var cartographer = new StubCartographerBuildClient
|
||||
{
|
||||
Result = new CartographerBuildResult(
|
||||
GraphJobStatus.Completed,
|
||||
CartographerJobId: "carto-1",
|
||||
GraphSnapshotId: "graph_snap",
|
||||
ResultUri: "oras://graph/result",
|
||||
Error: null)
|
||||
};
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 2,
|
||||
RetryBackoff = TimeSpan.FromMilliseconds(10)
|
||||
}
|
||||
});
|
||||
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
|
||||
|
||||
var job = CreateGraphJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphBuildExecutionResultType.Completed, result.Type);
|
||||
Assert.Single(completion.Notifications);
|
||||
var notification = completion.Notifications[0];
|
||||
Assert.Equal(job.Id, notification.JobId);
|
||||
Assert.Equal("Build", notification.JobType);
|
||||
Assert.Equal(GraphJobStatus.Completed, notification.Status);
|
||||
Assert.Equal("oras://graph/result", notification.ResultUri);
|
||||
Assert.Equal("graph_snap", notification.GraphSnapshotId);
|
||||
Assert.Null(notification.Error);
|
||||
Assert.Equal(1, cartographer.CallCount);
|
||||
Assert.True(repository.ReplaceCalls >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Fails_AfterMaxAttempts()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository();
|
||||
var cartographer = new StubCartographerBuildClient
|
||||
{
|
||||
ExceptionToThrow = new InvalidOperationException("network")
|
||||
};
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 2,
|
||||
RetryBackoff = TimeSpan.FromMilliseconds(1)
|
||||
}
|
||||
});
|
||||
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
|
||||
|
||||
var job = CreateGraphJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphBuildExecutionResultType.Failed, result.Type);
|
||||
Assert.Equal(2, cartographer.CallCount);
|
||||
Assert.Single(completion.Notifications);
|
||||
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
|
||||
Assert.Equal("network", completion.Notifications[0].Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository
|
||||
{
|
||||
ShouldReplaceSucceed = false
|
||||
};
|
||||
var cartographer = new StubCartographerBuildClient();
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = true
|
||||
}
|
||||
});
|
||||
var service = new GraphBuildExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphBuildExecutionService>.Instance);
|
||||
|
||||
var job = CreateGraphJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphBuildExecutionResultType.Skipped, result.Type);
|
||||
Assert.Equal("concurrency_conflict", result.Reason);
|
||||
Assert.Equal(0, cartographer.CallCount);
|
||||
Assert.Empty(completion.Notifications);
|
||||
}
|
||||
|
||||
private static GraphBuildJob CreateGraphJob() => new(
|
||||
id: "gbj_1",
|
||||
tenantId: "tenant-alpha",
|
||||
sbomId: "sbom-1",
|
||||
sbomVersionId: "sbom-1-v1",
|
||||
sbomDigest: "sha256:" + new string('a', 64),
|
||||
status: GraphJobStatus.Pending,
|
||||
trigger: GraphBuildJobTrigger.SbomVersion,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
attempts: 0,
|
||||
metadata: Array.Empty<KeyValuePair<string, string>>());
|
||||
|
||||
private sealed class RecordingGraphJobRepository : IGraphJobRepository
|
||||
{
|
||||
public int ReplaceCalls { get; private set; }
|
||||
|
||||
public bool ShouldReplaceSucceed { get; set; } = true;
|
||||
|
||||
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ShouldReplaceSucceed)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
ReplaceCalls++;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class StubCartographerBuildClient : ICartographerBuildClient
|
||||
{
|
||||
public CartographerBuildResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null, null);
|
||||
|
||||
public Exception? ExceptionToThrow { get; set; }
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<CartographerBuildResult> StartBuildAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
|
||||
{
|
||||
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
|
||||
|
||||
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
Notifications.Add(request);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class GraphOverlayExecutionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Skips_WhenGraphDisabled()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository();
|
||||
var cartographer = new StubCartographerOverlayClient();
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
|
||||
|
||||
var job = CreateOverlayJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
|
||||
Assert.Equal("graph_processing_disabled", result.Reason);
|
||||
Assert.Empty(completion.Notifications);
|
||||
Assert.Equal(0, cartographer.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_CompletesJob_OnSuccess()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository();
|
||||
var cartographer = new StubCartographerOverlayClient
|
||||
{
|
||||
Result = new CartographerOverlayResult(
|
||||
GraphJobStatus.Completed,
|
||||
GraphSnapshotId: "graph_snap_2",
|
||||
ResultUri: "oras://graph/overlay",
|
||||
Error: null)
|
||||
};
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 2,
|
||||
RetryBackoff = TimeSpan.FromMilliseconds(5)
|
||||
}
|
||||
});
|
||||
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
|
||||
|
||||
var job = CreateOverlayJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphOverlayExecutionResultType.Completed, result.Type);
|
||||
Assert.Single(completion.Notifications);
|
||||
var notification = completion.Notifications[0];
|
||||
Assert.Equal("Overlay", notification.JobType);
|
||||
Assert.Equal(GraphJobStatus.Completed, notification.Status);
|
||||
Assert.Equal("oras://graph/overlay", notification.ResultUri);
|
||||
Assert.Equal("graph_snap_2", notification.GraphSnapshotId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Fails_AfterRetries()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository();
|
||||
var cartographer = new StubCartographerOverlayClient
|
||||
{
|
||||
ExceptionToThrow = new InvalidOperationException("overlay failed")
|
||||
};
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 2,
|
||||
RetryBackoff = TimeSpan.FromMilliseconds(1)
|
||||
}
|
||||
});
|
||||
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
|
||||
|
||||
var job = CreateOverlayJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphOverlayExecutionResultType.Failed, result.Type);
|
||||
Assert.Single(completion.Notifications);
|
||||
Assert.Equal(GraphJobStatus.Failed, completion.Notifications[0].Status);
|
||||
Assert.Equal("overlay failed", completion.Notifications[0].Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Skips_WhenConcurrencyConflict()
|
||||
{
|
||||
var repository = new RecordingGraphJobRepository
|
||||
{
|
||||
ShouldReplaceSucceed = false
|
||||
};
|
||||
var cartographer = new StubCartographerOverlayClient();
|
||||
var completion = new RecordingCompletionClient();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions
|
||||
{
|
||||
Graph = new SchedulerWorkerOptions.GraphOptions
|
||||
{
|
||||
Enabled = true
|
||||
}
|
||||
});
|
||||
var service = new GraphOverlayExecutionService(repository, cartographer, completion, options, metrics, TimeProvider.System, NullLogger<GraphOverlayExecutionService>.Instance);
|
||||
|
||||
var job = CreateOverlayJob();
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GraphOverlayExecutionResultType.Skipped, result.Type);
|
||||
Assert.Equal("concurrency_conflict", result.Reason);
|
||||
Assert.Empty(completion.Notifications);
|
||||
Assert.Equal(0, cartographer.CallCount);
|
||||
}
|
||||
|
||||
private static GraphOverlayJob CreateOverlayJob() => new(
|
||||
id: "goj_1",
|
||||
tenantId: "tenant-alpha",
|
||||
graphSnapshotId: "snap-1",
|
||||
overlayKind: GraphOverlayKind.Policy,
|
||||
overlayKey: "policy@1",
|
||||
status: GraphJobStatus.Pending,
|
||||
trigger: GraphOverlayJobTrigger.Policy,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
subjects: Array.Empty<string>(),
|
||||
attempts: 0,
|
||||
metadata: Array.Empty<KeyValuePair<string, string>>());
|
||||
|
||||
private sealed class RecordingGraphJobRepository : IGraphJobRepository
|
||||
{
|
||||
public bool ShouldReplaceSucceed { get; set; } = true;
|
||||
|
||||
public int RunningReplacements { get; private set; }
|
||||
|
||||
public Task<bool> TryReplaceOverlayAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ShouldReplaceSucceed)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
RunningReplacements++;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> TryReplaceAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GraphBuildJob> ReplaceAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GraphOverlayJob> ReplaceAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task InsertAsync(GraphBuildJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task InsertAsync(GraphOverlayJob job, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphBuildJob>> ListBuildJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<GraphOverlayJob>> ListOverlayJobsAsync(GraphJobStatus? status, int limit, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyCollection<GraphOverlayJob>> ListOverlayJobsAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class StubCartographerOverlayClient : ICartographerOverlayClient
|
||||
{
|
||||
public CartographerOverlayResult Result { get; set; } = new(GraphJobStatus.Completed, null, null, null);
|
||||
|
||||
public Exception? ExceptionToThrow { get; set; }
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<CartographerOverlayResult> StartOverlayAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingCompletionClient : IGraphJobCompletionClient
|
||||
{
|
||||
public List<GraphJobCompletionRequestDto> Notifications { get; } = new();
|
||||
|
||||
public Task NotifyAsync(GraphJobCompletionRequestDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
Notifications.Add(request);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Execution;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class HttpScannerReportClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenReportReturnsFindings_ProducesDeltaSummary()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(request =>
|
||||
{
|
||||
if (request.RequestUri?.AbsolutePath.EndsWith("/api/v1/reports", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
report = new
|
||||
{
|
||||
reportId = "report-123",
|
||||
imageDigest = "sha256:abc",
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
verdict = "warn",
|
||||
policy = new { revisionId = "rev-1", digest = "digest-1" },
|
||||
summary = new { total = 3, blocked = 2, warned = 1, ignored = 0, quieted = 0 }
|
||||
},
|
||||
dsse = new
|
||||
{
|
||||
payloadType = "application/vnd.in-toto+json",
|
||||
payload = "eyJkYXRhIjoidGVzdCJ9",
|
||||
signatures = new[] { new { keyId = "test", algorithm = "ed25519", signature = "c2ln" } }
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(payload)
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.example")
|
||||
};
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions());
|
||||
options.Value.Runner.Scanner.BaseAddress = httpClient.BaseAddress;
|
||||
options.Value.Runner.Scanner.EnableContentRefresh = false;
|
||||
|
||||
var client = new HttpScannerReportClient(httpClient, options, NullLogger<HttpScannerReportClient>.Instance);
|
||||
|
||||
var result = await client.ExecuteAsync(
|
||||
new ScannerReportRequest("tenant-1", "run-1", "sha256:abc", ScheduleMode.AnalysisOnly, true, new Dictionary<string, string>()),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("sha256:abc", result.ImageDigest);
|
||||
Assert.NotNull(result.Delta);
|
||||
Assert.Equal(3, result.Delta!.NewFindings);
|
||||
Assert.Equal(2, result.Delta.NewCriticals);
|
||||
Assert.Equal(1, result.Delta.NewHigh);
|
||||
Assert.Equal(0, result.Delta.NewMedium);
|
||||
Assert.Equal(0, result.Delta.NewLow);
|
||||
Assert.Equal("report-123", result.Report.ReportId);
|
||||
Assert.Equal("rev-1", result.Report.PolicyRevisionId);
|
||||
Assert.NotNull(result.Dsse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenReportFails_RetriesAndThrows()
|
||||
{
|
||||
var callCount = 0;
|
||||
var handler = new StubHttpMessageHandler(_ =>
|
||||
{
|
||||
callCount++;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://scanner.example")
|
||||
};
|
||||
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SchedulerWorkerOptions());
|
||||
options.Value.Runner.Scanner.BaseAddress = httpClient.BaseAddress;
|
||||
options.Value.Runner.Scanner.EnableContentRefresh = false;
|
||||
options.Value.Runner.Scanner.MaxRetryAttempts = 2;
|
||||
options.Value.Runner.Scanner.RetryBaseDelay = TimeSpan.FromMilliseconds(1);
|
||||
|
||||
var client = new HttpScannerReportClient(httpClient, options, NullLogger<HttpScannerReportClient>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.ExecuteAsync(
|
||||
new ScannerReportRequest("tenant-1", "run-1", "sha256:abc", ScheduleMode.AnalysisOnly, true, new Dictionary<string, string>()),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(3, callCount);
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> _handler;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> _handler(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class ImpactShardPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlanShards_ReturnsSingleShardWhenParallelismNotSpecified()
|
||||
{
|
||||
var impactSet = CreateImpactSet(count: 3);
|
||||
var planner = new ImpactShardPlanner();
|
||||
|
||||
var shards = planner.PlanShards(impactSet, maxJobs: null, parallelism: null);
|
||||
|
||||
Assert.Single(shards);
|
||||
Assert.Equal(3, shards[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanShards_RespectsMaxJobsLimit()
|
||||
{
|
||||
var impactSet = CreateImpactSet(count: 5);
|
||||
var planner = new ImpactShardPlanner();
|
||||
|
||||
var shards = planner.PlanShards(impactSet, maxJobs: 2, parallelism: 4);
|
||||
|
||||
Assert.Equal(2, shards.Sum(shard => shard.Count));
|
||||
Assert.True(shards.All(shard => shard.Count <= 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanShards_DistributesImagesEvenly()
|
||||
{
|
||||
var impactSet = CreateImpactSet(count: 10);
|
||||
var planner = new ImpactShardPlanner();
|
||||
|
||||
var shards = planner.PlanShards(impactSet, maxJobs: null, parallelism: 3);
|
||||
|
||||
Assert.Equal(3, shards.Length);
|
||||
var counts = shards.Select(shard => shard.Count).OrderBy(count => count).ToArray();
|
||||
Assert.Equal(new[] {3, 3, 4}, counts);
|
||||
|
||||
var flattened = shards.SelectMany(shard => shard.Images).ToArray();
|
||||
Assert.Equal(impactSet.Images, flattened, ImpactImageEqualityComparer.Instance);
|
||||
}
|
||||
|
||||
private static ImpactSet CreateImpactSet(int count)
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var images = Enumerable.Range(0, count)
|
||||
.Select(i => new ImpactImage(
|
||||
$"sha256:{i:D64}",
|
||||
"registry",
|
||||
"repo/app",
|
||||
namespaces: new[] { "team" },
|
||||
tags: new[] { "prod" },
|
||||
usedByEntrypoint: true))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ImpactSet(selector, images, usageOnly: true, DateTimeOffset.UtcNow, total: count, snapshotId: null, schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
private sealed class ImpactImageEqualityComparer : IEqualityComparer<ImpactImage>
|
||||
{
|
||||
public static ImpactImageEqualityComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(ImpactImage? x, ImpactImage? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.ImageDigest, y.ImageDigest, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode(ImpactImage obj)
|
||||
=> StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ImageDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class ImpactTargetingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolveByPurlsAsync_DeduplicatesKeysAndInvokesIndex()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var expected = CreateEmptyImpactSet(selector, usageOnly: false);
|
||||
IEnumerable<string>? capturedKeys = null;
|
||||
|
||||
var index = new StubImpactIndex
|
||||
{
|
||||
OnResolveByPurls = (purls, usageOnly, sel, _) =>
|
||||
{
|
||||
capturedKeys = purls.ToArray();
|
||||
Assert.False(usageOnly);
|
||||
Assert.Equal(selector, sel);
|
||||
return ValueTask.FromResult(expected);
|
||||
}
|
||||
};
|
||||
|
||||
var service = new ImpactTargetingService(index);
|
||||
|
||||
var result = await service.ResolveByPurlsAsync(
|
||||
new[] { "pkg:npm/a", "pkg:npm/A ", null!, "pkg:npm/b" },
|
||||
usageOnly: false,
|
||||
selector);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
Assert.Equal(new[] { "pkg:npm/a", "pkg:npm/b" }, capturedKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveByVulnerabilitiesAsync_ReturnsEmptyWhenNoIds()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var index = new StubImpactIndex();
|
||||
var service = new ImpactTargetingService(index);
|
||||
|
||||
var result = await service.ResolveByVulnerabilitiesAsync(Array.Empty<string>(), usageOnly: true, selector);
|
||||
|
||||
Assert.Empty(result.Images);
|
||||
Assert.True(result.UsageOnly);
|
||||
Assert.Null(index.LastVulnerabilityIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAllAsync_DelegatesToIndex()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var expected = CreateEmptyImpactSet(selector, usageOnly: true);
|
||||
|
||||
var index = new StubImpactIndex
|
||||
{
|
||||
OnResolveAll = (sel, usageOnly, _) =>
|
||||
{
|
||||
Assert.Equal(selector, sel);
|
||||
Assert.True(usageOnly);
|
||||
return ValueTask.FromResult(expected);
|
||||
}
|
||||
};
|
||||
|
||||
var service = new ImpactTargetingService(index);
|
||||
var result = await service.ResolveAllAsync(selector, usageOnly: true);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveByPurlsAsync_DeduplicatesImpactImagesByDigest()
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
|
||||
var indexResult = new ImpactSet(
|
||||
selector,
|
||||
new[]
|
||||
{
|
||||
new ImpactImage(
|
||||
"sha256:111",
|
||||
"registry-1",
|
||||
"repo/app",
|
||||
namespaces: new[] { "team-a" },
|
||||
tags: new[] { "v1" },
|
||||
usedByEntrypoint: false,
|
||||
labels: new[] { KeyValuePair.Create("env", "prod") }),
|
||||
new ImpactImage(
|
||||
"sha256:111",
|
||||
"registry-1",
|
||||
"repo/app",
|
||||
namespaces: new[] { "team-b" },
|
||||
tags: new[] { "v2" },
|
||||
usedByEntrypoint: true,
|
||||
labels: new[]
|
||||
{
|
||||
KeyValuePair.Create("env", "prod"),
|
||||
KeyValuePair.Create("component", "api")
|
||||
})
|
||||
},
|
||||
usageOnly: false,
|
||||
DateTimeOffset.UtcNow,
|
||||
total: 2,
|
||||
snapshotId: "snap-1",
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
|
||||
var index = new StubImpactIndex
|
||||
{
|
||||
OnResolveByPurls = (_, _, _, _) => ValueTask.FromResult(indexResult)
|
||||
};
|
||||
|
||||
var service = new ImpactTargetingService(index);
|
||||
var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: false, selector);
|
||||
|
||||
Assert.Single(result.Images);
|
||||
var image = result.Images[0];
|
||||
Assert.Equal("sha256:111", image.ImageDigest);
|
||||
Assert.Equal(new[] { "team-a", "team-b" }, image.Namespaces);
|
||||
Assert.Equal(new[] { "v1", "v2" }, image.Tags);
|
||||
Assert.True(image.UsedByEntrypoint);
|
||||
Assert.Equal("registry-1", image.Registry);
|
||||
Assert.Equal("repo/app", image.Repository);
|
||||
Assert.Equal(2, result.Total);
|
||||
Assert.Equal("prod", image.Labels["env"]);
|
||||
Assert.Equal("api", image.Labels["component"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveByPurlsAsync_FiltersImagesBySelectorConstraints()
|
||||
{
|
||||
var selector = new Selector(
|
||||
SelectorScope.ByNamespace,
|
||||
tenantId: "tenant-alpha",
|
||||
namespaces: new[] { "team-a" },
|
||||
includeTags: new[] { "prod-*" },
|
||||
labels: new[] { new LabelSelector("env", new[] { "prod" }) });
|
||||
|
||||
var matching = new ImpactImage(
|
||||
"sha256:aaa",
|
||||
"registry-1",
|
||||
"repo/app",
|
||||
namespaces: new[] { "team-a" },
|
||||
tags: new[] { "prod-202510" },
|
||||
usedByEntrypoint: true,
|
||||
labels: new[] { KeyValuePair.Create("env", "prod") });
|
||||
|
||||
var nonMatching = new ImpactImage(
|
||||
"sha256:bbb",
|
||||
"registry-1",
|
||||
"repo/app",
|
||||
namespaces: new[] { "team-b" },
|
||||
tags: new[] { "dev" },
|
||||
usedByEntrypoint: false,
|
||||
labels: new[] { KeyValuePair.Create("env", "dev") });
|
||||
|
||||
var indexResult = new ImpactSet(
|
||||
selector,
|
||||
new[] { matching, nonMatching },
|
||||
usageOnly: true,
|
||||
DateTimeOffset.UtcNow,
|
||||
total: 2,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
|
||||
var index = new StubImpactIndex
|
||||
{
|
||||
OnResolveByPurls = (_, _, _, _) => ValueTask.FromResult(indexResult)
|
||||
};
|
||||
|
||||
var service = new ImpactTargetingService(index);
|
||||
var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: true, selector);
|
||||
|
||||
Assert.Single(result.Images);
|
||||
Assert.Equal("sha256:aaa", result.Images[0].ImageDigest);
|
||||
}
|
||||
|
||||
private static ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
|
||||
=> new(
|
||||
selector,
|
||||
ImmutableArray<ImpactImage>.Empty,
|
||||
usageOnly,
|
||||
DateTimeOffset.UtcNow,
|
||||
0,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
|
||||
private sealed class StubImpactIndex : IImpactIndex
|
||||
{
|
||||
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByPurls { get; set; }
|
||||
|
||||
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByVulnerabilities { get; set; }
|
||||
|
||||
public Func<Selector, bool, CancellationToken, ValueTask<ImpactSet>>? OnResolveAll { get; set; }
|
||||
|
||||
public IEnumerable<string>? LastVulnerabilityIds { get; private set; }
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> purls, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
=> OnResolveByPurls?.Invoke(purls, usageOnly, selector, cancellationToken)
|
||||
?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastVulnerabilityIds = vulnerabilityIds;
|
||||
return OnResolveByVulnerabilities?.Invoke(vulnerabilityIds, usageOnly, selector, cancellationToken)
|
||||
?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
}
|
||||
|
||||
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
|
||||
=> OnResolveAll?.Invoke(selector, usageOnly, cancellationToken)
|
||||
?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Projections;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using StellaOps.Scheduler.Worker.Planning;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class PlannerBackgroundServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RespectsTenantFairnessCap()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T12:00:00Z"));
|
||||
|
||||
var runs = new[]
|
||||
{
|
||||
CreateRun("run-a1", "tenant-a", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(1), "schedule-a"),
|
||||
CreateRun("run-a2", "tenant-a", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(2), "schedule-a"),
|
||||
CreateRun("run-b1", "tenant-b", RunTrigger.Feedser, timeProvider.GetUtcNow().AddMinutes(3), "schedule-b"),
|
||||
CreateRun("run-c1", "tenant-c", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(4), "schedule-c"),
|
||||
};
|
||||
|
||||
var repository = new TestRunRepository(runs, Array.Empty<Run>());
|
||||
var options = CreateOptions(maxConcurrentTenants: 2);
|
||||
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
|
||||
var snapshotRepository = new StubImpactSnapshotRepository();
|
||||
var runSummaryService = new StubRunSummaryService(timeProvider);
|
||||
var plannerQueue = new RecordingPlannerQueue();
|
||||
var targetingService = new StubImpactTargetingService(timeProvider);
|
||||
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var executionService = new PlannerExecutionService(
|
||||
scheduleRepository,
|
||||
repository,
|
||||
snapshotRepository,
|
||||
runSummaryService,
|
||||
targetingService,
|
||||
plannerQueue,
|
||||
options,
|
||||
timeProvider,
|
||||
metrics,
|
||||
NullLogger<PlannerExecutionService>.Instance);
|
||||
|
||||
var service = new PlannerBackgroundService(
|
||||
repository,
|
||||
executionService,
|
||||
options,
|
||||
timeProvider,
|
||||
NullLogger<PlannerBackgroundService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForConditionAsync(() => repository.UpdateCount >= 2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
|
||||
Assert.Equal(new[] { "run-a1", "run-b1" }, processedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PrioritizesManualAndEventTriggers()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-27T18:00:00Z"));
|
||||
|
||||
var runs = new[]
|
||||
{
|
||||
CreateRun("run-cron", "tenant-alpha", RunTrigger.Cron, timeProvider.GetUtcNow().AddMinutes(1), "schedule-cron"),
|
||||
CreateRun("run-feedser", "tenant-bravo", RunTrigger.Feedser, timeProvider.GetUtcNow().AddMinutes(2), "schedule-feedser"),
|
||||
CreateRun("run-manual", "tenant-charlie", RunTrigger.Manual, timeProvider.GetUtcNow().AddMinutes(3), "schedule-manual"),
|
||||
CreateRun("run-vexer", "tenant-delta", RunTrigger.Vexer, timeProvider.GetUtcNow().AddMinutes(4), "schedule-vexer"),
|
||||
};
|
||||
|
||||
var repository = new TestRunRepository(runs, Array.Empty<Run>());
|
||||
var options = CreateOptions(maxConcurrentTenants: 4);
|
||||
var scheduleRepository = new TestScheduleRepository(runs.Select(run => CreateSchedule(run.ScheduleId!, run.TenantId, timeProvider.GetUtcNow())));
|
||||
var snapshotRepository = new StubImpactSnapshotRepository();
|
||||
var runSummaryService = new StubRunSummaryService(timeProvider);
|
||||
var plannerQueue = new RecordingPlannerQueue();
|
||||
var targetingService = new StubImpactTargetingService(timeProvider);
|
||||
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var executionService = new PlannerExecutionService(
|
||||
scheduleRepository,
|
||||
repository,
|
||||
snapshotRepository,
|
||||
runSummaryService,
|
||||
targetingService,
|
||||
plannerQueue,
|
||||
options,
|
||||
timeProvider,
|
||||
metrics,
|
||||
NullLogger<PlannerExecutionService>.Instance);
|
||||
|
||||
var service = new PlannerBackgroundService(
|
||||
repository,
|
||||
executionService,
|
||||
options,
|
||||
timeProvider,
|
||||
NullLogger<PlannerBackgroundService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForConditionAsync(() => repository.UpdateCount >= runs.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var processedIds = repository.UpdatedRuns.Select(run => run.Id).ToArray();
|
||||
Assert.Equal(new[] { "run-manual", "run-feedser", "run-vexer", "run-cron" }, processedIds);
|
||||
}
|
||||
|
||||
private static SchedulerWorkerOptions CreateOptions(int maxConcurrentTenants)
|
||||
{
|
||||
return new SchedulerWorkerOptions
|
||||
{
|
||||
Planner =
|
||||
{
|
||||
BatchSize = 20,
|
||||
PollInterval = TimeSpan.FromMilliseconds(1),
|
||||
IdleDelay = TimeSpan.FromMilliseconds(1),
|
||||
MaxConcurrentTenants = maxConcurrentTenants,
|
||||
MaxRunsPerMinute = int.MaxValue,
|
||||
QueueLeaseDuration = TimeSpan.FromMinutes(5)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Run CreateRun(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunTrigger trigger,
|
||||
DateTimeOffset createdAt,
|
||||
string scheduleId)
|
||||
=> new(
|
||||
id: id,
|
||||
tenantId: tenantId,
|
||||
trigger: trigger,
|
||||
state: RunState.Planning,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: createdAt,
|
||||
reason: RunReason.Empty,
|
||||
scheduleId: scheduleId);
|
||||
|
||||
private static Schedule CreateSchedule(string scheduleId, string tenantId, DateTimeOffset now)
|
||||
=> new(
|
||||
id: scheduleId,
|
||||
tenantId: tenantId,
|
||||
name: $"Schedule-{scheduleId}",
|
||||
enabled: true,
|
||||
cronExpression: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: new Selector(SelectorScope.AllImages, tenantId),
|
||||
onlyIf: ScheduleOnlyIf.Default,
|
||||
notify: ScheduleNotify.Default,
|
||||
limits: ScheduleLimits.Default,
|
||||
createdAt: now,
|
||||
createdBy: "system",
|
||||
updatedAt: now,
|
||||
updatedBy: "system",
|
||||
subscribers: ImmutableArray<string>.Empty);
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> predicate, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(1));
|
||||
while (!predicate())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
{
|
||||
throw new TimeoutException("Planner background service did not reach expected state within the allotted time.");
|
||||
}
|
||||
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestRunRepository : IRunRepository
|
||||
{
|
||||
private readonly Queue<IReadOnlyList<Run>> _responses;
|
||||
private readonly ConcurrentQueue<Run> _updates = new();
|
||||
private int _updateCount;
|
||||
|
||||
public TestRunRepository(params IReadOnlyList<Run>[] responses)
|
||||
{
|
||||
if (responses is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(responses));
|
||||
}
|
||||
|
||||
_responses = new Queue<IReadOnlyList<Run>>(responses.Select(static runs => (IReadOnlyList<Run>)runs.ToArray()));
|
||||
}
|
||||
|
||||
public int UpdateCount => Volatile.Read(ref _updateCount);
|
||||
|
||||
public IReadOnlyList<Run> UpdatedRuns => _updates.ToArray();
|
||||
|
||||
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_updates.Enqueue(run);
|
||||
Interlocked.Increment(ref _updateCount);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Run?>(null);
|
||||
|
||||
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
|
||||
|
||||
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (state != RunState.Planning)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<Run>>(Array.Empty<Run>());
|
||||
}
|
||||
|
||||
var next = _responses.Count > 0 ? _responses.Dequeue() : Array.Empty<Run>();
|
||||
|
||||
if (next.Count > limit)
|
||||
{
|
||||
next = next.Take(limit).ToArray();
|
||||
}
|
||||
|
||||
return Task.FromResult(next);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestScheduleRepository : IScheduleRepository
|
||||
{
|
||||
public TestScheduleRepository(IEnumerable<Schedule> schedules)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedules);
|
||||
|
||||
_schedules = new Dictionary<(string TenantId, string ScheduleId), Schedule>();
|
||||
foreach (var schedule in schedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Dictionary<(string TenantId, string ScheduleId), Schedule> _schedules;
|
||||
|
||||
public Task UpsertAsync(Schedule schedule, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_schedules[(schedule.TenantId, schedule.Id)] = schedule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Schedule?> GetAsync(string tenantId, string scheduleId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_schedules.TryGetValue((tenantId, scheduleId), out var schedule);
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Schedule>> ListAsync(string tenantId, ScheduleQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = _schedules.Values.Where(schedule => schedule.TenantId == tenantId).ToArray();
|
||||
return Task.FromResult<IReadOnlyList<Schedule>>(results);
|
||||
}
|
||||
|
||||
public Task<bool> SoftDeleteAsync(string tenantId, string scheduleId, string deletedBy, DateTimeOffset deletedAt, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var removed = _schedules.Remove((tenantId, scheduleId));
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubImpactSnapshotRepository : IImpactSnapshotRepository
|
||||
{
|
||||
public ImpactSet? LastSnapshot { get; private set; }
|
||||
|
||||
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastSnapshot = snapshot;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ImpactSet?>(null);
|
||||
|
||||
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ImpactSet?>(null);
|
||||
}
|
||||
|
||||
private sealed class StubRunSummaryService : IRunSummaryService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public StubRunSummaryService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var projection = new RunSummaryProjection(
|
||||
run.TenantId,
|
||||
run.ScheduleId ?? string.Empty,
|
||||
_timeProvider.GetUtcNow(),
|
||||
null,
|
||||
ImmutableArray<RunSummarySnapshot>.Empty,
|
||||
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
|
||||
|
||||
return Task.FromResult(projection);
|
||||
}
|
||||
|
||||
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<RunSummaryProjection?>(null);
|
||||
|
||||
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
|
||||
}
|
||||
|
||||
private sealed class StubImpactTargetingService : IImpactTargetingService
|
||||
{
|
||||
private static readonly string DefaultDigest = "sha256:" + new string('a', 64);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public StubImpactTargetingService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var image = new ImpactImage(
|
||||
DefaultDigest,
|
||||
registry: "registry.test",
|
||||
repository: "repo/sample",
|
||||
namespaces: new[] { selector.TenantId ?? "unknown" },
|
||||
tags: new[] { "latest" },
|
||||
usedByEntrypoint: true);
|
||||
|
||||
var impactSet = new ImpactSet(
|
||||
selector,
|
||||
ImmutableArray.Create(image),
|
||||
usageOnly,
|
||||
_timeProvider.GetUtcNow(),
|
||||
total: 1,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
|
||||
return ValueTask.FromResult(impactSet);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingPlannerQueue : ISchedulerPlannerQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<PlannerQueueMessage> _messages = new();
|
||||
|
||||
public IReadOnlyList<PlannerQueueMessage> Messages => _messages.ToArray();
|
||||
|
||||
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(PlannerQueueMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_messages.Enqueue(message);
|
||||
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(message.Run.Id, Deduplicated: false));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>>(Array.Empty<ISchedulerQueueLease<PlannerQueueMessage>>());
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset initial)
|
||||
{
|
||||
_now = initial;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Driver;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Projections;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Planning;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class PlannerExecutionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessAsync_WithImpactedImages_QueuesPlannerMessage()
|
||||
{
|
||||
var schedule = CreateSchedule();
|
||||
var run = CreateRun(schedule.Id);
|
||||
var impactSet = CreateImpactSet(schedule.Selection, images: 2);
|
||||
|
||||
var scheduleRepository = new StubScheduleRepository(schedule);
|
||||
var runRepository = new InMemoryRunRepository(run);
|
||||
var snapshotRepository = new RecordingImpactSnapshotRepository();
|
||||
var runSummaryService = new RecordingRunSummaryService();
|
||||
var targetingService = new StubImpactTargetingService(impactSet);
|
||||
var plannerQueue = new RecordingPlannerQueue();
|
||||
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
|
||||
var service = new PlannerExecutionService(
|
||||
scheduleRepository,
|
||||
runRepository,
|
||||
snapshotRepository,
|
||||
runSummaryService,
|
||||
targetingService,
|
||||
plannerQueue,
|
||||
new SchedulerWorkerOptions(),
|
||||
TimeProvider.System,
|
||||
metrics,
|
||||
NullLogger<PlannerExecutionService>.Instance);
|
||||
|
||||
var result = await service.ProcessAsync(run, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlannerExecutionStatus.Enqueued, result.Status);
|
||||
Assert.Single(plannerQueue.Messages);
|
||||
Assert.NotNull(snapshotRepository.LastSnapshot);
|
||||
Assert.Equal(RunState.Queued, result.UpdatedRun!.State);
|
||||
Assert.Equal(impactSet.Images.Length, result.UpdatedRun.Stats.Queued);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_WithNoImpactedImages_CompletesWithoutWork()
|
||||
{
|
||||
var schedule = CreateSchedule();
|
||||
var run = CreateRun(schedule.Id);
|
||||
var impactSet = CreateImpactSet(schedule.Selection, images: 0);
|
||||
|
||||
var service = CreateService(schedule, run, impactSet, out var plannerQueue);
|
||||
|
||||
var result = await service.ProcessAsync(run, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlannerExecutionStatus.CompletedWithoutWork, result.Status);
|
||||
Assert.Equal(RunState.Completed, result.UpdatedRun!.State);
|
||||
Assert.Empty(plannerQueue.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_WhenScheduleMissing_MarksRunAsFailed()
|
||||
{
|
||||
var run = CreateRun(scheduleId: "missing");
|
||||
var scheduleRepository = new StubScheduleRepository(); // empty repository
|
||||
var runRepository = new InMemoryRunRepository(run);
|
||||
var snapshotRepository = new RecordingImpactSnapshotRepository();
|
||||
var runSummaryService = new RecordingRunSummaryService();
|
||||
var targetingService = new StubImpactTargetingService(CreateImpactSet(new Selector(SelectorScope.AllImages, run.TenantId), 0));
|
||||
var plannerQueue = new RecordingPlannerQueue();
|
||||
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
|
||||
var service = new PlannerExecutionService(
|
||||
scheduleRepository,
|
||||
runRepository,
|
||||
snapshotRepository,
|
||||
runSummaryService,
|
||||
targetingService,
|
||||
plannerQueue,
|
||||
new SchedulerWorkerOptions(),
|
||||
TimeProvider.System,
|
||||
metrics,
|
||||
NullLogger<PlannerExecutionService>.Instance);
|
||||
|
||||
var result = await service.ProcessAsync(run, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlannerExecutionStatus.Failed, result.Status);
|
||||
Assert.Equal(RunState.Error, result.UpdatedRun!.State);
|
||||
Assert.Empty(plannerQueue.Messages);
|
||||
}
|
||||
|
||||
private static PlannerExecutionService CreateService(
|
||||
Schedule schedule,
|
||||
Run run,
|
||||
ImpactSet impactSet,
|
||||
out RecordingPlannerQueue plannerQueue)
|
||||
{
|
||||
var scheduleRepository = new StubScheduleRepository(schedule);
|
||||
var runRepository = new InMemoryRunRepository(run);
|
||||
var snapshotRepository = new RecordingImpactSnapshotRepository();
|
||||
var runSummaryService = new RecordingRunSummaryService();
|
||||
var targetingService = new StubImpactTargetingService(impactSet);
|
||||
plannerQueue = new RecordingPlannerQueue();
|
||||
|
||||
return new PlannerExecutionService(
|
||||
scheduleRepository,
|
||||
runRepository,
|
||||
snapshotRepository,
|
||||
runSummaryService,
|
||||
targetingService,
|
||||
plannerQueue,
|
||||
new SchedulerWorkerOptions(),
|
||||
TimeProvider.System,
|
||||
new SchedulerWorkerMetrics(),
|
||||
NullLogger<PlannerExecutionService>.Instance);
|
||||
}
|
||||
|
||||
private static Run CreateRun(string scheduleId)
|
||||
=> new(
|
||||
id: "run_001",
|
||||
tenantId: "tenant-alpha",
|
||||
trigger: RunTrigger.Cron,
|
||||
state: RunState.Planning,
|
||||
stats: RunStats.Empty,
|
||||
createdAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
scheduleId: scheduleId);
|
||||
|
||||
private static Schedule CreateSchedule()
|
||||
=> new(
|
||||
id: "sch_001",
|
||||
tenantId: "tenant-alpha",
|
||||
name: "Nightly",
|
||||
enabled: true,
|
||||
cronExpression: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
|
||||
onlyIf: ScheduleOnlyIf.Default,
|
||||
notify: ScheduleNotify.Default,
|
||||
limits: ScheduleLimits.Default,
|
||||
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
createdBy: "system",
|
||||
updatedAt: DateTimeOffset.UtcNow.AddHours(-1),
|
||||
updatedBy: "system",
|
||||
subscribers: ImmutableArray<string>.Empty);
|
||||
|
||||
private static ImpactSet CreateImpactSet(Selector selector, int images)
|
||||
{
|
||||
var imageList = Enumerable.Range(0, images)
|
||||
.Select(index => new ImpactImage(
|
||||
imageDigest: $"sha256:{index:D64}",
|
||||
registry: "registry",
|
||||
repository: "repo/api",
|
||||
namespaces: new[] { "team-alpha" },
|
||||
tags: new[] { "latest" },
|
||||
usedByEntrypoint: true))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ImpactSet(
|
||||
selector,
|
||||
imageList,
|
||||
usageOnly: true,
|
||||
generatedAt: DateTimeOffset.UtcNow.AddSeconds(-10),
|
||||
total: imageList.Length,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
private sealed class StubScheduleRepository : IScheduleRepository
|
||||
{
|
||||
private readonly Dictionary<(string TenantId, string ScheduleId), Schedule> _store;
|
||||
|
||||
public StubScheduleRepository(params Schedule[] schedules)
|
||||
{
|
||||
_store = schedules.ToDictionary(schedule => (schedule.TenantId, schedule.Id), schedule => schedule);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(Schedule schedule, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[(schedule.TenantId, schedule.Id)] = schedule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Schedule?> GetAsync(string tenantId, string scheduleId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store.TryGetValue((tenantId, scheduleId), out var schedule);
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Schedule>> ListAsync(string tenantId, ScheduleQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Schedule>>(_store.Values.Where(schedule => schedule.TenantId == tenantId).ToArray());
|
||||
|
||||
public Task<bool> SoftDeleteAsync(string tenantId, string scheduleId, string deletedBy, DateTimeOffset deletedAt, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_store.Remove((tenantId, scheduleId)));
|
||||
}
|
||||
|
||||
private sealed class InMemoryRunRepository : IRunRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string RunId), Run> _runs = new();
|
||||
|
||||
public InMemoryRunRepository(params Run[] runs)
|
||||
{
|
||||
foreach (var run in runs)
|
||||
{
|
||||
_runs[(run.TenantId, run.Id)] = run;
|
||||
}
|
||||
}
|
||||
|
||||
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_runs[(run.TenantId, run.Id)] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_runs[(run.TenantId, run.Id)] = run;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_runs.TryGetValue((tenantId, runId), out var run);
|
||||
return Task.FromResult(run);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.TenantId == tenantId).ToArray());
|
||||
|
||||
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.State == state).Take(limit).ToArray());
|
||||
}
|
||||
|
||||
private sealed class RecordingImpactSnapshotRepository : IImpactSnapshotRepository
|
||||
{
|
||||
public ImpactSet? LastSnapshot { get; private set; }
|
||||
|
||||
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastSnapshot = snapshot;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ImpactSet?>(null);
|
||||
|
||||
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ImpactSet?>(null);
|
||||
}
|
||||
|
||||
private sealed class RecordingRunSummaryService : IRunSummaryService
|
||||
{
|
||||
public Run? LastRun { get; private set; }
|
||||
|
||||
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastRun = run;
|
||||
return Task.FromResult(new RunSummaryProjection(
|
||||
run.TenantId,
|
||||
run.ScheduleId ?? string.Empty,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
ImmutableArray<RunSummarySnapshot>.Empty,
|
||||
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)));
|
||||
}
|
||||
|
||||
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<RunSummaryProjection?>(null);
|
||||
|
||||
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
|
||||
}
|
||||
|
||||
private sealed class StubImpactTargetingService : IImpactTargetingService
|
||||
{
|
||||
private readonly ImpactSet _result;
|
||||
|
||||
public StubImpactTargetingService(ImpactSet result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
=> new(_result);
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
=> new(_result);
|
||||
|
||||
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
|
||||
=> new(_result);
|
||||
}
|
||||
|
||||
private sealed class RecordingPlannerQueue : ISchedulerPlannerQueue
|
||||
{
|
||||
public List<PlannerQueueMessage> Messages { get; } = new();
|
||||
|
||||
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(PlannerQueueMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Messages.Add(message);
|
||||
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(Guid.NewGuid().ToString(), Deduplicated: false));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> LeaseAsync(SchedulerQueueLeaseRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<PlannerQueueMessage>>> ClaimExpiredAsync(SchedulerQueueClaimOptions options, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scheduler.Worker.Planning;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class PlannerQueueDispatchServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DispatchAsync_EnqueuesRunnerSegmentsDeterministically()
|
||||
{
|
||||
var run = CreateRun();
|
||||
var schedule = CreateSchedule(parallelism: 2, maxJobs: 4, ratePerSecond: 11);
|
||||
var impactSet = CreateImpactSet(run.TenantId, count: 5);
|
||||
var message = new PlannerQueueMessage(run, impactSet, schedule, correlationId: "corr-123");
|
||||
|
||||
var shardPlanner = new ImpactShardPlanner();
|
||||
var runnerQueue = new RecordingRunnerQueue();
|
||||
var service = new PlannerQueueDispatchService(
|
||||
shardPlanner,
|
||||
runnerQueue,
|
||||
new SchedulerWorkerOptions(),
|
||||
NullLogger<PlannerQueueDispatchService>.Instance);
|
||||
|
||||
var result = await service.DispatchAsync(message, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlannerQueueDispatchStatus.DispatchCompleted, result.Status);
|
||||
Assert.Equal(2, result.SegmentCount);
|
||||
Assert.Equal(4, runnerQueue.Messages.Sum(msg => msg.ImageDigests.Count));
|
||||
Assert.All(runnerQueue.Messages, msg => Assert.Equal(run.Id, msg.RunId));
|
||||
Assert.All(runnerQueue.Messages, msg => Assert.Equal(run.TenantId, msg.TenantId));
|
||||
Assert.All(runnerQueue.Messages, msg => Assert.Equal(run.ScheduleId, msg.ScheduleId));
|
||||
Assert.All(runnerQueue.Messages, msg => Assert.Equal(impactSet.UsageOnly, msg.UsageOnly));
|
||||
Assert.All(runnerQueue.Messages, msg => Assert.Equal(11, msg.RatePerSecond));
|
||||
|
||||
Assert.Collection(
|
||||
runnerQueue.Messages.OrderBy(msg => msg.SegmentId),
|
||||
first =>
|
||||
{
|
||||
Assert.Equal($"{run.Id}:0000", first.SegmentId);
|
||||
Assert.Equal(2, first.ImageDigests.Count);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal($"{run.Id}:0001", second.SegmentId);
|
||||
Assert.Equal(2, second.ImageDigests.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_NoImages_ReturnsNoWork()
|
||||
{
|
||||
var run = CreateRun();
|
||||
var schedule = CreateSchedule();
|
||||
var impactSet = new ImpactSet(
|
||||
new Selector(SelectorScope.AllImages, run.TenantId),
|
||||
ImmutableArray<ImpactImage>.Empty,
|
||||
usageOnly: true,
|
||||
DateTimeOffset.UtcNow,
|
||||
total: 0,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
var message = new PlannerQueueMessage(run, impactSet, schedule);
|
||||
|
||||
var shardPlanner = new StubImpactShardPlanner(ImmutableArray<ImpactShard>.Empty);
|
||||
var runnerQueue = new RecordingRunnerQueue();
|
||||
var service = new PlannerQueueDispatchService(
|
||||
shardPlanner,
|
||||
runnerQueue,
|
||||
new SchedulerWorkerOptions(),
|
||||
NullLogger<PlannerQueueDispatchService>.Instance);
|
||||
|
||||
var result = await service.DispatchAsync(message, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlannerQueueDispatchStatus.NoWork, result.Status);
|
||||
Assert.Empty(runnerQueue.Messages);
|
||||
}
|
||||
|
||||
private static Run CreateRun()
|
||||
=> new(
|
||||
id: "run-123",
|
||||
tenantId: "tenant-abc",
|
||||
trigger: RunTrigger.Cron,
|
||||
state: RunState.Queued,
|
||||
stats: new RunStats(candidates: 6, deduped: 5, queued: 5),
|
||||
createdAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
scheduleId: "sched-789");
|
||||
|
||||
private static Schedule CreateSchedule(int? parallelism = null, int? maxJobs = null, int? ratePerSecond = null)
|
||||
=> new(
|
||||
id: "sched-789",
|
||||
tenantId: "tenant-abc",
|
||||
name: "Nightly",
|
||||
enabled: true,
|
||||
cronExpression: "0 2 * * *",
|
||||
timezone: "UTC",
|
||||
mode: ScheduleMode.AnalysisOnly,
|
||||
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-abc"),
|
||||
onlyIf: ScheduleOnlyIf.Default,
|
||||
notify: ScheduleNotify.Default,
|
||||
limits: new ScheduleLimits(maxJobs, ratePerSecond, parallelism),
|
||||
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
createdBy: "system",
|
||||
updatedAt: DateTimeOffset.UtcNow.AddHours(-1),
|
||||
updatedBy: "system",
|
||||
subscribers: ImmutableArray<string>.Empty);
|
||||
|
||||
private static ImpactSet CreateImpactSet(string tenantId, int count)
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId);
|
||||
var images = Enumerable.Range(0, count)
|
||||
.Select(index => new ImpactImage(
|
||||
imageDigest: $"sha256:{index:D64}",
|
||||
registry: "registry.example.com",
|
||||
repository: "service/api",
|
||||
namespaces: new[] { "team-a" },
|
||||
tags: new[] { $"v{index}" },
|
||||
usedByEntrypoint: index % 2 == 0))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ImpactSet(
|
||||
selector,
|
||||
images,
|
||||
usageOnly: true,
|
||||
generatedAt: DateTimeOffset.UtcNow.AddMinutes(-2),
|
||||
total: count,
|
||||
snapshotId: "snapshot-xyz",
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
private sealed class RecordingRunnerQueue : ISchedulerRunnerQueue
|
||||
{
|
||||
public List<RunnerSegmentQueueMessage> Messages { get; } = new();
|
||||
|
||||
public ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(RunnerSegmentQueueMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Messages.Add(message);
|
||||
return ValueTask.FromResult(new SchedulerQueueEnqueueResult(Guid.NewGuid().ToString(), Deduplicated: false));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<RunnerSegmentQueueMessage>>> LeaseAsync(
|
||||
SchedulerQueueLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<IReadOnlyList<ISchedulerQueueLease<RunnerSegmentQueueMessage>>> ClaimExpiredAsync(
|
||||
SchedulerQueueClaimOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class StubImpactShardPlanner : IImpactShardPlanner
|
||||
{
|
||||
private readonly ImmutableArray<ImpactShard> _result;
|
||||
|
||||
public StubImpactShardPlanner(ImmutableArray<ImpactShard> result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public ImmutableArray<ImpactShard> PlanShards(ImpactSet impactSet, int? maxJobs, int? parallelism) => _result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using StellaOps.Scheduler.Worker.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class PolicyRunExecutionServiceTests
|
||||
{
|
||||
private static readonly SchedulerWorkerOptions WorkerOptions = new()
|
||||
{
|
||||
Policy =
|
||||
{
|
||||
Dispatch =
|
||||
{
|
||||
LeaseOwner = "test-dispatch",
|
||||
BatchSize = 1,
|
||||
LeaseDuration = TimeSpan.FromMinutes(1),
|
||||
IdleDelay = TimeSpan.FromMilliseconds(10),
|
||||
MaxAttempts = 2,
|
||||
RetryBackoff = TimeSpan.FromSeconds(30)
|
||||
},
|
||||
Api =
|
||||
{
|
||||
BaseAddress = new Uri("https://policy.example.com"),
|
||||
RunsPath = "/api/policy/policies/{policyId}/runs",
|
||||
SimulatePath = "/api/policy/policies/{policyId}/simulate"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_CancelsJob_WhenCancellationRequested()
|
||||
{
|
||||
var repository = new RecordingPolicyRunJobRepository();
|
||||
var client = new StubPolicyRunClient();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
|
||||
{
|
||||
CancellationRequested = true,
|
||||
LeaseOwner = "test-dispatch",
|
||||
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
|
||||
};
|
||||
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Cancelled, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Cancelled, result.UpdatedJob.Status);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SubmitsJob_OnSuccess()
|
||||
{
|
||||
var repository = new RecordingPolicyRunJobRepository();
|
||||
var client = new StubPolicyRunClient
|
||||
{
|
||||
Result = PolicyRunSubmissionResult.Succeeded("run:P-7:2025", DateTimeOffset.Parse("2025-10-28T10:01:00Z"))
|
||||
};
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
|
||||
{
|
||||
LeaseOwner = "test-dispatch",
|
||||
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
|
||||
};
|
||||
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Submitted, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Submitted, result.UpdatedJob.Status);
|
||||
Assert.Equal("run:P-7:2025", result.UpdatedJob.RunId);
|
||||
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
|
||||
Assert.Null(result.UpdatedJob.LastError);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RetriesJob_OnFailure()
|
||||
{
|
||||
var repository = new RecordingPolicyRunJobRepository();
|
||||
var client = new StubPolicyRunClient
|
||||
{
|
||||
Result = PolicyRunSubmissionResult.Failed("timeout")
|
||||
};
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching) with
|
||||
{
|
||||
LeaseOwner = "test-dispatch",
|
||||
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
|
||||
};
|
||||
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Retrying, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Pending, result.UpdatedJob.Status);
|
||||
Assert.Equal(job.AttemptCount + 1, result.UpdatedJob.AttemptCount);
|
||||
Assert.Equal("timeout", result.UpdatedJob.LastError);
|
||||
Assert.True(result.UpdatedJob.AvailableAt > job.AvailableAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_MarksJobFailed_WhenAttemptsExceeded()
|
||||
{
|
||||
var repository = new RecordingPolicyRunJobRepository();
|
||||
var client = new StubPolicyRunClient
|
||||
{
|
||||
Result = PolicyRunSubmissionResult.Failed("bad request")
|
||||
};
|
||||
var optionsValue = CloneOptions();
|
||||
optionsValue.Policy.Dispatch.MaxAttempts = 1;
|
||||
var options = Microsoft.Extensions.Options.Options.Create(optionsValue);
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.Unchanged(job)
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, attemptCount: 0) with
|
||||
{
|
||||
LeaseOwner = "test-dispatch",
|
||||
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
|
||||
};
|
||||
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.Failed, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Failed, result.UpdatedJob.Status);
|
||||
Assert.Equal("bad request", result.UpdatedJob.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_NoWork_CompletesJob()
|
||||
{
|
||||
var repository = new RecordingPolicyRunJobRepository();
|
||||
var client = new StubPolicyRunClient();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(CloneOptions());
|
||||
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-28T10:00:00Z"));
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
var targeting = new StubPolicyRunTargetingService
|
||||
{
|
||||
OnEnsureTargets = job => PolicyRunTargetingResult.NoWork(job, "empty")
|
||||
};
|
||||
var service = new PolicyRunExecutionService(repository, client, options, timeProvider, metrics, targeting, NullLogger<PolicyRunExecutionService>.Instance);
|
||||
|
||||
var job = CreateJob(status: PolicyRunJobStatus.Dispatching, inputs: PolicyRunInputs.Empty) with
|
||||
{
|
||||
LeaseOwner = "test-dispatch",
|
||||
LeaseExpiresAt = timeProvider.GetUtcNow().AddMinutes(1)
|
||||
};
|
||||
|
||||
var result = await service.ExecuteAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunExecutionResultType.NoOp, result.Type);
|
||||
Assert.Equal(PolicyRunJobStatus.Completed, result.UpdatedJob.Status);
|
||||
Assert.True(repository.ReplaceCalled);
|
||||
Assert.Equal("test-dispatch", repository.ExpectedLeaseOwner);
|
||||
}
|
||||
|
||||
private static PolicyRunJob CreateJob(PolicyRunJobStatus status, int attemptCount = 0, PolicyRunInputs? inputs = null)
|
||||
{
|
||||
var resolvedInputs = inputs ?? new PolicyRunInputs(sbomSet: new[] { "sbom:S-42" }, captureExplain: true);
|
||||
var metadata = ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal);
|
||||
return new PolicyRunJob(
|
||||
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
|
||||
Id: "job_1",
|
||||
TenantId: "tenant-alpha",
|
||||
PolicyId: "P-7",
|
||||
PolicyVersion: 4,
|
||||
Mode: PolicyRunMode.Incremental,
|
||||
Priority: PolicyRunPriority.Normal,
|
||||
PriorityRank: -1,
|
||||
RunId: "run:P-7:2025",
|
||||
RequestedBy: "user:cli",
|
||||
CorrelationId: "corr-1",
|
||||
Metadata: metadata,
|
||||
Inputs: resolvedInputs,
|
||||
QueuedAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
|
||||
Status: status,
|
||||
AttemptCount: attemptCount,
|
||||
LastAttemptAt: null,
|
||||
LastError: null,
|
||||
CreatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
|
||||
UpdatedAt: DateTimeOffset.Parse("2025-10-28T09:58:00Z"),
|
||||
AvailableAt: DateTimeOffset.Parse("2025-10-28T09:59:00Z"),
|
||||
SubmittedAt: null,
|
||||
CompletedAt: null,
|
||||
LeaseOwner: null,
|
||||
LeaseExpiresAt: null,
|
||||
CancellationRequested: false,
|
||||
CancellationRequestedAt: null,
|
||||
CancellationReason: null,
|
||||
CancelledAt: null);
|
||||
}
|
||||
|
||||
private static SchedulerWorkerOptions CloneOptions()
|
||||
{
|
||||
return new SchedulerWorkerOptions
|
||||
{
|
||||
Policy = new SchedulerWorkerOptions.PolicyOptions
|
||||
{
|
||||
Enabled = WorkerOptions.Policy.Enabled,
|
||||
Dispatch = new SchedulerWorkerOptions.PolicyOptions.DispatchOptions
|
||||
{
|
||||
LeaseOwner = WorkerOptions.Policy.Dispatch.LeaseOwner,
|
||||
BatchSize = WorkerOptions.Policy.Dispatch.BatchSize,
|
||||
LeaseDuration = WorkerOptions.Policy.Dispatch.LeaseDuration,
|
||||
IdleDelay = WorkerOptions.Policy.Dispatch.IdleDelay,
|
||||
MaxAttempts = WorkerOptions.Policy.Dispatch.MaxAttempts,
|
||||
RetryBackoff = WorkerOptions.Policy.Dispatch.RetryBackoff
|
||||
},
|
||||
Api = new SchedulerWorkerOptions.PolicyOptions.ApiOptions
|
||||
{
|
||||
BaseAddress = WorkerOptions.Policy.Api.BaseAddress,
|
||||
RunsPath = WorkerOptions.Policy.Api.RunsPath,
|
||||
SimulatePath = WorkerOptions.Policy.Api.SimulatePath,
|
||||
TenantHeader = WorkerOptions.Policy.Api.TenantHeader,
|
||||
IdempotencyHeader = WorkerOptions.Policy.Api.IdempotencyHeader,
|
||||
RequestTimeout = WorkerOptions.Policy.Api.RequestTimeout
|
||||
},
|
||||
Targeting = new SchedulerWorkerOptions.PolicyOptions.TargetingOptions
|
||||
{
|
||||
Enabled = WorkerOptions.Policy.Targeting.Enabled,
|
||||
MaxSboms = WorkerOptions.Policy.Targeting.MaxSboms,
|
||||
DefaultUsageOnly = WorkerOptions.Policy.Targeting.DefaultUsageOnly
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubPolicyRunTargetingService : IPolicyRunTargetingService
|
||||
{
|
||||
public Func<PolicyRunJob, PolicyRunTargetingResult>? OnEnsureTargets { get; set; }
|
||||
|
||||
public Task<PolicyRunTargetingResult> EnsureTargetsAsync(PolicyRunJob job, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(OnEnsureTargets?.Invoke(job) ?? PolicyRunTargetingResult.Unchanged(job));
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicyRunJobRepository : IPolicyRunJobRepository
|
||||
{
|
||||
public bool ReplaceCalled { get; private set; }
|
||||
public string? ExpectedLeaseOwner { get; private set; }
|
||||
public PolicyRunJob? LastJob { get; private set; }
|
||||
|
||||
public Task<PolicyRunJob?> GetAsync(string tenantId, string jobId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
|
||||
public Task<PolicyRunJob?> GetByRunIdAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
|
||||
public Task InsertAsync(PolicyRunJob job, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastJob = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PolicyRunJob?> LeaseAsync(string leaseOwner, DateTimeOffset now, TimeSpan leaseDuration, int maxAttempts, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PolicyRunJob?>(null);
|
||||
|
||||
public Task<bool> ReplaceAsync(PolicyRunJob job, string? expectedLeaseOwner = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ReplaceCalled = true;
|
||||
ExpectedLeaseOwner = expectedLeaseOwner;
|
||||
LastJob = job;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyRunJob>> ListAsync(string tenantId, string? policyId = null, PolicyRunMode? mode = null, IReadOnlyCollection<PolicyRunJobStatus>? statuses = null, DateTimeOffset? queuedAfter = null, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<PolicyRunJob>>(Array.Empty<PolicyRunJob>());
|
||||
}
|
||||
|
||||
private sealed class StubPolicyRunClient : IPolicyRunClient
|
||||
{
|
||||
public PolicyRunSubmissionResult Result { get; set; } = PolicyRunSubmissionResult.Succeeded(null, null);
|
||||
|
||||
public Task<PolicyRunSubmissionResult> SubmitAsync(PolicyRunJob job, PolicyRunRequest request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Result);
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class PolicyRunTargetingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnsureTargetsAsync_ReturnsUnchanged_ForNonIncrementalJob()
|
||||
{
|
||||
var service = CreateService();
|
||||
var job = CreateJob(mode: PolicyRunMode.Full);
|
||||
|
||||
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunTargetingStatus.Unchanged, result.Status);
|
||||
Assert.Equal(job, result.Job);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTargetsAsync_ReturnsUnchanged_WhenSbomSetAlreadyPresent()
|
||||
{
|
||||
var service = CreateService();
|
||||
var inputs = new PolicyRunInputs(sbomSet: new[] { "sbom:S-1" });
|
||||
var job = CreateJob(inputs: inputs);
|
||||
|
||||
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunTargetingStatus.Unchanged, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTargetsAsync_ReturnsNoWork_WhenNoCandidatesResolved()
|
||||
{
|
||||
var impact = new StubImpactTargetingService();
|
||||
var service = CreateService(impact);
|
||||
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.purls", "pkg:npm/leftpad");
|
||||
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
|
||||
|
||||
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunTargetingStatus.NoWork, result.Status);
|
||||
Assert.Equal("no_matches", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTargetsAsync_TargetsDirectSboms()
|
||||
{
|
||||
var service = CreateService();
|
||||
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.sboms", "sbom:S-2, sbom:S-1, sbom:S-2");
|
||||
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
|
||||
|
||||
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunTargetingStatus.Targeted, result.Status);
|
||||
Assert.Equal(new[] { "sbom:S-1", "sbom:S-2" }, result.Job.Inputs.SbomSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTargetsAsync_TargetsUsingImpactIndex()
|
||||
{
|
||||
var impact = new StubImpactTargetingService
|
||||
{
|
||||
OnResolveByPurls = (keys, usageOnly, selector, _) =>
|
||||
{
|
||||
var image = new ImpactImage(
|
||||
"sha256:111",
|
||||
"registry",
|
||||
"repo",
|
||||
labels: ImmutableSortedDictionary.Create<string, string>(StringComparer.Ordinal).Add("sbomId", "sbom:S-42"));
|
||||
var impactSet = new ImpactSet(
|
||||
selector,
|
||||
new[] { image },
|
||||
usageOnly,
|
||||
DateTimeOffset.UtcNow,
|
||||
total: 1,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
return ValueTask.FromResult(impactSet);
|
||||
}
|
||||
};
|
||||
|
||||
var service = CreateService(impact);
|
||||
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.purls", "pkg:npm/example");
|
||||
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
|
||||
|
||||
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunTargetingStatus.Targeted, result.Status);
|
||||
Assert.Equal(new[] { "sbom:S-42" }, result.Job.Inputs.SbomSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTargetsAsync_FallsBack_WhenLimitExceeded()
|
||||
{
|
||||
var service = CreateService(configure: options => options.MaxSboms = 1);
|
||||
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.sboms", "sbom:S-1,sbom:S-2");
|
||||
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
|
||||
|
||||
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunTargetingStatus.Unchanged, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTargetsAsync_FallbacksToDigest_WhenLabelMissing()
|
||||
{
|
||||
var impact = new StubImpactTargetingService
|
||||
{
|
||||
OnResolveByVulnerabilities = (ids, usageOnly, selector, _) =>
|
||||
{
|
||||
var image = new ImpactImage("sha256:aaa", "registry", "repo");
|
||||
var impactSet = new ImpactSet(
|
||||
selector,
|
||||
new[] { image },
|
||||
usageOnly,
|
||||
DateTimeOffset.UtcNow,
|
||||
total: 1,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
return ValueTask.FromResult(impactSet);
|
||||
}
|
||||
};
|
||||
|
||||
var service = CreateService(impact);
|
||||
var metadata = ImmutableSortedDictionary<string, string>.Empty.Add("delta.vulns", "CVE-2025-1234");
|
||||
var job = CreateJob(metadata: metadata, inputs: PolicyRunInputs.Empty);
|
||||
|
||||
var result = await service.EnsureTargetsAsync(job, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PolicyRunTargetingStatus.Targeted, result.Status);
|
||||
Assert.Equal(new[] { "sbom:sha256:aaa" }, result.Job.Inputs.SbomSet);
|
||||
}
|
||||
|
||||
private static PolicyRunTargetingService CreateService(
|
||||
IImpactTargetingService? impact = null,
|
||||
Action<SchedulerWorkerOptions.PolicyOptions.TargetingOptions>? configure = null)
|
||||
{
|
||||
impact ??= new StubImpactTargetingService();
|
||||
var options = CreateOptions(configure);
|
||||
return new PolicyRunTargetingService(
|
||||
impact,
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
timeProvider: null,
|
||||
NullLogger<PolicyRunTargetingService>.Instance);
|
||||
}
|
||||
|
||||
private static SchedulerWorkerOptions CreateOptions(Action<SchedulerWorkerOptions.PolicyOptions.TargetingOptions>? configure)
|
||||
{
|
||||
var options = new SchedulerWorkerOptions
|
||||
{
|
||||
Policy =
|
||||
{
|
||||
Api =
|
||||
{
|
||||
BaseAddress = new Uri("https://policy.example.com"),
|
||||
RunsPath = "/runs",
|
||||
SimulatePath = "/simulate"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
configure?.Invoke(options.Policy.Targeting);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static PolicyRunJob CreateJob(
|
||||
PolicyRunMode mode = PolicyRunMode.Incremental,
|
||||
ImmutableSortedDictionary<string, string>? metadata = null,
|
||||
PolicyRunInputs? inputs = null)
|
||||
{
|
||||
return new PolicyRunJob(
|
||||
SchemaVersion: SchedulerSchemaVersions.PolicyRunJob,
|
||||
Id: "job-1",
|
||||
TenantId: "tenant-alpha",
|
||||
PolicyId: "P-7",
|
||||
PolicyVersion: 4,
|
||||
Mode: mode,
|
||||
Priority: PolicyRunPriority.Normal,
|
||||
PriorityRank: 0,
|
||||
RunId: null,
|
||||
RequestedBy: null,
|
||||
CorrelationId: null,
|
||||
Metadata: metadata ?? ImmutableSortedDictionary<string, string>.Empty,
|
||||
Inputs: inputs ?? PolicyRunInputs.Empty,
|
||||
QueuedAt: DateTimeOffset.UtcNow,
|
||||
Status: PolicyRunJobStatus.Dispatching,
|
||||
AttemptCount: 0,
|
||||
LastAttemptAt: null,
|
||||
LastError: null,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
UpdatedAt: DateTimeOffset.UtcNow,
|
||||
AvailableAt: DateTimeOffset.UtcNow,
|
||||
SubmittedAt: null,
|
||||
CompletedAt: null,
|
||||
LeaseOwner: "lease",
|
||||
LeaseExpiresAt: DateTimeOffset.UtcNow.AddMinutes(1),
|
||||
CancellationRequested: false,
|
||||
CancellationRequestedAt: null,
|
||||
CancellationReason: null,
|
||||
CancelledAt: null);
|
||||
}
|
||||
|
||||
private sealed class StubImpactTargetingService : IImpactTargetingService
|
||||
{
|
||||
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByPurls { get; set; }
|
||||
|
||||
public Func<IEnumerable<string>, bool, Selector, CancellationToken, ValueTask<ImpactSet>>? OnResolveByVulnerabilities { get; set; }
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByPurlsAsync(IEnumerable<string> productKeys, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (OnResolveByPurls is null)
|
||||
{
|
||||
return ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
}
|
||||
|
||||
return OnResolveByPurls(productKeys, usageOnly, selector, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(IEnumerable<string> vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (OnResolveByVulnerabilities is null)
|
||||
{
|
||||
return ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
}
|
||||
|
||||
return OnResolveByVulnerabilities(vulnerabilityIds, usageOnly, selector, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask<ImpactSet> ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly));
|
||||
|
||||
private static ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
|
||||
{
|
||||
return new ImpactSet(
|
||||
selector,
|
||||
ImmutableArray<ImpactImage>.Empty,
|
||||
usageOnly,
|
||||
DateTimeOffset.UtcNow,
|
||||
total: 0,
|
||||
snapshotId: null,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Projections;
|
||||
using StellaOps.Scheduler.Worker.Events;
|
||||
using StellaOps.Scheduler.Worker.Execution;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class RunnerExecutionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UpdatesRunStatsAndDeltas()
|
||||
{
|
||||
var run = CreateRun();
|
||||
var repository = new InMemoryRunRepository(run);
|
||||
var summaryService = new RecordingRunSummaryService();
|
||||
var impactRepository = new InMemoryImpactSnapshotRepository(run.Id,
|
||||
new[]
|
||||
{
|
||||
CreateImpactImage("sha256:1111111111111111111111111111111111111111111111111111111111111111", "registry-1", "repo-1"),
|
||||
CreateImpactImage("sha256:2222222222222222222222222222222222222222222222222222222222222222", "registry-1", "repo-2")
|
||||
});
|
||||
var scannerClient = new StubScannerReportClient(new Dictionary<string, RunnerImageResult>
|
||||
{
|
||||
["sha256:1111111111111111111111111111111111111111111111111111111111111111"] = CreateRunnerImageResult(
|
||||
"sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
new DeltaSummary(
|
||||
"sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
newFindings: 2,
|
||||
newCriticals: 1,
|
||||
newHigh: 0,
|
||||
newMedium: 1,
|
||||
newLow: 0,
|
||||
kevHits: ImmutableArray.Create("CVE-2025-0001"),
|
||||
topFindings: ImmutableArray.Create(new DeltaFinding("pkg:purl", "CVE-2025-0001", SeverityRank.Critical)),
|
||||
reportUrl: "https://scanner/reports/1",
|
||||
attestation: null,
|
||||
detectedAt: DateTimeOffset.UtcNow)),
|
||||
["sha256:2222222222222222222222222222222222222222222222222222222222222222"] = CreateRunnerImageResult(
|
||||
"sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
delta: null)
|
||||
});
|
||||
var eventPublisher = new RecordingSchedulerEventPublisher();
|
||||
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
|
||||
var service = new RunnerExecutionService(
|
||||
repository,
|
||||
summaryService,
|
||||
impactRepository,
|
||||
scannerClient,
|
||||
eventPublisher,
|
||||
metrics,
|
||||
TimeProvider.System,
|
||||
NullLogger<RunnerExecutionService>.Instance);
|
||||
|
||||
var message = new RunnerSegmentQueueMessage(
|
||||
segmentId: "run-123:0000",
|
||||
runId: run.Id,
|
||||
tenantId: run.TenantId,
|
||||
imageDigests: new[]
|
||||
{
|
||||
"sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||
},
|
||||
scheduleId: run.ScheduleId,
|
||||
ratePerSecond: null,
|
||||
usageOnly: true,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["scheduleMode"] = ScheduleMode.AnalysisOnly.ToString(),
|
||||
["impactSnapshotId"] = $"impact::{run.Id}"
|
||||
},
|
||||
correlationId: "corr-xyz");
|
||||
|
||||
var result = await service.ExecuteAsync(message, CancellationToken.None);
|
||||
|
||||
Assert.Equal(RunnerSegmentExecutionStatus.Completed, result.Status);
|
||||
Assert.True(result.RunCompleted);
|
||||
Assert.Equal(1, result.DeltaImages);
|
||||
|
||||
var persisted = repository.GetSnapshot(run.TenantId, run.Id);
|
||||
Assert.NotNull(persisted);
|
||||
Assert.Equal(2, persisted!.Stats.Completed);
|
||||
Assert.Equal(1, persisted.Stats.Deltas);
|
||||
Assert.Equal(1, persisted.Stats.NewCriticals);
|
||||
Assert.Equal(1, persisted.Stats.NewMedium);
|
||||
Assert.Contains(persisted.Deltas, delta => delta.ImageDigest == "sha256:1111111111111111111111111111111111111111111111111111111111111111");
|
||||
Assert.Equal(persisted, summaryService.LastProjected);
|
||||
Assert.Equal(2, eventPublisher.ReportReady.Count);
|
||||
Assert.Single(eventPublisher.RescanDeltaPayloads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenRunMissing_ReturnsRunMissing()
|
||||
{
|
||||
var repository = new InMemoryRunRepository();
|
||||
var impactRepository = new InMemoryImpactSnapshotRepository("run-123", Array.Empty<ImpactImage>());
|
||||
var eventPublisher = new RecordingSchedulerEventPublisher();
|
||||
using var metrics = new SchedulerWorkerMetrics();
|
||||
|
||||
var service = new RunnerExecutionService(
|
||||
repository,
|
||||
new RecordingRunSummaryService(),
|
||||
impactRepository,
|
||||
new StubScannerReportClient(new Dictionary<string, RunnerImageResult>()),
|
||||
eventPublisher,
|
||||
metrics,
|
||||
TimeProvider.System,
|
||||
NullLogger<RunnerExecutionService>.Instance);
|
||||
|
||||
var message = new RunnerSegmentQueueMessage(
|
||||
segmentId: "run-123:0000",
|
||||
runId: "run-123",
|
||||
tenantId: "tenant-abc",
|
||||
imageDigests: new[] { "sha256:3333333333333333333333333333333333333333333333333333333333333333" },
|
||||
scheduleId: "sched-1",
|
||||
ratePerSecond: null,
|
||||
usageOnly: true,
|
||||
attributes: new Dictionary<string, string>(),
|
||||
correlationId: null);
|
||||
|
||||
var result = await service.ExecuteAsync(message, CancellationToken.None);
|
||||
|
||||
Assert.Equal(RunnerSegmentExecutionStatus.RunMissing, result.Status);
|
||||
}
|
||||
|
||||
private static Run CreateRun()
|
||||
=> new(
|
||||
id: "run-123",
|
||||
tenantId: "tenant-abc",
|
||||
trigger: RunTrigger.Cron,
|
||||
state: RunState.Queued,
|
||||
stats: new RunStats(
|
||||
candidates: 4,
|
||||
deduped: 4,
|
||||
queued: 2,
|
||||
completed: 0,
|
||||
deltas: 0,
|
||||
newCriticals: 0,
|
||||
newHigh: 0,
|
||||
newMedium: 0,
|
||||
newLow: 0),
|
||||
createdAt: DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
scheduleId: "sched-1");
|
||||
|
||||
private static ImpactImage CreateImpactImage(string digest, string registry, string repository)
|
||||
=> new(
|
||||
imageDigest: digest,
|
||||
registry: registry,
|
||||
repository: repository,
|
||||
namespaces: null,
|
||||
tags: null,
|
||||
usedByEntrypoint: false,
|
||||
labels: null);
|
||||
|
||||
private static RunnerImageResult CreateRunnerImageResult(string digest, DeltaSummary? delta)
|
||||
{
|
||||
var newTotal = delta?.NewFindings ?? 0;
|
||||
var summary = new RunnerReportSummary(
|
||||
Total: newTotal,
|
||||
Blocked: delta?.NewCriticals ?? 0,
|
||||
Warned: delta?.NewHigh ?? 0,
|
||||
Ignored: delta?.NewLow ?? 0,
|
||||
Quieted: 0);
|
||||
|
||||
var snapshot = new RunnerReportSnapshot(
|
||||
ReportId: $"report-{digest[^4..]}",
|
||||
ImageDigest: digest,
|
||||
Verdict: "warn",
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
Summary: summary,
|
||||
PolicyRevisionId: "pol-rev",
|
||||
PolicyDigest: "pol-digest");
|
||||
|
||||
return new RunnerImageResult(
|
||||
digest,
|
||||
delta,
|
||||
ContentRefreshed: false,
|
||||
snapshot,
|
||||
Dsse: null);
|
||||
}
|
||||
|
||||
private sealed class InMemoryRunRepository : IRunRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string RunId), Run> _runs = new();
|
||||
|
||||
public InMemoryRunRepository(params Run[] runs)
|
||||
{
|
||||
foreach (var run in runs)
|
||||
{
|
||||
_runs[(run.TenantId, run.Id)] = run;
|
||||
}
|
||||
}
|
||||
|
||||
public Task InsertAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_runs[(run.TenantId, run.Id)] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(Run run, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_runs[(run.TenantId, run.Id)] = run;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<Run?> GetAsync(string tenantId, string runId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_runs.TryGetValue((tenantId, runId), out var run);
|
||||
return Task.FromResult(run);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Run>> ListAsync(string tenantId, RunQueryOptions? options = null, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.TenantId == tenantId).ToArray());
|
||||
|
||||
public Task<IReadOnlyList<Run>> ListByStateAsync(RunState state, int limit = 50, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<Run>>(_runs.Values.Where(run => run.State == state).Take(limit).ToArray());
|
||||
|
||||
public Run? GetSnapshot(string tenantId, string runId)
|
||||
{
|
||||
_runs.TryGetValue((tenantId, runId), out var run);
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryImpactSnapshotRepository : IImpactSnapshotRepository
|
||||
{
|
||||
private readonly string _snapshotId;
|
||||
private readonly ImpactSet _snapshot;
|
||||
|
||||
public InMemoryImpactSnapshotRepository(string runId, IEnumerable<ImpactImage> images)
|
||||
{
|
||||
_snapshotId = $"impact::{runId}";
|
||||
var imageArray = images.ToImmutableArray();
|
||||
_snapshot = new ImpactSet(
|
||||
new Selector(SelectorScope.AllImages, "tenant-abc"),
|
||||
imageArray,
|
||||
usageOnly: true,
|
||||
generatedAt: DateTimeOffset.UtcNow,
|
||||
total: imageArray.Length);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(ImpactSet snapshot, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<ImpactSet?> GetBySnapshotIdAsync(string snapshotId, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ImpactSet?>(string.Equals(snapshotId, _snapshotId, StringComparison.Ordinal) ? _snapshot : null);
|
||||
|
||||
public Task<ImpactSet?> GetLatestBySelectorAsync(Selector selector, IClientSessionHandle? session = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ImpactSet?>(_snapshot);
|
||||
}
|
||||
|
||||
private sealed class RecordingSchedulerEventPublisher : ISchedulerEventPublisher
|
||||
{
|
||||
public List<(Run run, RunnerImageResult result)> ReportReady { get; } = new();
|
||||
|
||||
public List<(Run run, IReadOnlyList<DeltaSummary> deltas)> RescanDeltaPayloads { get; } = new();
|
||||
|
||||
public Task PublishReportReadyAsync(Run run, RunnerSegmentQueueMessage message, RunnerImageResult result, ImpactImage? impactImage, CancellationToken cancellationToken)
|
||||
{
|
||||
ReportReady.Add((run, result));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishRescanDeltaAsync(Run run, RunnerSegmentQueueMessage message, IReadOnlyList<DeltaSummary> deltas, IReadOnlyDictionary<string, ImpactImage> impactLookup, CancellationToken cancellationToken)
|
||||
{
|
||||
RescanDeltaPayloads.Add((run, deltas));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingRunSummaryService : IRunSummaryService
|
||||
{
|
||||
public Run? LastProjected { get; private set; }
|
||||
|
||||
public Task<RunSummaryProjection> ProjectAsync(Run run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastProjected = run;
|
||||
return Task.FromResult(new RunSummaryProjection(
|
||||
run.TenantId,
|
||||
run.ScheduleId ?? string.Empty,
|
||||
DateTimeOffset.UtcNow,
|
||||
null,
|
||||
ImmutableArray<RunSummarySnapshot>.Empty,
|
||||
new RunSummaryCounters(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)));
|
||||
}
|
||||
|
||||
public Task<RunSummaryProjection?> GetAsync(string tenantId, string scheduleId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<RunSummaryProjection?>(null);
|
||||
|
||||
public Task<IReadOnlyList<RunSummaryProjection>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<RunSummaryProjection>>(Array.Empty<RunSummaryProjection>());
|
||||
}
|
||||
|
||||
private sealed class StubScannerReportClient : IScannerReportClient
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, RunnerImageResult> _responses;
|
||||
|
||||
public StubScannerReportClient(IReadOnlyDictionary<string, RunnerImageResult> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
public Task<RunnerImageResult> ExecuteAsync(ScannerReportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_responses.TryGetValue(request.ImageDigest, out var result))
|
||||
{
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
return Task.FromResult(CreateRunnerImageResult(request.ImageDigest, delta: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scheduler.Worker.Events;
|
||||
using StellaOps.Scheduler.Worker.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Tests;
|
||||
|
||||
public sealed class SchedulerEventPublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishReportReadyAsync_EnqueuesNotifyEvent()
|
||||
{
|
||||
var queue = new RecordingNotifyEventQueue();
|
||||
var options = new NotifyEventQueueOptions();
|
||||
var publisher = new SchedulerEventPublisher(queue, options, TimeProvider.System, NullLogger<SchedulerEventPublisher>.Instance);
|
||||
var run = CreateRun();
|
||||
var message = CreateMessage(run);
|
||||
var delta = new DeltaSummary(
|
||||
run.Id,
|
||||
newFindings: 2,
|
||||
newCriticals: 1,
|
||||
newHigh: 1,
|
||||
newMedium: 0,
|
||||
newLow: 0);
|
||||
var result = CreateRunnerImageResult(run.Id, delta);
|
||||
var impact = new ImpactImage(run.Id, "registry", "repository");
|
||||
|
||||
await publisher.PublishReportReadyAsync(run, message, result, impact, CancellationToken.None);
|
||||
|
||||
Assert.Single(queue.Messages);
|
||||
var notifyEvent = queue.Messages[0].Event;
|
||||
Assert.Equal(NotifyEventKinds.ScannerReportReady, notifyEvent.Kind);
|
||||
Assert.Equal(run.TenantId, notifyEvent.Tenant);
|
||||
Assert.NotNull(notifyEvent.Scope);
|
||||
Assert.Equal("repository", notifyEvent.Scope!.Repo);
|
||||
|
||||
var payload = Assert.IsType<JsonObject>(notifyEvent.Payload);
|
||||
Assert.Equal(result.Report.ReportId, payload["reportId"]!.GetValue<string>());
|
||||
Assert.Equal("warn", payload["verdict"]!.GetValue<string>());
|
||||
var deltaNode = Assert.IsType<JsonObject>(payload["delta"]);
|
||||
Assert.Equal(1, deltaNode["newCritical"]!.GetValue<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishRescanDeltaAsync_EnqueuesDeltaEvent()
|
||||
{
|
||||
var queue = new RecordingNotifyEventQueue();
|
||||
var options = new NotifyEventQueueOptions();
|
||||
var publisher = new SchedulerEventPublisher(queue, options, TimeProvider.System, NullLogger<SchedulerEventPublisher>.Instance);
|
||||
var run = CreateRun();
|
||||
var message = CreateMessage(run);
|
||||
var delta = new DeltaSummary(run.Id, 1, 1, 0, 0, 0);
|
||||
var impactLookup = new Dictionary<string, ImpactImage>
|
||||
{
|
||||
[run.Id] = new ImpactImage(run.Id, "registry", "repository")
|
||||
};
|
||||
|
||||
await publisher.PublishRescanDeltaAsync(run, message, new[] { delta }, impactLookup, CancellationToken.None);
|
||||
|
||||
Assert.Single(queue.Messages);
|
||||
var notifyEvent = queue.Messages[0].Event;
|
||||
Assert.Equal(NotifyEventKinds.SchedulerRescanDelta, notifyEvent.Kind);
|
||||
var payload = Assert.IsType<JsonObject>(notifyEvent.Payload);
|
||||
var digests = Assert.IsType<JsonArray>(payload["impactedDigests"]);
|
||||
Assert.Equal(run.Id, digests[0]!.GetValue<string>());
|
||||
}
|
||||
|
||||
private const string SampleDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
|
||||
|
||||
private static Run CreateRun()
|
||||
=> new(
|
||||
id: SampleDigest,
|
||||
tenantId: "tenant-1",
|
||||
trigger: RunTrigger.Cron,
|
||||
state: RunState.Running,
|
||||
stats: new RunStats(queued: 1, completed: 0),
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
scheduleId: "schedule-1");
|
||||
|
||||
private static RunnerSegmentQueueMessage CreateMessage(Run run)
|
||||
=> new(
|
||||
segmentId: $"{run.Id}:0000",
|
||||
runId: run.Id,
|
||||
tenantId: run.TenantId,
|
||||
imageDigests: new[] { run.Id },
|
||||
scheduleId: run.ScheduleId,
|
||||
ratePerSecond: null,
|
||||
usageOnly: true,
|
||||
attributes: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["scheduleMode"] = ScheduleMode.AnalysisOnly.ToString()
|
||||
});
|
||||
|
||||
private static RunnerImageResult CreateRunnerImageResult(string digest, DeltaSummary? delta)
|
||||
{
|
||||
var summary = new RunnerReportSummary(
|
||||
Total: delta?.NewFindings ?? 0,
|
||||
Blocked: delta?.NewCriticals ?? 0,
|
||||
Warned: delta?.NewHigh ?? 0,
|
||||
Ignored: delta?.NewLow ?? 0,
|
||||
Quieted: 0);
|
||||
|
||||
var snapshot = new RunnerReportSnapshot(
|
||||
ReportId: $"report-{digest[^4..]}",
|
||||
ImageDigest: digest,
|
||||
Verdict: "warn",
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
Summary: summary,
|
||||
PolicyRevisionId: null,
|
||||
PolicyDigest: null);
|
||||
|
||||
return new RunnerImageResult(digest, delta, ContentRefreshed: false, snapshot, Dsse: null);
|
||||
}
|
||||
|
||||
private sealed class RecordingNotifyEventQueue : INotifyEventQueue
|
||||
{
|
||||
public List<NotifyQueueEventMessage> Messages { get; } = new();
|
||||
|
||||
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Messages.Add(message);
|
||||
return ValueTask.FromResult(new NotifyQueueEnqueueResult(Guid.NewGuid().ToString("N"), false));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Worker/StellaOps.Scheduler.Worker.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user