Add channel test providers for Email, Slack, Teams, and Webhook
- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
@@ -358,6 +358,48 @@ internal static class CommandFactory
|
||||
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
|
||||
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
|
||||
{
|
||||
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
|
||||
};
|
||||
var backfillForceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Reprocess documents even if statements already exist."
|
||||
};
|
||||
var backfillBatchSizeOption = new Option<int>("--batch-size")
|
||||
{
|
||||
Description = "Number of raw documents to fetch per batch (default 100)."
|
||||
};
|
||||
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
|
||||
{
|
||||
Description = "Optional maximum number of raw documents to process."
|
||||
};
|
||||
backfill.Add(backfillRetrievedSinceOption);
|
||||
backfill.Add(backfillForceOption);
|
||||
backfill.Add(backfillBatchSizeOption);
|
||||
backfill.Add(backfillMaxDocumentsOption);
|
||||
backfill.SetAction((parseResult, _) =>
|
||||
{
|
||||
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
|
||||
var force = parseResult.GetValue(backfillForceOption);
|
||||
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
batchSize = 100;
|
||||
}
|
||||
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorBackfillStatementsAsync(
|
||||
services,
|
||||
retrievedSince,
|
||||
force,
|
||||
batchSize,
|
||||
maxDocuments,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var verify = new Command("verify", "Verify Excititor exports or attestations.");
|
||||
var exportIdOption = new Option<string?>("--export-id")
|
||||
{
|
||||
@@ -408,6 +450,7 @@ internal static class CommandFactory
|
||||
excititor.Add(resume);
|
||||
excititor.Add(list);
|
||||
excititor.Add(export);
|
||||
excititor.Add(backfill);
|
||||
excititor.Add(verify);
|
||||
excititor.Add(reconcile);
|
||||
return excititor;
|
||||
|
||||
@@ -25,103 +25,103 @@ using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scanner download");
|
||||
activity?.SetTag("stellaops.cli.channel", channel);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.FromCache)
|
||||
{
|
||||
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
|
||||
}
|
||||
|
||||
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
||||
|
||||
if (install)
|
||||
{
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScannerRunAsync(
|
||||
IServiceProvider services,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-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.scan.run", ActivityKind.Internal);
|
||||
activity?.SetTag("stellaops.cli.command", "scan run");
|
||||
activity?.SetTag("stellaops.cli.runner", runner);
|
||||
activity?.SetTag("stellaops.cli.entry", entry);
|
||||
activity?.SetTag("stellaops.cli.target", targetDirectory);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan run");
|
||||
|
||||
try
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
var resultsDirectory = options.ResultsDirectory;
|
||||
|
||||
var executionResult = await executor.RunAsync(
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsDirectory,
|
||||
arguments,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = executionResult.ExitCode;
|
||||
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
|
||||
|
||||
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scanner download");
|
||||
activity?.SetTag("stellaops.cli.channel", channel);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.FromCache)
|
||||
{
|
||||
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
|
||||
}
|
||||
|
||||
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
||||
|
||||
if (install)
|
||||
{
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScannerRunAsync(
|
||||
IServiceProvider services,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-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.scan.run", ActivityKind.Internal);
|
||||
activity?.SetTag("stellaops.cli.command", "scan run");
|
||||
activity?.SetTag("stellaops.cli.runner", runner);
|
||||
activity?.SetTag("stellaops.cli.entry", entry);
|
||||
activity?.SetTag("stellaops.cli.target", targetDirectory);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan run");
|
||||
|
||||
try
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
var resultsDirectory = options.ResultsDirectory;
|
||||
|
||||
var executionResult = await executor.RunAsync(
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsDirectory,
|
||||
arguments,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = executionResult.ExitCode;
|
||||
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
|
||||
|
||||
if (executionResult.ExitCode == 0)
|
||||
{
|
||||
var backend = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
@@ -138,128 +138,128 @@ internal static class CommandHandlers
|
||||
logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath);
|
||||
activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Scanner execution failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScanUploadAsync(
|
||||
IServiceProvider services,
|
||||
string file,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scan upload");
|
||||
activity?.SetTag("stellaops.cli.file", file);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
|
||||
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(file);
|
||||
await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Scan results uploaded successfully.");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to upload scan results.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleConnectorJobAsync(
|
||||
IServiceProvider services,
|
||||
string source,
|
||||
string stage,
|
||||
string? mode,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db fetch");
|
||||
activity?.SetTag("stellaops.cli.source", source);
|
||||
activity?.SetTag("stellaops.cli.stage", stage);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.mode", mode);
|
||||
}
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
|
||||
|
||||
try
|
||||
{
|
||||
var jobKind = $"source:{source}:{stage}";
|
||||
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
parameters["mode"] = mode;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Connector job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleMergeJobAsync(
|
||||
IServiceProvider services,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db merge");
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db merge");
|
||||
|
||||
try
|
||||
{
|
||||
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Merge job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Scanner execution failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScanUploadAsync(
|
||||
IServiceProvider services,
|
||||
string file,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scan upload");
|
||||
activity?.SetTag("stellaops.cli.file", file);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
|
||||
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(file);
|
||||
await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Scan results uploaded successfully.");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to upload scan results.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleConnectorJobAsync(
|
||||
IServiceProvider services,
|
||||
string source,
|
||||
string stage,
|
||||
string? mode,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db fetch");
|
||||
activity?.SetTag("stellaops.cli.source", source);
|
||||
activity?.SetTag("stellaops.cli.stage", stage);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.mode", mode);
|
||||
}
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
|
||||
|
||||
try
|
||||
{
|
||||
var jobKind = $"source:{source}:{stage}";
|
||||
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
parameters["mode"] = mode;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Connector job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleMergeJobAsync(
|
||||
IServiceProvider services,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db merge");
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db merge");
|
||||
|
||||
try
|
||||
{
|
||||
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Merge job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleExportJobAsync(
|
||||
IServiceProvider services,
|
||||
string format,
|
||||
@@ -271,16 +271,16 @@ internal static class CommandHandlers
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db export");
|
||||
activity?.SetTag("stellaops.cli.format", format);
|
||||
activity?.SetTag("stellaops.cli.delta", delta);
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db export");
|
||||
activity?.SetTag("stellaops.cli.format", format);
|
||||
activity?.SetTag("stellaops.cli.delta", delta);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db export");
|
||||
activity?.SetTag("stellaops.cli.publish_full", publishFull);
|
||||
activity?.SetTag("stellaops.cli.publish_delta", publishDelta);
|
||||
@@ -330,16 +330,16 @@ internal static class CommandHandlers
|
||||
{
|
||||
parameters["includeDelta"] = includeDelta.Value;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Export job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Export job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
@@ -723,6 +723,62 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
public static Task HandleExcititorBackfillStatementsAsync(
|
||||
IServiceProvider services,
|
||||
DateTimeOffset? retrievedSince,
|
||||
bool force,
|
||||
int batchSize,
|
||||
int? maxDocuments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be greater than zero.");
|
||||
}
|
||||
|
||||
if (maxDocuments.HasValue && maxDocuments.Value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxDocuments), "Max documents must be greater than zero when specified.");
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["force"] = force,
|
||||
["batchSize"] = batchSize,
|
||||
["maxDocuments"] = maxDocuments
|
||||
};
|
||||
|
||||
if (retrievedSince.HasValue)
|
||||
{
|
||||
payload["retrievedSince"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var activityTags = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["stellaops.cli.force"] = force,
|
||||
["stellaops.cli.batch_size"] = batchSize,
|
||||
["stellaops.cli.max_documents"] = maxDocuments
|
||||
};
|
||||
|
||||
if (retrievedSince.HasValue)
|
||||
{
|
||||
activityTags["stellaops.cli.retrieved_since"] = retrievedSince.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return ExecuteExcititorCommandAsync(
|
||||
services,
|
||||
commandName: "excititor backfill-statements",
|
||||
verbose,
|
||||
activityTags,
|
||||
client => client.ExecuteExcititorOperationAsync(
|
||||
"admin/backfill-statements",
|
||||
HttpMethod.Post,
|
||||
RemoveNullValues(payload),
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public static Task HandleExcititorVerifyAsync(
|
||||
IServiceProvider services,
|
||||
string? exportId,
|
||||
@@ -2208,7 +2264,7 @@ internal static class CommandHandlers
|
||||
{
|
||||
["policyVerdict"] = decision.PolicyVerdict,
|
||||
["signed"] = decision.Signed,
|
||||
["hasSbom"] = decision.HasSbom
|
||||
["hasSbomReferrers"] = decision.HasSbomReferrers
|
||||
};
|
||||
|
||||
if (decision.Reasons.Count > 0)
|
||||
@@ -2218,11 +2274,26 @@ internal static class CommandHandlers
|
||||
|
||||
if (decision.Rekor is not null)
|
||||
{
|
||||
map["rekor"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
var rekorMap = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid))
|
||||
{
|
||||
["uuid"] = decision.Rekor.Uuid,
|
||||
["url"] = decision.Rekor.Url
|
||||
};
|
||||
rekorMap["uuid"] = decision.Rekor.Uuid;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rekor.Url))
|
||||
{
|
||||
rekorMap["url"] = decision.Rekor.Url;
|
||||
}
|
||||
|
||||
if (decision.Rekor.Verified.HasValue)
|
||||
{
|
||||
rekorMap["verified"] = decision.Rekor.Verified;
|
||||
}
|
||||
|
||||
if (rekorMap.Count > 0)
|
||||
{
|
||||
map["rekor"] = rekorMap;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in decision.AdditionalProperties)
|
||||
@@ -2240,7 +2311,8 @@ internal static class CommandHandlers
|
||||
|
||||
if (AnsiConsole.Profile.Capabilities.Interactive)
|
||||
{
|
||||
var table = new Table().Border(TableBorder.Rounded).AddColumns("Image", "Verdict", "Signed", "SBOM", "Reasons", "Attestation");
|
||||
var table = new Table().Border(TableBorder.Rounded)
|
||||
.AddColumns("Image", "Verdict", "Signed", "SBOM Ref", "Quieted", "Confidence", "Reasons", "Attestation");
|
||||
|
||||
foreach (var image in orderedImages)
|
||||
{
|
||||
@@ -2250,9 +2322,11 @@ internal static class CommandHandlers
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
FormatBoolean(decision.Signed),
|
||||
FormatBoolean(decision.HasSbom),
|
||||
FormatBoolean(decision.HasSbomReferrers),
|
||||
FormatQuietedDisplay(decision.AdditionalProperties),
|
||||
FormatConfidenceDisplay(decision.AdditionalProperties),
|
||||
decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
|
||||
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!);
|
||||
FormatAttestation(decision.Rekor));
|
||||
|
||||
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
|
||||
|
||||
@@ -2264,7 +2338,7 @@ internal static class CommandHandlers
|
||||
}
|
||||
else
|
||||
{
|
||||
table.AddRow(image, "<missing>", "-", "-", "-", "-");
|
||||
table.AddRow(image, "<missing>", "-", "-", "-", "-", "-", "-");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2278,12 +2352,14 @@ internal static class CommandHandlers
|
||||
{
|
||||
var reasons = decision.Reasons.Count > 0 ? string.Join(", ", decision.Reasons) : "none";
|
||||
logger.LogInformation(
|
||||
"{Image} -> verdict={Verdict} signed={Signed} sbom={Sbom} attestation={Attestation} reasons={Reasons}",
|
||||
"{Image} -> verdict={Verdict} signed={Signed} sbomRef={Sbom} quieted={Quieted} confidence={Confidence} attestation={Attestation} reasons={Reasons}",
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
FormatBoolean(decision.Signed),
|
||||
FormatBoolean(decision.HasSbom),
|
||||
string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!,
|
||||
FormatBoolean(decision.HasSbomReferrers),
|
||||
FormatQuietedDisplay(decision.AdditionalProperties),
|
||||
FormatConfidenceDisplay(decision.AdditionalProperties),
|
||||
FormatAttestation(decision.Rekor),
|
||||
reasons);
|
||||
|
||||
summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
|
||||
@@ -2346,6 +2422,144 @@ internal static class CommandHandlers
|
||||
private static string FormatBoolean(bool? value)
|
||||
=> value is null ? "unknown" : value.Value ? "yes" : "no";
|
||||
|
||||
private static string FormatQuietedDisplay(IReadOnlyDictionary<string, object?> metadata)
|
||||
{
|
||||
var quieted = GetMetadataBoolean(metadata, "quieted", "quiet");
|
||||
var quietedBy = GetMetadataString(metadata, "quietedBy", "quietedReason");
|
||||
|
||||
if (quieted is true)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(quietedBy) ? "yes" : $"yes ({quietedBy})";
|
||||
}
|
||||
|
||||
if (quieted is false)
|
||||
{
|
||||
return "no";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(quietedBy) ? "-" : $"? ({quietedBy})";
|
||||
}
|
||||
|
||||
private static string FormatConfidenceDisplay(IReadOnlyDictionary<string, object?> metadata)
|
||||
{
|
||||
var confidence = GetMetadataDouble(metadata, "confidence");
|
||||
var confidenceBand = GetMetadataString(metadata, "confidenceBand", "confidenceTier");
|
||||
|
||||
if (confidence.HasValue && !string.IsNullOrWhiteSpace(confidenceBand))
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture, "{0:0.###} ({1})", confidence.Value, confidenceBand);
|
||||
}
|
||||
|
||||
if (confidence.HasValue)
|
||||
{
|
||||
return confidence.Value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(confidenceBand))
|
||||
{
|
||||
return confidenceBand!;
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
|
||||
private static string FormatAttestation(RuntimePolicyRekorReference? rekor)
|
||||
{
|
||||
if (rekor is null)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
var uuid = string.IsNullOrWhiteSpace(rekor.Uuid) ? null : rekor.Uuid;
|
||||
var url = string.IsNullOrWhiteSpace(rekor.Url) ? null : rekor.Url;
|
||||
var verified = rekor.Verified;
|
||||
|
||||
var core = uuid ?? url;
|
||||
if (!string.IsNullOrEmpty(core))
|
||||
{
|
||||
if (verified.HasValue)
|
||||
{
|
||||
var suffix = verified.Value ? " (verified)" : " (unverified)";
|
||||
return core + suffix;
|
||||
}
|
||||
|
||||
return core!;
|
||||
}
|
||||
|
||||
if (verified.HasValue)
|
||||
{
|
||||
return verified.Value ? "verified" : "unverified";
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
|
||||
private static bool? GetMetadataBoolean(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case bool b:
|
||||
return b;
|
||||
case string s when bool.TryParse(s, out var parsed):
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetMetadataString(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
if (value is string s)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double? GetMetadataDouble(IReadOnlyDictionary<string, object?> metadata, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value) && value is not null)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case double d:
|
||||
return d;
|
||||
case float f:
|
||||
return f;
|
||||
case decimal m:
|
||||
return (double)m;
|
||||
case long l:
|
||||
return l;
|
||||
case int i:
|
||||
return i;
|
||||
case string s when double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed):
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
|
||||
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
private static string FormatAdditionalValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
@@ -2359,8 +2573,6 @@ internal static class CommandHandlers
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
|
||||
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
|
||||
{
|
||||
@@ -2397,29 +2609,29 @@ internal static class CommandHandlers
|
||||
string jobKind,
|
||||
IDictionary<string, object?> parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.Location))
|
||||
{
|
||||
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
|
||||
}
|
||||
else if (result.Run is not null)
|
||||
{
|
||||
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Job accepted.");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
||||
Environment.ExitCode = 1;
|
||||
{
|
||||
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.Location))
|
||||
{
|
||||
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
|
||||
}
|
||||
else if (result.Run is not null)
|
||||
{
|
||||
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Job accepted.");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
@@ -19,9 +19,9 @@ using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -48,34 +48,34 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
@@ -83,55 +83,55 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -194,46 +194,46 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return new JobTriggerResult(false, failureMessage, null, null);
|
||||
}
|
||||
@@ -443,19 +443,24 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
var reasons = ExtractReasons(decision.Reasons);
|
||||
var metadata = ExtractExtensionMetadata(decision.ExtensionData);
|
||||
|
||||
var hasSbom = decision.HasSbomReferrers ?? decision.HasSbomLegacy;
|
||||
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
if (decision.Rekor is not null &&
|
||||
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url)))
|
||||
(!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) ||
|
||||
!string.IsNullOrWhiteSpace(decision.Rekor.Url) ||
|
||||
decision.Rekor.Verified.HasValue))
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
NormalizeOptionalString(decision.Rekor.Uuid),
|
||||
NormalizeOptionalString(decision.Rekor.Url));
|
||||
NormalizeOptionalString(decision.Rekor.Url),
|
||||
decision.Rekor.Verified);
|
||||
}
|
||||
|
||||
decisions[image] = new RuntimePolicyImageDecision(
|
||||
verdict,
|
||||
decision.Signed,
|
||||
decision.HasSbom,
|
||||
hasSbom,
|
||||
reasons,
|
||||
rekor,
|
||||
metadata);
|
||||
@@ -624,15 +629,15 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid request URI '{relativeUri}'.");
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
return new HttpRequestMessage(method, requestUri);
|
||||
@@ -820,76 +825,76 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
if (_httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeExpectedDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
@@ -909,23 +914,23 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
await using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
return digestHex;
|
||||
}
|
||||
|
||||
@@ -945,71 +950,71 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response)
|
||||
{
|
||||
var metadata = new
|
||||
{
|
||||
channel,
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ internal sealed record RuntimePolicyEvaluationResult(
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbom,
|
||||
bool? HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url);
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
@@ -42,8 +42,12 @@ internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool? HasSbomReferrers { get; set; }
|
||||
|
||||
// Legacy field kept for pre-contract-sync services.
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbom { get; set; }
|
||||
public bool? HasSbomLegacy { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
@@ -62,4 +66,7 @@ internal sealed class RuntimePolicyRekorDocument
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|
||||
|CLI-RUNTIME-13-005 – Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|**DONE (2025-10-19)** – Added `runtime policy test` command (stdin/file support, JSON output), backend client method + typed models, verdict table output, docs/tests updated (`dotnet test src/StellaOps.Cli.Tests`).|
|
||||
|CLI-OFFLINE-13-006 – Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|TODO – Implement `offline kit pull/import/status` commands with integrity checks, resumable downloads, and doc updates.|
|
||||
|CLI-PLUGIN-13-007 – Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO – Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).|
|
||||
|CLI-RUNTIME-13-008 – Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|TODO – Once `/api/v1/scanner/policy/runtime` exits TODO, verify CLI output against final schema (field names, metadata) and update formatter/tests if the contract moves. Capture joint review notes in docs/09 and link Scanner task sign-off.|
|
||||
|CLI-RUNTIME-13-009 – Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|TODO – Build Spectre test harness exercising `runtime policy test` against a stubbed backend to lock output shape (table + `--json`) and guard regressions. Integrate into `dotnet test` suite.|
|
||||
|CLI-RUNTIME-13-008 – Runtime policy contract sync|DevEx/CLI, Scanner WebService Guild|SCANNER-RUNTIME-12-302|**DONE (2025-10-19)** – CLI runtime table/JSON now align with SCANNER-RUNTIME-12-302 (SBOM referrers, quieted provenance, confidence, verified Rekor); docs/09 updated with joint sign-off note.|
|
||||
|CLI-RUNTIME-13-009 – Runtime policy smoke fixture|DevEx/CLI, QA Guild|CLI-RUNTIME-13-005|**DONE (2025-10-19)** – Spectre console harness + regression tests cover table and `--json` output paths for `runtime policy test`, using stubbed backend and integrated into `dotnet test` suite.|
|
||||
|
||||
Reference in New Issue
Block a user