using System.Globalization; using System.Diagnostics.Metrics; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using MongoDB.Driver; using StellaOps.SbomService.Models; using StellaOps.SbomService.Options; 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(); var mongoSection = builder.Configuration.GetSection("SbomService:Mongo"); builder.Services.Configure(mongoSection); var mongoConnectionString = mongoSection.GetValue("ConnectionString"); var mongoConfigured = !string.IsNullOrWhiteSpace(mongoConnectionString); // Register SBOM query services (Mongo when configured; otherwise file-backed fixtures when present; fallback to in-memory seeds). if (mongoConfigured) { builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var url = new MongoUrl(options.ConnectionString!); var settings = MongoClientSettings.FromUrl(url); settings.ServerSelectionTimeout = TimeSpan.FromSeconds(5); settings.RetryWrites = false; return new MongoClient(settings); }); builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var client = sp.GetRequiredService(); var url = new MongoUrl(options.ConnectionString!); var databaseName = string.IsNullOrWhiteSpace(options.Database) ? url.DatabaseName ?? "sbom_service" : options.Database; return client.GetDatabase(databaseName); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } else { builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); var env = sp.GetRequiredService(); var configured = config.GetValue("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(sp => { var config = sp.GetRequiredService(); var env = sp.GetRequiredService(); var configured = config.GetValue("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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new OrchestratorControlService( sp.GetRequiredService(), SbomMetrics.Meter)); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); var env = sp.GetRequiredService(); var configured = config.GetValue("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; } 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 ( [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 ( [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 ( [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 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 ( [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 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 ( [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(); 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 ( [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 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 ( [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 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 ( [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 ( [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.MapPost("/internal/sbom/events/backfill", async Task ( [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 ( [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 ( [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 ( [FromServices] ISbomEventStore store, CancellationToken cancellationToken) => { var feed = await store.ListResolverAsync(cancellationToken); return Results.Ok(feed); }); app.MapPost("/internal/sbom/resolver-feed/backfill", async Task ( [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 ( [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.MapGet("/internal/orchestrator/sources", async Task ( [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 ( 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 ( [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 ( 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 ( [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 ( [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;