Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Program.cs

936 lines
32 KiB
C#

using System.Globalization;
using System.Diagnostics.Metrics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.SbomService.Models;
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_");
builder.Services.AddOptions();
builder.Services.AddLogging();
// Register SBOM query services using file-backed fixtures when present; fallback to in-memory seeds.
builder.Services.AddSingleton<IComponentLookupRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:ComponentLookupPath");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return new FileComponentLookupRepository(configured!);
}
var candidate = FindFixture(env, "component_lookup.json");
return candidate is not null
? new FileComponentLookupRepository(candidate)
: new InMemoryComponentLookupRepository();
});
builder.Services.AddSingleton<ICatalogRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:CatalogPath");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return new FileCatalogRepository(configured!);
}
var candidate = FindFixture(env, "catalog.json");
return candidate is not null
? new FileCatalogRepository(candidate)
: new InMemoryCatalogRepository();
});
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<IOrchestratorRepository, InMemoryOrchestratorRepository>();
builder.Services.AddSingleton<IOrchestratorControlRepository, InMemoryOrchestratorControlRepository>();
builder.Services.AddSingleton<IOrchestratorControlService>(sp =>
new OrchestratorControlService(
sp.GetRequiredService<IOrchestratorControlRepository>(),
SbomMetrics.Meter));
builder.Services.AddSingleton<IWatermarkService, InMemoryWatermarkService>();
builder.Services.AddOptions<SbomLedgerOptions>()
.Bind(builder.Configuration.GetSection("SbomService:Ledger"));
builder.Services.AddSingleton<ISbomLedgerRepository, InMemorySbomLedgerRepository>();
builder.Services.AddSingleton<ISbomNormalizationService, SbomNormalizationService>();
builder.Services.AddSingleton<ISbomQualityScorer, SbomQualityScorer>();
builder.Services.AddSingleton<ISbomLedgerService, SbomLedgerService>();
builder.Services.AddSingleton<ISbomAnalysisTrigger, InMemorySbomAnalysisTrigger>();
builder.Services.AddSingleton<ISbomUploadService, SbomUploadService>();
builder.Services.AddSingleton<IProjectionRepository>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
var configured = config.GetValue<string>("SbomService:ProjectionsPath");
if (!string.IsNullOrWhiteSpace(configured))
{
return new FileProjectionRepository(configured!);
}
var candidateRoots = new[]
{
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);
}
}
return new FileProjectionRepository(string.Empty);
});
static string? FindFixture(IHostEnvironment env, string fileName)
{
var candidateRoots = new[]
{
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", fileName);
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
static int NormalizeLimit(int? requested, int defaultValue, int ceiling)
{
if (!requested.HasValue)
{
return defaultValue;
}
if (requested.Value <= 0)
{
return 0;
}
return Math.Min(requested.Value, ceiling);
}
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));
});
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;
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[]
{
new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
new KeyValuePair<string, object?>("env", string.Empty)
});
SbomMetrics.PathsQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
new KeyValuePair<string, object?>("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;
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[]
{
new KeyValuePair<string, object?>("scope", string.Empty),
new KeyValuePair<string, object?>("env", string.Empty)
});
SbomMetrics.PathsQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
new KeyValuePair<string, object?>("scope", string.Empty)
});
return Results.Ok(result.Result);
});
app.MapGet("/sbom/context", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromServices] IClock clock,
[FromQuery(Name = "artifactId")] string? artifactId,
[FromQuery] string? purl,
[FromQuery] int? maxTimelineEntries,
[FromQuery] int? maxDependencyPaths,
[FromQuery] bool? includeEnvironmentFlags,
[FromQuery] bool? includeBlastRadius,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifactId))
{
return Results.BadRequest(new { error = "artifactId is required" });
}
var normalizedArtifact = artifactId.Trim();
var normalizedPurl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
var timelineLimit = NormalizeLimit(maxTimelineEntries, 50, 500);
var dependencyLimit = NormalizeLimit(maxDependencyPaths, 25, 200);
var includeEnvFlags = includeEnvironmentFlags ?? true;
var includeBlast = includeBlastRadius ?? true;
IReadOnlyList<SbomVersion> versions = Array.Empty<SbomVersion>();
if (timelineLimit > 0)
{
var timeline = await service.GetTimelineAsync(
new SbomTimelineQuery(normalizedArtifact, timelineLimit, 0),
cancellationToken);
versions = timeline.Result.Versions;
}
IReadOnlyList<SbomPath> dependencyPaths = Array.Empty<SbomPath>();
if (dependencyLimit > 0 && !string.IsNullOrWhiteSpace(normalizedPurl))
{
var artifactFilter = normalizedArtifact.Contains('@', StringComparison.Ordinal)
? normalizedArtifact
: null;
var pathResult = await service.GetPathsAsync(
new SbomPathQuery(normalizedPurl!, artifactFilter, Scope: null, Environment: null, Limit: dependencyLimit, Offset: 0),
cancellationToken);
dependencyPaths = pathResult.Result.Paths;
}
if (versions.Count == 0 && dependencyPaths.Count == 0)
{
return Results.NotFound(new { error = "No SBOM context available for specified artifact/purl." });
}
var response = SbomContextAssembler.Build(
normalizedArtifact,
normalizedPurl,
clock.UtcNow,
versions,
dependencyPaths,
includeEnvFlags,
includeBlast);
return Results.Ok(response);
});
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) =>
{
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 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[]
{
new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
new KeyValuePair<string, object?>("env", environment ?? string.Empty)
});
SbomMetrics.PathsQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
new KeyValuePair<string, object?>("scope", scope ?? string.Empty)
});
return Results.Ok(result.Result);
});
app.MapGet("/sbom/versions", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromQuery] string? artifact,
[FromQuery] string? cursor,
[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[]
{
new KeyValuePair<string, object?>("artifact", artifact)
});
SbomMetrics.TimelineQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("artifact", artifact),
new KeyValuePair<string, object?>("cache_hit", result.CacheHit)
});
return Results.Ok(result.Result);
});
var sbomUploadHandler = async Task<IResult> (
[FromBody] SbomUploadRequest request,
[FromServices] ISbomUploadService uploadService,
CancellationToken cancellationToken) =>
{
var (response, validation) = await uploadService.UploadAsync(request, cancellationToken);
if (!validation.Valid)
{
return Results.BadRequest(new
{
error = "sbom_upload_validation_failed",
validation
});
}
SbomMetrics.LedgerUploadsTotal.Add(1);
return Results.Accepted($"/sbom/ledger/history?artifact={Uri.EscapeDataString(response.ArtifactRef)}", response);
};
app.MapPost("/sbom/upload", sbomUploadHandler);
app.MapPost("/api/v1/sbom/upload", sbomUploadHandler);
app.MapGet("/sbom/ledger/history", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
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 = NormalizeLimit(limit, 50, 200);
var history = await ledgerService.GetHistoryAsync(artifact.Trim(), pageSize, offset, cancellationToken);
if (history is null)
{
return Results.NotFound(new { error = "ledger history not found" });
}
return Results.Ok(history);
});
app.MapGet("/sbom/ledger/point", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
[FromQuery] string? at,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (string.IsNullOrWhiteSpace(at) || !DateTimeOffset.TryParse(at, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var atUtc))
{
return Results.BadRequest(new { error = "at must be an ISO-8601 timestamp" });
}
var result = await ledgerService.GetAtTimeAsync(artifact.Trim(), atUtc, cancellationToken);
if (result is null)
{
return Results.NotFound(new { error = "ledger point not found" });
}
return Results.Ok(result);
});
app.MapGet("/sbom/ledger/range", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
[FromQuery] string? start,
[FromQuery] string? end,
[FromQuery] string? cursor,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
if (string.IsNullOrWhiteSpace(start) || !DateTimeOffset.TryParse(start, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var startUtc))
{
return Results.BadRequest(new { error = "start must be an ISO-8601 timestamp" });
}
if (string.IsNullOrWhiteSpace(end) || !DateTimeOffset.TryParse(end, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var endUtc))
{
return Results.BadRequest(new { error = "end must be an ISO-8601 timestamp" });
}
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 = NormalizeLimit(limit, 50, 200);
var history = await ledgerService.GetRangeAsync(artifact.Trim(), startUtc, endUtc, pageSize, offset, cancellationToken);
if (history is null)
{
return Results.NotFound(new { error = "ledger range not found" });
}
return Results.Ok(history);
});
app.MapGet("/sbom/ledger/diff", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? before,
[FromQuery] string? after,
CancellationToken cancellationToken) =>
{
if (!Guid.TryParse(before, out var beforeId) || !Guid.TryParse(after, out var afterId))
{
return Results.BadRequest(new { error = "before and after must be GUIDs" });
}
var diff = await ledgerService.DiffAsync(beforeId, afterId, cancellationToken);
if (diff is null)
{
return Results.NotFound(new { error = "diff not found" });
}
SbomMetrics.LedgerDiffsTotal.Add(1);
return Results.Ok(diff);
});
app.MapGet("/sbom/ledger/lineage", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
var lineage = await ledgerService.GetLineageAsync(artifact.Trim(), cancellationToken);
if (lineage is null)
{
return Results.NotFound(new { error = "lineage not found" });
}
return Results.Ok(lineage);
});
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 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" });
}
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[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("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> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("events.list", ActivityKind.Server);
var events = await store.ListAsync(cancellationToken);
SbomMetrics.EventBacklogSize.Record(events.Count);
if (events.Count > 100)
{
app.Logger.LogWarning("sbom event backlog high: {Count}", events.Count);
}
return Results.Ok(events);
});
app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("asset-events.list", ActivityKind.Server);
var events = await store.ListAssetsAsync(cancellationToken);
SbomMetrics.EventBacklogSize.Record(events.Count);
if (events.Count > 100)
{
app.Logger.LogWarning("sbom asset event backlog high: {Count}", events.Count);
}
return Results.Ok(events);
});
app.MapGet("/internal/sbom/ledger/audit", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
var audit = await ledgerService.GetAuditAsync(artifact.Trim(), cancellationToken);
return Results.Ok(audit.OrderBy(a => a.TimestampUtc).ToList());
});
app.MapGet("/internal/sbom/analysis/jobs", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
[FromQuery] string? artifact,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new { error = "artifact is required" });
}
var jobs = await ledgerService.ListAnalysisJobsAsync(artifact.Trim(), cancellationToken);
return Results.Ok(jobs.OrderBy(j => j.CreatedAtUtc).ToList());
});
app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
[FromServices] IProjectionRepository repository,
[FromServices] ISbomEventPublisher publisher,
[FromServices] IClock clock,
CancellationToken cancellationToken) =>
{
var projections = await repository.ListAsync(cancellationToken);
var published = 0;
foreach (var projection in projections)
{
var evt = new SbomVersionCreatedEvent(
projection.SnapshotId,
projection.TenantId,
projection.ProjectionHash,
projection.SchemaVersion,
clock.UtcNow);
if (await publisher.PublishVersionCreatedAsync(evt, cancellationToken))
{
published++;
}
}
SbomMetrics.EventBacklogSize.Record(published);
if (published > 0)
{
app.Logger.LogInformation("sbom events backfilled={Count}", published);
}
return Results.Ok(new { published });
});
app.MapGet("/internal/sbom/inventory", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
using var activity = SbomTracing.Source.StartActivity("inventory.list", ActivityKind.Server);
var items = await store.ListInventoryAsync(cancellationToken);
return Results.Ok(items);
});
app.MapPost("/internal/sbom/inventory/backfill", async Task<IResult> (
[FromServices] ISbomQueryService service,
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
// clear existing inventory and replay by listing projections
await store.ClearInventoryAsync(cancellationToken);
var projections = new[] { ("snap-001", "tenant-a") };
var published = 0;
foreach (var (snapshot, tenant) in projections)
{
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
published++;
}
return Results.Ok(new { published });
});
app.MapGet("/internal/sbom/resolver-feed", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
var feed = await store.ListResolverAsync(cancellationToken);
return Results.Ok(feed);
});
app.MapPost("/internal/sbom/resolver-feed/backfill", async Task<IResult> (
[FromServices] ISbomEventStore store,
[FromServices] ISbomQueryService service,
CancellationToken cancellationToken) =>
{
await store.ClearResolverAsync(cancellationToken);
var projections = new[] { ("snap-001", "tenant-a") };
foreach (var (snapshot, tenant) in projections)
{
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
}
var feed = await store.ListResolverAsync(cancellationToken);
return Results.Ok(new { published = feed.Count });
});
app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
[FromServices] ISbomEventStore store,
CancellationToken cancellationToken) =>
{
var feed = await store.ListResolverAsync(cancellationToken);
var lines = feed.Select(candidate => JsonSerializer.Serialize(candidate));
var ndjson = string.Join('\n', lines);
return Results.Text(ndjson, "application/x-ndjson");
});
app.MapPost("/internal/sbom/retention/prune", async Task<IResult> (
[FromServices] ISbomLedgerService ledgerService,
CancellationToken cancellationToken) =>
{
var result = await ledgerService.ApplyRetentionAsync(cancellationToken);
if (result.VersionsPruned > 0)
{
SbomMetrics.LedgerRetentionPrunedTotal.Add(result.VersionsPruned);
}
return Results.Ok(result);
});
app.MapGet("/internal/orchestrator/sources", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IOrchestratorRepository repository,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var sources = await repository.ListAsync(tenant.Trim(), cancellationToken);
return Results.Ok(new { tenant = tenant.Trim(), items = sources });
});
app.MapPost("/internal/orchestrator/sources", async Task<IResult> (
RegisterOrchestratorSourceRequest request,
[FromServices] IOrchestratorRepository repository,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenant required" });
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return Results.BadRequest(new { error = "artifactDigest required" });
}
if (string.IsNullOrWhiteSpace(request.SourceType))
{
return Results.BadRequest(new { error = "sourceType required" });
}
var source = await repository.RegisterAsync(request, cancellationToken);
return Results.Ok(source);
});
app.MapGet("/internal/orchestrator/control", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IOrchestratorControlService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
return Results.Ok(state);
});
app.MapPost("/internal/orchestrator/control", async Task<IResult> (
OrchestratorControlRequest request,
[FromServices] IOrchestratorControlService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenant required" });
}
var updated = await service.UpdateAsync(request, cancellationToken);
return Results.Ok(updated);
});
app.MapGet("/internal/orchestrator/watermarks", async Task<IResult> (
[FromQuery] string? tenant,
[FromServices] IWatermarkService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
return Results.Ok(state);
});
app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
[FromQuery] string? tenant,
[FromQuery] string? watermark,
[FromServices] IWatermarkService service,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant required" });
}
var updated = await service.SetAsync(tenant.Trim(), watermark ?? string.Empty, cancellationToken);
return Results.Ok(updated);
});
app.Run();
public partial class Program;