feat: Implement MongoDB orchestrator storage with registry, commands, and heartbeats
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added NullAdvisoryObservationEventTransport for handling advisory observation events.
- Created IOrchestratorRegistryStore interface for orchestrator registry operations.
- Implemented MongoOrchestratorRegistryStore for MongoDB interactions with orchestrator data.
- Defined OrchestratorCommandDocument and OrchestratorCommandRecord for command handling.
- Added OrchestratorHeartbeatDocument and OrchestratorHeartbeatRecord for heartbeat tracking.
- Created OrchestratorRegistryDocument and OrchestratorRegistryRecord for registry management.
- Developed tests for orchestrator collections migration and MongoOrchestratorRegistryStore functionality.
- Introduced AirgapImportRequest and AirgapImportValidator for air-gapped VEX bundle imports.
- Added incident mode rules sample JSON for notifier configuration.
This commit is contained in:
StellaOps Bot
2025-11-22 12:35:38 +02:00
parent cbdc05b24d
commit f43e828b4e
96 changed files with 3425 additions and 976 deletions

View File

@@ -60,6 +60,44 @@ public sealed class AdvisoryObservationAggregationTests
Assert.Null(normalized); // no purls supplied
}
[Fact]
public void BuildAggregateLinkset_ComputesConflictsAndConfidenceFromObservations()
{
var obsA = CreateObservation(
"obs-a",
new RawLinkset
{
Aliases = ImmutableArray.Create("CVE-2025-0001"),
PackageUrls = ImmutableArray.Create("pkg:npm/foo@1.0.0"),
References = ImmutableArray.Create(new RawReference("advisory", "https://a.example/advisory"))
},
fetchedAt: DateTimeOffset.UtcNow.AddHours(-1),
vendor: "vendor-a");
var obsB = CreateObservation(
"obs-b",
new RawLinkset
{
Aliases = ImmutableArray.Create("GHSA-xxxx-xxxx"),
PackageUrls = ImmutableArray.Create("pkg:npm/foo@2.0.0"),
References = ImmutableArray.Create(new RawReference("advisory", "https://b.example/advisory"))
},
fetchedAt: DateTimeOffset.UtcNow,
vendor: "vendor-b");
var method = typeof(AdvisoryObservationQueryService).GetMethod(
"BuildAggregateLinkset",
BindingFlags.NonPublic | BindingFlags.Static)!;
var aggregate = (AdvisoryObservationLinksetAggregate)method.Invoke(
null,
new object?[] { ImmutableArray.Create(obsA, obsB) })!;
Assert.Contains(aggregate.Conflicts, c => c.Reason == "alias-inconsistency");
Assert.Contains(aggregate.Conflicts, c => c.Reason == "affected-range-divergence");
Assert.True(aggregate.Confidence is > 0.0 and < 1.0);
}
[Fact]
public void BuildAggregateLinkset_EmptyInputReturnsEmptyArrays()
{
@@ -75,13 +113,17 @@ public sealed class AdvisoryObservationAggregationTests
Assert.True(aggregate.Relationships.IsEmpty);
}
private static AdvisoryObservation CreateObservation(string id, RawLinkset rawLinkset)
private static AdvisoryObservation CreateObservation(
string id,
RawLinkset rawLinkset,
DateTimeOffset? fetchedAt = null,
string vendor = "vendor")
{
var source = new AdvisoryObservationSource("vendor", "stream", "api");
var source = new AdvisoryObservationSource(vendor, "stream", "api");
var upstream = new AdvisoryObservationUpstream(
"adv-id",
null,
DateTimeOffset.UtcNow,
(fetchedAt ?? DateTimeOffset.UtcNow),
DateTimeOffset.UtcNow,
"sha256:abc",
new AdvisoryObservationSignature(false, null, null, null));

View File

@@ -36,7 +36,7 @@ public sealed class ConcelierMongoLinksetStoreTests : IClassFixture<MongoIntegra
0.82,
new List<AdvisoryLinksetConflict>
{
new("severity", "disagree", new[] { "HIGH", "MEDIUM" })
new("severity", "disagree", new[] { "HIGH", "MEDIUM" }, new[] { "source-a", "source-b" })
},
DateTimeOffset.UtcNow,
"job-1");
@@ -54,6 +54,7 @@ public sealed class ConcelierMongoLinksetStoreTests : IClassFixture<MongoIntegra
Assert.Single(document.Conflicts!);
Assert.Equal("severity", document.Conflicts![0].Field);
Assert.Equal("disagree", document.Conflicts![0].Reason);
Assert.Equal(new[] { "source-a", "source-b" }, document.Conflicts![0].SourceIds);
}
[Fact]
@@ -72,7 +73,8 @@ public sealed class ConcelierMongoLinksetStoreTests : IClassFixture<MongoIntegra
{
Field = "references",
Reason = "mismatch",
Values = new List<string> { "url1", "url2" }
Values = new List<string> { "url1", "url2" },
SourceIds = new List<string> { "src-a", "src-b" }
}
},
CreatedAt = DateTime.UtcNow
@@ -90,6 +92,7 @@ public sealed class ConcelierMongoLinksetStoreTests : IClassFixture<MongoIntegra
Assert.NotNull(model.Conflicts);
Assert.Single(model.Conflicts!);
Assert.Equal("references", model.Conflicts![0].Field);
Assert.Equal(new[] { "src-a", "src-b" }, model.Conflicts![0].SourceIds);
}
[Fact]

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Storage.Mongo.Observations;
using StellaOps.Concelier.Models.Observations;
using Xunit;
namespace StellaOps.Concelier.Storage.Mongo.Tests.Observations;
public class AdvisoryObservationTransportWorkerTests
{
[Fact]
public async Task Worker_publishes_outbox_entries_and_marks_published_once()
{
var evt = new AdvisoryObservationUpdatedEvent(
Guid.NewGuid(),
"tenant-1",
"obs-1",
"adv-1",
new Models.Observations.AdvisoryObservationSource("vendor", "stream", "api", "1.0.0"),
new AdvisoryObservationLinksetSummary(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<AdvisoryObservationRelationshipSummary>.Empty),
"doc-sha",
"hash-1",
DateTimeOffset.UtcNow,
ReplayCursor: "cursor-1",
supersedesId: null,
traceId: "trace-1");
var outbox = new FakeOutbox(evt);
var transport = new FakeTransport();
var options = Options.Create(new AdvisoryObservationEventPublisherOptions
{
Enabled = true,
Transport = "nats",
Subject = "subject",
Stream = "stream",
NatsUrl = "nats://localhost:4222"
});
var worker = new AdvisoryObservationTransportWorker(outbox, transport, options, NullLogger<AdvisoryObservationTransportWorker>.Instance);
await worker.StartAsync(CancellationToken.None);
await Task.Delay(150, CancellationToken.None);
await worker.StopAsync(CancellationToken.None);
Assert.Equal(1, transport.Sent.Count);
Assert.Equal(evt.EventId, transport.Sent[0].EventId);
Assert.Equal(1, outbox.MarkedCount);
}
private sealed class FakeOutbox : IAdvisoryObservationEventOutbox
{
private readonly AdvisoryObservationUpdatedEvent _event;
private bool _dequeued;
public int MarkedCount { get; private set; }
public FakeOutbox(AdvisoryObservationUpdatedEvent @event)
{
_event = @event;
}
public Task<IReadOnlyCollection<AdvisoryObservationUpdatedEvent>> DequeueAsync(int take, CancellationToken cancellationToken)
{
if (_dequeued)
{
return Task.FromResult<IReadOnlyCollection<AdvisoryObservationUpdatedEvent>>(Array.Empty<AdvisoryObservationUpdatedEvent>());
}
_dequeued = true;
return Task.FromResult<IReadOnlyCollection<AdvisoryObservationUpdatedEvent>>(new[] { _event });
}
public Task MarkPublishedAsync(Guid eventId, DateTimeOffset publishedAt, CancellationToken cancellationToken)
{
MarkedCount++;
return Task.CompletedTask;
}
}
private sealed class FakeTransport : IAdvisoryObservationEventTransport
{
public List<AdvisoryObservationUpdatedEvent> Sent { get; } = new();
public Task SendAsync(AdvisoryObservationUpdatedEvent @event, CancellationToken cancellationToken)
{
Sent.Add(@event);
return Task.CompletedTask;
}
}
}

