work work ... haaaard work

This commit is contained in:
StellaOps Bot
2025-11-24 00:34:20 +02:00
parent 0d4a986b7b
commit bb709b643e
36 changed files with 933 additions and 197 deletions

View File

@@ -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;
}

View File

@@ -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);

View 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);
}

View File

@@ -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";
}
}

View File

@@ -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)
{

View File

@@ -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));

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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");

View File

@@ -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);

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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> (

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -15,6 +15,5 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
</ItemGroup>
</Project>

View File

@@ -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 |