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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user