feat: Enhance Task Runner with simulation and failure policy support
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added tests for output projection and failure policy population in TaskPackPlanner. - Introduced new failure policy manifest in TestManifests. - Implemented simulation endpoints in the web service for task execution. - Created TaskRunnerServiceOptions for configuration management. - Updated appsettings.json to include TaskRunner configuration. - Enhanced PackRunWorkerService to handle execution graphs and state management. - Added support for parallel execution and conditional steps in the worker service. - Updated documentation to reflect new features and changes in execution flow.
This commit is contained in:
@@ -0,0 +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));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public sealed class AdvisoryAiServiceOptions
|
||||
{
|
||||
public Uri? SbomBaseAddress { get; set; }
|
||||
|
||||
public string? SbomTenant { get; set; }
|
||||
|
||||
public string SbomTenantHeaderName { get; set; } = X-StellaOps-Tenant;
|
||||
|
||||
public AdvisoryAiQueueOptions Queue { get; set; } = new();
|
||||
|
||||
internal string ResolveQueueDirectory(string contentRoot)
|
||||
{
|
||||
var path = Queue.DirectoryPath;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
path = Path.Combine(contentRoot, data, advisory-ai, queue);
|
||||
}
|
||||
|
||||
if (!Path.IsPathFullyQualified(path))
|
||||
{
|
||||
path = Path.GetFullPath(Path.Combine(contentRoot, path));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdvisoryAiQueueOptions
|
||||
{
|
||||
public string DirectoryPath { get; set; } = Path.Combine(data, advisory-ai, queue);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
internal static class AdvisoryAiServiceOptionsValidator
|
||||
{
|
||||
public static void Validate(AdvisoryAiServiceOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
if (options.SbomBaseAddress is null || !options.SbomBaseAddress.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException(AdvisoryAI:SbomBaseAddress
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public interface IAdvisoryPipelineQueuePublisher
|
||||
{
|
||||
Task EnqueueAsync(AdvisoryPipelineExecutionMessage message, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IAdvisoryPipelineQueueReceiver
|
||||
{
|
||||
Task<AdvisoryPipelineExecutionMessage?> DequeueAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class FileSystemAdvisoryPipelineQueue : IAdvisoryPipelineQueuePublisher, IAdvisoryPipelineQueueReceiver
|
||||
{
|
||||
private readonly ILogger<FileSystemAdvisoryPipelineQueue> _logger;
|
||||
private readonly string _queueDirectory;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public FileSystemAdvisoryPipelineQueue(
|
||||
IOptions<AdvisoryAiServiceOptions> options,
|
||||
ILogger<FileSystemAdvisoryPipelineQueue> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var serviceOptions = options.Value ?? throw new InvalidOperationException("Advisory AI options not configured.");
|
||||
AdvisoryAiServiceOptionsValidator.Validate(serviceOptions);
|
||||
_queueDirectory = serviceOptions.ResolveQueueDirectory(AppContext.BaseDirectory);
|
||||
Directory.CreateDirectory(_queueDirectory);
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync(AdvisoryPipelineExecutionMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var envelope = FileQueueEnvelope.FromMessage(message);
|
||||
var payload = JsonSerializer.Serialize(envelope, _serializerOptions);
|
||||
|
||||
var fileName = $"{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfff}_{Guid.NewGuid():N}.json";
|
||||
var targetPath = Path.Combine(_queueDirectory, fileName);
|
||||
var tempPath = targetPath + ".tmp";
|
||||
|
||||
await File.WriteAllTextAsync(tempPath, payload, cancellationToken).ConfigureAwait(false);
|
||||
File.Move(tempPath, targetPath, overwrite: true);
|
||||
_logger.LogInformation("Queued advisory pipeline execution message {CacheKey}", message.PlanCacheKey);
|
||||
}
|
||||
|
||||
public async Task<AdvisoryPipelineExecutionMessage?> DequeueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var files = Directory.EnumerateFiles(_queueDirectory, "*.json")
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
AdvisoryPipelineExecutionMessage? message = null;
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
var envelope = await JsonSerializer.DeserializeAsync<FileQueueEnvelope>(stream, _serializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (envelope is not null)
|
||||
{
|
||||
message = envelope.ToMessage();
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File may be locked by another worker; skip.
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize advisory pipeline message from {File}", file);
|
||||
}
|
||||
|
||||
if (message is not null)
|
||||
{
|
||||
TryDelete(file);
|
||||
_logger.LogInformation("Dequeued advisory pipeline execution message {CacheKey}", message.PlanCacheKey);
|
||||
return message;
|
||||
}
|
||||
|
||||
TryDelete(file);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDelete(string file)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to delete queue file {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record FileQueueEnvelope(
|
||||
string PlanCacheKey,
|
||||
AdvisoryPipelineRequestEnvelope Request,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static FileQueueEnvelope FromMessage(AdvisoryPipelineExecutionMessage message)
|
||||
=> new(
|
||||
message.PlanCacheKey,
|
||||
AdvisoryPipelineRequestEnvelope.FromRequest(message.Request),
|
||||
new Dictionary<string, string>(message.PlanMetadata, StringComparer.Ordinal));
|
||||
|
||||
public AdvisoryPipelineExecutionMessage ToMessage()
|
||||
=> new(
|
||||
PlanCacheKey,
|
||||
Request.ToRequest(),
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private sealed record AdvisoryPipelineRequestEnvelope(
|
||||
AdvisoryTaskType TaskType,
|
||||
string AdvisoryKey,
|
||||
string? ArtifactId,
|
||||
string? ArtifactPurl,
|
||||
string? PolicyVersion,
|
||||
string Profile,
|
||||
string[]? PreferredSections,
|
||||
bool ForceRefresh)
|
||||
{
|
||||
public static AdvisoryPipelineRequestEnvelope FromRequest(AdvisoryTaskRequest request)
|
||||
=> new(
|
||||
request.TaskType,
|
||||
request.AdvisoryKey,
|
||||
request.ArtifactId,
|
||||
request.ArtifactPurl,
|
||||
request.PolicyVersion,
|
||||
request.Profile,
|
||||
request.PreferredSections?.ToArray(),
|
||||
request.ForceRefresh);
|
||||
|
||||
public AdvisoryTaskRequest ToRequest()
|
||||
=> new(
|
||||
TaskType,
|
||||
AdvisoryKey,
|
||||
ArtifactId,
|
||||
ArtifactPurl,
|
||||
PolicyVersion,
|
||||
Profile,
|
||||
PreferredSections,
|
||||
ForceRefresh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdvisoryAiCore(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Action<AdvisoryAiServiceOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOptions<AdvisoryAiServiceOptions>()
|
||||
.Bind(configuration.GetSection("AdvisoryAI"))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
configure?.Invoke(options);
|
||||
AdvisoryAiServiceOptionsValidator.Validate(options);
|
||||
})
|
||||
.Validate(AdvisoryAiServiceOptionsValidator.TryValidate)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<SbomContextClientOptions>()
|
||||
.Configure<IOptions<AdvisoryAiServiceOptions>>((target, source) =>
|
||||
{
|
||||
var advisoryOptions = source.Value;
|
||||
target.BaseAddress = advisoryOptions.SbomBaseAddress;
|
||||
target.Tenant = advisoryOptions.SbomTenant;
|
||||
target.TenantHeaderName = advisoryOptions.SbomTenantHeaderName;
|
||||
})
|
||||
.Validate(opt => opt.BaseAddress is not null && opt.BaseAddress.IsAbsoluteUri, "SBOM base address must be absolute.");
|
||||
|
||||
services.AddSbomContext();
|
||||
services.AddAdvisoryPipeline();
|
||||
|
||||
services.TryAddSingleton<FileSystemAdvisoryPipelineQueue>();
|
||||
services.TryAddSingleton<IAdvisoryPipelineQueuePublisher>(sp => sp.GetRequiredService<FileSystemAdvisoryPipelineQueue>());
|
||||
services.TryAddSingleton<IAdvisoryPipelineQueueReceiver>(sp => sp.GetRequiredService<FileSystemAdvisoryPipelineQueue>());
|
||||
services.TryAddSingleton<AdvisoryAiMetrics>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,289 +1,86 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.AdvisoryAI.Execution;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables(prefix: "ADVISORYAI_");
|
||||
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddMetrics();
|
||||
|
||||
builder.Services.AddAdvisoryPipeline(options => builder.Configuration.GetSection("AdvisoryAI:Pipeline").Bind(options));
|
||||
builder.Services.AddAdvisoryPipelineInfrastructure();
|
||||
|
||||
builder.Services.Configure<AdvisoryPlanCacheOptions>(builder.Configuration.GetSection("AdvisoryAI:PlanCache"));
|
||||
builder.Services.Configure<AdvisoryTaskQueueOptions>(builder.Configuration.GetSection("AdvisoryAI:TaskQueue"));
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" }));
|
||||
|
||||
app.MapPost("/api/v1/advisory/plan", async Task<Results<Ok<AdvisoryPlanResponse>, ValidationProblem>> (
|
||||
[FromBody] AdvisoryPlanRequest request,
|
||||
IAdvisoryPipelineOrchestrator orchestrator,
|
||||
IAdvisoryPlanCache cache,
|
||||
AdvisoryPipelineMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
app.UseExceptionHandler(static options => options.Run(async context =>
|
||||
{
|
||||
if (!MiniValidator.TryValidate(request, out var errors))
|
||||
{
|
||||
return TypedResults.ValidationProblem(errors);
|
||||
}
|
||||
var problem = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
|
||||
await problem.ExecuteAsync(context);
|
||||
}));
|
||||
|
||||
var taskRequest = request.ToTaskRequest();
|
||||
var start = timeProvider.GetTimestamp();
|
||||
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
|
||||
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
|
||||
var elapsed = timeProvider.GetElapsedTime(start);
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
metrics.RecordPlanCreated(elapsed.TotalSeconds, taskRequest.TaskType);
|
||||
|
||||
var response = new AdvisoryPlanResponse(
|
||||
plan.CacheKey,
|
||||
plan.Request.TaskType,
|
||||
plan.Request.AdvisoryKey,
|
||||
plan.Request.Profile,
|
||||
plan.StructuredChunks.Length,
|
||||
plan.VectorResults.Sum(result => result.Matches.Length),
|
||||
plan.SbomContext is not null,
|
||||
plan.Metadata,
|
||||
timeProvider.GetUtcNow());
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/advisory/queue", async Task<Results<Accepted<AdvisoryQueueResponse>, ValidationProblem>> (
|
||||
[FromBody] AdvisoryQueueRequest request,
|
||||
IAdvisoryPlanCache cache,
|
||||
IAdvisoryTaskQueue queue,
|
||||
IAdvisoryPipelineOrchestrator orchestrator,
|
||||
AdvisoryPipelineMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["request"] = new[] { "Request payload is required." }
|
||||
});
|
||||
}
|
||||
|
||||
AdvisoryTaskPlan? plan = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.PlanCacheKey))
|
||||
{
|
||||
plan = await cache.TryGetAsync(request.PlanCacheKey!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (plan is null)
|
||||
{
|
||||
if (request.Plan is null)
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["plan"] = new[] { "Either planCacheKey or plan must be supplied." }
|
||||
});
|
||||
}
|
||||
|
||||
if (!MiniValidator.TryValidate(request.Plan, out var planErrors))
|
||||
{
|
||||
return TypedResults.ValidationProblem(planErrors);
|
||||
}
|
||||
|
||||
var taskRequest = request.Plan.ToTaskRequest();
|
||||
var start = timeProvider.GetTimestamp();
|
||||
plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
|
||||
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
|
||||
var elapsed = timeProvider.GetElapsedTime(start);
|
||||
metrics.RecordPlanCreated(elapsed.TotalSeconds, plan.Request.TaskType);
|
||||
}
|
||||
|
||||
await queue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
|
||||
metrics.RecordPlanQueued(plan.Request.TaskType);
|
||||
|
||||
var response = new AdvisoryQueueResponse(
|
||||
plan.CacheKey,
|
||||
plan.Request.TaskType,
|
||||
plan.Metadata,
|
||||
"Plan enqueued for processing.");
|
||||
|
||||
return TypedResults.Accepted($"/api/v1/advisory/queue/{plan.CacheKey}", response);
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/advisory/{taskType}", async Task<Results<Ok<AdvisoryOutputResponse>, ValidationProblem>> (
|
||||
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", async (
|
||||
string taskType,
|
||||
[FromBody] AdvisoryExecuteRequest request,
|
||||
PipelinePlanRequest request,
|
||||
IAdvisoryPipelineOrchestrator orchestrator,
|
||||
IAdvisoryPlanCache cache,
|
||||
IAdvisoryPipelineExecutor executor,
|
||||
IAdvisoryOutputStore outputStore,
|
||||
AdvisoryPipelineMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
IAdvisoryPipelineQueuePublisher queue,
|
||||
AdvisoryAiMetrics metrics,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryParseTaskType(taskType, out var taskTypeEnum, out var routeError))
|
||||
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["taskType"] = new[] { routeError }
|
||||
});
|
||||
return Results.BadRequest(new { error = $"Unknown task type {taskType}." });
|
||||
}
|
||||
|
||||
if (!MiniValidator.TryValidate(request, out var errors))
|
||||
{
|
||||
return TypedResults.ValidationProblem(errors);
|
||||
}
|
||||
var httpRequest = request with { TaskType = parsedType };
|
||||
var orchestratorRequest = httpRequest.ToTaskRequest();
|
||||
|
||||
var taskRequest = request.ToTaskRequest(taskTypeEnum);
|
||||
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
|
||||
var plan = await orchestrator.CreatePlanAsync(orchestratorRequest, cancellationToken).ConfigureAwait(false);
|
||||
metrics.RecordRequest(plan.Request.TaskType.ToString());
|
||||
|
||||
var existingPlan = await cache.TryGetAsync(plan.CacheKey, cancellationToken).ConfigureAwait(false);
|
||||
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
|
||||
await queue.EnqueueAsync(new AdvisoryPipelineExecutionMessage(plan.CacheKey, plan.Request, plan.Metadata), cancellationToken).ConfigureAwait(false);
|
||||
metrics.RecordEnqueued(plan.Request.TaskType.ToString());
|
||||
|
||||
var planFromCache = existingPlan is not null && !request.ForceRefresh;
|
||||
|
||||
AdvisoryPipelineOutput? output = null;
|
||||
if (!request.ForceRefresh)
|
||||
{
|
||||
output = await outputStore.TryGetAsync(plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (output is null)
|
||||
{
|
||||
var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
|
||||
await executor.ExecuteAsync(plan, message, planFromCache, cancellationToken).ConfigureAwait(false);
|
||||
output = await outputStore.TryGetAsync(plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (output is null)
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["execution"] = new[] { "Failed to generate advisory output." }
|
||||
});
|
||||
}
|
||||
|
||||
metrics.RecordPlanProcessed(plan.Request.TaskType, planFromCache);
|
||||
|
||||
var response = ToOutputResponse(output);
|
||||
return TypedResults.Ok(response);
|
||||
});
|
||||
|
||||
app.MapGet("/api/v1/advisory/outputs/{cacheKey}", async Task<Results<Ok<AdvisoryOutputResponse>, ValidationProblem, NotFound>> (
|
||||
string cacheKey,
|
||||
[FromQuery] AdvisoryTaskType? taskType,
|
||||
[FromQuery] string? profile,
|
||||
IAdvisoryOutputStore outputStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["cacheKey"] = new[] { "Cache key is required." }
|
||||
});
|
||||
}
|
||||
|
||||
if (taskType is null)
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["taskType"] = new[] { "Task type query parameter is required." }
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile))
|
||||
{
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["profile"] = new[] { "Profile query parameter is required." }
|
||||
});
|
||||
}
|
||||
|
||||
var output = await outputStore.TryGetAsync(cacheKey, taskType.Value, profile!, cancellationToken).ConfigureAwait(false);
|
||||
if (output is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(ToOutputResponse(output));
|
||||
return Results.Ok(AdvisoryPipelinePlanResponse.FromPlan(plan));
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool TryParseTaskType(string routeValue, out AdvisoryTaskType taskType, out string error)
|
||||
internal sealed record PipelinePlanRequest(
|
||||
AdvisoryTaskType? TaskType,
|
||||
string AdvisoryKey,
|
||||
string? ArtifactId,
|
||||
string? ArtifactPurl,
|
||||
string? PolicyVersion,
|
||||
string Profile = "default",
|
||||
IReadOnlyCollection<string>? PreferredSections = null,
|
||||
bool ForceRefresh = false)
|
||||
{
|
||||
if (Enum.TryParse(routeValue, ignoreCase: true, out taskType))
|
||||
public AdvisoryTaskRequest ToTaskRequest()
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"Unsupported advisory task type {routeValue}. Expected summary, conflict, or remediation.";
|
||||
return false;
|
||||
}
|
||||
|
||||
static AdvisoryOutputResponse ToOutputResponse(AdvisoryPipelineOutput output)
|
||||
{
|
||||
var violations = output.Guardrail.Violations
|
||||
.Select(AdvisoryGuardrailViolationResponse.From)
|
||||
.ToImmutableArray();
|
||||
|
||||
var citations = output.Citations
|
||||
.Select(citation => new AdvisoryCitationResponse(citation.Index, citation.DocumentId, citation.ChunkId))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AdvisoryOutputResponse(
|
||||
output.CacheKey,
|
||||
output.TaskType,
|
||||
output.Profile,
|
||||
output.Provenance.OutputHash,
|
||||
output.Guardrail.Blocked,
|
||||
violations,
|
||||
output.Guardrail.Metadata,
|
||||
output.Prompt,
|
||||
citations,
|
||||
output.Metadata,
|
||||
output.GeneratedAtUtc,
|
||||
output.PlanFromCache);
|
||||
}
|
||||
|
||||
internal static class MiniValidator
|
||||
{
|
||||
public static bool TryValidate(object instance, out Dictionary<string, string[]> errors)
|
||||
{
|
||||
var context = new ValidationContext(instance);
|
||||
var results = new List<ValidationResult>();
|
||||
if (!Validator.TryValidateObject(instance, context, results, validateAllProperties: true))
|
||||
if (TaskType is null)
|
||||
{
|
||||
errors = results
|
||||
.GroupBy(result => result.MemberNames.FirstOrDefault() ?? string.Empty)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(result => result.ErrorMessage ?? "Invalid value.").ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
return false;
|
||||
throw new InvalidOperationException("Task type must be specified.");
|
||||
}
|
||||
|
||||
errors = new Dictionary<string, string[]>(0);
|
||||
return true;
|
||||
return new AdvisoryTaskRequest(
|
||||
TaskType.Value,
|
||||
AdvisoryKey,
|
||||
ArtifactId,
|
||||
ArtifactPurl,
|
||||
PolicyVersion,
|
||||
Profile,
|
||||
PreferredSections,
|
||||
ForceRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"AdvisoryAI": {
|
||||
"SbomBaseAddress": "http://localhost:5210/",
|
||||
"Queue": {
|
||||
"DirectoryPath": "../var/advisory-ai-queue"
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,74 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.Worker.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddMetrics();
|
||||
builder.Services.AddAdvisoryPipeline(options => builder.Configuration.GetSection("AdvisoryAI:Pipeline").Bind(options));
|
||||
builder.Services.AddAdvisoryPipelineInfrastructure();
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables(prefix: "ADVISORYAI_");
|
||||
|
||||
builder.Services.Configure<AdvisoryPlanCacheOptions>(builder.Configuration.GetSection("AdvisoryAI:PlanCache"));
|
||||
builder.Services.Configure<AdvisoryTaskQueueOptions>(builder.Configuration.GetSection("AdvisoryAI:TaskQueue"));
|
||||
|
||||
builder.Services.AddHostedService<AdvisoryTaskWorker>();
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
builder.Services.AddHostedService<AdvisoryPipelineWorker>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
|
||||
internal sealed class AdvisoryPipelineWorker : BackgroundService
|
||||
{
|
||||
private readonly IAdvisoryPipelineQueueReceiver _queue;
|
||||
private readonly IAdvisoryPipelineOrchestrator _orchestrator;
|
||||
private readonly AdvisoryAiMetrics _metrics;
|
||||
private readonly ILogger<AdvisoryPipelineWorker> _logger;
|
||||
|
||||
public AdvisoryPipelineWorker(
|
||||
IAdvisoryPipelineQueueReceiver queue,
|
||||
IAdvisoryPipelineOrchestrator orchestrator,
|
||||
AdvisoryAiMetrics metrics,
|
||||
ILogger<AdvisoryPipelineWorker> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Advisory AI worker started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = await _queue.DequeueAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (message is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_metrics.RecordProcessed(message.Request.TaskType.ToString());
|
||||
_logger.LogInformation(
|
||||
"Processing advisory pipeline message {CacheKey} for {Task}",
|
||||
message.PlanCacheKey,
|
||||
message.Request.TaskType);
|
||||
|
||||
// TODO: Execute prompt assembly, guardrails, and inference workflows in future tasks.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception while processing advisory pipeline queue");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Advisory AI worker stopped");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.json
Normal file
13
src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"AdvisoryAI": {
|
||||
"SbomBaseAddress": "http://localhost:5210/",
|
||||
"Queue": {
|
||||
"DirectoryPath": "../var/advisory-ai-queue"
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjecti
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "..\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{C8CE71D3-952A-43F7-9346-20113E37F672}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.WebService", "StellaOps.AdvisoryAI.WebService\\StellaOps.AdvisoryAI.WebService.csproj", "{E2F673A3-7B0E-489B-8BA6-65BF9E3A1D5C}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Hosting", "StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj", "{F3E0EA9E-E4F0-428A-804B-A599870B971D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Worker", "StellaOps.AdvisoryAI.Worker\\StellaOps.AdvisoryAI.Worker.csproj", "{6813F3CD-6B46-4955-AB1A-30546AB10A05}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.WebService", "StellaOps.AdvisoryAI.WebService\StellaOps.AdvisoryAI.WebService.csproj", "{AD5CEACE-7BF5-4D48-B473-D60188844A0A}"
|
||||
EndProject
|
||||
lobal
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AdvisoryAI.Worker", "StellaOps.AdvisoryAI.Worker\StellaOps.AdvisoryAI.Worker.csproj", "{BC68381E-B6EF-4481-8487-00267624D18C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
@@ -187,6 +189,42 @@ lobal
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C8CE71D3-952A-43F7-9346-20113E37F672}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F3E0EA9E-E4F0-428A-804B-A599870B971D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AD5CEACE-7BF5-4D48-B473-D60188844A0A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BC68381E-B6EF-4481-8487-00267624D18C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,49 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
{
|
||||
builder["sbom_version_count"] = sbom.VersionTimeline.Count.ToString(CultureInfo.InvariantCulture);
|
||||
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Count.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;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
@@ -201,12 +244,100 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
{
|
||||
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Count);
|
||||
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Count);
|
||||
foreach (var kvp in sbom.Metadata.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
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("|sbommeta:")
|
||||
.Append(kvp.Key)
|
||||
.Append('=')
|
||||
.Append(kvp.Value);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +351,20 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
|
||||
.Append(':')
|
||||
.Append(node.RuntimeOccurrences)
|
||||
.Append(':')
|
||||
.Append(node.DevelopmentOccurrences);
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
@@ -0,0 +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";
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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.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();
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomContextPayload>(SerializerOptions, 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);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| AIAI-31-001 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
|
||||
| AIAI-31-002 | DOING | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
|
||||
| AIAI-31-003 | DOING | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-002 | DONE (2025-11-04) | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
|
||||
| AIAI-31-003 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-004 | DOING | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
|
||||
| AIAI-31-004A | DONE (2025-11-03) | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
|
||||
> 2025-11-03: In-memory plan cache + task queue implemented, WebService exposes `/api/v1/advisory/plan` & `/api/v1/advisory/queue`, pipeline metrics wired, worker hosted service dequeues plans and logs processed runs; docs/sprint notes updated.
|
||||
| AIAI-31-004B | DONE (2025-11-03) | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
|
||||
> 2025-11-03: Added deterministic prompt assembler, no-op guardrail pipeline hooks, DSSE-ready output persistence with provenance, updated metrics/DI wiring, and golden prompt tests.
|
||||
| AIAI-31-004A | DOING (2025-11-04) | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
|
||||
| AIAI-31-004B | TODO | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
|
||||
| AIAI-31-004C | TODO | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run <task>` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. |
|
||||
| AIAI-31-005 | DOING (2025-11-03) | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | DOING (2025-11-03) | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
|
||||
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
|
||||
| AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. |
|
||||
@@ -19,10 +17,10 @@
|
||||
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
|
||||
|
||||
> 2025-11-02: AIAI-31-002 – SBOM context domain models finalized with limiter guards; retriever tests now cover flag toggles and path dedupe. Service client integration still pending with SBOM guild.
|
||||
> 2025-11-03: AIAI-31-002 – HTTP SBOM context client wired with configurable headers/timeouts, DI registers fallback null client and typed retriever; tests cover request shaping, response mapping, and 404 handling.
|
||||
> 2025-11-03: Blocking follow-up tracked via SBOM-AIAI-31-003 – waiting on SBOM base URL/API key hand-off plus joint smoke test before enabling live retrieval in staging.
|
||||
> 2025-11-04: AIAI-31-002 – Introduced `SbomContextHttpClient`, DI helper (`AddSbomContext`), and HTTP-mapping tests; retriever wired to typed client with tenant header support and deterministic query construction.
|
||||
|
||||
> 2025-11-02: AIAI-31-003 moved to DOING – starting deterministic tooling surface (version comparators & dependency analysis). Added semantic-version + EVR comparators and published toolset interface; awaiting downstream wiring.
|
||||
> 2025-11-04: AIAI-31-003 completed – toolset wired via DI/orchestrator, SBOM context client available, and unit coverage for compare/range/dependency analysis extended.
|
||||
|
||||
> 2025-11-02: AIAI-31-004 started orchestration pipeline work – begin designing summary/conflict/remediation workflow (deterministic sequence + cache keys).
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
@@ -58,6 +60,77 @@ public sealed class AdvisoryPipelineOrchestratorTests
|
||||
Assert.Equal(plan.CacheKey, secondPlan.CacheKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WhenArtifactIdMissing_SkipsSbomContext()
|
||||
{
|
||||
var structuredRetriever = new FakeStructuredRetriever();
|
||||
var vectorRetriever = new FakeVectorRetriever();
|
||||
var sbomRetriever = new FakeSbomContextRetriever();
|
||||
var options = Options.Create(new AdvisoryPipelineOptions());
|
||||
var orchestrator = new AdvisoryPipelineOrchestrator(
|
||||
structuredRetriever,
|
||||
vectorRetriever,
|
||||
sbomRetriever,
|
||||
new DeterministicToolset(),
|
||||
options,
|
||||
NullLogger<AdvisoryPipelineOrchestrator>.Instance);
|
||||
|
||||
var request = new AdvisoryTaskRequest(
|
||||
AdvisoryTaskType.Conflict,
|
||||
advisoryKey: "adv-key",
|
||||
artifactId: null,
|
||||
artifactPurl: null,
|
||||
policyVersion: null,
|
||||
profile: "fips-local");
|
||||
|
||||
var plan = await orchestrator.CreatePlanAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Null(plan.SbomContext);
|
||||
Assert.Null(plan.DependencyAnalysis);
|
||||
Assert.Equal("False", plan.Metadata["includes_sbom"]);
|
||||
Assert.DoesNotContain("sbom_version_count", plan.Metadata.Keys);
|
||||
Assert.DoesNotContain("sbom_dependency_path_count", plan.Metadata.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_RespectsOptionFlagsAndProducesStableCacheKey()
|
||||
{
|
||||
var structuredRetriever = new FakeStructuredRetriever();
|
||||
var vectorRetriever = new FakeVectorRetriever();
|
||||
var togglingRetriever = new TogglingSbomContextRetriever();
|
||||
var options = Options.Create(new AdvisoryPipelineOptions());
|
||||
options.Value.Tasks[AdvisoryTaskType.Summary].IncludeEnvironmentFlags = false;
|
||||
options.Value.Tasks[AdvisoryTaskType.Summary].IncludeBlastRadius = false;
|
||||
options.Value.Tasks[AdvisoryTaskType.Summary].SbomMaxTimelineEntries = 2;
|
||||
options.Value.Tasks[AdvisoryTaskType.Summary].SbomMaxDependencyPaths = 1;
|
||||
var orchestrator = new AdvisoryPipelineOrchestrator(
|
||||
structuredRetriever,
|
||||
vectorRetriever,
|
||||
togglingRetriever,
|
||||
new DeterministicToolset(),
|
||||
options,
|
||||
NullLogger<AdvisoryPipelineOrchestrator>.Instance);
|
||||
|
||||
var request = new AdvisoryTaskRequest(
|
||||
AdvisoryTaskType.Summary,
|
||||
advisoryKey: "adv-key",
|
||||
artifactId: "artifact-1",
|
||||
artifactPurl: "pkg:npm/example@1.0.0",
|
||||
policyVersion: "policy-1",
|
||||
profile: "default");
|
||||
|
||||
var planOne = await orchestrator.CreatePlanAsync(request, CancellationToken.None);
|
||||
var planTwo = await orchestrator.CreatePlanAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(planOne.CacheKey, planTwo.CacheKey);
|
||||
Assert.True(togglingRetriever.RecordedRequests.All(r => r.IncludeEnvironmentFlags == false));
|
||||
Assert.True(togglingRetriever.RecordedRequests.All(r => r.IncludeBlastRadius == false));
|
||||
Assert.True(togglingRetriever.RecordedRequests.All(r => r.MaxTimelineEntries == 2));
|
||||
Assert.True(togglingRetriever.RecordedRequests.All(r => r.MaxDependencyPaths == 1));
|
||||
Assert.DoesNotContain(planOne.Metadata.Keys, key => key.StartsWith("sbom_env_", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(planOne.Metadata.Keys, key => key.StartsWith("sbom_blast_", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class FakeStructuredRetriever : IAdvisoryStructuredRetriever
|
||||
{
|
||||
public Task<AdvisoryRetrievalResult> RetrieveAsync(AdvisoryRetrievalRequest request, CancellationToken cancellationToken)
|
||||
@@ -128,4 +201,52 @@ public sealed class AdvisoryPipelineOrchestratorTests
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TogglingSbomContextRetriever : ISbomContextRetriever
|
||||
{
|
||||
private int _invocation;
|
||||
|
||||
public List<SbomContextRequest> RecordedRequests { get; } = new();
|
||||
|
||||
public Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
RecordedRequests.Add(request);
|
||||
|
||||
var evenCall = (_invocation++ % 2) == 0;
|
||||
var envFlags = evenCall
|
||||
? new Dictionary<string, string>(StringComparer.Ordinal) { ["prod"] = "true", ["stage"] = "false" }
|
||||
: new Dictionary<string, string>(StringComparer.Ordinal) { ["stage"] = "false", ["prod"] = "true" };
|
||||
|
||||
var overlapMetadata = evenCall
|
||||
? new Dictionary<string, string>(StringComparer.Ordinal) { ["source"] = "scanner", ["tenant"] = "alpha" }
|
||||
: new Dictionary<string, string>(StringComparer.Ordinal) { ["tenant"] = "alpha", ["source"] = "scanner" };
|
||||
|
||||
var context = SbomContextResult.Create(
|
||||
request.ArtifactId!,
|
||||
request.Purl,
|
||||
new[]
|
||||
{
|
||||
new SbomVersionTimelineEntry("1.0.0", DateTimeOffset.UtcNow.AddDays(-10), DateTimeOffset.UtcNow.AddDays(-5), "affected", "scanner"),
|
||||
new SbomVersionTimelineEntry("1.1.0", DateTimeOffset.UtcNow.AddDays(-4), null, "fixed", "scanner"),
|
||||
},
|
||||
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.5.1"),
|
||||
}, isRuntime: false),
|
||||
},
|
||||
envFlags,
|
||||
new SbomBlastRadiusSummary(5, 3, 2, 0.25, overlapMetadata),
|
||||
overlapMetadata);
|
||||
|
||||
return Task.FromResult(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +51,29 @@ public sealed class DeterministicToolsetTests
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +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);
|
||||
Assert.Single(document.VersionTimeline);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
using Xunit;
|
||||
@@ -30,6 +28,12 @@ public sealed class ToolsetServiceCollectionExtensionsTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSbomContext(options =>
|
||||
{
|
||||
options.BaseAddress = new Uri("https://sbom.example/");
|
||||
options.Tenant = "tenant-alpha";
|
||||
});
|
||||
|
||||
services.AddAdvisoryPipeline();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
@@ -38,17 +42,4 @@ public sealed class ToolsetServiceCollectionExtensionsTests
|
||||
var again = provider.GetRequiredService<IAdvisoryPipelineOrchestrator>();
|
||||
Assert.Same(orchestrator, again);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAdvisoryPipelineInfrastructure_RegistersDependencies()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddAdvisoryPipelineInfrastructure();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
provider.GetRequiredService<IAdvisoryPlanCache>().Should().NotBeNull();
|
||||
provider.GetRequiredService<IAdvisoryTaskQueue>().Should().NotBeNull();
|
||||
provider.GetRequiredService<AdvisoryPipelineMetrics>().Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user