View File

@@ -603,6 +603,33 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal("GHSA-2025-0001", evidence!.AdvisoryKey);
Assert.Equal(2, evidence.Records.Count);
Assert.All(evidence.Records, record => Assert.Equal("tenant-a", record.Tenant));
Assert.Null(evidence.Attestation);
}
[Fact]
public async Task AdvisoryEvidenceEndpoint_AttachesAttestationWhenBundleProvided()
{
await SeedAdvisoryRawDocumentsAsync(
CreateAdvisoryRawDocument("tenant-a", "vendor-x", "GHSA-2025-0003", "sha256:201", new BsonDocument("id", "GHSA-2025-0003:1")));
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
var manifestPath = Path.Combine(sampleDir, "manifest.json");
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
using var client = _factory.CreateClient();
var requestUri = $"/vuln/evidence/advisories/GHSA-2025-0003?tenant=tenant-a&bundlePath={Uri.EscapeDataString(tarPath)}&manifestPath={Uri.EscapeDataString(manifestPath)}&transparencyPath={Uri.EscapeDataString(transparencyPath)}&pipelineVersion=git:test-sha";
var response = await client.GetAsync(requestUri);
response.EnsureSuccessStatusCode();
var evidence = await response.Content.ReadFromJsonAsync<AdvisoryEvidenceResponse>();
Assert.NotNull(evidence);
Assert.NotNull(evidence!.Attestation);
Assert.Equal("evidence-bundle-m0", evidence.Attestation!.SubjectName);
Assert.Equal("git:test-sha", evidence.Attestation.PipelineVersion);
Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath);
}
[Fact]