blocker move 1
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record ConcelierHealthResponse(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("queueDepth")] int QueueDepth,
|
||||
[property: JsonPropertyName("ingestLatencyP50Ms")] int IngestLatencyP50Ms,
|
||||
[property: JsonPropertyName("ingestLatencyP99Ms")] int IngestLatencyP99Ms,
|
||||
[property: JsonPropertyName("errorRate1h")] double ErrorRate1h,
|
||||
[property: JsonPropertyName("sloBurnRate")] double SloBurnRate,
|
||||
[property: JsonPropertyName("window")] string Window,
|
||||
[property: JsonPropertyName("updatedAt")] string UpdatedAt);
|
||||
|
||||
public sealed record ConcelierTimelineEvent(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("queueDepth")] int QueueDepth,
|
||||
[property: JsonPropertyName("p50Ms")] int P50Ms,
|
||||
[property: JsonPropertyName("p99Ms")] int P99Ms,
|
||||
[property: JsonPropertyName("errors")] int Errors,
|
||||
[property: JsonPropertyName("sloBurnRate")] double SloBurnRate,
|
||||
[property: JsonPropertyName("traceId")] string? TraceId,
|
||||
[property: JsonPropertyName("occurredAt")] string OccurredAt);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Telemetry;
|
||||
|
||||
internal static class IngestObservability
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Concelier.WebService", "1.0.0");
|
||||
|
||||
public static readonly Histogram<double> IngestLatencySeconds =
|
||||
Meter.CreateHistogram<double>("concelier_ingest_latency_seconds", "s", "Ingest pipeline latency.");
|
||||
|
||||
public static readonly ObservableGauge<long> QueueDepth =
|
||||
Meter.CreateObservableGauge("concelier_ingest_queue_depth", observeQueueDepth, "items", "Queued ingest items.");
|
||||
|
||||
public static readonly Counter<long> IngestErrorsTotal =
|
||||
Meter.CreateCounter<long>("concelier_ingest_errors_total", "errors", "Ingest errors by reason.");
|
||||
|
||||
public static readonly ObservableGauge<double> SloBurnRate =
|
||||
Meter.CreateObservableGauge("concelier_ingest_slo_burn_rate", observeSloBurn, "ratio", "SLO burn rate over window.");
|
||||
|
||||
private static long observeQueueDepth() => 0;
|
||||
|
||||
private static double observeSloBurn() => 0.0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class ConcelierHealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ConcelierHealthEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(_ => { });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_requires_tenant_header()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/obs/concelier/health");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_returns_payload()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-a");
|
||||
|
||||
var response = await client.GetAsync("/obs/concelier/health");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<HealthResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.tenant.Should().Be("tenant-a");
|
||||
payload.queueDepth.Should().Be(0);
|
||||
payload.window.Should().Be("5m");
|
||||
}
|
||||
|
||||
private sealed record HealthResponse(string tenant, int queueDepth, int ingestLatencyP50Ms, int ingestLatencyP99Ms, double errorRate1h, double sloBurnRate, string window, string updatedAt);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class ConcelierTimelineEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ConcelierTimelineEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(_ => { });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_requires_tenant_header()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/obs/concelier/timeline");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_returns_sse_event()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-a");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/obs/concelier/timeline");
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
var firstLine = await reader.ReadLineAsync();
|
||||
firstLine.Should().NotBeNull();
|
||||
firstLine!.Should().StartWith("event: ingest.update");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class ProjectionEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ProjectionEndpointTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(_ => { });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Projection_requires_tenant()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/sboms/snap-001/projection");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Projection_returns_payload_and_hash()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<ProjectionResponse>();
|
||||
json.Should().NotBeNull();
|
||||
json!.snapshotId.Should().Be("snap-001");
|
||||
json.tenantId.Should().Be("tenant-a");
|
||||
json.hash.Should().NotBeNullOrEmpty();
|
||||
json.projection.GetProperty("purl").GetString().Should().Be("pkg:npm/lodash@4.17.21");
|
||||
}
|
||||
|
||||
private sealed record ProjectionResponse(string snapshotId, string tenantId, string schemaVersion, string hash, System.Text.Json.JsonElement projection);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
public sealed record SbomProjectionResult(
|
||||
string SnapshotId,
|
||||
string TenantId,
|
||||
JsonElement Projection,
|
||||
string ProjectionHash,
|
||||
string SchemaVersion);
|
||||
@@ -1,22 +1,23 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Services;
|
||||
using StellaOps.SbomService.Observability;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables("SBOM_");
|
||||
|
||||
builder.Services.AddOptions();
|
||||
builder.Services.AddLogging();
|
||||
|
||||
// Register SBOM query services (InMemory seed; replace with Mongo-backed repository later).
|
||||
using System.Text.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables("SBOM_");
|
||||
|
||||
builder.Services.AddOptions();
|
||||
builder.Services.AddLogging();
|
||||
|
||||
// Register SBOM query services (InMemory seed; replace with Mongo-backed repository later).
|
||||
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
@@ -28,148 +29,179 @@ builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
|
||||
});
|
||||
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
|
||||
|
||||
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,
|
||||
[FromQuery] string? scope,
|
||||
[FromQuery(Name = "assetTag")] string? assetTag,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
builder.Services.AddSingleton<IProjectionRepository>(sp =>
|
||||
{
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var env = sp.GetRequiredService<IHostEnvironment>();
|
||||
|
||||
var configured = config.GetValue<string>("SbomService:ProjectionsPath");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
return new FileProjectionRepository(configured!);
|
||||
}
|
||||
|
||||
if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
var candidateRoots = new[]
|
||||
{
|
||||
return Results.BadRequest(new { error = "cursor must be an integer offset" });
|
||||
env.ContentRootPath,
|
||||
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..")),
|
||||
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..")),
|
||||
Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "..", ".."))
|
||||
};
|
||||
|
||||
foreach (var root in candidateRoots)
|
||||
{
|
||||
var candidate = Path.Combine(root, "docs", "modules", "sbomservice", "fixtures", "lnm-v1", "projections.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return new FileProjectionRepository(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
{
|
||||
{ "scope", scope ?? string.Empty },
|
||||
{ "env", string.Empty }
|
||||
});
|
||||
SbomMetrics.PathsQueryTotal.Add(1, new TagList
|
||||
{
|
||||
{ "cache_hit", result.CacheHit },
|
||||
{ "scope", scope ?? string.Empty }
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
return new FileProjectionRepository(string.Empty);
|
||||
});
|
||||
|
||||
app.MapGet("/components/lookup", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return Results.BadRequest(new { error = "purl is required" });
|
||||
}
|
||||
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
}
|
||||
|
||||
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.GetComponentLookupAsync(
|
||||
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
{
|
||||
{ "scope", string.Empty },
|
||||
{ "env", string.Empty }
|
||||
});
|
||||
SbomMetrics.PathsQueryTotal.Add(1, new TagList
|
||||
{
|
||||
{ "cache_hit", result.CacheHit },
|
||||
{ "scope", string.Empty }
|
||||
});
|
||||
|
||||
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,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return Results.BadRequest(new { error = "purl is required" });
|
||||
}
|
||||
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
}
|
||||
|
||||
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 elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
{
|
||||
{ "scope", scope ?? string.Empty },
|
||||
{ "env", environment ?? string.Empty }
|
||||
});
|
||||
SbomMetrics.PathsQueryTotal.Add(1, new TagList
|
||||
{
|
||||
{ "cache_hit", result.CacheHit },
|
||||
{ "scope", scope ?? string.Empty }
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
|
||||
|
||||
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,
|
||||
[FromQuery] string? scope,
|
||||
[FromQuery(Name = "assetTag")] string? assetTag,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
}
|
||||
|
||||
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.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 },
|
||||
{ "env", string.Empty }
|
||||
});
|
||||
SbomMetrics.PathsQueryTotal.Add(1, new TagList
|
||||
{
|
||||
{ "cache_hit", result.CacheHit },
|
||||
{ "scope", scope ?? string.Empty }
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
|
||||
app.MapGet("/components/lookup", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return Results.BadRequest(new { error = "purl is required" });
|
||||
}
|
||||
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
}
|
||||
|
||||
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.GetComponentLookupAsync(
|
||||
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
{
|
||||
{ "scope", string.Empty },
|
||||
{ "env", string.Empty }
|
||||
});
|
||||
SbomMetrics.PathsQueryTotal.Add(1, new TagList
|
||||
{
|
||||
{ "cache_hit", result.CacheHit },
|
||||
{ "scope", string.Empty }
|
||||
});
|
||||
|
||||
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,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return Results.BadRequest(new { error = "purl is required" });
|
||||
}
|
||||
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
}
|
||||
|
||||
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 elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList
|
||||
{
|
||||
{ "scope", scope ?? string.Empty },
|
||||
{ "env", environment ?? string.Empty }
|
||||
});
|
||||
SbomMetrics.PathsQueryTotal.Add(1, new TagList
|
||||
{
|
||||
{ "cache_hit", result.CacheHit },
|
||||
{ "scope", scope ?? string.Empty }
|
||||
});
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
|
||||
app.MapGet("/sbom/versions", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromQuery] string? artifact,
|
||||
@@ -177,36 +209,68 @@ app.MapGet("/sbom/versions", async Task<IResult> (
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
return Results.BadRequest(new { error = "artifact is required" });
|
||||
}
|
||||
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
}
|
||||
|
||||
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.GetTimelineAsync(
|
||||
new SbomTimelineQuery(artifact.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new TagList { { "artifact", artifact } });
|
||||
SbomMetrics.TimelineQueryTotal.Add(1, new TagList { { "artifact", artifact }, { "cache_hit", result.CacheHit } });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
return Results.BadRequest(new { error = "artifact is required" });
|
||||
}
|
||||
|
||||
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
|
||||
{
|
||||
return Results.BadRequest(new { error = "limit must be between 1 and 200" });
|
||||
}
|
||||
|
||||
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.GetTimelineAsync(
|
||||
new SbomTimelineQuery(artifact.Trim(), pageSize, offset),
|
||||
cancellationToken);
|
||||
|
||||
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
|
||||
SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new TagList { { "artifact", artifact } });
|
||||
SbomMetrics.TimelineQueryTotal.Add(1, new TagList { { "artifact", artifact }, { "cache_hit", result.CacheHit } });
|
||||
|
||||
return Results.Ok(result.Result);
|
||||
});
|
||||
|
||||
app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromRoute] string? snapshotId,
|
||||
[FromQuery(Name = "tenant")] string? tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "snapshotId is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant is required" });
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
snapshotId = projection.SnapshotId,
|
||||
tenantId = projection.TenantId,
|
||||
schemaVersion = projection.SchemaVersion,
|
||||
hash = projection.ProjectionHash,
|
||||
projection = projection.Projection
|
||||
});
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
internal sealed class FileProjectionRepository : IProjectionRepository
|
||||
{
|
||||
private readonly IReadOnlyDictionary<(string SnapshotId, string TenantId), SbomProjectionResult> _projections;
|
||||
|
||||
public FileProjectionRepository(string fixturesPath)
|
||||
{
|
||||
if (!File.Exists(fixturesPath))
|
||||
{
|
||||
_projections = new Dictionary<(string, string), SbomProjectionResult>();
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(fixturesPath);
|
||||
var root = JsonNode.Parse(stream) as JsonArray ?? throw new InvalidOperationException("projections.json must be a JSON array");
|
||||
|
||||
var map = new Dictionary<(string, string), SbomProjectionResult>();
|
||||
|
||||
foreach (var node in root.OfType<JsonObject>())
|
||||
{
|
||||
var snapshotId = node["snapshotId"]?.GetValue<string>();
|
||||
var tenantId = node["tenantId"]?.GetValue<string>();
|
||||
var projectionNode = node["projection"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(snapshotId) || string.IsNullOrWhiteSpace(tenantId) || projectionNode is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var projectionElement = ToElement(projectionNode);
|
||||
var schemaVersion = node["schemaVersion"]?.GetValue<string>()
|
||||
?? projectionNode["metadata"]? ["schemaVersion"]?.GetValue<string>()
|
||||
?? "1.0.0";
|
||||
|
||||
var projectionHash = ComputeHash(projectionElement);
|
||||
|
||||
map[(snapshotId!, tenantId!)] = new SbomProjectionResult(
|
||||
snapshotId!,
|
||||
tenantId!,
|
||||
projectionElement,
|
||||
projectionHash,
|
||||
schemaVersion);
|
||||
}
|
||||
|
||||
_projections = map;
|
||||
}
|
||||
|
||||
public Task<SbomProjectionResult?> GetAsync(string snapshotId, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
_projections.TryGetValue((snapshotId, tenantId), out var result);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static string ComputeHash(JsonElement element)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = false });
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static JsonElement ToElement(JsonNode node)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(node.ToJsonString(new JsonSerializerOptions { WriteIndented = false }));
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public interface IProjectionRepository
|
||||
{
|
||||
Task<SbomProjectionResult?> GetAsync(string snapshotId, string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
public interface ISbomQueryService
|
||||
{
|
||||
Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken);
|
||||
@@ -11,4 +11,6 @@ public interface ISbomQueryService
|
||||
Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken);
|
||||
|
||||
Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken);
|
||||
|
||||
Task<SbomProjectionResult?> GetProjectionAsync(string snapshotId, string tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,138 +1,140 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
{
|
||||
private readonly IReadOnlyList<PathRecord> _paths;
|
||||
private readonly IReadOnlyList<TimelineRecord> _timelines;
|
||||
private readonly IReadOnlyList<CatalogRecord> _catalog;
|
||||
private readonly IComponentLookupRepository _componentLookupRepository;
|
||||
private readonly IProjectionRepository _projectionRepository;
|
||||
private readonly ConcurrentDictionary<string, object> _cache = new();
|
||||
|
||||
public InMemorySbomQueryService(IComponentLookupRepository componentLookupRepository)
|
||||
public InMemorySbomQueryService(IComponentLookupRepository componentLookupRepository, IProjectionRepository projectionRepository)
|
||||
{
|
||||
_componentLookupRepository = componentLookupRepository;
|
||||
_projectionRepository = projectionRepository;
|
||||
// Deterministic seed data for early contract testing; replace with Mongo-backed implementation later.
|
||||
_paths = SeedPaths();
|
||||
_timelines = SeedTimelines();
|
||||
_catalog = SeedCatalog();
|
||||
}
|
||||
|
||||
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult)
|
||||
{
|
||||
return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true));
|
||||
}
|
||||
|
||||
var filtered = _paths
|
||||
.Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => p.Artifact)
|
||||
.ThenBy(p => p.Environment)
|
||||
.ThenBy(p => p.Scope)
|
||||
.ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name)))
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomPathResult(
|
||||
Purl: query.Purl,
|
||||
Artifact: query.Artifact,
|
||||
Scope: query.Scope,
|
||||
Environment: query.Environment,
|
||||
Paths: page,
|
||||
NextCursor: nextCursor);
|
||||
|
||||
_cache[cacheKey] = result;
|
||||
return Task.FromResult(new QueryResult<SbomPathResult>(result, false));
|
||||
}
|
||||
|
||||
public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline)
|
||||
{
|
||||
return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true));
|
||||
}
|
||||
|
||||
var filtered = _timelines
|
||||
.Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ThenByDescending(t => t.Version)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomTimelineResult(query.Artifact, page, nextCursor);
|
||||
_cache[cacheKey] = result;
|
||||
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
|
||||
}
|
||||
|
||||
public Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomCatalogResult cachedCatalog)
|
||||
{
|
||||
return Task.FromResult(new QueryResult<SbomCatalogResult>(cachedCatalog, true));
|
||||
}
|
||||
|
||||
var filtered = _catalog
|
||||
.Where(c => query.Artifact is null || c.Artifact.Contains(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.License is null || string.Equals(c.License, query.License, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.Scope is null || string.Equals(c.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.AssetTag is null || c.AssetTags.ContainsKey(query.AssetTag))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ThenBy(c => c.Artifact)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(c => new SbomCatalogItem(
|
||||
c.Artifact,
|
||||
c.SbomVersion,
|
||||
c.Digest,
|
||||
c.License,
|
||||
c.Scope,
|
||||
c.AssetTags,
|
||||
c.CreatedAt,
|
||||
c.ProjectionHash,
|
||||
c.EvaluationMetadata))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomCatalogResult(page, nextCursor);
|
||||
_cache[cacheKey] = result;
|
||||
return Task.FromResult(new QueryResult<SbomCatalogResult>(result, false));
|
||||
}
|
||||
|
||||
|
||||
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult)
|
||||
{
|
||||
return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true));
|
||||
}
|
||||
|
||||
var filtered = _paths
|
||||
.Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => p.Artifact)
|
||||
.ThenBy(p => p.Environment)
|
||||
.ThenBy(p => p.Scope)
|
||||
.ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name)))
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomPathResult(
|
||||
Purl: query.Purl,
|
||||
Artifact: query.Artifact,
|
||||
Scope: query.Scope,
|
||||
Environment: query.Environment,
|
||||
Paths: page,
|
||||
NextCursor: nextCursor);
|
||||
|
||||
_cache[cacheKey] = result;
|
||||
return Task.FromResult(new QueryResult<SbomPathResult>(result, false));
|
||||
}
|
||||
|
||||
public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline)
|
||||
{
|
||||
return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true));
|
||||
}
|
||||
|
||||
var filtered = _timelines
|
||||
.Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ThenByDescending(t => t.Version)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomTimelineResult(query.Artifact, page, nextCursor);
|
||||
_cache[cacheKey] = result;
|
||||
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
|
||||
}
|
||||
|
||||
public Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomCatalogResult cachedCatalog)
|
||||
{
|
||||
return Task.FromResult(new QueryResult<SbomCatalogResult>(cachedCatalog, true));
|
||||
}
|
||||
|
||||
var filtered = _catalog
|
||||
.Where(c => query.Artifact is null || c.Artifact.Contains(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.License is null || string.Equals(c.License, query.License, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.Scope is null || string.Equals(c.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(c => query.AssetTag is null || c.AssetTags.ContainsKey(query.AssetTag))
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ThenBy(c => c.Artifact)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(c => new SbomCatalogItem(
|
||||
c.Artifact,
|
||||
c.SbomVersion,
|
||||
c.Digest,
|
||||
c.License,
|
||||
c.Scope,
|
||||
c.AssetTags,
|
||||
c.CreatedAt,
|
||||
c.ProjectionHash,
|
||||
c.EvaluationMetadata))
|
||||
.ToList();
|
||||
|
||||
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
||||
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var result = new SbomCatalogResult(page, nextCursor);
|
||||
_cache[cacheKey] = result;
|
||||
return Task.FromResult(new QueryResult<SbomCatalogResult>(result, false));
|
||||
}
|
||||
|
||||
public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}";
|
||||
@@ -140,217 +142,234 @@ 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 result = new ComponentLookupResult(query.Purl, query.Artifact, neighbors, nextCursor, CacheHint: "seeded");
|
||||
|
||||
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 result = new ComponentLookupResult(query.Purl, query.Artifact, neighbors, nextCursor, CacheHint: "seeded");
|
||||
_cache[cacheKey] = result;
|
||||
return new QueryResult<ComponentLookupResult>(result, false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PathRecord> SeedPaths()
|
||||
public async Task<SbomProjectionResult?> GetProjectionAsync(string snapshotId, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
return new List<PathRecord>
|
||||
var cacheKey = $"projection|{snapshotId}|{tenantId}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomProjectionResult cachedProjection)
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
Scope: "runtime",
|
||||
Environment: "prod",
|
||||
RuntimeFlag: true,
|
||||
BlastRadius: "medium",
|
||||
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
|
||||
Nodes: new[]
|
||||
{
|
||||
new SbomPathNode("sample-api", "artifact"),
|
||||
new SbomPathNode("express", "npm"),
|
||||
new SbomPathNode("lodash", "npm")
|
||||
}),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
Scope: "build",
|
||||
Environment: "prod",
|
||||
RuntimeFlag: false,
|
||||
BlastRadius: "low",
|
||||
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
|
||||
Nodes: new[]
|
||||
{
|
||||
new SbomPathNode("sample-api", "artifact"),
|
||||
new SbomPathNode("rollup", "npm"),
|
||||
new SbomPathNode("lodash", "npm")
|
||||
}),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api@sha256:222",
|
||||
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
||||
Scope: "runtime",
|
||||
Environment: "staging",
|
||||
RuntimeFlag: true,
|
||||
BlastRadius: "high",
|
||||
NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
Nodes: new[]
|
||||
{
|
||||
new SbomPathNode("sample-worker", "artifact"),
|
||||
new SbomPathNode("StellaOps.Core", "nuget"),
|
||||
new SbomPathNode("Newtonsoft.Json", "nuget")
|
||||
})
|
||||
};
|
||||
}
|
||||
return cachedProjection;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TimelineRecord> SeedTimelines()
|
||||
{
|
||||
return new List<TimelineRecord>
|
||||
var projection = await _projectionRepository.GetAsync(snapshotId, tenantId, cancellationToken);
|
||||
if (projection is not null)
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Version: "2025.11.15.1",
|
||||
Digest: "sha256:111",
|
||||
SourceBundleHash: "sha256:bundle111",
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Version: "2025.11.16.1",
|
||||
Digest: "sha256:112",
|
||||
SourceBundleHash: "sha256:bundle112",
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
Version: "2025.11.12.0",
|
||||
Digest: "sha256:222",
|
||||
SourceBundleHash: "sha256:bundle222",
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
|
||||
Provenance: "upload:spdx:worker"),
|
||||
};
|
||||
_cache[cacheKey] = projection;
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CatalogRecord> SeedCatalog()
|
||||
{
|
||||
return new List<CatalogRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
SbomVersion: "2025.11.16.1",
|
||||
Digest: "sha256:112",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
AssetTags: new Dictionary<string, string>
|
||||
{
|
||||
["owner"] = "payments",
|
||||
["criticality"] = "high",
|
||||
["env"] = "prod"
|
||||
},
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
ProjectionHash: "sha256:proj112",
|
||||
EvaluationMetadata: "eval:passed:v1"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
SbomVersion: "2025.11.15.1",
|
||||
Digest: "sha256:111",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
AssetTags: new Dictionary<string, string>
|
||||
{
|
||||
["owner"] = "payments",
|
||||
["criticality"] = "high",
|
||||
["env"] = "prod"
|
||||
},
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
ProjectionHash: "sha256:proj111",
|
||||
EvaluationMetadata: "eval:passed:v1"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
SbomVersion: "2025.11.12.0",
|
||||
Digest: "sha256:222",
|
||||
License: "Apache-2.0",
|
||||
Scope: "runtime",
|
||||
AssetTags: new Dictionary<string, string>
|
||||
{
|
||||
["owner"] = "platform",
|
||||
["criticality"] = "medium",
|
||||
["env"] = "staging"
|
||||
},
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
|
||||
ProjectionHash: "sha256:proj222",
|
||||
EvaluationMetadata: "eval:pending:v1"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComponentLookupRecord> SeedComponents()
|
||||
{
|
||||
return new List<ComponentLookupRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
NeighborPurl: "pkg:npm/express@4.18.2",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
NeighborPurl: "pkg:npm/rollup@3.0.0",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "MIT",
|
||||
Scope: "build",
|
||||
RuntimeFlag: false),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
||||
NeighborPurl: "pkg:nuget/StellaOps.Core@1.0.0",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "Apache-2.0",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record PathRecord(
|
||||
string Artifact,
|
||||
string Purl,
|
||||
string? Scope,
|
||||
string? Environment,
|
||||
bool RuntimeFlag,
|
||||
string? BlastRadius,
|
||||
string? NearestSafeVersion,
|
||||
IReadOnlyList<SbomPathNode> Nodes);
|
||||
|
||||
private sealed record TimelineRecord(
|
||||
string Artifact,
|
||||
string Version,
|
||||
string Digest,
|
||||
string SourceBundleHash,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? Provenance);
|
||||
|
||||
private sealed record CatalogRecord(
|
||||
string Artifact,
|
||||
string SbomVersion,
|
||||
string Digest,
|
||||
string? License,
|
||||
string Scope,
|
||||
IReadOnlyDictionary<string, string> AssetTags,
|
||||
DateTimeOffset CreatedAt,
|
||||
string ProjectionHash,
|
||||
string EvaluationMetadata);
|
||||
|
||||
private sealed record ComponentLookupRecord(
|
||||
string Artifact,
|
||||
string Purl,
|
||||
string NeighborPurl,
|
||||
string Relationship,
|
||||
string? License,
|
||||
string Scope,
|
||||
bool RuntimeFlag);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PathRecord> SeedPaths()
|
||||
{
|
||||
return new List<PathRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
Scope: "runtime",
|
||||
Environment: "prod",
|
||||
RuntimeFlag: true,
|
||||
BlastRadius: "medium",
|
||||
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
|
||||
Nodes: new[]
|
||||
{
|
||||
new SbomPathNode("sample-api", "artifact"),
|
||||
new SbomPathNode("express", "npm"),
|
||||
new SbomPathNode("lodash", "npm")
|
||||
}),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
Scope: "build",
|
||||
Environment: "prod",
|
||||
RuntimeFlag: false,
|
||||
BlastRadius: "low",
|
||||
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
|
||||
Nodes: new[]
|
||||
{
|
||||
new SbomPathNode("sample-api", "artifact"),
|
||||
new SbomPathNode("rollup", "npm"),
|
||||
new SbomPathNode("lodash", "npm")
|
||||
}),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api@sha256:222",
|
||||
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
||||
Scope: "runtime",
|
||||
Environment: "staging",
|
||||
RuntimeFlag: true,
|
||||
BlastRadius: "high",
|
||||
NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3",
|
||||
Nodes: new[]
|
||||
{
|
||||
new SbomPathNode("sample-worker", "artifact"),
|
||||
new SbomPathNode("StellaOps.Core", "nuget"),
|
||||
new SbomPathNode("Newtonsoft.Json", "nuget")
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TimelineRecord> SeedTimelines()
|
||||
{
|
||||
return new List<TimelineRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Version: "2025.11.15.1",
|
||||
Digest: "sha256:111",
|
||||
SourceBundleHash: "sha256:bundle111",
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Version: "2025.11.16.1",
|
||||
Digest: "sha256:112",
|
||||
SourceBundleHash: "sha256:bundle112",
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
Version: "2025.11.12.0",
|
||||
Digest: "sha256:222",
|
||||
SourceBundleHash: "sha256:bundle222",
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
|
||||
Provenance: "upload:spdx:worker"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CatalogRecord> SeedCatalog()
|
||||
{
|
||||
return new List<CatalogRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
SbomVersion: "2025.11.16.1",
|
||||
Digest: "sha256:112",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
AssetTags: new Dictionary<string, string>
|
||||
{
|
||||
["owner"] = "payments",
|
||||
["criticality"] = "high",
|
||||
["env"] = "prod"
|
||||
},
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
|
||||
ProjectionHash: "sha256:proj112",
|
||||
EvaluationMetadata: "eval:passed:v1"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
SbomVersion: "2025.11.15.1",
|
||||
Digest: "sha256:111",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
AssetTags: new Dictionary<string, string>
|
||||
{
|
||||
["owner"] = "payments",
|
||||
["criticality"] = "high",
|
||||
["env"] = "prod"
|
||||
},
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
ProjectionHash: "sha256:proj111",
|
||||
EvaluationMetadata: "eval:passed:v1"),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
SbomVersion: "2025.11.12.0",
|
||||
Digest: "sha256:222",
|
||||
License: "Apache-2.0",
|
||||
Scope: "runtime",
|
||||
AssetTags: new Dictionary<string, string>
|
||||
{
|
||||
["owner"] = "platform",
|
||||
["criticality"] = "medium",
|
||||
["env"] = "staging"
|
||||
},
|
||||
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
|
||||
ProjectionHash: "sha256:proj222",
|
||||
EvaluationMetadata: "eval:pending:v1"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComponentLookupRecord> SeedComponents()
|
||||
{
|
||||
return new List<ComponentLookupRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
NeighborPurl: "pkg:npm/express@4.18.2",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
NeighborPurl: "pkg:npm/rollup@3.0.0",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "MIT",
|
||||
Scope: "build",
|
||||
RuntimeFlag: false),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
||||
NeighborPurl: "pkg:nuget/StellaOps.Core@1.0.0",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "Apache-2.0",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record PathRecord(
|
||||
string Artifact,
|
||||
string Purl,
|
||||
string? Scope,
|
||||
string? Environment,
|
||||
bool RuntimeFlag,
|
||||
string? BlastRadius,
|
||||
string? NearestSafeVersion,
|
||||
IReadOnlyList<SbomPathNode> Nodes);
|
||||
|
||||
private sealed record TimelineRecord(
|
||||
string Artifact,
|
||||
string Version,
|
||||
string Digest,
|
||||
string SourceBundleHash,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? Provenance);
|
||||
|
||||
private sealed record CatalogRecord(
|
||||
string Artifact,
|
||||
string SbomVersion,
|
||||
string Digest,
|
||||
string? License,
|
||||
string Scope,
|
||||
IReadOnlyDictionary<string, string> AssetTags,
|
||||
DateTimeOffset CreatedAt,
|
||||
string ProjectionHash,
|
||||
string EvaluationMetadata);
|
||||
|
||||
private sealed record ComponentLookupRecord(
|
||||
string Artifact,
|
||||
string Purl,
|
||||
string NeighborPurl,
|
||||
string Relationship,
|
||||
string? License,
|
||||
string Scope,
|
||||
bool RuntimeFlag);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user