Implement MongoDB-based storage for Pack Run approval, artifact, log, and state management
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added MongoPackRunApprovalStore for managing approval states with MongoDB.
- Introduced MongoPackRunArtifactUploader for uploading and storing artifacts.
- Created MongoPackRunLogStore to handle logging of pack run events.
- Developed MongoPackRunStateStore for persisting and retrieving pack run states.
- Implemented unit tests for MongoDB stores to ensure correct functionality.
- Added MongoTaskRunnerTestContext for setting up MongoDB test environment.
- Enhanced PackRunStateFactory to correctly initialize state with gate reasons.
This commit is contained in:
master
2025-11-07 10:01:35 +02:00
parent e5ffcd6535
commit a1ce3f74fa
122 changed files with 8730 additions and 914 deletions

View File

@@ -4,7 +4,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Plugins;
using StellaOps.Cli.Plugins;
using StellaOps.Cli.Services.Models.AdvisoryAi;
namespace StellaOps.Cli.Commands;
@@ -35,12 +36,13 @@ internal static class CommandFactory
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken));
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
root.Add(BuildKmsCommand(services, verboseOption, cancellationToken));
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken));
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
root.Add(BuildAdviseCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
root.Add(BuildKmsCommand(services, verboseOption, cancellationToken));
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
@@ -733,7 +735,7 @@ internal static class CommandFactory
var activateVersionOption = new Option<int>("--version")
{
Description = "Revision version to activate.",
IsRequired = true
Arity = ArgumentArity.ExactlyOne
};
var activationNoteOption = new Option<string?>("--note")
@@ -809,11 +811,11 @@ internal static class CommandFactory
var taskRunner = new Command("task-runner", "Interact with Task Runner operations.");
var simulate = new Command("simulate", "Simulate a task pack and inspect the execution graph.");
var manifestOption = new Option<string>("--manifest")
{
Description = "Path to the task pack manifest (YAML).",
IsRequired = true
};
var manifestOption = new Option<string>("--manifest")
{
Description = "Path to the task pack manifest (YAML).",
Arity = ArgumentArity.ExactlyOne
};
var inputsOption = new Option<string?>("--inputs")
{
Description = "Optional JSON file containing Task Pack input values."
@@ -1042,13 +1044,110 @@ internal static class CommandFactory
cancellationToken);
});
findings.Add(list);
findings.Add(get);
findings.Add(explain);
return findings;
}
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
findings.Add(list);
findings.Add(get);
findings.Add(explain);
return findings;
}
private static Command BuildAdviseCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var advise = new Command("advise", "Interact with Advisory AI pipelines.");
_ = options;
var run = new Command("run", "Generate Advisory AI output for the specified task.");
var taskArgument = new Argument<string>("task")
{
Description = "Task to run (summary, conflict, remediation)."
};
run.Add(taskArgument);
var advisoryKeyOption = new Option<string>("--advisory-key")
{
Description = "Advisory identifier to summarise (required).",
Required = true
};
var artifactIdOption = new Option<string?>("--artifact-id")
{
Description = "Optional artifact identifier to scope SBOM context."
};
var artifactPurlOption = new Option<string?>("--artifact-purl")
{
Description = "Optional package URL to scope dependency context."
};
var policyVersionOption = new Option<string?>("--policy-version")
{
Description = "Policy revision to evaluate (defaults to current)."
};
var profileOption = new Option<string?>("--profile")
{
Description = "Advisory AI execution profile (default, fips-local, etc.)."
};
var sectionOption = new Option<string[]>("--section")
{
Description = "Preferred context sections to emphasise (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
sectionOption.AllowMultipleArgumentsPerToken = true;
var forceRefreshOption = new Option<bool>("--force-refresh")
{
Description = "Bypass cached plan/output and recompute."
};
var timeoutOption = new Option<int?>("--timeout")
{
Description = "Seconds to wait for generated output before timing out (0 = single attempt)."
};
timeoutOption.Arity = ArgumentArity.ZeroOrOne;
run.Add(advisoryKeyOption);
run.Add(artifactIdOption);
run.Add(artifactPurlOption);
run.Add(policyVersionOption);
run.Add(profileOption);
run.Add(sectionOption);
run.Add(forceRefreshOption);
run.Add(timeoutOption);
run.SetAction((parseResult, _) =>
{
var taskValue = parseResult.GetValue(taskArgument);
var advisoryKey = parseResult.GetValue(advisoryKeyOption) ?? string.Empty;
var artifactId = parseResult.GetValue(artifactIdOption);
var artifactPurl = parseResult.GetValue(artifactPurlOption);
var policyVersion = parseResult.GetValue(policyVersionOption);
var profile = parseResult.GetValue(profileOption) ?? "default";
var sections = parseResult.GetValue(sectionOption) ?? Array.Empty<string>();
var forceRefresh = parseResult.GetValue(forceRefreshOption);
var timeoutSeconds = parseResult.GetValue(timeoutOption) ?? 120;
var verbose = parseResult.GetValue(verboseOption);
if (!Enum.TryParse<AdvisoryAiTaskType>(taskValue, ignoreCase: true, out var taskType))
{
throw new InvalidOperationException($"Unknown advisory task '{taskValue}'. Expected summary, conflict, or remediation.");
}
return CommandHandlers.HandleAdviseRunAsync(
services,
taskType,
advisoryKey,
artifactId,
artifactPurl,
policyVersion,
profile,
sections,
forceRefresh,
timeoutSeconds,
verbose,
cancellationToken);
});
advise.Add(run);
return advise;
}
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var vuln = new Command("vuln", "Explore vulnerability observations and overlays.");

