Add new features and tests for AirGap and Time modules
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Introduced `SbomService` tasks documentation.
- Updated `StellaOps.sln` to include new projects: `StellaOps.AirGap.Time` and `StellaOps.AirGap.Importer`.
- Added unit tests for `BundleImportPlanner`, `DsseVerifier`, `ImportValidator`, and other components in the `StellaOps.AirGap.Importer.Tests` namespace.
- Implemented `InMemoryBundleRepositories` for testing bundle catalog and item repositories.
- Created `MerkleRootCalculator`, `RootRotationPolicy`, and `TufMetadataValidator` tests.
- Developed `StalenessCalculator` and `TimeAnchorLoader` tests in the `StellaOps.AirGap.Time.Tests` namespace.
- Added `fetch-sbomservice-deps.sh` script for offline dependency fetching.
This commit is contained in:
master
2025-11-20 23:29:54 +02:00
parent 65b1599229
commit 79b8e53441
182 changed files with 6660 additions and 1242 deletions

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record LnmLinksetResponse(
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations,
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
[property: JsonPropertyName("conflicts")] IReadOnlyList<LnmLinksetConflict>? Conflicts,
[property: JsonPropertyName("provenance")] LnmLinksetProvenance? Provenance,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("builtByJobId")] string? BuiltByJobId,
[property: JsonPropertyName("cached")] bool Cached);
public sealed record LnmLinksetNormalized(
[property: JsonPropertyName("purls")] IReadOnlyList<string>? Purls,
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions,
[property: JsonPropertyName("ranges")] IReadOnlyList<object>? Ranges,
[property: JsonPropertyName("severities")] IReadOnlyList<object>? Severities);
public sealed record LnmLinksetConflict(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("values")] IReadOnlyList<string>? Values);
public sealed record LnmLinksetProvenance(
[property: JsonPropertyName("observationHashes")] IReadOnlyList<string>? ObservationHashes,
[property: JsonPropertyName("toolVersion")] string? ToolVersion,
[property: JsonPropertyName("policyHash")] string? PolicyHash);
public sealed record LnmLinksetQuery(
[Required]
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
[Required]
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("includeConflicts")] bool IncludeConflicts = true);

View File

@@ -117,6 +117,8 @@ builder.Services.AddOptions<AdvisoryObservationEventPublisherOptions>()
.ValidateOnStart();
builder.Services.AddConcelierAocGuards();
builder.Services.AddConcelierLinksetMappers();
builder.Services.AddSingleton<IMeterFactory>(MeterProvider.Default.GetMeterProvider());
builder.Services.AddSingleton<LinksetCacheTelemetry>();
builder.Services.AddAdvisoryRawServices();
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
@@ -460,6 +462,66 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async (
return Results.Ok(response);
}).WithName("GetConcelierObservations");
app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
HttpContext context,
string advisoryId,
[FromQuery(Name = "source")] string source,
[FromQuery(Name = "includeConflicts")] bool includeConflicts,
[FromServices] IAdvisoryLinksetLookup linksetLookup,
[FromServices] LinksetCacheTelemetry telemetry,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
if (string.IsNullOrWhiteSpace(advisoryId) || string.IsNullOrWhiteSpace(source))
{
return Results.BadRequest("advisoryId and source are required.");
}
var stopwatch = Stopwatch.StartNew();
var options = new AdvisoryLinksetQueryOptions(tenant!, Source: source.Trim(), AdvisoryId: advisoryId.Trim());
var linksets = await linksetLookup.FindByTenantAsync(options.TenantId, options.Source, options.AdvisoryId, cancellationToken).ConfigureAwait(false);
if (linksets.Count == 0)
{
return Results.NotFound();
}
var linkset = linksets[0];
var response = new LnmLinksetResponse(
linkset.AdvisoryId,
linkset.Source,
linkset.Observations,
linkset.Normalized is null
? null
: new LnmLinksetNormalized(linkset.Normalized.Purls, linkset.Normalized.Versions, linkset.Normalized.Ranges, linkset.Normalized.Severities),
includeConflicts ? linkset.Conflicts : Array.Empty<LnmLinksetConflict>(),
linkset.Provenance is null
? null
: new LnmLinksetProvenance(linkset.Provenance.ObservationHashes, linkset.Provenance.ToolVersion, linkset.Provenance.PolicyHash),
linkset.CreatedAt,
linkset.BuiltByJobId,
Cached: true);
telemetry.RecordHit(tenant, linkset.Source);
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
return Results.Ok(response);
}).WithName("GetLnmLinkset");
if (authorityConfigured)
{
observationsEndpoint.RequireAuthorization(ObservationsPolicyName);

View File

@@ -0,0 +1,48 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Concelier.WebService.Telemetry;
internal sealed class LinksetCacheTelemetry
{
private readonly Counter<long> _hitTotal;
private readonly Counter<long> _writeTotal;
private readonly Histogram<double> _rebuildMs;
public LinksetCacheTelemetry(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("StellaOps.Concelier.Linksets");
_hitTotal = meter.CreateCounter<long>("lnm.cache.hit_total", unit: "hit", description: "Cache hits for LNM linksets");
_writeTotal = meter.CreateCounter<long>("lnm.cache.write_total", unit: "write", description: "Cache writes for LNM linksets");
_rebuildMs = meter.CreateHistogram<double>("lnm.cache.rebuild_ms", unit: "ms", description: "Synchronous rebuild latency for LNM cache");
}
public void RecordHit(string? tenant, string source)
{
var tags = new TagList
{
{ "tenant", tenant ?? string.Empty },
{ "source", source }
};
_hitTotal.Add(1, tags);
}
public void RecordWrite(string? tenant, string source)
{
var tags = new TagList
{
{ "tenant", tenant ?? string.Empty },
{ "source", source }
};
_writeTotal.Add(1, tags);
}
public void RecordRebuild(string? tenant, string source, double elapsedMs)
{
var tags = new TagList
{
{ "tenant", tenant ?? string.Empty },
{ "source", source }
};
_rebuildMs.Record(elapsedMs, tags);
}
}

View File

@@ -12,6 +12,7 @@ public static class ObservationPipelineServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IAdvisoryObservationSink, NullObservationSink>();
services.TryAddSingleton<IAdvisoryObservationEventPublisher, NullObservationEventPublisher>();
services.TryAddSingleton<IAdvisoryLinksetSink, NullLinksetSink>();
services.TryAddSingleton<IAdvisoryLinksetLookup, NullLinksetLookup>();
services.TryAddSingleton<IAdvisoryLinksetBackfillService, AdvisoryLinksetBackfillService>();
@@ -25,6 +26,12 @@ public static class ObservationPipelineServiceCollectionExtensions
=> Task.CompletedTask;
}
private sealed class NullObservationEventPublisher : IAdvisoryObservationEventPublisher
{
public Task PublishAsync(AdvisoryObservationUpdatedEvent @event, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class NullLinksetSink : IAdvisoryLinksetSink
{
public Task UpsertAsync(AdvisoryLinkset linkset, CancellationToken cancellationToken)

View File

@@ -32,7 +32,9 @@ public sealed class AdvisoryObservationAggregationTests
null,
new object?[] { ImmutableArray.Create(observation) })!;
Assert.Equal(ImmutableArray.Create("os:debian", "pkg:npm/foo"), aggregate.Scopes);
Assert.Equal(2, aggregate.Scopes.Length);
Assert.Contains("os:debian", aggregate.Scopes);
Assert.Contains("pkg:npm/foo", aggregate.Scopes);
Assert.Single(aggregate.Relationships);
Assert.Equal("depends_on", aggregate.Relationships[0].Type);
}