This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record AdvisorySummaryResponse(
|
||||
AdvisorySummaryMeta Meta,
|
||||
IReadOnlyList<AdvisorySummaryItem> Items);
|
||||
|
||||
public sealed record AdvisorySummaryMeta(
|
||||
string Tenant,
|
||||
int Count,
|
||||
string? Next,
|
||||
string Sort);
|
||||
|
||||
public sealed record AdvisorySummaryItem(
|
||||
string AdvisoryKey,
|
||||
string Source,
|
||||
string? LinksetId,
|
||||
double? Confidence,
|
||||
IReadOnlyList<AdvisorySummaryConflict>? Conflicts,
|
||||
AdvisorySummaryCounts Counts,
|
||||
AdvisorySummaryProvenance Provenance,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string? ObservedAt);
|
||||
|
||||
public sealed record AdvisorySummaryConflict(
|
||||
string Field,
|
||||
string Reason,
|
||||
IReadOnlyList<string>? SourceIds);
|
||||
|
||||
public sealed record AdvisorySummaryCounts(
|
||||
int Observations,
|
||||
int ConflictFields);
|
||||
|
||||
public sealed record AdvisorySummaryProvenance(
|
||||
IReadOnlyList<string>? ObservationIds,
|
||||
string? Schema);
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
internal static class AdvisorySummaryMapper
|
||||
{
|
||||
public static AdvisorySummaryItem ToSummary(AdvisoryLinkset linkset)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(linkset);
|
||||
|
||||
var aliases = linkset.Normalized?.Purls ?? Array.Empty<string>();
|
||||
var conflictFields = linkset.Conflicts?.Select(c => c.Field).Distinct(StringComparer.Ordinal).Count() ?? 0;
|
||||
|
||||
var conflicts = linkset.Conflicts?.Select(c => new AdvisorySummaryConflict(
|
||||
c.Field,
|
||||
c.Reason,
|
||||
c.SourceIds?.ToArray()
|
||||
)).ToArray();
|
||||
|
||||
return new AdvisorySummaryItem(
|
||||
AdvisoryKey: linkset.AdvisoryId,
|
||||
Source: linkset.Source,
|
||||
LinksetId: linkset.BuiltByJobId,
|
||||
Confidence: linkset.Confidence,
|
||||
Conflicts: conflicts,
|
||||
Counts: new AdvisorySummaryCounts(
|
||||
Observations: linkset.ObservationIds.Length,
|
||||
ConflictFields: conflictFields),
|
||||
Provenance: new AdvisorySummaryProvenance(
|
||||
ObservationIds: linkset.ObservationIds.ToArray(),
|
||||
Schema: "lnm-1.0"),
|
||||
Aliases: aliases.ToArray(),
|
||||
ObservedAt: linkset.CreatedAt.UtcDateTime.ToString("O"));
|
||||
}
|
||||
|
||||
public static AdvisorySummaryResponse ToResponse(
|
||||
string tenant,
|
||||
IReadOnlyList<AdvisorySummaryItem> items,
|
||||
string? nextCursor,
|
||||
string sort)
|
||||
{
|
||||
return new AdvisorySummaryResponse(
|
||||
new AdvisorySummaryMeta(
|
||||
Tenant: tenant,
|
||||
Count: items.Count,
|
||||
Next: nextCursor,
|
||||
Sort: sort),
|
||||
items);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,49 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Telemetry;
|
||||
|
||||
internal sealed class LinksetCacheTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Concelier.Linksets");
|
||||
|
||||
private readonly Counter<long> _hitTotal;
|
||||
private readonly Counter<long> _writeTotal;
|
||||
private readonly Histogram<double> _rebuildMs;
|
||||
|
||||
public LinksetCacheTelemetry(IMeterFactory meterFactory)
|
||||
public LinksetCacheTelemetry()
|
||||
{
|
||||
var meter = meterFactory.Create("StellaOps.Concelier.Linksets");
|
||||
_hitTotal = meter.CreateCounter<long>("lnm.cache.hit_total", unit: "hit", description: "Cache hits for LNM linksets");
|
||||
_writeTotal = meter.CreateCounter<long>("lnm.cache.write_total", unit: "write", description: "Cache writes for LNM linksets");
|
||||
_rebuildMs = meter.CreateHistogram<double>("lnm.cache.rebuild_ms", unit: "ms", description: "Synchronous rebuild latency for LNM cache");
|
||||
_hitTotal = Meter.CreateCounter<long>("lnm.cache.hit_total", unit: "hit", description: "Cache hits for LNM linksets");
|
||||
_writeTotal = Meter.CreateCounter<long>("lnm.cache.write_total", unit: "write", description: "Cache writes for LNM linksets");
|
||||
_rebuildMs = Meter.CreateHistogram<double>("lnm.cache.rebuild_ms", unit: "ms", description: "Synchronous rebuild latency for LNM cache");
|
||||
}
|
||||
|
||||
public void RecordHit(string? tenant, string source)
|
||||
{
|
||||
var tags = new TagList
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
{ "tenant", tenant ?? string.Empty },
|
||||
{ "source", source }
|
||||
new("tenant", tenant ?? string.Empty),
|
||||
new("source", source)
|
||||
};
|
||||
_hitTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordWrite(string? tenant, string source)
|
||||
{
|
||||
var tags = new TagList
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
{ "tenant", tenant ?? string.Empty },
|
||||
{ "source", source }
|
||||
new("tenant", tenant ?? string.Empty),
|
||||
new("source", source)
|
||||
};
|
||||
_writeTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordRebuild(string? tenant, string source, double elapsedMs)
|
||||
{
|
||||
var tags = new TagList
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
{ "tenant", tenant ?? string.Empty },
|
||||
{ "source", source }
|
||||
new("tenant", tenant ?? string.Empty),
|
||||
new("source", source)
|
||||
};
|
||||
_rebuildMs.Record(elapsedMs, tags);
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ public class AdvisoryChunkBuilderTests
|
||||
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
fingerprint: "fp",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
sectionFilter: ImmutableHashSet.Create("workaround"),
|
||||
formatFilter: ImmutableHashSet<string>.Empty,
|
||||
minimumLength: 1);
|
||||
"fp",
|
||||
5,
|
||||
5,
|
||||
ImmutableHashSet.Create("workaround"),
|
||||
ImmutableHashSet<string>.Empty,
|
||||
1);
|
||||
|
||||
var builder = new AdvisoryChunkBuilder(_hash);
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
@@ -54,12 +54,12 @@ public class AdvisoryChunkBuilderTests
|
||||
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
fingerprint: "fp",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
sectionFilter: ImmutableHashSet.Create("workaround"),
|
||||
formatFilter: ImmutableHashSet<string>.Empty,
|
||||
minimumLength: 1);
|
||||
"fp",
|
||||
5,
|
||||
5,
|
||||
ImmutableHashSet.Create("workaround"),
|
||||
ImmutableHashSet<string>.Empty,
|
||||
1);
|
||||
|
||||
var builder = new AdvisoryChunkBuilder(_hash);
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
@@ -115,9 +115,9 @@ public class AdvisoryChunkBuilderTests
|
||||
fetchedAt: timestamp,
|
||||
receivedAt: timestamp,
|
||||
contentHash: "sha256:deadbeef",
|
||||
signature: new AdvisoryObservationSignature(present: false)),
|
||||
signature: new AdvisoryObservationSignature(present: false, format: null, keyId: null, signature: null)),
|
||||
content: new AdvisoryObservationContent("csaf", "2.0", JsonNode.Parse("{}")!),
|
||||
linkset: new AdvisoryObservationLinkset(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<AdvisoryObservationReference>()),
|
||||
linkset: new AdvisoryObservationLinkset(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<AdvisoryObservationReference>()),
|
||||
rawLinkset: new RawLinkset(),
|
||||
createdAt: timestamp);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ public class AdvisoryChunkCacheKeyTests
|
||||
public void Create_NormalizesObservationOrdering()
|
||||
{
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
AdvisoryKey: "CVE-2025-0001",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 10,
|
||||
ObservationLimit: 10,
|
||||
SectionFilter: ImmutableHashSet.Create("workaround"),
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 8);
|
||||
"CVE-2025-0001",
|
||||
"fp",
|
||||
10,
|
||||
10,
|
||||
ImmutableHashSet.Create("workaround"),
|
||||
ImmutableHashSet<string>.Empty,
|
||||
8);
|
||||
|
||||
var first = BuildObservation("obs-1", "sha256:one", "2025-11-18T00:00:00Z");
|
||||
var second = BuildObservation("obs-2", "sha256:two", "2025-11-18T00:05:00Z");
|
||||
@@ -29,7 +29,6 @@ public class AdvisoryChunkCacheKeyTests
|
||||
var reversed = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0001", options, new[] { second, first }, "fp");
|
||||
|
||||
Assert.Equal(ordered.Value, reversed.Value);
|
||||
Assert.Equal(ordered.ComputeHash(), reversed.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -37,21 +36,21 @@ public class AdvisoryChunkCacheKeyTests
|
||||
{
|
||||
var optionsLower = new AdvisoryChunkBuildOptions(
|
||||
"CVE-2025-0002",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 5,
|
||||
ObservationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet.Create("workaround", "fix"),
|
||||
FormatFilter: ImmutableHashSet.Create("ndjson"),
|
||||
MinimumLength: 1);
|
||||
"fp",
|
||||
5,
|
||||
5,
|
||||
ImmutableHashSet.Create("workaround", "fix"),
|
||||
ImmutableHashSet.Create("ndjson"),
|
||||
1);
|
||||
|
||||
var optionsUpper = new AdvisoryChunkBuildOptions(
|
||||
"CVE-2025-0002",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 5,
|
||||
ObservationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet.Create("WorkAround", "FIX"),
|
||||
FormatFilter: ImmutableHashSet.Create("NDJSON"),
|
||||
MinimumLength: 1);
|
||||
"fp",
|
||||
5,
|
||||
5,
|
||||
ImmutableHashSet.Create("WorkAround", "FIX"),
|
||||
ImmutableHashSet.Create("NDJSON"),
|
||||
1);
|
||||
|
||||
var observation = BuildObservation("obs-3", "sha256:three", "2025-11-18T00:10:00Z");
|
||||
|
||||
@@ -59,7 +58,6 @@ public class AdvisoryChunkCacheKeyTests
|
||||
var upper = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0002", optionsUpper, new[] { observation }, "fp");
|
||||
|
||||
Assert.Equal(lower.Value, upper.Value);
|
||||
Assert.Equal(lower.ComputeHash(), upper.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -67,12 +65,12 @@ public class AdvisoryChunkCacheKeyTests
|
||||
{
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
"CVE-2025-0003",
|
||||
Fingerprint: "fp",
|
||||
ChunkLimit: 5,
|
||||
ObservationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet<string>.Empty,
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 1);
|
||||
"fp",
|
||||
5,
|
||||
5,
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableHashSet<string>.Empty,
|
||||
1);
|
||||
|
||||
var original = BuildObservation("obs-4", "sha256:orig", "2025-11-18T00:15:00Z");
|
||||
var mutated = BuildObservation("obs-4", "sha256:mut", "2025-11-18T00:15:00Z");
|
||||
@@ -81,7 +79,6 @@ public class AdvisoryChunkCacheKeyTests
|
||||
var mutatedKey = AdvisoryChunkCacheKey.Create("tenant-a", "CVE-2025-0003", options, new[] { mutated }, "fp");
|
||||
|
||||
Assert.NotEqual(originalKey.Value, mutatedKey.Value);
|
||||
Assert.NotEqual(originalKey.ComputeHash(), mutatedKey.ComputeHash());
|
||||
}
|
||||
|
||||
private static AdvisoryObservation BuildObservation(string id, string contentHash, string timestamp)
|
||||
@@ -98,9 +95,9 @@ public class AdvisoryChunkCacheKeyTests
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: contentHash,
|
||||
signature: new AdvisoryObservationSignature(false)),
|
||||
signature: new AdvisoryObservationSignature(false, null, null, null)),
|
||||
content: new AdvisoryObservationContent("csaf", "2.0", JsonNode.Parse("{}")!),
|
||||
linkset: new AdvisoryObservationLinkset(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<AdvisoryObservationReference>()),
|
||||
linkset: new AdvisoryObservationLinkset(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>(), Array.Empty<AdvisoryObservationReference>()),
|
||||
rawLinkset: new RawLinkset(),
|
||||
createdAt: createdAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.WebService.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class AdvisorySummaryMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Maps_basic_fields()
|
||||
{
|
||||
var linkset = new AdvisoryLinkset(
|
||||
TenantId: "tenant-a",
|
||||
Source: "nvd",
|
||||
AdvisoryId: "CVE-2024-1234",
|
||||
ObservationIds: ImmutableArray.Create("obs1", "obs2"),
|
||||
Normalized: new AdvisoryLinksetNormalized(
|
||||
Purls: new[] { "pkg:maven/log4j/log4j@2.17.1" },
|
||||
Versions: null,
|
||||
Ranges: null,
|
||||
Severities: null),
|
||||
Provenance: null,
|
||||
Confidence: 0.8,
|
||||
Conflicts: new[]
|
||||
{
|
||||
new AdvisoryLinksetConflict("severity", "severity-mismatch", Array.Empty<string>(), new [] { "nvd", "vendor" })
|
||||
},
|
||||
CreatedAt: DateTimeOffset.UnixEpoch,
|
||||
BuiltByJobId: "job-123");
|
||||
|
||||
var summary = AdvisorySummaryMapper.ToSummary(linkset);
|
||||
|
||||
Assert.Equal("CVE-2024-1234", summary.AdvisoryKey);
|
||||
Assert.Equal("nvd", summary.Source);
|
||||
Assert.Equal(2, summary.Counts.Observations);
|
||||
Assert.Equal(1, summary.Counts.ConflictFields);
|
||||
Assert.NotNull(summary.Conflicts);
|
||||
Assert.Equal("job-123", summary.LinksetId);
|
||||
Assert.Equal("pkg:maven/log4j/log4j@2.17.1", Assert.Single(summary.Aliases));
|
||||
}
|
||||
}
|
||||
@@ -37,18 +37,17 @@ public sealed class AdvisoryChunkBuilderTests
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
"fingerprint-1",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet<string>.Empty,
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 0);
|
||||
5,
|
||||
5,
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableHashSet<string>.Empty,
|
||||
0);
|
||||
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
|
||||
var entry = Assert.Single(result.Response.Entries);
|
||||
Assert.Equal("/references/0/title", entry.Provenance.ObservationPath);
|
||||
Assert.Equal(observation.ObservationId, entry.Provenance.DocumentId);
|
||||
Assert.Equal(observation.Upstream.ContentHash, entry.Provenance.ContentHash);
|
||||
Assert.Equal(new[] { "/references/0/title" }, entry.Provenance.FieldMask);
|
||||
Assert.Equal(ComputeChunkId(observation.ObservationId, "/references/0/title"), entry.ChunkId);
|
||||
}
|
||||
@@ -69,18 +68,17 @@ public sealed class AdvisoryChunkBuilderTests
|
||||
var options = new AdvisoryChunkBuildOptions(
|
||||
advisory.AdvisoryKey,
|
||||
"fingerprint-2",
|
||||
chunkLimit: 5,
|
||||
observationLimit: 5,
|
||||
SectionFilter: ImmutableHashSet<string>.Empty,
|
||||
FormatFilter: ImmutableHashSet<string>.Empty,
|
||||
MinimumLength: 0);
|
||||
5,
|
||||
5,
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableHashSet<string>.Empty,
|
||||
0);
|
||||
|
||||
var result = builder.Build(options, advisory, new[] { observation });
|
||||
|
||||
var entry = Assert.Single(result.Response.Entries);
|
||||
Assert.Equal("/references/0", entry.Provenance.ObservationPath);
|
||||
Assert.Equal(observation.ObservationId, entry.Provenance.DocumentId);
|
||||
Assert.Equal(observation.Upstream.ContentHash, entry.Provenance.ContentHash);
|
||||
Assert.Equal(new[] { "/references/0" }, entry.Provenance.FieldMask);
|
||||
Assert.Equal(ComputeChunkId(observation.ObservationId, "/references/0"), entry.ChunkId);
|
||||
}
|
||||
|
||||
@@ -21,4 +21,6 @@
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
@@ -60,6 +61,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
|
||||
private readonly ITestOutputHelper _output;
|
||||
private MongoDbRunner _runner = null!;
|
||||
private Process? _externalMongo;
|
||||
private string? _externalMongoDataPath;
|
||||
private ConcelierApplicationFactory _factory = null!;
|
||||
|
||||
public WebServiceEndpointsTests(ITestOutputHelper output)
|
||||
@@ -70,8 +73,15 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
PrepareMongoEnvironment();
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
_factory = new ConcelierApplicationFactory(_runner.ConnectionString);
|
||||
if (TryStartExternalMongo(out var externalConnectionString))
|
||||
{
|
||||
_factory = new ConcelierApplicationFactory(externalConnectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
_factory = new ConcelierApplicationFactory(_runner.ConnectionString);
|
||||
}
|
||||
WarmupFactory(_factory);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -79,7 +89,30 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_factory.Dispose();
|
||||
_runner.Dispose();
|
||||
if (_externalMongo is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_externalMongo.HasExited)
|
||||
{
|
||||
_externalMongo.Kill(true);
|
||||
_externalMongo.WaitForExit(2000);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup errors in tests
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_externalMongoDataPath) && Directory.Exists(_externalMongoDataPath))
|
||||
{
|
||||
try { Directory.Delete(_externalMongoDataPath, recursive: true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -2605,6 +2638,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable("MONGO2GO_CACHE_LOCATION", cacheDir);
|
||||
Environment.SetEnvironmentVariable("MONGO2GO_DOWNLOADS", cacheDir);
|
||||
Environment.SetEnvironmentVariable("MONGO2GO_MONGODB_VERSION", "4.4.4");
|
||||
Environment.SetEnvironmentVariable("MONGO2GO_MONGODB_PLATFORM", "linux");
|
||||
|
||||
var opensslPath = Path.Combine(repoRoot, "tests", "native", "openssl-1.1", "linux-x64");
|
||||
if (Directory.Exists(opensslPath))
|
||||
@@ -2616,16 +2650,80 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
// Also drop the OpenSSL libs next to the mongod binary Mongo2Go will spawn, in case LD_LIBRARY_PATH is ignored.
|
||||
var mongoBin = Directory.Exists(Path.Combine(repoRoot, ".nuget"))
|
||||
? Directory.GetFiles(Path.Combine(repoRoot, ".nuget", "packages", "mongo2go"), "mongod", SearchOption.AllDirectories)
|
||||
var repoNuget = Path.Combine(repoRoot, ".nuget", "packages", "mongo2go");
|
||||
var homeNuget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages", "mongo2go");
|
||||
var mongoBin = Directory.Exists(repoNuget)
|
||||
? Directory.GetFiles(repoNuget, "mongod", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(path => path.Contains("mongodb-linux-4.4.4", StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
|
||||
// Prefer globally cached Mongo2Go binaries if repo-local cache is missing.
|
||||
mongoBin ??= Directory.Exists(homeNuget)
|
||||
? Directory.GetFiles(homeNuget, "mongod", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(path => path.Contains("mongodb-linux-4.4.4", StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
|
||||
if (mongoBin is not null && File.Exists(mongoBin) && Directory.Exists(opensslPath))
|
||||
{
|
||||
var binDir = Path.GetDirectoryName(mongoBin)!;
|
||||
|
||||
// Create a tiny wrapper so the loader always gets LD_LIBRARY_PATH even if vstest strips it.
|
||||
var wrapperPath = Path.Combine(cacheDir, "mongod-wrapper.sh");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
var script = $"#!/usr/bin/env bash\nset -euo pipefail\nexport LD_LIBRARY_PATH=\"{opensslPath}:${{LD_LIBRARY_PATH:-}}\"\nexec \"{mongoBin}\" \"$@\"\n";
|
||||
File.WriteAllText(wrapperPath, script);
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(wrapperPath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort; if not supported, chmod will fall back to default permissions.
|
||||
}
|
||||
}
|
||||
|
||||
// Force Mongo2Go to use the wrapper to avoid downloads and inject OpenSSL search path.
|
||||
Environment.SetEnvironmentVariable("MONGO2GO_MONGODB_BINARY", wrapperPath);
|
||||
|
||||
// Keep direct LD_LIBRARY_PATH/PATH hints for any code paths that still honour parent env.
|
||||
var existing = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
|
||||
var combined = string.IsNullOrEmpty(existing) ? binDir : $"{binDir}:{existing}";
|
||||
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", combined);
|
||||
Environment.SetEnvironmentVariable("PATH", $"{binDir}:{Environment.GetEnvironmentVariable("PATH")}");
|
||||
|
||||
foreach (var libName in new[] { "libssl.so.1.1", "libcrypto.so.1.1" })
|
||||
{
|
||||
var target = Path.Combine(binDir, libName);
|
||||
var source = Path.Combine(opensslPath, libName);
|
||||
if (File.Exists(source) && !File.Exists(target))
|
||||
{
|
||||
File.Copy(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
// If the Mongo2Go global cache is different from the first hit, add its bin dir too.
|
||||
var globalBin = Directory.Exists(homeNuget)
|
||||
? Directory.GetFiles(homeNuget, "mongod", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(path => path.Contains("mongodb-linux-4.4.4", StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
if (mongoBin is not null && File.Exists(mongoBin) && Directory.Exists(opensslPath))
|
||||
if (globalBin is not null)
|
||||
{
|
||||
var binDir = Path.GetDirectoryName(mongoBin)!;
|
||||
var globalDir = Path.GetDirectoryName(globalBin)!;
|
||||
var withGlobal = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH") ?? string.Empty;
|
||||
if (!withGlobal.Split(':', StringSplitOptions.RemoveEmptyEntries).Contains(globalDir))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", $"{globalDir}:{withGlobal}".TrimEnd(':'));
|
||||
}
|
||||
Environment.SetEnvironmentVariable("PATH", $"{globalDir}:{Environment.GetEnvironmentVariable("PATH")}");
|
||||
foreach (var libName in new[] { "libssl.so.1.1", "libcrypto.so.1.1" })
|
||||
{
|
||||
var target = Path.Combine(binDir, libName);
|
||||
var target = Path.Combine(globalDir, libName);
|
||||
var source = Path.Combine(opensslPath, libName);
|
||||
if (File.Exists(source) && !File.Exists(target))
|
||||
{
|
||||
@@ -2634,29 +2732,142 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot()
|
||||
private bool TryStartExternalMongo(out string? connectionString)
|
||||
{
|
||||
connectionString = null;
|
||||
|
||||
var repoRoot = FindRepoRoot();
|
||||
if (repoRoot is null)
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
return false;
|
||||
}
|
||||
|
||||
var mongodCandidates = new List<string>();
|
||||
void AddCandidates(string root)
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
if (File.Exists(Path.Combine(current, "Directory.Build.props")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
mongodCandidates.AddRange(Directory.GetFiles(root, "mongod", SearchOption.AllDirectories)
|
||||
.Where(p => p.Contains("mongodb-linux-4.4.4", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(current);
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
AddCandidates(Path.Combine(repoRoot, ".nuget", "packages", "mongo2go"));
|
||||
AddCandidates(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages", "mongo2go"));
|
||||
|
||||
current = parent.FullName;
|
||||
var mongodPath = mongodCandidates.FirstOrDefault();
|
||||
if (mongodPath is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dataDir = Path.Combine(repoRoot, ".cache", "mongodb-local", $"manual-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
|
||||
var opensslPath = Path.Combine(repoRoot, "tests", "native", "openssl-1.1", "linux-x64");
|
||||
var port = GetEphemeralPort();
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = mongodPath,
|
||||
ArgumentList =
|
||||
{
|
||||
"--dbpath", dataDir,
|
||||
"--bind_ip", "127.0.0.1",
|
||||
"--port", port.ToString(),
|
||||
"--nojournal",
|
||||
"--quiet",
|
||||
"--replSet", "rs0"
|
||||
},
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true
|
||||
};
|
||||
|
||||
var existingLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
|
||||
var ldCombined = string.IsNullOrEmpty(existingLd) ? opensslPath : $"{opensslPath}:{existingLd}";
|
||||
psi.Environment["LD_LIBRARY_PATH"] = ldCombined;
|
||||
psi.Environment["PATH"] = $"{Path.GetDirectoryName(mongodPath)}:{Environment.GetEnvironmentVariable("PATH")}";
|
||||
|
||||
_externalMongo = Process.Start(psi);
|
||||
_externalMongoDataPath = dataDir;
|
||||
|
||||
if (_externalMongo is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Small ping loop to ensure mongod is ready
|
||||
var client = new MongoClient($"mongodb://127.0.0.1:{port}");
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (sw.Elapsed < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
try
|
||||
{
|
||||
client.GetDatabase("admin").RunCommand<BsonDocument>("{ ping: 1 }");
|
||||
// Initiate single-node replica set so features expecting replset work.
|
||||
client.GetDatabase("admin").RunCommand<BsonDocument>(BsonDocument.Parse("{ replSetInitiate: { _id: \"rs0\", members: [ { _id: 0, host: \"127.0.0.1:" + port + "\" } ] } }"));
|
||||
// Wait for primary
|
||||
var readySw = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (readySw.Elapsed < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
var status = client.GetDatabase("admin").RunCommand<BsonDocument>(BsonDocument.Parse("{ replSetGetStatus: 1 }"));
|
||||
var myState = status["members"].AsBsonArray.FirstOrDefault(x => x["self"].AsBoolean);
|
||||
if (myState != null && myState["state"].ToInt32() == 1)
|
||||
{
|
||||
connectionString = $"mongodb://127.0.0.1:{port}/?replicaSet=rs0";
|
||||
return true;
|
||||
}
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
// fallback if primary not reached
|
||||
connectionString = $"mongodb://127.0.0.1:{port}";
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
try { _externalMongo.Kill(true); } catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int GetEphemeralPort()
|
||||
{
|
||||
var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
string? lastMatch = null;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
if (File.Exists(Path.Combine(current, "Directory.Build.props")))
|
||||
{
|
||||
lastMatch = current;
|
||||
}
|
||||
|
||||
return null;
|
||||
var parent = Directory.GetParent(current);
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent.FullName;
|
||||
}
|
||||
|
||||
return lastMatch;
|
||||
}
|
||||
|
||||
private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(
|
||||
string? contentHash,
|
||||
string upstreamId,
|
||||
|
||||
Reference in New Issue
Block a user