up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,28 +1,28 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public sealed class AdvisoryAiMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0");
|
||||
|
||||
private readonly Counter<long> _requests;
|
||||
private readonly Counter<long> _queuePublished;
|
||||
private readonly Counter<long> _queueProcessed;
|
||||
|
||||
public AdvisoryAiMetrics()
|
||||
{
|
||||
_requests = Meter.CreateCounter<long>("advisory_ai_pipeline_requests_total");
|
||||
_queuePublished = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_enqueued_total");
|
||||
_queueProcessed = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_processed_total");
|
||||
}
|
||||
|
||||
public void RecordRequest(string taskType)
|
||||
=> _requests.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
|
||||
|
||||
public void RecordEnqueued(string taskType)
|
||||
=> _queuePublished.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
|
||||
|
||||
public void RecordProcessed(string taskType)
|
||||
=> _queueProcessed.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
|
||||
}
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public sealed class AdvisoryAiMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0");
|
||||
|
||||
private readonly Counter<long> _requests;
|
||||
private readonly Counter<long> _queuePublished;
|
||||
private readonly Counter<long> _queueProcessed;
|
||||
|
||||
public AdvisoryAiMetrics()
|
||||
{
|
||||
_requests = Meter.CreateCounter<long>("advisory_ai_pipeline_requests_total");
|
||||
_queuePublished = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_enqueued_total");
|
||||
_queueProcessed = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_processed_total");
|
||||
}
|
||||
|
||||
public void RecordRequest(string taskType)
|
||||
=> _requests.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
|
||||
|
||||
public void RecordEnqueued(string taskType)
|
||||
=> _queuePublished.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
|
||||
|
||||
public void RecordProcessed(string taskType)
|
||||
=> _queueProcessed.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.DependencyInjection;
|
||||
|
||||
public static class SbomContextServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSbomContext(this IServiceCollection services, Action<SbomContextClientOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var optionsBuilder = services.AddOptions<SbomContextClientOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddHttpClient<ISbomContextClient, SbomContextHttpClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<SbomContextClientOptions>>().Value;
|
||||
if (options.BaseAddress is not null)
|
||||
{
|
||||
client.BaseAddress = options.BaseAddress;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName))
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove(options.TenantHeaderName);
|
||||
client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant);
|
||||
}
|
||||
});
|
||||
|
||||
services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.DependencyInjection;
|
||||
|
||||
public static class SbomContextServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSbomContext(this IServiceCollection services, Action<SbomContextClientOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var optionsBuilder = services.AddOptions<SbomContextClientOptions>();
|
||||
if (configure is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddHttpClient<ISbomContextClient, SbomContextHttpClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<SbomContextClientOptions>>().Value;
|
||||
if (options.BaseAddress is not null)
|
||||
{
|
||||
client.BaseAddress = options.BaseAddress;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName))
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove(options.TenantHeaderName);
|
||||
client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant);
|
||||
}
|
||||
});
|
||||
|
||||
services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,52 +4,52 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator
|
||||
{
|
||||
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
|
||||
private readonly IAdvisoryVectorRetriever _vectorRetriever;
|
||||
private readonly ISbomContextRetriever _sbomContextRetriever;
|
||||
private readonly IDeterministicToolset _toolset;
|
||||
private readonly AdvisoryPipelineOptions _options;
|
||||
private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger;
|
||||
|
||||
public AdvisoryPipelineOrchestrator(
|
||||
IAdvisoryStructuredRetriever structuredRetriever,
|
||||
IAdvisoryVectorRetriever vectorRetriever,
|
||||
ISbomContextRetriever sbomContextRetriever,
|
||||
IDeterministicToolset toolset,
|
||||
IOptions<AdvisoryPipelineOptions> options,
|
||||
ILogger<AdvisoryPipelineOrchestrator>? logger = null)
|
||||
{
|
||||
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
|
||||
_vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever));
|
||||
_sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever));
|
||||
_toolset = toolset ?? throw new ArgumentNullException(nameof(toolset));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.ApplyDefaults();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var config = _options.GetConfiguration(request.TaskType);
|
||||
|
||||
var structuredRequest = new AdvisoryRetrievalRequest(
|
||||
request.AdvisoryKey,
|
||||
request.PreferredSections,
|
||||
config.StructuredMaxChunks);
|
||||
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator
|
||||
{
|
||||
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
|
||||
private readonly IAdvisoryVectorRetriever _vectorRetriever;
|
||||
private readonly ISbomContextRetriever _sbomContextRetriever;
|
||||
private readonly IDeterministicToolset _toolset;
|
||||
private readonly AdvisoryPipelineOptions _options;
|
||||
private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger;
|
||||
|
||||
public AdvisoryPipelineOrchestrator(
|
||||
IAdvisoryStructuredRetriever structuredRetriever,
|
||||
IAdvisoryVectorRetriever vectorRetriever,
|
||||
ISbomContextRetriever sbomContextRetriever,
|
||||
IDeterministicToolset toolset,
|
||||
IOptions<AdvisoryPipelineOptions> options,
|
||||
ILogger<AdvisoryPipelineOrchestrator>? logger = null)
|
||||
{
|
||||
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
|
||||
_vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever));
|
||||
_sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever));
|
||||
_toolset = toolset ?? throw new ArgumentNullException(nameof(toolset));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.ApplyDefaults();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var config = _options.GetConfiguration(request.TaskType);
|
||||
|
||||
var structuredRequest = new AdvisoryRetrievalRequest(
|
||||
request.AdvisoryKey,
|
||||
request.PreferredSections,
|
||||
config.StructuredMaxChunks);
|
||||
|
||||
var structured = await _structuredRetriever
|
||||
.RetrieveAsync(structuredRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -57,10 +57,10 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
var structuredChunks = NormalizeStructuredChunks(structured);
|
||||
var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false);
|
||||
var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis);
|
||||
var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis);
|
||||
|
||||
|
||||
var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis);
|
||||
var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis);
|
||||
|
||||
var plan = new AdvisoryTaskPlan(
|
||||
request,
|
||||
cacheKey,
|
||||
@@ -69,27 +69,27 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
vectorResults,
|
||||
sbomContext,
|
||||
dependencyAnalysis,
|
||||
config.Budget,
|
||||
metadata);
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalRequest structuredRequest,
|
||||
AdvisoryTaskConfiguration configuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (configuration.VectorQueries.Count == 0)
|
||||
{
|
||||
return ImmutableArray<AdvisoryVectorResult>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count);
|
||||
foreach (var query in configuration.GetVectorQueries())
|
||||
{
|
||||
var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK);
|
||||
config.Budget,
|
||||
metadata);
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalRequest structuredRequest,
|
||||
AdvisoryTaskConfiguration configuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (configuration.VectorQueries.Count == 0)
|
||||
{
|
||||
return ImmutableArray<AdvisoryVectorResult>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count);
|
||||
foreach (var query in configuration.GetVectorQueries())
|
||||
{
|
||||
var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK);
|
||||
var matches = await _vectorRetriever
|
||||
.SearchAsync(vectorRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -102,27 +102,27 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
builder.Add(new AdvisoryVectorResult(query, orderedMatches));
|
||||
}
|
||||
|
||||
return builder.MoveToImmutable();
|
||||
}
|
||||
|
||||
private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryTaskConfiguration configuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.ArtifactId))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var sbomRequest = new SbomContextRequest(
|
||||
artifactId: request.ArtifactId!,
|
||||
purl: request.ArtifactPurl,
|
||||
maxTimelineEntries: configuration.SbomMaxTimelineEntries,
|
||||
maxDependencyPaths: configuration.SbomMaxDependencyPaths,
|
||||
includeEnvironmentFlags: configuration.IncludeEnvironmentFlags,
|
||||
includeBlastRadius: configuration.IncludeBlastRadius);
|
||||
|
||||
return builder.MoveToImmutable();
|
||||
}
|
||||
|
||||
private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryTaskConfiguration configuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.ArtifactId))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var sbomRequest = new SbomContextRequest(
|
||||
artifactId: request.ArtifactId!,
|
||||
purl: request.ArtifactPurl,
|
||||
maxTimelineEntries: configuration.SbomMaxTimelineEntries,
|
||||
maxDependencyPaths: configuration.SbomMaxDependencyPaths,
|
||||
includeEnvironmentFlags: configuration.IncludeEnvironmentFlags,
|
||||
includeBlastRadius: configuration.IncludeBlastRadius);
|
||||
|
||||
var context = await _sbomContextRetriever
|
||||
.RetrieveAsync(sbomRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -135,73 +135,73 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
private static ImmutableDictionary<string, string> BuildMetadata(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalResult structured,
|
||||
ImmutableArray<AdvisoryVectorResult> vectors,
|
||||
SbomContextResult? sbom,
|
||||
DependencyAnalysisResult? dependency)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
builder["task_type"] = request.TaskType.ToString();
|
||||
builder["advisory_key"] = request.AdvisoryKey;
|
||||
builder["profile"] = request.Profile;
|
||||
ImmutableArray<AdvisoryVectorResult> vectors,
|
||||
SbomContextResult? sbom,
|
||||
DependencyAnalysisResult? dependency)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
builder["task_type"] = request.TaskType.ToString();
|
||||
builder["advisory_key"] = request.AdvisoryKey;
|
||||
builder["profile"] = request.Profile;
|
||||
builder["structured_chunk_count"] = structured.Chunks.Count().ToString(CultureInfo.InvariantCulture);
|
||||
builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture);
|
||||
builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
|
||||
builder["includes_sbom"] = (sbom is not null).ToString();
|
||||
builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||
builder["force_refresh"] = request.ForceRefresh.ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(request.PolicyVersion))
|
||||
{
|
||||
builder["policy_version"] = request.PolicyVersion!;
|
||||
}
|
||||
|
||||
if (sbom is not null)
|
||||
{
|
||||
builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture);
|
||||
builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
|
||||
builder["includes_sbom"] = (sbom is not null).ToString();
|
||||
builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||
builder["force_refresh"] = request.ForceRefresh.ToString();
|
||||
|
||||
if (!string.IsNullOrEmpty(request.PolicyVersion))
|
||||
{
|
||||
builder["policy_version"] = request.PolicyVersion!;
|
||||
}
|
||||
|
||||
if (sbom is not null)
|
||||
{
|
||||
builder["sbom_version_count"] = sbom.VersionTimeline.Length.ToString(CultureInfo.InvariantCulture);
|
||||
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Length.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
if (!sbom.EnvironmentFlags.IsEmpty)
|
||||
{
|
||||
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"sbom_env_{flag.Key}"] = flag.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (sbom.BlastRadius is not null)
|
||||
{
|
||||
builder["sbom_blast_impacted_assets"] = sbom.BlastRadius.ImpactedAssets.ToString(CultureInfo.InvariantCulture);
|
||||
builder["sbom_blast_impacted_workloads"] = sbom.BlastRadius.ImpactedWorkloads.ToString(CultureInfo.InvariantCulture);
|
||||
builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture);
|
||||
if (sbom.BlastRadius.ImpactedPercentage is not null)
|
||||
{
|
||||
builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!sbom.BlastRadius.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sbom.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"sbom_meta_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dependency is not null)
|
||||
{
|
||||
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"dependency_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
|
||||
if (!sbom.EnvironmentFlags.IsEmpty)
|
||||
{
|
||||
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"sbom_env_{flag.Key}"] = flag.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (sbom.BlastRadius is not null)
|
||||
{
|
||||
builder["sbom_blast_impacted_assets"] = sbom.BlastRadius.ImpactedAssets.ToString(CultureInfo.InvariantCulture);
|
||||
builder["sbom_blast_impacted_workloads"] = sbom.BlastRadius.ImpactedWorkloads.ToString(CultureInfo.InvariantCulture);
|
||||
builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture);
|
||||
if (sbom.BlastRadius.ImpactedPercentage is not null)
|
||||
{
|
||||
builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!sbom.BlastRadius.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sbom.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"sbom_meta_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dependency is not null)
|
||||
{
|
||||
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"dependency_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
@@ -249,177 +249,177 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
}
|
||||
|
||||
private static string ComputeCacheKey(
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalResult structured,
|
||||
ImmutableArray<AdvisoryVectorResult> vectors,
|
||||
SbomContextResult? sbom,
|
||||
DependencyAnalysisResult? dependency)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(request.TaskType)
|
||||
.Append('|').Append(request.AdvisoryKey)
|
||||
.Append('|').Append(request.ArtifactId ?? string.Empty)
|
||||
.Append('|').Append(request.PolicyVersion ?? string.Empty)
|
||||
.Append('|').Append(request.Profile);
|
||||
|
||||
if (request.PreferredSections is not null)
|
||||
{
|
||||
foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Append('|').Append(section);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var chunkId in structured.Chunks
|
||||
.Select(chunk => chunk.ChunkId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|chunk:").Append(chunkId);
|
||||
}
|
||||
|
||||
foreach (var vector in vectors)
|
||||
{
|
||||
builder.Append("|query:").Append(vector.Query);
|
||||
foreach (var match in vector.Matches
|
||||
.OrderBy(m => m.ChunkId, StringComparer.Ordinal)
|
||||
.ThenBy(m => m.Score))
|
||||
{
|
||||
builder.Append("|match:")
|
||||
.Append(match.ChunkId)
|
||||
.Append('@')
|
||||
.Append(match.Score.ToString("G", CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (sbom is not null)
|
||||
{
|
||||
AdvisoryTaskRequest request,
|
||||
AdvisoryRetrievalResult structured,
|
||||
ImmutableArray<AdvisoryVectorResult> vectors,
|
||||
SbomContextResult? sbom,
|
||||
DependencyAnalysisResult? dependency)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(request.TaskType)
|
||||
.Append('|').Append(request.AdvisoryKey)
|
||||
.Append('|').Append(request.ArtifactId ?? string.Empty)
|
||||
.Append('|').Append(request.PolicyVersion ?? string.Empty)
|
||||
.Append('|').Append(request.Profile);
|
||||
|
||||
if (request.PreferredSections is not null)
|
||||
{
|
||||
foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Append('|').Append(section);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var chunkId in structured.Chunks
|
||||
.Select(chunk => chunk.ChunkId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|chunk:").Append(chunkId);
|
||||
}
|
||||
|
||||
foreach (var vector in vectors)
|
||||
{
|
||||
builder.Append("|query:").Append(vector.Query);
|
||||
foreach (var match in vector.Matches
|
||||
.OrderBy(m => m.ChunkId, StringComparer.Ordinal)
|
||||
.ThenBy(m => m.Score))
|
||||
{
|
||||
builder.Append("|match:")
|
||||
.Append(match.ChunkId)
|
||||
.Append('@')
|
||||
.Append(match.Score.ToString("G", CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (sbom is not null)
|
||||
{
|
||||
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Length);
|
||||
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Length);
|
||||
foreach (var entry in sbom.VersionTimeline
|
||||
.OrderBy(e => e.Version, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds())
|
||||
.ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue)
|
||||
.ThenBy(e => e.Status, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Source, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|timeline:")
|
||||
.Append(entry.Version)
|
||||
.Append('@')
|
||||
.Append(entry.FirstObserved.ToUnixTimeMilliseconds())
|
||||
.Append('@')
|
||||
.Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1)
|
||||
.Append('@')
|
||||
.Append(entry.Status)
|
||||
.Append('@')
|
||||
.Append(entry.Source);
|
||||
}
|
||||
|
||||
foreach (var path in sbom.DependencyPaths
|
||||
.OrderBy(path => path.IsRuntime)
|
||||
.ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|path:")
|
||||
.Append(path.IsRuntime ? 'R' : 'D');
|
||||
|
||||
foreach (var node in path.Nodes)
|
||||
{
|
||||
builder.Append(":")
|
||||
.Append(node.Identifier)
|
||||
.Append('@')
|
||||
.Append(node.Version ?? string.Empty);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path.Source))
|
||||
{
|
||||
builder.Append("|pathsrc:").Append(path.Source);
|
||||
}
|
||||
|
||||
if (!path.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|pathmeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sbom.EnvironmentFlags.IsEmpty)
|
||||
{
|
||||
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|env:")
|
||||
.Append(flag.Key)
|
||||
.Append('=')
|
||||
.Append(flag.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (sbom.BlastRadius is not null)
|
||||
{
|
||||
builder.Append("|blast:")
|
||||
.Append(sbom.BlastRadius.ImpactedAssets)
|
||||
.Append(',')
|
||||
.Append(sbom.BlastRadius.ImpactedWorkloads)
|
||||
.Append(',')
|
||||
.Append(sbom.BlastRadius.ImpactedNamespaces)
|
||||
.Append(',')
|
||||
.Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
|
||||
if (!sbom.BlastRadius.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|blastmeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sbom.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|sbommeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dependency is not null)
|
||||
{
|
||||
foreach (var node in dependency.Nodes
|
||||
.OrderBy(n => n.Identifier, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|dep:")
|
||||
.Append(node.Identifier)
|
||||
.Append(':')
|
||||
.Append(node.RuntimeOccurrences)
|
||||
.Append(':')
|
||||
.Append(node.DevelopmentOccurrences)
|
||||
.Append(':')
|
||||
.Append(string.Join(',', node.Versions));
|
||||
}
|
||||
|
||||
if (!dependency.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|depmeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
foreach (var entry in sbom.VersionTimeline
|
||||
.OrderBy(e => e.Version, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds())
|
||||
.ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue)
|
||||
.ThenBy(e => e.Status, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Source, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|timeline:")
|
||||
.Append(entry.Version)
|
||||
.Append('@')
|
||||
.Append(entry.FirstObserved.ToUnixTimeMilliseconds())
|
||||
.Append('@')
|
||||
.Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1)
|
||||
.Append('@')
|
||||
.Append(entry.Status)
|
||||
.Append('@')
|
||||
.Append(entry.Source);
|
||||
}
|
||||
|
||||
foreach (var path in sbom.DependencyPaths
|
||||
.OrderBy(path => path.IsRuntime)
|
||||
.ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|path:")
|
||||
.Append(path.IsRuntime ? 'R' : 'D');
|
||||
|
||||
foreach (var node in path.Nodes)
|
||||
{
|
||||
builder.Append(":")
|
||||
.Append(node.Identifier)
|
||||
.Append('@')
|
||||
.Append(node.Version ?? string.Empty);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path.Source))
|
||||
{
|
||||
builder.Append("|pathsrc:").Append(path.Source);
|
||||
}
|
||||
|
||||
if (!path.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|pathmeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sbom.EnvironmentFlags.IsEmpty)
|
||||
{
|
||||
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|env:")
|
||||
.Append(flag.Key)
|
||||
.Append('=')
|
||||
.Append(flag.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (sbom.BlastRadius is not null)
|
||||
{
|
||||
builder.Append("|blast:")
|
||||
.Append(sbom.BlastRadius.ImpactedAssets)
|
||||
.Append(',')
|
||||
.Append(sbom.BlastRadius.ImpactedWorkloads)
|
||||
.Append(',')
|
||||
.Append(sbom.BlastRadius.ImpactedNamespaces)
|
||||
.Append(',')
|
||||
.Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
|
||||
if (!sbom.BlastRadius.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|blastmeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sbom.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|sbommeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dependency is not null)
|
||||
{
|
||||
foreach (var node in dependency.Nodes
|
||||
.OrderBy(n => n.Identifier, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|dep:")
|
||||
.Append(node.Identifier)
|
||||
.Append(':')
|
||||
.Append(node.RuntimeOccurrences)
|
||||
.Append(':')
|
||||
.Append(node.DevelopmentOccurrences)
|
||||
.Append(':')
|
||||
.Append(string.Join(',', node.Versions));
|
||||
}
|
||||
|
||||
if (!dependency.Metadata.IsEmpty)
|
||||
{
|
||||
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append("|depmeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
public sealed class AdvisoryTaskPlan
|
||||
{
|
||||
public AdvisoryTaskPlan(
|
||||
AdvisoryTaskRequest request,
|
||||
string cacheKey,
|
||||
string promptTemplate,
|
||||
ImmutableArray<AdvisoryChunk> structuredChunks,
|
||||
ImmutableArray<AdvisoryVectorResult> vectorResults,
|
||||
SbomContextResult? sbomContext,
|
||||
DependencyAnalysisResult? dependencyAnalysis,
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
public sealed class AdvisoryTaskPlan
|
||||
{
|
||||
public AdvisoryTaskPlan(
|
||||
AdvisoryTaskRequest request,
|
||||
string cacheKey,
|
||||
string promptTemplate,
|
||||
ImmutableArray<AdvisoryChunk> structuredChunks,
|
||||
ImmutableArray<AdvisoryVectorResult> vectorResults,
|
||||
SbomContextResult? sbomContext,
|
||||
DependencyAnalysisResult? dependencyAnalysis,
|
||||
AdvisoryTaskBudget budget,
|
||||
ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
Request = request ?? throw new ArgumentNullException(nameof(request));
|
||||
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
|
||||
PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate));
|
||||
StructuredChunks = structuredChunks;
|
||||
VectorResults = vectorResults;
|
||||
SbomContext = sbomContext;
|
||||
DependencyAnalysis = dependencyAnalysis;
|
||||
Budget = budget ?? throw new ArgumentNullException(nameof(budget));
|
||||
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
|
||||
}
|
||||
|
||||
public AdvisoryTaskRequest Request { get; }
|
||||
|
||||
public string CacheKey { get; }
|
||||
|
||||
public string PromptTemplate { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryChunk> StructuredChunks { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryVectorResult> VectorResults { get; }
|
||||
|
||||
public SbomContextResult? SbomContext { get; }
|
||||
|
||||
public DependencyAnalysisResult? DependencyAnalysis { get; }
|
||||
|
||||
public AdvisoryTaskBudget Budget { get; }
|
||||
|
||||
{
|
||||
Request = request ?? throw new ArgumentNullException(nameof(request));
|
||||
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
|
||||
PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate));
|
||||
StructuredChunks = structuredChunks;
|
||||
VectorResults = vectorResults;
|
||||
SbomContext = sbomContext;
|
||||
DependencyAnalysis = dependencyAnalysis;
|
||||
Budget = budget ?? throw new ArgumentNullException(nameof(budget));
|
||||
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
|
||||
}
|
||||
|
||||
public AdvisoryTaskRequest Request { get; }
|
||||
|
||||
public string CacheKey { get; }
|
||||
|
||||
public string PromptTemplate { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryChunk> StructuredChunks { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryVectorResult> VectorResults { get; }
|
||||
|
||||
public SbomContextResult? SbomContext { get; }
|
||||
|
||||
public DependencyAnalysisResult? DependencyAnalysis { get; }
|
||||
|
||||
public AdvisoryTaskBudget Budget { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
}
|
||||
|
||||
public sealed class AdvisoryVectorResult
|
||||
{
|
||||
public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches)
|
||||
{
|
||||
Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query;
|
||||
Matches = matches;
|
||||
}
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public ImmutableArray<VectorRetrievalMatch> Matches { get; }
|
||||
}
|
||||
|
||||
public sealed class AdvisoryTaskBudget
|
||||
{
|
||||
public int PromptTokens { get; init; } = 2048;
|
||||
|
||||
public int CompletionTokens { get; init; } = 512;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdvisoryVectorResult
|
||||
{
|
||||
public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches)
|
||||
{
|
||||
Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query;
|
||||
Matches = matches;
|
||||
}
|
||||
|
||||
public string Query { get; }
|
||||
|
||||
public ImmutableArray<VectorRetrievalMatch> Matches { get; }
|
||||
}
|
||||
|
||||
public sealed class AdvisoryTaskBudget
|
||||
{
|
||||
public int PromptTokens { get; init; } = 2048;
|
||||
|
||||
public int CompletionTokens { get; init; } = 512;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the SBOM context HTTP client.
|
||||
/// </summary>
|
||||
public sealed class SbomContextClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base address for the SBOM service. Required.
|
||||
/// </summary>
|
||||
public Uri? BaseAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative endpoint that returns SBOM context payloads.
|
||||
/// Defaults to <c>api/sbom/context</c>.
|
||||
/// </summary>
|
||||
public string ContextEndpoint { get; set; } = "api/sbom/context";
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant identifier that should be forwarded to the SBOM service.
|
||||
/// </summary>
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name used when forwarding the tenant. Defaults to <c>X-StellaOps-Tenant</c>.
|
||||
/// </summary>
|
||||
public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant";
|
||||
}
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the SBOM context HTTP client.
|
||||
/// </summary>
|
||||
public sealed class SbomContextClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base address for the SBOM service. Required.
|
||||
/// </summary>
|
||||
public Uri? BaseAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative endpoint that returns SBOM context payloads.
|
||||
/// Defaults to <c>api/sbom/context</c>.
|
||||
/// </summary>
|
||||
public string ContextEndpoint { get; set; } = "api/sbom/context";
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant identifier that should be forwarded to the SBOM service.
|
||||
/// </summary>
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name used when forwarding the tenant. Defaults to <c>X-StellaOps-Tenant</c>.
|
||||
/// </summary>
|
||||
public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant";
|
||||
}
|
||||
|
||||
@@ -1,234 +1,234 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
internal sealed class SbomContextHttpClient : ISbomContextClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly SbomContextClientOptions options;
|
||||
private readonly ILogger<SbomContextHttpClient>? logger;
|
||||
|
||||
public SbomContextHttpClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SbomContextClientOptions> options,
|
||||
ILogger<SbomContextHttpClient>? logger = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
this.options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (this.options.BaseAddress is not null && this.httpClient.BaseAddress is null)
|
||||
{
|
||||
this.httpClient.BaseAddress = this.options.BaseAddress;
|
||||
}
|
||||
|
||||
if (this.httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured.");
|
||||
}
|
||||
|
||||
this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
if (query is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query));
|
||||
}
|
||||
|
||||
var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty;
|
||||
if (endpoint.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SBOM context endpoint must be configured.");
|
||||
}
|
||||
|
||||
var requestUri = BuildRequestUri(endpoint, query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
ApplyTenantHeader(request);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger?.LogWarning(
|
||||
"SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}",
|
||||
requestUri,
|
||||
(int)response.StatusCode,
|
||||
content);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
internal sealed class SbomContextHttpClient : ISbomContextClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly SbomContextClientOptions options;
|
||||
private readonly ILogger<SbomContextHttpClient>? logger;
|
||||
|
||||
public SbomContextHttpClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SbomContextClientOptions> options,
|
||||
ILogger<SbomContextHttpClient>? logger = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
this.options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (this.options.BaseAddress is not null && this.httpClient.BaseAddress is null)
|
||||
{
|
||||
this.httpClient.BaseAddress = this.options.BaseAddress;
|
||||
}
|
||||
|
||||
if (this.httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured.");
|
||||
}
|
||||
|
||||
this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
if (query is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query));
|
||||
}
|
||||
|
||||
var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty;
|
||||
if (endpoint.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SBOM context endpoint must be configured.");
|
||||
}
|
||||
|
||||
var requestUri = BuildRequestUri(endpoint, query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
ApplyTenantHeader(request);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger?.LogWarning(
|
||||
"SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}",
|
||||
requestUri,
|
||||
(int)response.StatusCode,
|
||||
content);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var httpContent = response.Content ?? throw new InvalidOperationException("SBOM context response did not include content.");
|
||||
var payload = await httpContent.ReadFromJsonAsync<SbomContextPayload>(SerializerOptions, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri);
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload.ToDocument();
|
||||
}
|
||||
|
||||
private Uri BuildRequestUri(string endpoint, SbomContextQuery query)
|
||||
{
|
||||
var relative = endpoint.StartsWith("/", StringComparison.Ordinal)
|
||||
? endpoint[1..]
|
||||
: endpoint;
|
||||
|
||||
var queryBuilder = new StringBuilder();
|
||||
|
||||
AppendQuery(queryBuilder, "artifactId", query.ArtifactId);
|
||||
AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture));
|
||||
AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture));
|
||||
AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false");
|
||||
AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Purl))
|
||||
{
|
||||
AppendQuery(queryBuilder, "purl", query.Purl!);
|
||||
}
|
||||
|
||||
var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative;
|
||||
return new Uri(httpClient.BaseAddress!, uriString);
|
||||
|
||||
static void AppendQuery(StringBuilder builder, string name, string value)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append('&');
|
||||
}
|
||||
|
||||
builder.Append(Uri.EscapeDataString(name));
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyTenantHeader(HttpRequestMessage request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.Headers.Contains(options.TenantHeaderName))
|
||||
{
|
||||
request.Headers.Add(options.TenantHeaderName, options.Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SbomContextPayload(
|
||||
[property: JsonPropertyName("artifactId")] string ArtifactId,
|
||||
[property: JsonPropertyName("purl")] string? Purl,
|
||||
[property: JsonPropertyName("versions")] ImmutableArray<SbomVersionPayload> Versions,
|
||||
[property: JsonPropertyName("dependencyPaths")] ImmutableArray<SbomDependencyPathPayload> DependencyPaths,
|
||||
[property: JsonPropertyName("environmentFlags")] ImmutableDictionary<string, string> EnvironmentFlags,
|
||||
[property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomContextDocument ToDocument()
|
||||
=> new(
|
||||
ArtifactId,
|
||||
Purl,
|
||||
Versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(),
|
||||
DependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(),
|
||||
EnvironmentFlags == default ? ImmutableDictionary<string, string>.Empty : EnvironmentFlags,
|
||||
BlastRadius?.ToRecord(),
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomVersionPayload(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved,
|
||||
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomVersionRecord ToRecord()
|
||||
=> new(
|
||||
Version,
|
||||
FirstObserved,
|
||||
LastObserved,
|
||||
Status,
|
||||
Source,
|
||||
IsFixAvailable,
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomDependencyPathPayload(
|
||||
[property: JsonPropertyName("nodes")] ImmutableArray<SbomDependencyNodePayload> Nodes,
|
||||
[property: JsonPropertyName("isRuntime")] bool IsRuntime,
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomDependencyPathRecord ToRecord()
|
||||
=> new(
|
||||
Nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(),
|
||||
IsRuntime,
|
||||
Source,
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomDependencyNodePayload(
|
||||
[property: JsonPropertyName("identifier")] string Identifier,
|
||||
[property: JsonPropertyName("version")] string? Version)
|
||||
{
|
||||
public SbomDependencyNodeRecord ToRecord()
|
||||
=> new(Identifier, Version);
|
||||
}
|
||||
|
||||
private sealed record SbomBlastRadiusPayload(
|
||||
[property: JsonPropertyName("impactedAssets")] int ImpactedAssets,
|
||||
[property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads,
|
||||
[property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces,
|
||||
[property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomBlastRadiusRecord ToRecord()
|
||||
=> new(
|
||||
ImpactedAssets,
|
||||
ImpactedWorkloads,
|
||||
ImpactedNamespaces,
|
||||
ImpactedPercentage,
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri);
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload.ToDocument();
|
||||
}
|
||||
|
||||
private Uri BuildRequestUri(string endpoint, SbomContextQuery query)
|
||||
{
|
||||
var relative = endpoint.StartsWith("/", StringComparison.Ordinal)
|
||||
? endpoint[1..]
|
||||
: endpoint;
|
||||
|
||||
var queryBuilder = new StringBuilder();
|
||||
|
||||
AppendQuery(queryBuilder, "artifactId", query.ArtifactId);
|
||||
AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture));
|
||||
AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture));
|
||||
AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false");
|
||||
AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Purl))
|
||||
{
|
||||
AppendQuery(queryBuilder, "purl", query.Purl!);
|
||||
}
|
||||
|
||||
var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative;
|
||||
return new Uri(httpClient.BaseAddress!, uriString);
|
||||
|
||||
static void AppendQuery(StringBuilder builder, string name, string value)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append('&');
|
||||
}
|
||||
|
||||
builder.Append(Uri.EscapeDataString(name));
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyTenantHeader(HttpRequestMessage request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.Headers.Contains(options.TenantHeaderName))
|
||||
{
|
||||
request.Headers.Add(options.TenantHeaderName, options.Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SbomContextPayload(
|
||||
[property: JsonPropertyName("artifactId")] string ArtifactId,
|
||||
[property: JsonPropertyName("purl")] string? Purl,
|
||||
[property: JsonPropertyName("versions")] ImmutableArray<SbomVersionPayload> Versions,
|
||||
[property: JsonPropertyName("dependencyPaths")] ImmutableArray<SbomDependencyPathPayload> DependencyPaths,
|
||||
[property: JsonPropertyName("environmentFlags")] ImmutableDictionary<string, string> EnvironmentFlags,
|
||||
[property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomContextDocument ToDocument()
|
||||
=> new(
|
||||
ArtifactId,
|
||||
Purl,
|
||||
Versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(),
|
||||
DependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(),
|
||||
EnvironmentFlags == default ? ImmutableDictionary<string, string>.Empty : EnvironmentFlags,
|
||||
BlastRadius?.ToRecord(),
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomVersionPayload(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved,
|
||||
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomVersionRecord ToRecord()
|
||||
=> new(
|
||||
Version,
|
||||
FirstObserved,
|
||||
LastObserved,
|
||||
Status,
|
||||
Source,
|
||||
IsFixAvailable,
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomDependencyPathPayload(
|
||||
[property: JsonPropertyName("nodes")] ImmutableArray<SbomDependencyNodePayload> Nodes,
|
||||
[property: JsonPropertyName("isRuntime")] bool IsRuntime,
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomDependencyPathRecord ToRecord()
|
||||
=> new(
|
||||
Nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(),
|
||||
IsRuntime,
|
||||
Source,
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomDependencyNodePayload(
|
||||
[property: JsonPropertyName("identifier")] string Identifier,
|
||||
[property: JsonPropertyName("version")] string? Version)
|
||||
{
|
||||
public SbomDependencyNodeRecord ToRecord()
|
||||
=> new(Identifier, Version);
|
||||
}
|
||||
|
||||
private sealed record SbomBlastRadiusPayload(
|
||||
[property: JsonPropertyName("impactedAssets")] int ImpactedAssets,
|
||||
[property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads,
|
||||
[property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces,
|
||||
[property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage,
|
||||
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public SbomBlastRadiusRecord ToRecord()
|
||||
=> new(
|
||||
ImpactedAssets,
|
||||
ImpactedWorkloads,
|
||||
ImpactedNamespaces,
|
||||
ImpactedPercentage,
|
||||
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class DeterministicToolsetTests
|
||||
{
|
||||
[Fact]
|
||||
public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts()
|
||||
{
|
||||
var context = SbomContextResult.Create(
|
||||
"artifact-123",
|
||||
purl: null,
|
||||
versionTimeline: Array.Empty<SbomVersionTimelineEntry>(),
|
||||
dependencyPaths: new[]
|
||||
{
|
||||
new SbomDependencyPath(
|
||||
new[]
|
||||
{
|
||||
new SbomDependencyNode("root", "1.0.0"),
|
||||
new SbomDependencyNode("lib-a", "2.0.0"),
|
||||
},
|
||||
isRuntime: true),
|
||||
new SbomDependencyPath(
|
||||
new[]
|
||||
{
|
||||
new SbomDependencyNode("root", "1.0.0"),
|
||||
new SbomDependencyNode("lib-b", "3.1.4"),
|
||||
},
|
||||
isRuntime: false),
|
||||
});
|
||||
|
||||
IDeterministicToolset toolset = new DeterministicToolset();
|
||||
var analysis = toolset.AnalyzeDependencies(context);
|
||||
|
||||
analysis.ArtifactId.Should().Be("artifact-123");
|
||||
analysis.Metadata["path_count"].Should().Be("2");
|
||||
analysis.Metadata["runtime_path_count"].Should().Be("1");
|
||||
analysis.Metadata["development_path_count"].Should().Be("1");
|
||||
analysis.Nodes.Should().HaveCount(3);
|
||||
|
||||
var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a");
|
||||
libA.RuntimeOccurrences.Should().Be(1);
|
||||
libA.DevelopmentOccurrences.Should().Be(0);
|
||||
|
||||
var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b");
|
||||
libB.RuntimeOccurrences.Should().Be(0);
|
||||
libB.DevelopmentOccurrences.Should().Be(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("semver", "1.2.3", "1.2.4", -1)]
|
||||
[InlineData("semver", "1.2.3", "1.2.3", 0)]
|
||||
[InlineData("semver", "1.2.4", "1.2.3", 1)]
|
||||
[InlineData("evr", "1:1.0-1", "1:1.0-2", -1)]
|
||||
[InlineData("evr", "0:2.0-0", "0:2.0-0", 0)]
|
||||
[InlineData("evr", "0:2.1-0", "0:2.0-5", 1)]
|
||||
public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected)
|
||||
{
|
||||
IDeterministicToolset toolset = new DeterministicToolset();
|
||||
toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue();
|
||||
comparison.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")]
|
||||
[InlineData("semver", "2.0.0", ">=2.0.0")]
|
||||
[InlineData("evr", "0:1.2-3", ">=0:1.0-0 <0:2.0-0")]
|
||||
[InlineData("evr", "1:3.4-1", ">=1:3.0-0")]
|
||||
public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range)
|
||||
{
|
||||
IDeterministicToolset toolset = new DeterministicToolset();
|
||||
toolset.SatisfiesRange(scheme, version, range).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class DeterministicToolsetTests
|
||||
{
|
||||
[Fact]
|
||||
public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts()
|
||||
{
|
||||
var context = SbomContextResult.Create(
|
||||
"artifact-123",
|
||||
purl: null,
|
||||
versionTimeline: Array.Empty<SbomVersionTimelineEntry>(),
|
||||
dependencyPaths: new[]
|
||||
{
|
||||
new SbomDependencyPath(
|
||||
new[]
|
||||
{
|
||||
new SbomDependencyNode("root", "1.0.0"),
|
||||
new SbomDependencyNode("lib-a", "2.0.0"),
|
||||
},
|
||||
isRuntime: true),
|
||||
new SbomDependencyPath(
|
||||
new[]
|
||||
{
|
||||
new SbomDependencyNode("root", "1.0.0"),
|
||||
new SbomDependencyNode("lib-b", "3.1.4"),
|
||||
},
|
||||
isRuntime: false),
|
||||
});
|
||||
|
||||
IDeterministicToolset toolset = new DeterministicToolset();
|
||||
var analysis = toolset.AnalyzeDependencies(context);
|
||||
|
||||
analysis.ArtifactId.Should().Be("artifact-123");
|
||||
analysis.Metadata["path_count"].Should().Be("2");
|
||||
analysis.Metadata["runtime_path_count"].Should().Be("1");
|
||||
analysis.Metadata["development_path_count"].Should().Be("1");
|
||||
analysis.Nodes.Should().HaveCount(3);
|
||||
|
||||
var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a");
|
||||
libA.RuntimeOccurrences.Should().Be(1);
|
||||
libA.DevelopmentOccurrences.Should().Be(0);
|
||||
|
||||
var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b");
|
||||
libB.RuntimeOccurrences.Should().Be(0);
|
||||
libB.DevelopmentOccurrences.Should().Be(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("semver", "1.2.3", "1.2.4", -1)]
|
||||
[InlineData("semver", "1.2.3", "1.2.3", 0)]
|
||||
[InlineData("semver", "1.2.4", "1.2.3", 1)]
|
||||
[InlineData("evr", "1:1.0-1", "1:1.0-2", -1)]
|
||||
[InlineData("evr", "0:2.0-0", "0:2.0-0", 0)]
|
||||
[InlineData("evr", "0:2.1-0", "0:2.0-5", 1)]
|
||||
public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected)
|
||||
{
|
||||
IDeterministicToolset toolset = new DeterministicToolset();
|
||||
toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue();
|
||||
comparison.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")]
|
||||
[InlineData("semver", "2.0.0", ">=2.0.0")]
|
||||
[InlineData("evr", "0:1.2-3", ">=0:1.0-0 <0:2.0-0")]
|
||||
[InlineData("evr", "1:3.4-1", ">=1:3.0-0")]
|
||||
public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range)
|
||||
{
|
||||
IDeterministicToolset toolset = new DeterministicToolset();
|
||||
toolset.SatisfiesRange(scheme, version, range).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,144 +1,144 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class SbomContextHttpClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetContextAsync_MapsPayloadToDocument()
|
||||
{
|
||||
const string payload = """
|
||||
{
|
||||
"artifactId": "artifact-001",
|
||||
"purl": "pkg:npm/react@18.3.0",
|
||||
"versions": [
|
||||
{
|
||||
"version": "18.3.0",
|
||||
"firstObserved": "2025-10-01T00:00:00Z",
|
||||
"lastObserved": null,
|
||||
"status": "affected",
|
||||
"source": "inventory",
|
||||
"isFixAvailable": false,
|
||||
"metadata": { "note": "current" }
|
||||
}
|
||||
],
|
||||
"dependencyPaths": [
|
||||
{
|
||||
"nodes": [
|
||||
{ "identifier": "app", "version": "1.0.0" },
|
||||
{ "identifier": "react", "version": "18.3.0" }
|
||||
],
|
||||
"isRuntime": true,
|
||||
"source": "scanner",
|
||||
"metadata": { "scope": "production" }
|
||||
}
|
||||
],
|
||||
"environmentFlags": {
|
||||
"environment/prod": "true"
|
||||
},
|
||||
"blastRadius": {
|
||||
"impactedAssets": 10,
|
||||
"impactedWorkloads": 4,
|
||||
"impactedNamespaces": 2,
|
||||
"impactedPercentage": 0.25,
|
||||
"metadata": { "note": "simulated" }
|
||||
},
|
||||
"metadata": {
|
||||
"source": "sbom-service"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://sbom.example/")
|
||||
};
|
||||
|
||||
var options = Options.Create(new SbomContextClientOptions
|
||||
{
|
||||
ContextEndpoint = "api/sbom/context",
|
||||
Tenant = "tenant-alpha",
|
||||
TenantHeaderName = "X-StellaOps-Tenant"
|
||||
});
|
||||
|
||||
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
|
||||
|
||||
var query = new SbomContextQuery("artifact-001", "pkg:npm/react@18.3.0", 25, 10, includeEnvironmentFlags: true, includeBlastRadius: true);
|
||||
var document = await client.GetContextAsync(query, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("artifact-001", document!.ArtifactId);
|
||||
Assert.Equal("pkg:npm/react@18.3.0", document.Purl);
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class SbomContextHttpClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetContextAsync_MapsPayloadToDocument()
|
||||
{
|
||||
const string payload = """
|
||||
{
|
||||
"artifactId": "artifact-001",
|
||||
"purl": "pkg:npm/react@18.3.0",
|
||||
"versions": [
|
||||
{
|
||||
"version": "18.3.0",
|
||||
"firstObserved": "2025-10-01T00:00:00Z",
|
||||
"lastObserved": null,
|
||||
"status": "affected",
|
||||
"source": "inventory",
|
||||
"isFixAvailable": false,
|
||||
"metadata": { "note": "current" }
|
||||
}
|
||||
],
|
||||
"dependencyPaths": [
|
||||
{
|
||||
"nodes": [
|
||||
{ "identifier": "app", "version": "1.0.0" },
|
||||
{ "identifier": "react", "version": "18.3.0" }
|
||||
],
|
||||
"isRuntime": true,
|
||||
"source": "scanner",
|
||||
"metadata": { "scope": "production" }
|
||||
}
|
||||
],
|
||||
"environmentFlags": {
|
||||
"environment/prod": "true"
|
||||
},
|
||||
"blastRadius": {
|
||||
"impactedAssets": 10,
|
||||
"impactedWorkloads": 4,
|
||||
"impactedNamespaces": 2,
|
||||
"impactedPercentage": 0.25,
|
||||
"metadata": { "note": "simulated" }
|
||||
},
|
||||
"metadata": {
|
||||
"source": "sbom-service"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://sbom.example/")
|
||||
};
|
||||
|
||||
var options = Options.Create(new SbomContextClientOptions
|
||||
{
|
||||
ContextEndpoint = "api/sbom/context",
|
||||
Tenant = "tenant-alpha",
|
||||
TenantHeaderName = "X-StellaOps-Tenant"
|
||||
});
|
||||
|
||||
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
|
||||
|
||||
var query = new SbomContextQuery("artifact-001", "pkg:npm/react@18.3.0", 25, 10, includeEnvironmentFlags: true, includeBlastRadius: true);
|
||||
var document = await client.GetContextAsync(query, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("artifact-001", document!.ArtifactId);
|
||||
Assert.Equal("pkg:npm/react@18.3.0", document.Purl);
|
||||
Assert.Single(document.Versions);
|
||||
Assert.Single(document.DependencyPaths);
|
||||
Assert.Single(document.EnvironmentFlags);
|
||||
Assert.NotNull(document.BlastRadius);
|
||||
Assert.Equal("sbom-service", document.Metadata["source"]);
|
||||
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single());
|
||||
Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query);
|
||||
Assert.Contains("purl=pkg%3Anpm%2Freact%4018.3.0", handler.LastRequest.RequestUri!.Query);
|
||||
Assert.Contains("includeEnvironmentFlags=true", handler.LastRequest.RequestUri!.Query);
|
||||
Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_ReturnsNullOnNotFound()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
|
||||
var options = Options.Create(new SbomContextClientOptions());
|
||||
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
|
||||
|
||||
var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_ThrowsForServerError()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json")
|
||||
});
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
|
||||
var options = Options.Create(new SbomContextClientOptions());
|
||||
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
this.responder = responder ?? throw new ArgumentNullException(nameof(responder));
|
||||
}
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert.Single(document.DependencyPaths);
|
||||
Assert.Single(document.EnvironmentFlags);
|
||||
Assert.NotNull(document.BlastRadius);
|
||||
Assert.Equal("sbom-service", document.Metadata["source"]);
|
||||
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single());
|
||||
Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query);
|
||||
Assert.Contains("purl=pkg%3Anpm%2Freact%4018.3.0", handler.LastRequest.RequestUri!.Query);
|
||||
Assert.Contains("includeEnvironmentFlags=true", handler.LastRequest.RequestUri!.Query);
|
||||
Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_ReturnsNullOnNotFound()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
|
||||
var options = Options.Create(new SbomContextClientOptions());
|
||||
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
|
||||
|
||||
var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContextAsync_ThrowsForServerError()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json")
|
||||
});
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
|
||||
var options = Options.Create(new SbomContextClientOptions());
|
||||
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
this.responder = responder ?? throw new ArgumentNullException(nameof(responder));
|
||||
}
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,30 +10,30 @@ using StellaOps.AdvisoryAI.Tools;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class ToolsetServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddAdvisoryDeterministicToolset_RegistersSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddAdvisoryDeterministicToolset();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var toolsetA = provider.GetRequiredService<IDeterministicToolset>();
|
||||
var toolsetB = provider.GetRequiredService<IDeterministicToolset>();
|
||||
|
||||
Assert.Same(toolsetA, toolsetB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAdvisoryPipeline_RegistersOrchestrator()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class ToolsetServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddAdvisoryDeterministicToolset_RegistersSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddAdvisoryDeterministicToolset();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var toolsetA = provider.GetRequiredService<IDeterministicToolset>();
|
||||
var toolsetB = provider.GetRequiredService<IDeterministicToolset>();
|
||||
|
||||
Assert.Same(toolsetA, toolsetB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAdvisoryPipeline_RegistersOrchestrator()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSbomContext(options =>
|
||||
{
|
||||
options.BaseAddress = new Uri("https://sbom.example/");
|
||||
|
||||
Reference in New Issue
Block a user