work work ... haaaard work
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
@@ -10,8 +11,7 @@ public static class PathScopeSimulationEndpoint
|
||||
public static IEndpointRouteBuilder MapPathScopeSimulation(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/simulation/path-scope", HandleAsync)
|
||||
.WithName("PolicyEngine.PathScopeSimulation")
|
||||
.WithOpenApi();
|
||||
.WithName("PolicyEngine.PathScopeSimulation");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
@@ -104,13 +104,14 @@ builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
|
||||
185
src/Policy/StellaOps.Policy.Engine/Services/PathScopeMetrics.cs
Normal file
185
src/Policy/StellaOps.Policy.Engine/Services/PathScopeMetrics.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Metrics sink for path/scope-aware policy evaluation (POLICY-ENGINE-29-004).
|
||||
/// Mirrors the prep contract at docs/modules/policy/prep/2025-11-20-policy-engine-29-004-prep.md.
|
||||
/// </summary>
|
||||
internal sealed class PathScopeMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _evaluations;
|
||||
private readonly Histogram<double> _evaluationDurationMs;
|
||||
private readonly Counter<long> _cacheHit;
|
||||
private readonly Counter<long> _scopeMismatch;
|
||||
private readonly ConcurrentDictionary<(string Tenant, string Source), CoverageState> _coverage = new();
|
||||
private readonly ObservableGauge<double> _coverageGauge;
|
||||
|
||||
public PathScopeMetrics()
|
||||
{
|
||||
_meter = new Meter("StellaOps.Policy.Engine", "1.0.0");
|
||||
|
||||
_evaluations = _meter.CreateCounter<long>(
|
||||
name: "policy.path.eval.total",
|
||||
unit: "count",
|
||||
description: "Total path/scope-aware evaluations processed.");
|
||||
|
||||
_evaluationDurationMs = _meter.CreateHistogram<double>(
|
||||
name: "policy.path.eval.duration.ms",
|
||||
unit: "ms",
|
||||
description: "Latency distribution for path/scope-aware evaluations.");
|
||||
|
||||
_cacheHit = _meter.CreateCounter<long>(
|
||||
name: "policy.path.eval.cache.hit",
|
||||
unit: "count",
|
||||
description: "Cache hit/miss counts for path/scope rule lookups.");
|
||||
|
||||
_scopeMismatch = _meter.CreateCounter<long>(
|
||||
name: "policy.path.eval.scope.mismatch",
|
||||
unit: "count",
|
||||
description: "Counts of scope mismatches (depth/confidence/coverage).");
|
||||
|
||||
Func<IEnumerable<Measurement<double>>> observe = ObserveCoverage;
|
||||
_coverageGauge = _meter.CreateObservableGauge<double>(
|
||||
name: "policy.path.eval.coverage",
|
||||
observeValues: observe,
|
||||
unit: "percent",
|
||||
description: "Share of observations with matching scope.");
|
||||
}
|
||||
|
||||
public void RecordEvaluation(
|
||||
string tenant,
|
||||
string subject,
|
||||
string ruleId,
|
||||
string pathMatch,
|
||||
string result,
|
||||
double durationMs,
|
||||
bool scopeMatched = true)
|
||||
{
|
||||
var evalTags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("subject", NormalizeSubject(subject)),
|
||||
new KeyValuePair<string, object?>("result", NormalizeResult(result)),
|
||||
new KeyValuePair<string, object?>("ruleId", TruncateRule(ruleId)),
|
||||
new KeyValuePair<string, object?>("pathMatch", NormalizePathMatch(pathMatch))
|
||||
};
|
||||
|
||||
_evaluations.Add(1, evalTags);
|
||||
|
||||
var durationTags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("subject", NormalizeSubject(subject)),
|
||||
new KeyValuePair<string, object?>("ruleId", TruncateRule(ruleId))
|
||||
};
|
||||
|
||||
_evaluationDurationMs.Record(durationMs, durationTags);
|
||||
RecordCoverage(tenant, "path-scope", scopeMatched);
|
||||
}
|
||||
|
||||
public void RecordCacheHit(string tenant, string cache, bool hit)
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("cache", NormalizeCache(cache)),
|
||||
new KeyValuePair<string, object?>("hit", hit ? "true" : "false")
|
||||
};
|
||||
|
||||
_cacheHit.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordScopeMismatch(string tenant, string reason)
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
||||
new KeyValuePair<string, object?>("reason", NormalizeScopeReason(reason))
|
||||
};
|
||||
|
||||
_scopeMismatch.Add(1, tags);
|
||||
RecordCoverage(tenant, "path-scope", matched: false);
|
||||
}
|
||||
|
||||
private void RecordCoverage(string tenant, string source, bool matched)
|
||||
{
|
||||
var key = (NormalizeTenant(tenant), NormalizeSource(source));
|
||||
|
||||
_coverage.AddOrUpdate(
|
||||
key,
|
||||
_ => matched ? new CoverageState(1, 1) : new CoverageState(0, 1),
|
||||
(_, state) => matched
|
||||
? new CoverageState(state.Matched + 1, state.Total + 1)
|
||||
: new CoverageState(state.Matched, state.Total + 1));
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<double>> ObserveCoverage()
|
||||
{
|
||||
foreach (var kvp in _coverage)
|
||||
{
|
||||
var state = kvp.Value;
|
||||
if (state.Total == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var percentage = state.Matched * 100.0 / state.Total;
|
||||
yield return new Measurement<double>(
|
||||
percentage,
|
||||
new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", kvp.Key.Tenant),
|
||||
new KeyValuePair<string, object?>("source", kvp.Key.Source)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeTenant(string tenant) =>
|
||||
string.IsNullOrWhiteSpace(tenant) ? "unknown" : tenant;
|
||||
|
||||
private static string NormalizeSubject(string subject) =>
|
||||
string.IsNullOrWhiteSpace(subject) ? "unknown" : subject;
|
||||
|
||||
private static string NormalizeResult(string result) =>
|
||||
result switch
|
||||
{
|
||||
"allow" or "deny" or "error" => result,
|
||||
_ => "deny"
|
||||
};
|
||||
|
||||
private static string NormalizePathMatch(string pathMatch) =>
|
||||
pathMatch switch
|
||||
{
|
||||
"exact" or "prefix" or "glob" => pathMatch,
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static string NormalizeCache(string cache) =>
|
||||
string.IsNullOrWhiteSpace(cache) ? "decision" : cache;
|
||||
|
||||
private static string NormalizeScopeReason(string reason) =>
|
||||
string.IsNullOrWhiteSpace(reason) ? "no-scope" : reason;
|
||||
|
||||
private static string NormalizeSource(string source) =>
|
||||
string.IsNullOrWhiteSpace(source) ? "path-scope" : source;
|
||||
|
||||
private static string TruncateRule(string ruleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ruleId))
|
||||
{
|
||||
return "unspecified";
|
||||
}
|
||||
|
||||
return ruleId.Length <= 32 ? ruleId : ruleId[..32];
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
|
||||
private readonly record struct CoverageState(long Matched, long Total);
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
public sealed partial class PolicyEvaluationService
|
||||
internal sealed partial class PolicyEvaluationService
|
||||
{
|
||||
private const string StubRuleId = "policy.rules.path-scope.stub";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public Task<JsonObject> EvaluatePathScopeAsync(
|
||||
PathScopeSimulationRequest request,
|
||||
PathScopeTarget target,
|
||||
@@ -12,13 +25,16 @@ public sealed partial class PolicyEvaluationService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var stableKey = string.Create(CultureInfo.InvariantCulture, $"{request.BasePolicyRef}|{request.CandidatePolicyRef}|{target.FilePath}|{target.Pattern}");
|
||||
var verdictDelta = ComputeDelta(stableKey);
|
||||
var result = NormalizeResult(verdictDelta.candidateVerdict);
|
||||
var correlationId = ComputeCorrelationId(stableKey);
|
||||
|
||||
var finding = new JsonObject
|
||||
{
|
||||
["id"] = target.EvidenceHash ?? "stub-ghsa",
|
||||
["ruleId"] = "policy.rules.path-scope.stub",
|
||||
["ruleId"] = StubRuleId,
|
||||
["severity"] = "info",
|
||||
["verdict"] = new JsonObject
|
||||
{
|
||||
@@ -67,6 +83,34 @@ public sealed partial class PolicyEvaluationService
|
||||
}
|
||||
};
|
||||
|
||||
var durationMs = ElapsedMilliseconds(start);
|
||||
((JsonObject)envelope["metrics"]!)[@"durationMs"] = Math.Round(durationMs, 3, MidpointRounding.ToZero);
|
||||
|
||||
_pathMetrics.RecordEvaluation(
|
||||
tenant: request.Tenant,
|
||||
subject: SimplifySubject(request.Subject),
|
||||
ruleId: StubRuleId,
|
||||
pathMatch: target.PathMatch,
|
||||
result: result,
|
||||
durationMs: durationMs,
|
||||
scopeMatched: true);
|
||||
|
||||
_pathMetrics.RecordCacheHit(request.Tenant, cache: "rule", hit: false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy.PathEval {@Tenant} {@RuleId} {@Subject} {@FilePath} {@PathMatch} {@Pattern} {@Confidence} {@Decision} {@DurationMs} {@EvidenceHash} {@CorrelationId}",
|
||||
request.Tenant,
|
||||
StubRuleId,
|
||||
SimplifySubject(request.Subject),
|
||||
target.FilePath,
|
||||
target.PathMatch,
|
||||
target.Pattern,
|
||||
target.Confidence,
|
||||
verdictDelta.candidateVerdict,
|
||||
durationMs,
|
||||
target.EvidenceHash ?? string.Empty,
|
||||
correlationId);
|
||||
|
||||
return Task.FromResult(envelope);
|
||||
}
|
||||
|
||||
@@ -83,4 +127,56 @@ public sealed partial class PolicyEvaluationService
|
||||
var delta = baseVerdict == candidateVerdict ? "unchanged" : "softened";
|
||||
return (baseVerdict, candidateVerdict, delta);
|
||||
}
|
||||
|
||||
private static string NormalizeResult(string candidateVerdict) =>
|
||||
string.Equals(candidateVerdict, "deny", StringComparison.OrdinalIgnoreCase) ? "deny" : "allow";
|
||||
|
||||
private static double ElapsedMilliseconds(long startTimestamp)
|
||||
{
|
||||
var elapsedTicks = Stopwatch.GetTimestamp() - startTimestamp;
|
||||
return elapsedTicks * 1000.0 / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
private static string ComputeCorrelationId(string stableKey)
|
||||
{
|
||||
Span<byte> hashBytes = stackalloc byte[16];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
|
||||
private static string SimplifySubject(PathScopeSubject subject)
|
||||
{
|
||||
if (subject is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subject.Purl))
|
||||
{
|
||||
var purl = subject.Purl;
|
||||
var trimmed = purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) ? purl[4..] : purl;
|
||||
var slashIndex = trimmed.IndexOf('/', StringComparison.Ordinal);
|
||||
if (slashIndex >= 0 && slashIndex + 1 < trimmed.Length)
|
||||
{
|
||||
var remainder = trimmed[(slashIndex + 1)..];
|
||||
var atIndex = remainder.IndexOf('@');
|
||||
var withoutVersion = atIndex >= 0 ? remainder[..atIndex] : remainder;
|
||||
var lastSlash = withoutVersion.LastIndexOf('/');
|
||||
return lastSlash >= 0 ? withoutVersion[(lastSlash + 1)..] : withoutVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subject.Cpe))
|
||||
{
|
||||
var parts = subject.Cpe.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length > 4)
|
||||
{
|
||||
return parts[4];
|
||||
}
|
||||
|
||||
return parts.LastOrDefault() ?? "unknown";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class PolicyEvaluationService
|
||||
internal sealed partial class PolicyEvaluationService
|
||||
{
|
||||
private readonly PolicyEvaluator evaluator = new();
|
||||
private readonly PathScopeMetrics _pathMetrics;
|
||||
private readonly ILogger<PolicyEvaluationService> _logger;
|
||||
|
||||
public PolicyEvaluationService() : this(new PathScopeMetrics())
|
||||
public PolicyEvaluationService()
|
||||
: this(new PathScopeMetrics(), NullLogger<PolicyEvaluationService>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public PolicyEvaluationService(PathScopeMetrics pathMetrics)
|
||||
public PolicyEvaluationService(PathScopeMetrics pathMetrics, ILogger<PolicyEvaluationService> logger)
|
||||
{
|
||||
_pathMetrics = pathMetrics ?? throw new ArgumentNullException(nameof(pathMetrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
internal PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Streaming;
|
||||
@@ -10,7 +11,7 @@ namespace StellaOps.Policy.Engine.Streaming;
|
||||
/// Current behaviour emits no findings but enforces request validation, canonical ordering,
|
||||
/// and NDJSON framing so downstream consumers can integrate without schema drift.
|
||||
/// </summary>
|
||||
public sealed class PathScopeSimulationService
|
||||
internal sealed class PathScopeSimulationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
@@ -19,6 +20,10 @@ public sealed class PathScopeSimulationService
|
||||
|
||||
private readonly PolicyEvaluationService _evaluationService;
|
||||
|
||||
public PathScopeSimulationService() : this(new PolicyEvaluationService())
|
||||
{
|
||||
}
|
||||
|
||||
public PathScopeSimulationService(PolicyEvaluationService evaluationService)
|
||||
{
|
||||
_evaluationService = evaluationService ?? throw new ArgumentNullException(nameof(evaluationService));
|
||||
|
||||
@@ -27,8 +27,8 @@ public sealed class PathScopeSimulationServiceTests
|
||||
var lines = await service.StreamAsync(request).ToListAsync();
|
||||
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.Contains(lines[0], s => s.Contains("\"filePath\":\"a/file.js\""));
|
||||
Assert.Contains(lines[1], s => s.Contains("\"filePath\":\"b/file.js\""));
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", lines[0]);
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", lines[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -44,6 +44,7 @@ public sealed class PathScopeSimulationServiceTests
|
||||
Targets: Array.Empty<PathScopeTarget>(),
|
||||
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
|
||||
|
||||
await Assert.ThrowsAsync<PathScopeSimulationException>(() => service.StreamAsync(request).ToListAsync());
|
||||
await Assert.ThrowsAsync<PathScopeSimulationException>(async () =>
|
||||
await service.StreamAsync(request).ToListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class EntrypointEndpointsTests : IClassFixture<SbomServiceWebApplicationFactory>
|
||||
{
|
||||
private readonly SbomServiceWebApplicationFactory _factory;
|
||||
|
||||
public EntrypointEndpointsTests(SbomServiceWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_entrypoints_requires_tenant()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/entrypoints");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_entrypoints_returns_seeded_list()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/entrypoints?tenant=tenant-a");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<EntrypointListResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.Tenant.Should().Be("tenant-a");
|
||||
payload.Items.Should().NotBeEmpty();
|
||||
payload.Items.Select(e => e.Artifact).Should().Contain("ghcr.io/stellaops/sample-api");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_entrypoints_upserts_and_returns_ordered_list()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var upsert = new EntrypointUpsertRequest(
|
||||
Tenant: "tenant-a",
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Service: "web",
|
||||
Path: "/api/v2",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true);
|
||||
|
||||
var post = await client.PostAsJsonAsync("/entrypoints", upsert);
|
||||
post.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await post.Content.ReadFromJsonAsync<EntrypointListResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.Items.First(e => e.Service == "web").Path.Should().Be("/api/v2");
|
||||
payload.Items.Should().BeInAscendingOrder(e => e.Artifact);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
@@ -23,7 +23,8 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
|
||||
var response = await client.GetAsync("/sbom/paths");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,7 +48,7 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/sbom/versions?artifact=ghcr.io/stellaops/sample-api");
|
||||
response.EnsureSuccessStatusCode();
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomTimelineResult>();
|
||||
payload.Should().NotBeNull();
|
||||
|
||||
@@ -31,9 +31,10 @@ public class SbomEventEndpointsTests : IClassFixture<WebApplicationFactory<Progr
|
||||
|
||||
var events = await client.GetFromJsonAsync<List<SbomVersionCreatedEvent>>("/internal/sbom/events");
|
||||
events.Should().NotBeNull();
|
||||
events!.Should().HaveCount(1);
|
||||
events[0].SnapshotId.Should().Be("snap-001");
|
||||
events[0].TenantId.Should().Be("tenant-a");
|
||||
var nonNullEvents = events!;
|
||||
nonNullEvents.Should().HaveCount(1);
|
||||
nonNullEvents[0].SnapshotId.Should().Be("snap-001");
|
||||
nonNullEvents[0].TenantId.Should().Be("tenant-a");
|
||||
|
||||
// Requesting the projection should not duplicate events.
|
||||
var projectionResponse = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
public sealed record Entrypoint(
|
||||
string Artifact,
|
||||
string Service,
|
||||
string Path,
|
||||
string Scope,
|
||||
bool RuntimeFlag);
|
||||
|
||||
public sealed record EntrypointUpsertRequest(
|
||||
string Tenant,
|
||||
string Artifact,
|
||||
string Service,
|
||||
string Path,
|
||||
string Scope,
|
||||
bool RuntimeFlag);
|
||||
|
||||
public sealed record EntrypointListResponse(
|
||||
string Tenant,
|
||||
IReadOnlyList<Entrypoint> Items);
|
||||
@@ -21,4 +21,19 @@ internal static class SbomMetrics
|
||||
public static readonly Counter<long> TimelineQueryTotal =
|
||||
Meter.CreateCounter<long>("sbom_timeline_queries_total",
|
||||
description: "Total SBOM timeline queries");
|
||||
}
|
||||
|
||||
public static readonly Histogram<double> ProjectionLatencySeconds =
|
||||
Meter.CreateHistogram<double>("sbom_projection_seconds", unit: "s",
|
||||
description: "Latency for SBOM projection reads");
|
||||
|
||||
public static readonly Histogram<long> ProjectionSizeBytes =
|
||||
Meter.CreateHistogram<long>("sbom_projection_size_bytes", unit: "By",
|
||||
description: "Payload size of SBOM projections returned");
|
||||
|
||||
public static readonly Counter<long> ProjectionQueryTotal =
|
||||
Meter.CreateCounter<long>("sbom_projection_queries_total",
|
||||
description: "Total SBOM projection queries");
|
||||
|
||||
public static readonly Histogram<long> EventBacklogSize =
|
||||
Meter.CreateHistogram<long>("sbom_events_backlog", unit: "events",
|
||||
description: "Observed size of the SBOM event outbox (in-memory)
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.SbomService.Observability;
|
||||
|
||||
internal static class SbomTracing
|
||||
{
|
||||
public const string SourceName = "StellaOps.SbomService";
|
||||
public static readonly ActivitySource Source = new(SourceName);
|
||||
}
|
||||
@@ -7,22 +7,24 @@ using StellaOps.SbomService.Services;
|
||||
using StellaOps.SbomService.Observability;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables("SBOM_");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables("SBOM_");
|
||||
|
||||
builder.Services.AddOptions();
|
||||
builder.Services.AddLogging();
|
||||
|
||||
builder.Services.AddOptions();
|
||||
builder.Services.AddLogging();
|
||||
|
||||
// Register SBOM query services (InMemory seed; replace with Mongo-backed repository later).
|
||||
builder.Services.AddSingleton<IComponentLookupRepository>(_ => new InMemoryComponentLookupRepository());
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
|
||||
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
|
||||
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
|
||||
builder.Services.AddSingleton<IEntrypointRepository, InMemoryEntrypointRepository>();
|
||||
|
||||
builder.Services.AddSingleton<IProjectionRepository>(sp =>
|
||||
{
|
||||
@@ -54,16 +56,85 @@ builder.Services.AddSingleton<IProjectionRepository>(sp =>
|
||||
|
||||
return new FileProjectionRepository(string.Empty);
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[dev-exception] {ex}");
|
||||
throw;
|
||||
}
|
||||
});
|
||||
app.UseDeveloperExceptionPage();
|
||||
}
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
|
||||
|
||||
app.MapGet("/entrypoints", async Task<IResult> (
|
||||
[FromServices] IEntrypointRepository repo,
|
||||
[FromQuery] string? tenant,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant is required" });
|
||||
}
|
||||
|
||||
var tenantId = tenant.Trim();
|
||||
using var activity = SbomTracing.Source.StartActivity("entrypoints.list", ActivityKind.Server);
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
|
||||
var items = await repo.ListAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(new EntrypointListResponse(tenantId, items));
|
||||
});
|
||||
|
||||
app.MapPost("/entrypoints", async Task<IResult> (
|
||||
[FromServices] IEntrypointRepository repo,
|
||||
[FromBody] EntrypointUpsertRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Artifact) || string.IsNullOrWhiteSpace(request.Service) || string.IsNullOrWhiteSpace(request.Path))
|
||||
{
|
||||
return Results.BadRequest(new { error = "artifact, service, and path are required" });
|
||||
}
|
||||
|
||||
var entrypoint = new Entrypoint(
|
||||
request.Artifact.Trim(),
|
||||
request.Service.Trim(),
|
||||
request.Path.Trim(),
|
||||
string.IsNullOrWhiteSpace(request.Scope) ? "runtime" : request.Scope.Trim(),
|
||||
request.RuntimeFlag);
|
||||
|
||||
var tenantId = request.Tenant.Trim();
|
||||
using var activity = SbomTracing.Source.StartActivity("entrypoints.upsert", ActivityKind.Server);
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("artifact", entrypoint.Artifact);
|
||||
activity?.SetTag("service", entrypoint.Service);
|
||||
|
||||
await repo.UpsertAsync(tenantId, entrypoint, cancellationToken);
|
||||
|
||||
var items = await repo.ListAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(new EntrypointListResponse(tenantId, items));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));
|
||||
|
||||
app.MapGet("/console/sboms", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? license,
|
||||
app.MapGet("/console/sboms", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? license,
|
||||
[FromQuery] string? scope,
|
||||
[FromQuery(Name = "assetTag")] string? assetTag,
|
||||
[FromQuery] string? cursor,
|
||||
@@ -80,15 +151,17 @@ app.MapGet("/console/sboms", async Task<IResult> (
|
||||
return Results.BadRequest(new { error = "cursor must be an integer offset" });
|
||||
}
|
||||
|
||||
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
||||
var pageSize = limit ?? 50;
|
||||
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var result = await service.GetConsoleCatalogAsync(
|
||||
new SbomCatalogQuery(artifact?.Trim(), license?.Trim(), scope?.Trim(), assetTag?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
||||
var pageSize = limit ?? 50;
|
||||
|
||||
using var activity = SbomTracing.Source.StartActivity("console.sboms", ActivityKind.Server);
|
||||
activity?.SetTag("artifact", artifact);
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var result = await service.GetConsoleCatalogAsync(
|
||||
new SbomCatalogQuery(artifact?.Trim(), license?.Trim(), scope?.Trim(), assetTag?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
{
|
||||
{ "scope", scope ?? string.Empty },
|
||||
@@ -103,10 +176,10 @@ app.MapGet("/console/sboms", async Task<IResult> (
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
|
||||
app.MapGet("/components/lookup", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] string? artifact,
|
||||
app.MapGet("/components/lookup", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -126,13 +199,16 @@ app.MapGet("/components/lookup", async Task<IResult> (
|
||||
return Results.BadRequest(new { error = "cursor must be an integer offset" });
|
||||
}
|
||||
|
||||
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
||||
var pageSize = limit ?? 50;
|
||||
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var result = await service.GetComponentLookupAsync(
|
||||
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
||||
var pageSize = limit ?? 50;
|
||||
|
||||
using var activity = SbomTracing.Source.StartActivity("components.lookup", ActivityKind.Server);
|
||||
activity?.SetTag("purl", purl);
|
||||
activity?.SetTag("artifact", artifact);
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var result = await service.GetComponentLookupAsync(
|
||||
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
@@ -149,13 +225,13 @@ app.MapGet("/components/lookup", async Task<IResult> (
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
|
||||
app.MapGet("/sbom/paths", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? scope,
|
||||
[FromQuery(Name = "env")] string? environment,
|
||||
[FromQuery] string? cursor,
|
||||
app.MapGet("/sbom/paths", async Task<IResult> (
|
||||
[FromServices] IServiceProvider services,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? scope,
|
||||
[FromQuery(Name = "env")] string? environment,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
@@ -172,15 +248,16 @@ app.MapGet("/sbom/paths", async Task<IResult> (
|
||||
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return Results.BadRequest(new { error = "cursor must be an integer offset" });
|
||||
}
|
||||
|
||||
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
||||
var pageSize = limit ?? 50;
|
||||
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var result = await service.GetPathsAsync(
|
||||
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
|
||||
var pageSize = limit ?? 50;
|
||||
|
||||
var service = services.GetRequiredService<ISbomQueryService>();
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var result = await service.GetPathsAsync(
|
||||
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
@@ -250,20 +327,37 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
|
||||
return Results.BadRequest(new { error = "tenant is required" });
|
||||
}
|
||||
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
var projection = await service.GetProjectionAsync(snapshotId.Trim(), tenantId.Trim(), cancellationToken);
|
||||
if (projection is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "projection not found" });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
using var activity = SbomTracing.Source.StartActivity("sbom.projection", ActivityKind.Server);
|
||||
activity?.SetTag("tenant", projection.TenantId);
|
||||
activity?.SetTag("snapshotId", projection.SnapshotId);
|
||||
activity?.SetTag("schema", projection.SchemaVersion);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
snapshotId = projection.SnapshotId,
|
||||
tenantId = projection.TenantId,
|
||||
schemaVersion = projection.SchemaVersion,
|
||||
hash = projection.ProjectionHash,
|
||||
projection = projection.Projection
|
||||
});
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
|
||||
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new TagList { { "tenant", projection.TenantId } });
|
||||
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
|
||||
new TagList { { "tenant", projection.TenantId } });
|
||||
SbomMetrics.ProjectionQueryTotal.Add(1, new TagList { { "tenant", projection.TenantId } });
|
||||
|
||||
app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes);
|
||||
|
||||
return Results.Ok(payload);
|
||||
});
|
||||
|
||||
app.MapGet("/internal/sbom/events", async Task<IResult> (
|
||||
|
||||
@@ -4,5 +4,9 @@ namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public interface IComponentLookupRepository
|
||||
{
|
||||
Task<IReadOnlyList<ComponentLookupRecord>> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Returns a page of component neighbors along with the total count that match the query filters.
|
||||
/// The total is required for deterministic pagination cursors.
|
||||
/// </summary>
|
||||
Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public interface IEntrypointRepository
|
||||
{
|
||||
Task<IReadOnlyList<Entrypoint>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
Task UpsertAsync(string tenantId, Entrypoint entrypoint, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ public sealed class InMemoryComponentLookupRepository : IComponentLookupReposito
|
||||
{
|
||||
private static readonly IReadOnlyList<ComponentLookupRecord> Components = Seed();
|
||||
|
||||
public Task<IReadOnlyList<ComponentLookupRecord>> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
public Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var filtered = Components
|
||||
.Where(c => c.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -20,7 +20,7 @@ public sealed class InMemoryComponentLookupRepository : IComponentLookupReposito
|
||||
.Take(query.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ComponentLookupRecord>>(page);
|
||||
return Task.FromResult<(IReadOnlyList<ComponentLookupRecord>, int)>((page, filtered.Count));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComponentLookupRecord> Seed()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public sealed class InMemoryEntrypointRepository : IEntrypointRepository
|
||||
{
|
||||
// tenant -> list of entrypoints
|
||||
private readonly ConcurrentDictionary<string, List<Entrypoint>> _store = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public InMemoryEntrypointRepository()
|
||||
{
|
||||
_store["tenant-a"] = new List<Entrypoint>
|
||||
{
|
||||
new("ghcr.io/stellaops/sample-api", "web", "/api", "runtime", true),
|
||||
new("ghcr.io/stellaops/sample-worker", "worker", "queue:jobs", "runtime", true)
|
||||
};
|
||||
|
||||
_store["tenant-b"] = new List<Entrypoint>
|
||||
{
|
||||
new("ghcr.io/stellaops/console", "ui", "/", "runtime", true)
|
||||
};
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Entrypoint>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = _store.TryGetValue(tenantId, out var list)
|
||||
? list.OrderBy(e => e.Artifact, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(e => e.Service, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(e => e.Path, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: new List<Entrypoint>();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<Entrypoint>>(items);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(string tenantId, Entrypoint entrypoint, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _store.GetOrAdd(tenantId, _ => new List<Entrypoint>());
|
||||
|
||||
var existingIndex = list.FindIndex(e =>
|
||||
e.Artifact.Equals(entrypoint.Artifact, StringComparison.OrdinalIgnoreCase) &&
|
||||
e.Service.Equals(entrypoint.Service, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
list[existingIndex] = entrypoint;
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add(entrypoint);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
internal sealed class MongoComponentLookupRepository : IComponentLookupRepository
|
||||
{
|
||||
private readonly IMongoCollection<ComponentLookupRecord> _collection;
|
||||
|
||||
public MongoComponentLookupRepository(IMongoDatabase database)
|
||||
{
|
||||
_collection = database.GetCollection<ComponentLookupRecord>("sbom_components");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ComponentLookupRecord>> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ComponentLookupRecord>.Filter.Eq(c => c.Purl, query.Purl);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
filter &= Builders<ComponentLookupRecord>.Filter.Eq(c => c.Artifact, query.Artifact);
|
||||
}
|
||||
|
||||
var results = await _collection
|
||||
.Find(filter)
|
||||
.Skip(query.Offset)
|
||||
.Limit(query.Limit)
|
||||
.Sort(Builders<ComponentLookupRecord>.Sort.Ascending(c => c.Artifact).Ascending(c => c.NeighborPurl))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -152,15 +152,15 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
return new QueryResult<ComponentLookupResult>(cachedResult, true);
|
||||
}
|
||||
|
||||
var page = await _componentLookupRepository.QueryAsync(query, cancellationToken);
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < page.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var neighbors = page
|
||||
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
|
||||
.ToList();
|
||||
var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken);
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < total
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var neighbors = items
|
||||
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
|
||||
.ToList();
|
||||
|
||||
var result = new ComponentLookupResult(query.Purl, query.Artifact, neighbors, nextCursor, CacheHint: "seeded");
|
||||
_cache[cacheKey] = result;
|
||||
|
||||
@@ -15,6 +15,5 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
| Task ID | Status | Notes | Updated (UTC) |
|
||||
| --- | --- | --- | --- |
|
||||
| PREP-SBOM-CONSOLE-23-001-BUILD-TEST-FAILING-D | DONE | Offline feed cache + script added; see `docs/modules/sbomservice/offline-feed-plan.md`. | 2025-11-20 |
|
||||
| SBOM-SERVICE-21-002 | DONE | `sbom.version.created` events emitted via in-memory publisher; `/internal/sbom/events` + backfill wired; component lookup pagination cursor fixed; tests pass. | 2025-11-23 |
|
||||
| SBOM-SERVICE-21-003 | DONE | Entrypoint/service node API (`GET/POST /entrypoints`) with tenant guard, deterministic ordering, seeded data; tests added. | 2025-11-23 |
|
||||
|
||||
Reference in New Issue
Block a user