View File

@@ -24,9 +24,10 @@ using Spectre.Console.Rendering;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Prompts;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Telemetry;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Telemetry;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
@@ -426,14 +427,154 @@ internal static class CommandHandlers
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleSourcesIngestAsync(
IServiceProvider services,
bool dryRun,
string source,
string input,
}
public static async Task HandleAdviseRunAsync(
IServiceProvider services,
AdvisoryAiTaskType taskType,
string advisoryKey,
string? artifactId,
string? artifactPurl,
string? policyVersion,
string profile,
IReadOnlyList<string> preferredSections,
bool forceRefresh,
int timeoutSeconds,
bool verbose,
CancellationToken cancellationToken)
{
await using var scope = services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advise-run");
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
var previousLevel = verbosity.MinimumLevel;
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.run", ActivityKind.Client);
activity?.SetTag("stellaops.cli.command", "advise run");
activity?.SetTag("stellaops.cli.task", taskType.ToString());
using var duration = CliMetrics.MeasureCommandDuration("advisory run");
activity?.SetTag("stellaops.cli.force_refresh", forceRefresh);
var outcome = "error";
try
{
var normalizedKey = advisoryKey?.Trim();
if (string.IsNullOrWhiteSpace(normalizedKey))
{
throw new ArgumentException("Advisory key is required.", nameof(advisoryKey));
}
activity?.SetTag("stellaops.cli.advisory.key", normalizedKey);
var normalizedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
activity?.SetTag("stellaops.cli.profile", normalizedProfile);
var normalizedSections = NormalizeSections(preferredSections);
var request = new AdvisoryPipelinePlanRequestModel
{
TaskType = taskType,
AdvisoryKey = normalizedKey,
ArtifactId = string.IsNullOrWhiteSpace(artifactId) ? null : artifactId!.Trim(),
ArtifactPurl = string.IsNullOrWhiteSpace(artifactPurl) ? null : artifactPurl!.Trim(),
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion!.Trim(),
Profile = normalizedProfile,
PreferredSections = normalizedSections.Length > 0 ? normalizedSections : null,
ForceRefresh = forceRefresh
};
logger.LogInformation("Requesting advisory plan for {TaskType} (advisory={AdvisoryKey}).", taskType, normalizedKey);
var plan = await client.CreateAdvisoryPipelinePlanAsync(taskType, request, cancellationToken).ConfigureAwait(false);
activity?.SetTag("stellaops.cli.advisory.cache_key", plan.CacheKey);
RenderAdvisoryPlan(plan);
logger.LogInformation("Plan {CacheKey} queued with {Chunks} chunks and {Vectors} vectors.",
plan.CacheKey,
plan.Chunks.Count,
plan.Vectors.Count);
var pollDelay = TimeSpan.FromSeconds(1);
var shouldWait = timeoutSeconds > 0;
var deadline = shouldWait ? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(timeoutSeconds) : DateTimeOffset.UtcNow;
AdvisoryPipelineOutputModel? output = null;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
output = await client
.TryGetAdvisoryPipelineOutputAsync(plan.CacheKey, taskType, normalizedProfile, cancellationToken)
.ConfigureAwait(false);
if (output is not null)
{
break;
}
if (!shouldWait || DateTimeOffset.UtcNow >= deadline)
{
break;
}
logger.LogDebug("Advisory output pending for {CacheKey}; retrying in {DelaySeconds}s.", plan.CacheKey, pollDelay.TotalSeconds);
await Task.Delay(pollDelay, cancellationToken).ConfigureAwait(false);
}
if (output is null)
{
logger.LogError("Timed out after {Timeout}s waiting for advisory output (cache key {CacheKey}).",
Math.Max(timeoutSeconds, 0),
plan.CacheKey);
activity?.SetStatus(ActivityStatusCode.Error, "timeout");
outcome = "timeout";
Environment.ExitCode = Environment.ExitCode == 0 ? 70 : Environment.ExitCode;
return;
}
activity?.SetTag("stellaops.cli.advisory.generated_at", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
activity?.SetTag("stellaops.cli.advisory.cache_hit", output.PlanFromCache);
logger.LogInformation("Advisory output ready (cache key {CacheKey}).", output.CacheKey);
RenderAdvisoryOutput(output);
if (output.Guardrail.Blocked)
{
logger.LogError("Guardrail blocked advisory output (cache key {CacheKey}).", output.CacheKey);
activity?.SetStatus(ActivityStatusCode.Error, "guardrail_blocked");
outcome = "blocked";
Environment.ExitCode = Environment.ExitCode == 0 ? 65 : Environment.ExitCode;
return;
}
activity?.SetStatus(ActivityStatusCode.Ok);
outcome = output.PlanFromCache ? "cache-hit" : "ok";
Environment.ExitCode = 0;
}
catch (OperationCanceledException)
{
outcome = "cancelled";
activity?.SetStatus(ActivityStatusCode.Error, "cancelled");
Environment.ExitCode = Environment.ExitCode == 0 ? 130 : Environment.ExitCode;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
logger.LogError(ex, "Failed to run advisory task.");
outcome = "error";
Environment.ExitCode = Environment.ExitCode == 0 ? 1 : Environment.ExitCode;
}
finally
{
activity?.SetTag("stellaops.cli.advisory.outcome", outcome);
CliMetrics.RecordAdvisoryRun(taskType.ToString(), outcome);
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleSourcesIngestAsync(
IServiceProvider services,
bool dryRun,
string source,
string input,
string? tenantOverride,
string format,
bool disableColor,
@@ -6137,7 +6278,156 @@ internal static class CommandHandlers
["ERR_AOC_007"] = 17
};
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
private static string[] NormalizeSections(IReadOnlyList<string> sections)
{
if (sections is null || sections.Count == 0)
{
return Array.Empty<string>();
}
return sections
.Where(section => !string.IsNullOrWhiteSpace(section))
.Select(section => section.Trim())
.Where(section => section.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static void RenderAdvisoryPlan(AdvisoryPipelinePlanResponseModel plan)
{
var console = AnsiConsole.Console;
var summary = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Advisory Plan[/]");
summary.AddColumn("Field");
summary.AddColumn("Value");
summary.AddRow("Task", Markup.Escape(plan.TaskType));
summary.AddRow("Cache Key", Markup.Escape(plan.CacheKey));
summary.AddRow("Prompt Template", Markup.Escape(plan.PromptTemplate));
summary.AddRow("Chunks", plan.Chunks.Count.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Vectors", plan.Vectors.Count.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Prompt Tokens", plan.Budget.PromptTokens.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Completion Tokens", plan.Budget.CompletionTokens.ToString(CultureInfo.InvariantCulture));
console.Write(summary);
if (plan.Metadata.Count > 0)
{
console.Write(CreateKeyValueTable("Plan Metadata", plan.Metadata));
}
}
private static void RenderAdvisoryOutput(AdvisoryPipelineOutputModel output)
{
var console = AnsiConsole.Console;
var summary = new Table()
.Border(TableBorder.Rounded)
.Title("[bold]Advisory Output[/]");
summary.AddColumn("Field");
summary.AddColumn("Value");
summary.AddRow("Cache Key", Markup.Escape(output.CacheKey));
summary.AddRow("Task", Markup.Escape(output.TaskType));
summary.AddRow("Profile", Markup.Escape(output.Profile));
summary.AddRow("Generated", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
summary.AddRow("Plan From Cache", output.PlanFromCache ? "yes" : "no");
summary.AddRow("Citations", output.Citations.Count.ToString(CultureInfo.InvariantCulture));
summary.AddRow("Guardrail Blocked", output.Guardrail.Blocked ? "[red]yes[/]" : "no");
console.Write(summary);
if (!string.IsNullOrWhiteSpace(output.Prompt))
{
var panel = new Panel(new Markup(Markup.Escape(output.Prompt)))
{
Header = new PanelHeader("Prompt"),
Border = BoxBorder.Rounded,
Expand = true
};
console.Write(panel);
}
if (output.Citations.Count > 0)
{
var citations = new Table()
.Border(TableBorder.Minimal)
.Title("[grey]Citations[/]");
citations.AddColumn("Index");
citations.AddColumn("Document");
citations.AddColumn("Chunk");
foreach (var citation in output.Citations.OrderBy(c => c.Index))
{
citations.AddRow(
citation.Index.ToString(CultureInfo.InvariantCulture),
Markup.Escape(citation.DocumentId),
Markup.Escape(citation.ChunkId));
}
console.Write(citations);
}
if (output.Metadata.Count > 0)
{
console.Write(CreateKeyValueTable("Output Metadata", output.Metadata));
}
if (output.Guardrail.Metadata.Count > 0)
{
console.Write(CreateKeyValueTable("Guardrail Metadata", output.Guardrail.Metadata));
}
if (output.Guardrail.Violations.Count > 0)
{
var violations = new Table()
.Border(TableBorder.Minimal)
.Title("[red]Guardrail Violations[/]");
violations.AddColumn("Code");
violations.AddColumn("Message");
foreach (var violation in output.Guardrail.Violations)
{
violations.AddRow(Markup.Escape(violation.Code), Markup.Escape(violation.Message));
}
console.Write(violations);
}
var provenance = new Table()
.Border(TableBorder.Minimal)
.Title("[grey]Provenance[/]");
provenance.AddColumn("Field");
provenance.AddColumn("Value");
provenance.AddRow("Input Digest", Markup.Escape(output.Provenance.InputDigest));
provenance.AddRow("Output Hash", Markup.Escape(output.Provenance.OutputHash));
var signatures = output.Provenance.Signatures.Count == 0
? "none"
: string.Join(Environment.NewLine, output.Provenance.Signatures.Select(Markup.Escape));
provenance.AddRow("Signatures", signatures);
console.Write(provenance);
}
private static Table CreateKeyValueTable(string title, IReadOnlyDictionary<string, string> entries)
{
var table = new Table()
.Border(TableBorder.Minimal)
.Title($"[grey]{Markup.Escape(title)}[/]");
table.AddColumn("Key");
table.AddColumn("Value");
foreach (var kvp in entries.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
table.AddRow(Markup.Escape(kvp.Key), Markup.Escape(kvp.Value));
}
return table;
}
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
{
foreach (var key in source.Where(kvp => kvp.Value is null).Select(kvp => kvp.Key).ToList())
{

View File

@@ -26,13 +26,15 @@ public static class CliBootstrapper
options.PostBind = (cliOptions, configuration) =>
{
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
cliOptions.AdvisoryAiUrl = ResolveWithFallback(cliOptions.AdvisoryAiUrl, configuration, "STELLAOPS_ADVISORYAI_URL", "StellaOps:AdvisoryAiUrl", "AdvisoryAiUrl");
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
cliOptions.AdvisoryAiUrl = cliOptions.AdvisoryAiUrl?.Trim() ?? string.Empty;
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
var attemptsRaw = ResolveWithFallback(

View File

@@ -11,7 +11,9 @@ public sealed class StellaOpsCliOptions
public string BackendUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string AdvisoryAiUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";

View File

@@ -18,7 +18,8 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Services;
@@ -30,10 +31,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
private const string OperatorReasonParameterName = "operator_reason";
private const string OperatorTicketParameterName = "operator_ticket";
private const string BackfillReasonParameterName = "backfill_reason";
private const string BackfillTicketParameterName = "backfill_ticket";
private const string OperatorReasonParameterName = "operator_reason";
private const string OperatorTicketParameterName = "operator_ticket";
private const string BackfillReasonParameterName = "backfill_reason";
private const string BackfillTicketParameterName = "backfill_ticket";
private const string AdvisoryScopesHeader = "X-StellaOps-Scopes";
private const string AdvisoryRunScope = "advisory:run";
private readonly HttpClient _httpClient;
private readonly StellaOpsCliOptions _options;
@@ -885,13 +888,122 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
return result;
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
return result;
}
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
AdvisoryAiTaskType taskType,
AdvisoryPipelinePlanRequestModel request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var taskSegment = taskType.ToString().ToLowerInvariant();
var relative = $"v1/advisory-ai/pipeline/{taskSegment}";
var payload = new AdvisoryPipelinePlanRequestModel
{
TaskType = taskType,
AdvisoryKey = string.IsNullOrWhiteSpace(request.AdvisoryKey) ? string.Empty : request.AdvisoryKey.Trim(),
ArtifactId = string.IsNullOrWhiteSpace(request.ArtifactId) ? null : request.ArtifactId!.Trim(),
ArtifactPurl = string.IsNullOrWhiteSpace(request.ArtifactPurl) ? null : request.ArtifactPurl!.Trim(),
PolicyVersion = string.IsNullOrWhiteSpace(request.PolicyVersion) ? null : request.PolicyVersion!.Trim(),
Profile = string.IsNullOrWhiteSpace(request.Profile) ? "default" : request.Profile!.Trim(),
PreferredSections = request.PreferredSections is null
? null
: request.PreferredSections
.Where(static section => !string.IsNullOrWhiteSpace(section))
.Select(static section => section.Trim())
.ToArray(),
ForceRefresh = request.ForceRefresh
};
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
ApplyAdvisoryAiEndpoint(httpRequest, taskType);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
var plan = await response.Content.ReadFromJsonAsync<AdvisoryPipelinePlanResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (plan is null)
{
throw new InvalidOperationException("Advisory AI plan response was empty.");
}
return plan;
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory plan response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(
string cacheKey,
AdvisoryAiTaskType taskType,
string profile,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
throw new ArgumentException("Cache key is required.", nameof(cacheKey));
}
var encodedKey = Uri.EscapeDataString(cacheKey);
var taskSegment = Uri.EscapeDataString(taskType.ToString().ToLowerInvariant());
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
var relative = $"v1/advisory-ai/outputs/{encodedKey}?taskType={taskSegment}&profile={Uri.EscapeDataString(resolvedProfile)}";
using var request = CreateRequest(HttpMethod.Get, relative);
ApplyAdvisoryAiEndpoint(request, taskType);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
return await response.Content.ReadFromJsonAsync<AdvisoryPipelineOutputModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory output response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
@@ -1778,7 +1890,44 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI was not initialized.");
if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl) &&
Uri.TryCreate(_options.AdvisoryAiUrl, UriKind.Absolute, out var advisoryBase))
{
if (!requestUri.IsAbsoluteUri)
{
request.RequestUri = new Uri(advisoryBase, requestUri.ToString());
}
}
else if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl))
{
throw new InvalidOperationException($"Advisory AI URL '{_options.AdvisoryAiUrl}' is not a valid absolute URI.");
}
else
{
EnsureBackendConfigured();
}
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
var combined = $"{AdvisoryRunScope} {taskScope}";
if (request.Headers.Contains(AdvisoryScopesHeader))
{
request.Headers.Remove(AdvisoryScopesHeader);
}
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
{
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
{

View File

@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
namespace StellaOps.Cli.Services;
@@ -46,4 +47,8 @@ internal interface IBackendOperationsClient
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models.AdvisoryAi;
internal enum AdvisoryAiTaskType
{
Summary,
Conflict,
Remediation
}
internal sealed class AdvisoryPipelinePlanRequestModel
{
public AdvisoryAiTaskType TaskType { get; init; }
public string AdvisoryKey { get; init; } = string.Empty;
public string? ArtifactId { get; init; }
public string? ArtifactPurl { get; init; }
public string? PolicyVersion { get; init; }
public string Profile { get; init; } = "default";
public IReadOnlyList<string>? PreferredSections { get; init; }
public bool ForceRefresh { get; init; }
}
internal sealed class AdvisoryPipelinePlanResponseModel
{
public string CacheKey { get; init; } = string.Empty;
public string TaskType { get; init; } = string.Empty;
public string PromptTemplate { get; init; } = string.Empty;
public AdvisoryTaskBudgetModel Budget { get; init; } = new();
public IReadOnlyList<PipelineChunkSummaryModel> Chunks { get; init; } = Array.Empty<PipelineChunkSummaryModel>();
public IReadOnlyList<PipelineVectorSummaryModel> Vectors { get; init; } = Array.Empty<PipelineVectorSummaryModel>();
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
}
internal sealed class AdvisoryTaskBudgetModel
{
public int PromptTokens { get; init; }
public int CompletionTokens { get; init; }
}
internal sealed class PipelineChunkSummaryModel
{
public string DocumentId { get; init; } = string.Empty;
public string ChunkId { get; init; } = string.Empty;
public string Section { get; init; } = string.Empty;
public string? DisplaySection { get; init; }
}
internal sealed class PipelineVectorSummaryModel
{
public string Query { get; init; } = string.Empty;
public IReadOnlyList<PipelineVectorMatchSummaryModel> Matches { get; init; } = Array.Empty<PipelineVectorMatchSummaryModel>();
}
internal sealed class PipelineVectorMatchSummaryModel
{
public string ChunkId { get; init; } = string.Empty;
public double Score { get; init; }
}
internal sealed class AdvisoryPipelineOutputModel
{
public string CacheKey { get; init; } = string.Empty;
public string TaskType { get; init; } = string.Empty;
public string Profile { get; init; } = string.Empty;
public string Prompt { get; init; } = string.Empty;
public IReadOnlyList<AdvisoryOutputCitationModel> Citations { get; init; } = Array.Empty<AdvisoryOutputCitationModel>();
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
public AdvisoryOutputGuardrailModel Guardrail { get; init; } = new();
public AdvisoryOutputProvenanceModel Provenance { get; init; } = new();
public DateTimeOffset GeneratedAtUtc { get; init; }
public bool PlanFromCache { get; init; }
}
internal sealed class AdvisoryOutputCitationModel
{
public int Index { get; init; }
public string DocumentId { get; init; } = string.Empty;
public string ChunkId { get; init; } = string.Empty;
}
internal sealed class AdvisoryOutputGuardrailModel
{
public bool Blocked { get; init; }
public string SanitizedPrompt { get; init; } = string.Empty;
public IReadOnlyList<AdvisoryOutputGuardrailViolationModel> Violations { get; init; } = Array.Empty<AdvisoryOutputGuardrailViolationModel>();
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
}
internal sealed class AdvisoryOutputGuardrailViolationModel
{
public string Code { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
internal sealed class AdvisoryOutputProvenanceModel
{
public string InputDigest { get; init; } = string.Empty;
public string OutputHash { get; init; } = string.Empty;
public IReadOnlyList<string> Signatures { get; init; } = Array.Empty<string>();
}

View File

@@ -20,6 +20,7 @@ internal static class CliMetrics
private static readonly Counter<long> PolicyFindingsListCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.list.count");
private static readonly Counter<long> PolicyFindingsGetCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.get.count");
private static readonly Counter<long> PolicyFindingsExplainCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.explain.count");
private static readonly Counter<long> AdvisoryRunCounter = Meter.CreateCounter<long>("stellaops.cli.advisory.run.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
@@ -70,6 +71,13 @@ internal static class CliMetrics
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordAdvisoryRun(string taskType, string outcome)
=> AdvisoryRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("task", string.IsNullOrWhiteSpace(taskType) ? "unknown" : taskType.ToLowerInvariant()),
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordSourcesDryRun(string status)
=> SourcesDryRunCounter.Add(1, new KeyValuePair<string, object?>[]
{

View File

@@ -23,6 +23,7 @@ using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Telemetry;
using StellaOps.Cli.Tests.Testing;
using StellaOps.Cryptography;
@@ -223,6 +224,291 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandleAdviseRunAsync_WritesOutputAndSetsExitCode()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var testConsole = new TestConsole();
try
{
Environment.ExitCode = 0;
AnsiConsole.Console = testConsole;
var planResponse = new AdvisoryPipelinePlanResponseModel
{
TaskType = AdvisoryAiTaskType.Summary.ToString(),
CacheKey = "cache-123",
PromptTemplate = "prompts/advisory/summary.liquid",
Budget = new AdvisoryTaskBudgetModel
{
PromptTokens = 512,
CompletionTokens = 128
},
Chunks = new[]
{
new PipelineChunkSummaryModel
{
DocumentId = "doc-1",
ChunkId = "chunk-1",
Section = "Summary",
DisplaySection = "Summary"
}
},
Vectors = new[]
{
new PipelineVectorSummaryModel
{
Query = "summary query",
Matches = new[]
{
new PipelineVectorMatchSummaryModel
{
ChunkId = "chunk-1",
Score = 0.9
}
}
}
},
Metadata = new Dictionary<string, string>
{
["profile"] = "default"
}
};
var outputResponse = new AdvisoryPipelineOutputModel
{
CacheKey = planResponse.CacheKey,
TaskType = planResponse.TaskType,
Profile = "default",
Prompt = "Summary result",
Citations = new[]
{
new AdvisoryOutputCitationModel
{
Index = 0,
DocumentId = "doc-1",
ChunkId = "chunk-1"
}
},
Metadata = new Dictionary<string, string>
{
["confidence"] = "high"
},
Guardrail = new AdvisoryOutputGuardrailModel
{
Blocked = false,
SanitizedPrompt = "Summary result",
Violations = Array.Empty<AdvisoryOutputGuardrailViolationModel>(),
Metadata = new Dictionary<string, string>()
},
Provenance = new AdvisoryOutputProvenanceModel
{
InputDigest = "sha256:aaa",
OutputHash = "sha256:bbb",
Signatures = Array.Empty<string>()
},
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T12:00:00Z", CultureInfo.InvariantCulture),
PlanFromCache = false
};
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
AdvisoryPlanResponse = planResponse,
AdvisoryOutputResponse = outputResponse
};
var provider = BuildServiceProvider(backend);
await CommandHandlers.HandleAdviseRunAsync(
provider,
AdvisoryAiTaskType.Summary,
" ADV-1 ",
null,
null,
null,
"default",
new[] { "impact", "impact " },
forceRefresh: false,
timeoutSeconds: 0,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.Single(backend.AdvisoryPlanRequests);
var request = backend.AdvisoryPlanRequests[0];
Assert.Equal(AdvisoryAiTaskType.Summary, request.TaskType);
Assert.Equal("ADV-1", request.Request.AdvisoryKey);
Assert.NotNull(request.Request.PreferredSections);
Assert.Single(request.Request.PreferredSections!);
Assert.Equal("impact", request.Request.PreferredSections![0]);
Assert.Single(backend.AdvisoryOutputRequests);
Assert.Equal(planResponse.CacheKey, backend.AdvisoryOutputRequests[0].CacheKey);
Assert.Equal("default", backend.AdvisoryOutputRequests[0].Profile);
var output = testConsole.Output;
Assert.Contains("Advisory Output", output, StringComparison.OrdinalIgnoreCase);
Assert.Contains(planResponse.CacheKey, output, StringComparison.Ordinal);
Assert.Contains("Summary result", output, StringComparison.Ordinal);
}
finally
{
AnsiConsole.Console = originalConsole;
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleAdviseRunAsync_ReturnsGuardrailExitCodeOnBlock()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var testConsole = new TestConsole();
try
{
Environment.ExitCode = 0;
AnsiConsole.Console = testConsole;
var planResponse = new AdvisoryPipelinePlanResponseModel
{
TaskType = AdvisoryAiTaskType.Remediation.ToString(),
CacheKey = "cache-guard",
PromptTemplate = "prompts/advisory/remediation.liquid",
Budget = new AdvisoryTaskBudgetModel
{
PromptTokens = 256,
CompletionTokens = 64
},
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
Metadata = new Dictionary<string, string>()
};
var outputResponse = new AdvisoryPipelineOutputModel
{
CacheKey = planResponse.CacheKey,
TaskType = planResponse.TaskType,
Profile = "default",
Prompt = "Blocked output",
Citations = Array.Empty<AdvisoryOutputCitationModel>(),
Metadata = new Dictionary<string, string>(),
Guardrail = new AdvisoryOutputGuardrailModel
{
Blocked = true,
SanitizedPrompt = "Blocked output",
Violations = new[]
{
new AdvisoryOutputGuardrailViolationModel
{
Code = "PROMPT_INJECTION",
Message = "Detected prompt injection attempt."
}
},
Metadata = new Dictionary<string, string>()
},
Provenance = new AdvisoryOutputProvenanceModel
{
InputDigest = "sha256:ccc",
OutputHash = "sha256:ddd",
Signatures = Array.Empty<string>()
},
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T13:05:00Z", CultureInfo.InvariantCulture),
PlanFromCache = true
};
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
AdvisoryPlanResponse = planResponse,
AdvisoryOutputResponse = outputResponse
};
var provider = BuildServiceProvider(backend);
await CommandHandlers.HandleAdviseRunAsync(
provider,
AdvisoryAiTaskType.Remediation,
"ADV-2",
null,
null,
null,
"default",
Array.Empty<string>(),
forceRefresh: true,
timeoutSeconds: 0,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(65, Environment.ExitCode);
Assert.Contains("Guardrail Violations", testConsole.Output, StringComparison.OrdinalIgnoreCase);
}
finally
{
AnsiConsole.Console = originalConsole;
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleAdviseRunAsync_TimesOutWhenOutputMissing()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
try
{
Environment.ExitCode = 0;
AnsiConsole.Console = new TestConsole();
var planResponse = new AdvisoryPipelinePlanResponseModel
{
TaskType = AdvisoryAiTaskType.Conflict.ToString(),
CacheKey = "cache-timeout",
PromptTemplate = "prompts/advisory/conflict.liquid",
Budget = new AdvisoryTaskBudgetModel
{
PromptTokens = 128,
CompletionTokens = 32
},
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
Metadata = new Dictionary<string, string>()
};
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
AdvisoryPlanResponse = planResponse,
AdvisoryOutputResponse = null
};
var provider = BuildServiceProvider(backend);
await CommandHandlers.HandleAdviseRunAsync(
provider,
AdvisoryAiTaskType.Conflict,
"ADV-3",
null,
null,
null,
"default",
Array.Empty<string>(),
forceRefresh: false,
timeoutSeconds: 0,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(70, Environment.ExitCode);
Assert.Single(backend.AdvisoryOutputRequests);
}
finally
{
AnsiConsole.Console = originalConsole;
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
{
@@ -1726,10 +2012,16 @@ spec:
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
var consoleOutput = writer.ToString();
Assert.Contains("\"planHash\":\"hash-xyz789\"", consoleOutput, StringComparison.Ordinal);
using (var consoleJson = JsonDocument.Parse(consoleOutput))
{
Assert.Equal("hash-xyz789", consoleJson.RootElement.GetProperty("planHash").GetString());
}
var fileOutput = await File.ReadAllTextAsync(outputPath);
Assert.Contains("\"planHash\":\"hash-xyz789\"", fileOutput, StringComparison.Ordinal);
using (var fileJson = JsonDocument.Parse(fileOutput))
{
Assert.Equal("hash-xyz789", fileJson.RootElement.GetProperty("planHash").GetString());
}
Assert.True(backend.LastTaskRunnerSimulationRequest!.Inputs!.TryGetPropertyValue("dryRun", out var dryRunNode));
Assert.False(dryRunNode!.GetValue<bool>());
@@ -2738,6 +3030,13 @@ spec:
public EntryTraceResponseModel? EntryTraceResponse { get; set; }
public Exception? EntryTraceException { get; set; }
public string? LastEntryTraceScanId { get; private set; }
public List<(AdvisoryAiTaskType TaskType, AdvisoryPipelinePlanRequestModel Request)> AdvisoryPlanRequests { get; } = new();
public AdvisoryPipelinePlanResponseModel? AdvisoryPlanResponse { get; set; }
public Exception? AdvisoryPlanException { get; set; }
public Queue<AdvisoryPipelineOutputModel?> AdvisoryOutputQueue { get; } = new();
public AdvisoryPipelineOutputModel? AdvisoryOutputResponse { get; set; }
public Exception? AdvisoryOutputException { get; set; }
public List<(string CacheKey, AdvisoryAiTaskType TaskType, string Profile)> AdvisoryOutputRequests { get; } = new();
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
=> throw new NotImplementedException();
@@ -2890,10 +3189,52 @@ spec:
return Task.FromResult(EntryTraceResponse);
}
public Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken)
{
AdvisoryPlanRequests.Add((taskType, request));
if (AdvisoryPlanException is not null)
{
throw AdvisoryPlanException;
}
var response = AdvisoryPlanResponse ?? new AdvisoryPipelinePlanResponseModel
{
TaskType = taskType.ToString(),
CacheKey = "stub-cache-key",
PromptTemplate = "prompts/advisory/stub.liquid",
Budget = new AdvisoryTaskBudgetModel
{
PromptTokens = 0,
CompletionTokens = 0
},
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
};
return Task.FromResult(response);
}
public Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken)
{
AdvisoryOutputRequests.Add((cacheKey, taskType, profile));
if (AdvisoryOutputException is not null)
{
throw AdvisoryOutputException;
}
if (AdvisoryOutputQueue.Count > 0)
{
return Task.FromResult(AdvisoryOutputQueue.Dequeue());
}
return Task.FromResult(AdvisoryOutputResponse);
}
}
private sealed class StubExecutor : IScannerExecutor
{
private sealed class StubExecutor : IScannerExecutor
{
private readonly ScannerExecutionResult _result;
public StubExecutor(ScannerExecutionResult result)

View File

@@ -19,16 +19,20 @@ public sealed class EgressPolicyHttpMessageHandlerTests
{
Mode = EgressPolicyMode.Sealed
};
options.AddAllowRule(example.com);
options.AddAllowRule("example.com");
var policy = new EgressPolicy(options);
var handler = new EgressPolicyHttpMessageHandler(policy, NullLogger<EgressPolicyHttpMessageHandler>.Instance, cli, test)
var handler = new EgressPolicyHttpMessageHandler(
policy,
NullLogger.Instance,
component: "cli-tests",
intent: "allow-test")
{
InnerHandler = new StubHandler()
};
var client = new HttpClient(handler, disposeHandler: true);
var response = await client.GetAsync(https://example.com/resource, CancellationToken.None).ConfigureAwait(false);
using var client = new HttpClient(handler, disposeHandler: true);
var response = await client.GetAsync("https://example.com/resource", CancellationToken.None);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
@@ -42,15 +46,19 @@ public sealed class EgressPolicyHttpMessageHandlerTests
};
var policy = new EgressPolicy(options);
var handler = new EgressPolicyHttpMessageHandler(policy, NullLogger<EgressPolicyHttpMessageHandler>.Instance, cli, test)
var handler = new EgressPolicyHttpMessageHandler(
policy,
NullLogger.Instance,
component: "cli-tests",
intent: "deny-test")
{
InnerHandler = new StubHandler()
};
var client = new HttpClient(handler, disposeHandler: true);
using var client = new HttpClient(handler, disposeHandler: true);
var exception = await Assert.ThrowsAsync<AirGapEgressBlockedException>(
() => client.GetAsync(https://blocked.example, CancellationToken.None)).ConfigureAwait(false);
() => client.GetAsync("https://blocked.example", CancellationToken.None));
Assert.Contains(AirGapEgressBlockedException.ErrorCode, exception.Message, StringComparison.OrdinalIgnoreCase);
}

View File

@@ -574,7 +574,7 @@ public sealed class BackendOperationsClientTests
var result = await client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None);
Assert.True(result.Success);
var metadata = Assert.NotNull(tokenClient.LastAdditionalParameters);
var metadata = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(tokenClient.LastAdditionalParameters);
Assert.Equal("Resume operations", metadata["operator_reason"]);
Assert.Equal("INC-6006", metadata["operator_ticket"]);
Assert.Equal("Historical rebuild", metadata["backfill_reason"]);