feat: Add Bun language analyzer and related functionality
- Implemented BunPackageNormalizer to deduplicate packages by name and version. - Created BunProjectDiscoverer to identify Bun project roots in the filesystem. - Added project files for the Bun analyzer including manifest and project configuration. - Developed comprehensive tests for Bun language analyzer covering various scenarios. - Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces. - Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
namespace StellaOps.Concelier.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Standard HTTP deprecation headers per RFC 8594 and Sunset header spec.
|
||||
/// Per CONCELIER-WEB-OAS-63-001.
|
||||
/// </summary>
|
||||
public static class DeprecationHeaders
|
||||
{
|
||||
/// <summary>
|
||||
/// The Deprecation header field (RFC 8594).
|
||||
/// Value is a date when the API was deprecated.
|
||||
/// </summary>
|
||||
public const string Deprecation = "Deprecation";
|
||||
|
||||
/// <summary>
|
||||
/// The Sunset header field.
|
||||
/// Value is an HTTP-date when the API will be removed.
|
||||
/// </summary>
|
||||
public const string Sunset = "Sunset";
|
||||
|
||||
/// <summary>
|
||||
/// Link header with relation type pointing to successor API.
|
||||
/// </summary>
|
||||
public const string Link = "Link";
|
||||
|
||||
/// <summary>
|
||||
/// Custom header for deprecation notice message.
|
||||
/// </summary>
|
||||
public const string XDeprecationNotice = "X-Deprecation-Notice";
|
||||
|
||||
/// <summary>
|
||||
/// Custom header for migration guide URL.
|
||||
/// </summary>
|
||||
public const string XDeprecationGuide = "X-Deprecation-Guide";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation information for an API endpoint.
|
||||
/// </summary>
|
||||
public sealed record DeprecationInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Date when the API was deprecated (RFC 8594 format).
|
||||
/// </summary>
|
||||
public required DateTimeOffset DeprecatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Date when the API will be removed (Sunset header).
|
||||
/// Null if no sunset date is set.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SunsetAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI of the successor API endpoint.
|
||||
/// </summary>
|
||||
public required string SuccessorUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable deprecation message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to migration guide documentation.
|
||||
/// </summary>
|
||||
public string? MigrationGuideUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of deprecated endpoints and their successors.
|
||||
/// </summary>
|
||||
public static class DeprecatedEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Date when legacy linkset/observation APIs were deprecated.
|
||||
/// </summary>
|
||||
public static readonly DateTimeOffset LegacyApisDeprecatedAt = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Date when legacy linkset/observation APIs will be removed.
|
||||
/// </summary>
|
||||
public static readonly DateTimeOffset LegacyApisSunsetAt = new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for migration documentation.
|
||||
/// </summary>
|
||||
public const string MigrationGuideBaseUrl = "https://docs.stellaops.io/concelier/migration/lnm-v1";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy /linksets endpoint deprecation info.
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo LegacyLinksets = new()
|
||||
{
|
||||
DeprecatedAt = LegacyApisDeprecatedAt,
|
||||
SunsetAt = LegacyApisSunsetAt,
|
||||
SuccessorUri = "/v1/lnm/linksets",
|
||||
Message = "This endpoint is deprecated. Use /v1/lnm/linksets instead for Link-Not-Merge linkset retrieval.",
|
||||
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#linksets"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Legacy /advisories/observations endpoint deprecation info.
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo LegacyAdvisoryObservations = new()
|
||||
{
|
||||
DeprecatedAt = LegacyApisDeprecatedAt,
|
||||
SunsetAt = LegacyApisSunsetAt,
|
||||
SuccessorUri = "/v1/lnm/linksets",
|
||||
Message = "This endpoint is deprecated. Use /v1/lnm/linksets with includeObservations=true instead.",
|
||||
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#observations"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Legacy /advisories/linksets endpoint deprecation info.
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo LegacyAdvisoryLinksets = new()
|
||||
{
|
||||
DeprecatedAt = LegacyApisDeprecatedAt,
|
||||
SunsetAt = LegacyApisSunsetAt,
|
||||
SuccessorUri = "/v1/lnm/linksets",
|
||||
Message = "This endpoint is deprecated. Use /v1/lnm/linksets instead for Link-Not-Merge linkset retrieval.",
|
||||
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#linksets"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Legacy /advisories/linksets/export endpoint deprecation info.
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo LegacyAdvisoryLinksetsExport = new()
|
||||
{
|
||||
DeprecatedAt = LegacyApisDeprecatedAt,
|
||||
SunsetAt = LegacyApisSunsetAt,
|
||||
SuccessorUri = "/v1/lnm/linksets",
|
||||
Message = "This endpoint is deprecated. Use /v1/lnm/linksets with appropriate pagination for bulk export.",
|
||||
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#export"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Legacy /concelier/observations endpoint deprecation info.
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo LegacyConcelierObservations = new()
|
||||
{
|
||||
DeprecatedAt = LegacyApisDeprecatedAt,
|
||||
SunsetAt = LegacyApisSunsetAt,
|
||||
SuccessorUri = "/v1/lnm/linksets",
|
||||
Message = "This endpoint is deprecated. Use /v1/lnm/linksets with includeObservations=true instead.",
|
||||
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#observations"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding deprecation headers to HTTP responses.
|
||||
/// Per CONCELIER-WEB-OAS-63-001.
|
||||
/// </summary>
|
||||
public static class DeprecationMiddlewareExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds deprecation headers to the HTTP response.
|
||||
/// </summary>
|
||||
public static void AddDeprecationHeaders(this HttpContext context, DeprecationInfo deprecation)
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
|
||||
// RFC 8594 Deprecation header (HTTP-date format)
|
||||
headers[DeprecationHeaders.Deprecation] = FormatHttpDate(deprecation.DeprecatedAt);
|
||||
|
||||
// Sunset header if set
|
||||
if (deprecation.SunsetAt.HasValue)
|
||||
{
|
||||
headers[DeprecationHeaders.Sunset] = FormatHttpDate(deprecation.SunsetAt.Value);
|
||||
}
|
||||
|
||||
// Link header pointing to successor
|
||||
headers[DeprecationHeaders.Link] = $"<{deprecation.SuccessorUri}>; rel=\"successor-version\"";
|
||||
|
||||
// Custom deprecation notice
|
||||
headers[DeprecationHeaders.XDeprecationNotice] = deprecation.Message;
|
||||
|
||||
// Migration guide URL if available
|
||||
if (!string.IsNullOrEmpty(deprecation.MigrationGuideUrl))
|
||||
{
|
||||
headers[DeprecationHeaders.XDeprecationGuide] = deprecation.MigrationGuideUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a DateTimeOffset as an HTTP-date (RFC 7231).
|
||||
/// </summary>
|
||||
private static string FormatHttpDate(DateTimeOffset date)
|
||||
{
|
||||
// HTTP-date format: "Sun, 06 Nov 1994 08:49:37 GMT"
|
||||
return date.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that adds deprecation headers to deprecated endpoints.
|
||||
/// </summary>
|
||||
public sealed class DeprecationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly Dictionary<string, DeprecationInfo> _deprecatedPaths;
|
||||
|
||||
public DeprecationMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
_deprecatedPaths = new Dictionary<string, DeprecationInfo>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["/linksets"] = DeprecatedEndpoints.LegacyLinksets,
|
||||
["/advisories/observations"] = DeprecatedEndpoints.LegacyAdvisoryObservations,
|
||||
["/advisories/linksets"] = DeprecatedEndpoints.LegacyAdvisoryLinksets,
|
||||
["/advisories/linksets/export"] = DeprecatedEndpoints.LegacyAdvisoryLinksetsExport,
|
||||
["/concelier/observations"] = DeprecatedEndpoints.LegacyConcelierObservations
|
||||
};
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
// Check if this is a deprecated path
|
||||
if (_deprecatedPaths.TryGetValue(path, out var deprecation))
|
||||
{
|
||||
context.AddDeprecationHeaders(deprecation);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering the deprecation middleware.
|
||||
/// </summary>
|
||||
public static class DeprecationMiddlewareRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the deprecation middleware to the pipeline.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseDeprecationHeaders(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<DeprecationMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Concelier.WebService.Deprecation;
|
||||
using StellaOps.Aoc.AspNetCore.Routing;
|
||||
using StellaOps.Aoc.AspNetCore.Results;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
@@ -229,6 +230,30 @@ builder.Services.AddConcelierAocGuards();
|
||||
builder.Services.AddConcelierLinksetMappers();
|
||||
builder.Services.TryAddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
|
||||
builder.Services.AddSingleton<LinksetCacheTelemetry>();
|
||||
builder.Services.AddSingleton<ILinksetCacheTelemetry>(sp => sp.GetRequiredService<LinksetCacheTelemetry>());
|
||||
|
||||
// Register read-through cache service for LNM linksets (CONCELIER-AIAI-31-002)
|
||||
// When Postgres is enabled, uses it as cache backing; otherwise builds from observations directly
|
||||
builder.Services.AddSingleton<ReadThroughLinksetCacheService>(sp =>
|
||||
{
|
||||
var observations = sp.GetRequiredService<IAdvisoryObservationLookup>();
|
||||
var telemetry = sp.GetRequiredService<ILinksetCacheTelemetry>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
|
||||
// Get Postgres cache if available (registered by AddConcelierPostgresStorage)
|
||||
var cacheLookup = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetLookup;
|
||||
var cacheSink = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetSink;
|
||||
|
||||
return new ReadThroughLinksetCacheService(
|
||||
observations,
|
||||
telemetry,
|
||||
timeProvider,
|
||||
cacheLookup,
|
||||
cacheSink);
|
||||
});
|
||||
|
||||
// Use read-through cache as the primary linkset lookup
|
||||
builder.Services.AddSingleton<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<ReadThroughLinksetCacheService>());
|
||||
builder.Services.AddAdvisoryRawServices();
|
||||
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
|
||||
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
|
||||
@@ -462,6 +487,9 @@ if (authorityConfigured)
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
// Deprecation headers for legacy endpoints (CONCELIER-WEB-OAS-63-001)
|
||||
app.UseDeprecationHeaders();
|
||||
|
||||
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
|
||||
|
||||
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
|
||||
@@ -848,6 +876,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
[FromQuery(Name = "source")] string? source,
|
||||
[FromServices] IAdvisoryLinksetQueryService queryService,
|
||||
[FromServices] IAdvisoryObservationQueryService observationQueryService,
|
||||
[FromServices] IAdvisoryLinksetStore linksetStore,
|
||||
[FromServices] LinksetCacheTelemetry telemetry,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery(Name = "includeConflicts")] bool includeConflicts = true,
|
||||
@@ -872,24 +901,57 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var advisoryIds = new[] { advisoryId.Trim() };
|
||||
var normalizedAdvisoryId = advisoryId.Trim();
|
||||
var advisoryIds = new[] { normalizedAdvisoryId };
|
||||
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
|
||||
|
||||
var result = await queryService
|
||||
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
|
||||
// Phase 1: Try cache lookup first (CONCELIER-AIAI-31-002)
|
||||
var cached = await linksetStore
|
||||
.FindByTenantAsync(tenant!, advisoryIds, sources, cursor: null, limit: 1, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.Linksets.IsDefaultOrEmpty)
|
||||
AdvisoryLinkset linkset;
|
||||
bool fromCache = false;
|
||||
|
||||
if (cached.Count > 0)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
|
||||
// Cache hit
|
||||
linkset = cached[0];
|
||||
fromCache = true;
|
||||
telemetry.RecordHit(tenant, linkset.Source);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cache miss - rebuild from query service
|
||||
var result = await queryService
|
||||
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.Linksets.IsDefaultOrEmpty)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
|
||||
}
|
||||
|
||||
linkset = result.Linksets[0];
|
||||
|
||||
// Write to cache
|
||||
try
|
||||
{
|
||||
await linksetStore.UpsertAsync(linkset, cancellationToken).ConfigureAwait(false);
|
||||
telemetry.RecordWrite(tenant, linkset.Source);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't fail request on cache write errors
|
||||
context.RequestServices.GetRequiredService<ILogger<Program>>()
|
||||
.LogWarning(ex, "Failed to write linkset to cache for {AdvisoryId}", normalizedAdvisoryId);
|
||||
}
|
||||
|
||||
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
var linkset = result.Linksets[0];
|
||||
var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false);
|
||||
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary);
|
||||
|
||||
telemetry.RecordHit(tenant, linkset.Source);
|
||||
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
|
||||
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary, cached: fromCache);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetLnmLinkset");
|
||||
@@ -2553,7 +2615,8 @@ LnmLinksetResponse ToLnmResponse(
|
||||
bool includeTimeline,
|
||||
bool includeObservations,
|
||||
LinksetObservationSummary summary,
|
||||
DataFreshnessInfo? freshness = null)
|
||||
DataFreshnessInfo? freshness = null,
|
||||
bool cached = false)
|
||||
{
|
||||
var normalized = linkset.Normalized;
|
||||
var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict
|
||||
@@ -2606,7 +2669,7 @@ LnmLinksetResponse ToLnmResponse(
|
||||
conflicts,
|
||||
timeline,
|
||||
normalizedDto,
|
||||
Cached: false,
|
||||
Cached: cached,
|
||||
Remarks: Array.Empty<string>(),
|
||||
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>(),
|
||||
Freshness: freshness);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Telemetry;
|
||||
|
||||
internal sealed class LinksetCacheTelemetry
|
||||
/// <summary>
|
||||
/// Telemetry for LNM linkset cache operations.
|
||||
/// Per CONCELIER-AIAI-31-002.
|
||||
/// </summary>
|
||||
internal sealed class LinksetCacheTelemetry : ILinksetCacheTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Concelier.Linksets");
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for linkset cache telemetry.
|
||||
/// Per CONCELIER-AIAI-31-002.
|
||||
/// </summary>
|
||||
public interface ILinksetCacheTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a cache hit.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="source">Source vendor (e.g., "ghsa", "nvd").</param>
|
||||
void RecordHit(string? tenant, string source);
|
||||
|
||||
/// <summary>
|
||||
/// Records a cache write.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="source">Source vendor.</param>
|
||||
void RecordWrite(string? tenant, string source);
|
||||
|
||||
/// <summary>
|
||||
/// Records a synchronous rebuild latency.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="source">Source vendor.</param>
|
||||
/// <param name="elapsedMs">Elapsed time in milliseconds.</param>
|
||||
void RecordRebuild(string? tenant, string source, double elapsedMs);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Concelier.Core.Observations;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
/// <summary>
|
||||
/// Provides read-through caching for LNM linksets.
|
||||
/// Per CONCELIER-AIAI-31-002.
|
||||
///
|
||||
/// Read-through behavior:
|
||||
/// 1. First queries the configured cache (Postgres via IAdvisoryLinksetLookup)
|
||||
/// 2. On cache miss, rebuilds from MongoDB observations
|
||||
/// 3. Stores rebuilt linksets in cache
|
||||
/// 4. Returns results
|
||||
/// </summary>
|
||||
public sealed class ReadThroughLinksetCacheService : IAdvisoryLinksetLookup
|
||||
{
|
||||
private readonly IAdvisoryLinksetLookup? _cacheLookup;
|
||||
private readonly IAdvisoryLinksetSink? _cacheSink;
|
||||
private readonly IAdvisoryObservationLookup _observations;
|
||||
private readonly ILinksetCacheTelemetry _telemetry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReadThroughLinksetCacheService(
|
||||
IAdvisoryObservationLookup observations,
|
||||
ILinksetCacheTelemetry telemetry,
|
||||
TimeProvider timeProvider,
|
||||
IAdvisoryLinksetLookup? cacheLookup = null,
|
||||
IAdvisoryLinksetSink? cacheSink = null)
|
||||
{
|
||||
_observations = observations ?? throw new ArgumentNullException(nameof(observations));
|
||||
_telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_cacheLookup = cacheLookup;
|
||||
_cacheSink = cacheSink;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
|
||||
string tenantId,
|
||||
IEnumerable<string>? advisoryIds,
|
||||
IEnumerable<string>? sources,
|
||||
AdvisoryLinksetCursor? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
if (limit <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be positive.");
|
||||
}
|
||||
|
||||
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
|
||||
var advisoryIdSet = advisoryIds?.Select(a => a.Trim()).Where(a => !string.IsNullOrWhiteSpace(a)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var sourceSet = sources?.Select(s => s.Trim()).Where(s => !string.IsNullOrWhiteSpace(s)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Step 1: Try cache first if available
|
||||
if (_cacheLookup is not null)
|
||||
{
|
||||
var cached = await _cacheLookup
|
||||
.FindByTenantAsync(normalizedTenant, advisoryIdSet, sourceSet, cursor, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (cached.Count > 0)
|
||||
{
|
||||
// Cache hit
|
||||
foreach (var linkset in cached)
|
||||
{
|
||||
_telemetry.RecordHit(normalizedTenant, linkset.Source);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Cache miss - rebuild from observations
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var linksets = await RebuildFromObservationsAsync(
|
||||
normalizedTenant,
|
||||
advisoryIdSet,
|
||||
sourceSet,
|
||||
cursor,
|
||||
limit,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
if (linksets.Count == 0)
|
||||
{
|
||||
return linksets;
|
||||
}
|
||||
|
||||
// Step 3: Store in cache if sink is available
|
||||
if (_cacheSink is not null)
|
||||
{
|
||||
foreach (var linkset in linksets)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _cacheSink.UpsertAsync(linkset, cancellationToken).ConfigureAwait(false);
|
||||
_telemetry.RecordWrite(normalizedTenant, linkset.Source);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cache write failure should not fail the request
|
||||
// Log would be handled by the sink implementation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record rebuild metrics
|
||||
foreach (var linkset in linksets)
|
||||
{
|
||||
_telemetry.RecordRebuild(normalizedTenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
return linksets;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AdvisoryLinkset>> RebuildFromObservationsAsync(
|
||||
string tenant,
|
||||
IReadOnlySet<string>? advisoryIds,
|
||||
IReadOnlySet<string>? sources,
|
||||
AdvisoryLinksetCursor? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Query observations for the tenant
|
||||
// Note: For specific advisoryIds, we'd ideally have a more targeted query
|
||||
// but the current interface returns all tenant observations
|
||||
var observations = await _observations
|
||||
.ListByTenantAsync(tenant, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryLinkset>();
|
||||
}
|
||||
|
||||
// Filter by advisoryId and source if specified
|
||||
var filtered = observations.AsEnumerable();
|
||||
|
||||
if (advisoryIds is { Count: > 0 })
|
||||
{
|
||||
filtered = filtered.Where(o => advisoryIds.Contains(o.Upstream.UpstreamId));
|
||||
}
|
||||
|
||||
if (sources is { Count: > 0 })
|
||||
{
|
||||
filtered = filtered.Where(o => sources.Contains(o.Source.Vendor));
|
||||
}
|
||||
|
||||
// Group by (source, advisoryId) to build linksets
|
||||
var groups = filtered
|
||||
.GroupBy(
|
||||
o => (o.Source.Vendor, o.Upstream.UpstreamId),
|
||||
new VendorUpstreamComparer())
|
||||
.ToList();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var linksets = new List<AdvisoryLinkset>(groups.Count);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var observationIds = group
|
||||
.Select(o => o.ObservationId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var createdAt = group.Max(o => o.CreatedAt);
|
||||
var normalized = BuildNormalized(group);
|
||||
var provenance = BuildProvenance(group, now);
|
||||
|
||||
var linkset = new AdvisoryLinkset(
|
||||
tenant,
|
||||
group.Key.Vendor,
|
||||
group.Key.UpstreamId,
|
||||
observationIds,
|
||||
normalized,
|
||||
provenance,
|
||||
ComputeConfidence(group),
|
||||
DetectConflicts(group),
|
||||
createdAt,
|
||||
null);
|
||||
|
||||
linksets.Add(linkset);
|
||||
}
|
||||
|
||||
// Apply cursor-based pagination
|
||||
var ordered = linksets
|
||||
.OrderByDescending(ls => ls.CreatedAt)
|
||||
.ThenBy(ls => ls.AdvisoryId, StringComparer.Ordinal)
|
||||
.AsEnumerable();
|
||||
|
||||
if (cursor is not null)
|
||||
{
|
||||
ordered = ordered.Where(ls =>
|
||||
ls.CreatedAt < cursor.CreatedAt ||
|
||||
(ls.CreatedAt == cursor.CreatedAt &&
|
||||
string.Compare(ls.AdvisoryId, cursor.AdvisoryId, StringComparison.Ordinal) > 0));
|
||||
}
|
||||
|
||||
return ordered.Take(limit).ToList();
|
||||
}
|
||||
|
||||
private static AdvisoryLinksetNormalized? BuildNormalized(IEnumerable<AdvisoryObservation> observations)
|
||||
{
|
||||
var purls = observations
|
||||
.SelectMany(o => o.Linkset.Purls.IsDefaultOrEmpty ? Enumerable.Empty<string>() : o.Linkset.Purls)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var cpes = observations
|
||||
.SelectMany(o => o.Linkset.Cpes.IsDefaultOrEmpty ? Enumerable.Empty<string>() : o.Linkset.Cpes)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(c => c, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (purls.Length == 0 && cpes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdvisoryLinksetNormalized(
|
||||
purls.Length > 0 ? purls : null,
|
||||
cpes.Length > 0 ? cpes : null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
private static AdvisoryLinksetProvenance BuildProvenance(
|
||||
IEnumerable<AdvisoryObservation> observations,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var hashes = observations
|
||||
.Select(o => o.ObservationId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(h => h, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AdvisoryLinksetProvenance(
|
||||
hashes,
|
||||
"read-through-cache",
|
||||
null);
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(IEnumerable<AdvisoryObservation> observations)
|
||||
{
|
||||
// Simple confidence: based on number of corroborating observations
|
||||
var count = observations.Count();
|
||||
return count switch
|
||||
{
|
||||
1 => 0.5,
|
||||
2 => 0.7,
|
||||
3 => 0.85,
|
||||
_ => Math.Min(1.0, 0.85 + (count - 3) * 0.03)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryLinksetConflict> DetectConflicts(
|
||||
IEnumerable<AdvisoryObservation> observations)
|
||||
{
|
||||
var conflicts = new List<AdvisoryLinksetConflict>();
|
||||
var obsList = observations.ToList();
|
||||
|
||||
if (obsList.Count <= 1)
|
||||
{
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
// Detect PURL conflicts (same package, different versions mentioned)
|
||||
var purlsByPackage = obsList
|
||||
.SelectMany(o => o.Linkset.Purls.IsDefaultOrEmpty ? Enumerable.Empty<string>() : o.Linkset.Purls)
|
||||
.Where(p => p.Contains('@'))
|
||||
.GroupBy(p => p.Split('@')[0], StringComparer.Ordinal)
|
||||
.Where(g => g.Distinct(StringComparer.Ordinal).Count() > 1);
|
||||
|
||||
foreach (var group in purlsByPackage)
|
||||
{
|
||||
var values = group.Distinct(StringComparer.Ordinal).ToImmutableArray();
|
||||
conflicts.Add(new AdvisoryLinksetConflict(
|
||||
"purl_version",
|
||||
"Multiple versions specified for same package",
|
||||
values,
|
||||
null));
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
private sealed class VendorUpstreamComparer : IEqualityComparer<(string Vendor, string UpstreamId)>
|
||||
{
|
||||
public bool Equals((string Vendor, string UpstreamId) x, (string Vendor, string UpstreamId) y)
|
||||
=> StringComparer.OrdinalIgnoreCase.Equals(x.Vendor, y.Vendor)
|
||||
&& StringComparer.Ordinal.Equals(x.UpstreamId, y.UpstreamId);
|
||||
|
||||
public int GetHashCode((string Vendor, string UpstreamId) obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Vendor, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.UpstreamId, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for LNM linkset cache read-through behavior.
|
||||
/// Per CONCELIER-AIAI-31-002.
|
||||
/// </summary>
|
||||
public sealed class LinksetCacheReadThroughTests
|
||||
{
|
||||
[Fact]
|
||||
public void AdvisoryLinkset_CanBeCreatedForCache()
|
||||
{
|
||||
var linkset = new AdvisoryLinkset(
|
||||
TenantId: "test-tenant",
|
||||
Source: "nvd",
|
||||
AdvisoryId: "CVE-2024-0001",
|
||||
ObservationIds: ImmutableArray.Create("obs-1", "obs-2"),
|
||||
Normalized: new AdvisoryLinksetNormalized(
|
||||
Purls: new[] { "pkg:npm/lodash@4.17.20" },
|
||||
Cpes: new[] { "cpe:2.3:a:lodash:lodash:*" },
|
||||
Versions: new[] { "4.17.20" },
|
||||
Ranges: null,
|
||||
Severities: null),
|
||||
Provenance: new AdvisoryLinksetProvenance(
|
||||
ObservationHashes: new[] { "sha256:abc123" },
|
||||
ToolVersion: "1.0.0",
|
||||
PolicyHash: null),
|
||||
Confidence: 0.95,
|
||||
Conflicts: null,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
BuiltByJobId: "job-123");
|
||||
|
||||
Assert.Equal("test-tenant", linkset.TenantId);
|
||||
Assert.Equal("nvd", linkset.Source);
|
||||
Assert.Equal("CVE-2024-0001", linkset.AdvisoryId);
|
||||
Assert.Equal(2, linkset.ObservationIds.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvisoryLinkset_WithConflicts_CanBeCreated()
|
||||
{
|
||||
var conflicts = new List<AdvisoryLinksetConflict>
|
||||
{
|
||||
new AdvisoryLinksetConflict(
|
||||
Field: "severity",
|
||||
Reason: "severity-mismatch",
|
||||
Values: new[] { "critical", "high" },
|
||||
SourceIds: new[] { "nvd", "github" })
|
||||
};
|
||||
|
||||
var linkset = new AdvisoryLinkset(
|
||||
TenantId: "test-tenant",
|
||||
Source: "aggregated",
|
||||
AdvisoryId: "CVE-2024-0002",
|
||||
ObservationIds: ImmutableArray.Create("obs-1"),
|
||||
Normalized: null,
|
||||
Provenance: null,
|
||||
Confidence: 0.72,
|
||||
Conflicts: conflicts,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
BuiltByJobId: null);
|
||||
|
||||
Assert.NotNull(linkset.Conflicts);
|
||||
Assert.Single(linkset.Conflicts);
|
||||
Assert.Equal("severity", linkset.Conflicts[0].Field);
|
||||
Assert.Equal("severity-mismatch", linkset.Conflicts[0].Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvisoryLinksetNormalized_ContainsExpectedFields()
|
||||
{
|
||||
var normalized = new AdvisoryLinksetNormalized(
|
||||
Purls: new[] { "pkg:npm/example@1.0.0", "pkg:npm/example@1.0.1" },
|
||||
Cpes: new[] { "cpe:2.3:a:example:*" },
|
||||
Versions: new[] { "1.0.0", "1.0.1" },
|
||||
Ranges: new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = "SEMVER",
|
||||
["events"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?> { ["introduced"] = "0" },
|
||||
new Dictionary<string, object?> { ["fixed"] = "1.0.2" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Severities: new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = "CVSS_V3",
|
||||
["score"] = 9.8
|
||||
}
|
||||
});
|
||||
|
||||
Assert.NotNull(normalized.Purls);
|
||||
Assert.Equal(2, normalized.Purls.Count);
|
||||
Assert.NotNull(normalized.Versions);
|
||||
Assert.Equal(2, normalized.Versions.Count);
|
||||
Assert.NotNull(normalized.Ranges);
|
||||
Assert.Single(normalized.Ranges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvisoryLinksetProvenance_ContainsHashes()
|
||||
{
|
||||
var provenance = new AdvisoryLinksetProvenance(
|
||||
ObservationHashes: new[] { "sha256:abc123", "sha256:def456" },
|
||||
ToolVersion: "concelier-v1.0.0",
|
||||
PolicyHash: "sha256:policy789");
|
||||
|
||||
Assert.Equal(2, provenance.ObservationHashes!.Count);
|
||||
Assert.Equal("concelier-v1.0.0", provenance.ToolVersion);
|
||||
Assert.Equal("sha256:policy789", provenance.PolicyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheKey_DeterministicFromLinkset()
|
||||
{
|
||||
// Cache key should be deterministic: {tenant}:{advisoryId}:{source}
|
||||
var linkset = new AdvisoryLinkset(
|
||||
TenantId: "acme",
|
||||
Source: "nvd",
|
||||
AdvisoryId: "CVE-2024-0001",
|
||||
ObservationIds: ImmutableArray<string>.Empty,
|
||||
Normalized: null,
|
||||
Provenance: null,
|
||||
Confidence: null,
|
||||
Conflicts: null,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
BuiltByJobId: null);
|
||||
|
||||
var cacheKey = $"{linkset.TenantId}:{linkset.AdvisoryId}:{linkset.Source}";
|
||||
Assert.Equal("acme:CVE-2024-0001:nvd", cacheKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using StellaOps.Concelier.WebService.Deprecation;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for deprecation headers infrastructure.
|
||||
/// Per CONCELIER-WEB-OAS-63-001.
|
||||
/// </summary>
|
||||
public sealed class DeprecationHeadersTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeprecationInfo_LegacyLinksets_HasCorrectValues()
|
||||
{
|
||||
var info = DeprecatedEndpoints.LegacyLinksets;
|
||||
|
||||
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
|
||||
Assert.Equal(DeprecatedEndpoints.LegacyApisSunsetAt, info.SunsetAt);
|
||||
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
|
||||
Assert.NotEmpty(info.Message);
|
||||
Assert.NotNull(info.MigrationGuideUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationInfo_LegacyAdvisoryObservations_HasCorrectValues()
|
||||
{
|
||||
var info = DeprecatedEndpoints.LegacyAdvisoryObservations;
|
||||
|
||||
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
|
||||
Assert.Equal(DeprecatedEndpoints.LegacyApisSunsetAt, info.SunsetAt);
|
||||
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
|
||||
Assert.Contains("includeObservations", info.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationInfo_LegacyAdvisoryLinksets_HasCorrectValues()
|
||||
{
|
||||
var info = DeprecatedEndpoints.LegacyAdvisoryLinksets;
|
||||
|
||||
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
|
||||
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationInfo_LegacyAdvisoryLinksetsExport_HasCorrectValues()
|
||||
{
|
||||
var info = DeprecatedEndpoints.LegacyAdvisoryLinksetsExport;
|
||||
|
||||
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
|
||||
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
|
||||
Assert.Contains("pagination", info.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationInfo_LegacyConcelierObservations_HasCorrectValues()
|
||||
{
|
||||
var info = DeprecatedEndpoints.LegacyConcelierObservations;
|
||||
|
||||
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
|
||||
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllDeprecatedEndpoints_HaveMigrationGuides()
|
||||
{
|
||||
var endpoints = new[]
|
||||
{
|
||||
DeprecatedEndpoints.LegacyLinksets,
|
||||
DeprecatedEndpoints.LegacyAdvisoryObservations,
|
||||
DeprecatedEndpoints.LegacyAdvisoryLinksets,
|
||||
DeprecatedEndpoints.LegacyAdvisoryLinksetsExport,
|
||||
DeprecatedEndpoints.LegacyConcelierObservations
|
||||
};
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
Assert.NotNull(endpoint.MigrationGuideUrl);
|
||||
Assert.StartsWith(DeprecatedEndpoints.MigrationGuideBaseUrl, endpoint.MigrationGuideUrl);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllDeprecatedEndpoints_HaveSunsetDates()
|
||||
{
|
||||
var endpoints = new[]
|
||||
{
|
||||
DeprecatedEndpoints.LegacyLinksets,
|
||||
DeprecatedEndpoints.LegacyAdvisoryObservations,
|
||||
DeprecatedEndpoints.LegacyAdvisoryLinksets,
|
||||
DeprecatedEndpoints.LegacyAdvisoryLinksetsExport,
|
||||
DeprecatedEndpoints.LegacyConcelierObservations
|
||||
};
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
Assert.NotNull(endpoint.SunsetAt);
|
||||
Assert.True(endpoint.SunsetAt > endpoint.DeprecatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunsetDate_IsAfterDeprecationDate()
|
||||
{
|
||||
Assert.True(
|
||||
DeprecatedEndpoints.LegacyApisSunsetAt > DeprecatedEndpoints.LegacyApisDeprecatedAt,
|
||||
"Sunset date must be after deprecation date");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationHeaders_ConstantsAreDefined()
|
||||
{
|
||||
Assert.Equal("Deprecation", DeprecationHeaders.Deprecation);
|
||||
Assert.Equal("Sunset", DeprecationHeaders.Sunset);
|
||||
Assert.Equal("Link", DeprecationHeaders.Link);
|
||||
Assert.Equal("X-Deprecation-Notice", DeprecationHeaders.XDeprecationNotice);
|
||||
Assert.Equal("X-Deprecation-Guide", DeprecationHeaders.XDeprecationGuide);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user