partly or unimplemented features - now implemented
This commit is contained in:
@@ -7,8 +7,11 @@ using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -28,10 +31,16 @@ internal static class AdviseChatCommandGroup
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var queryArgument = new Argument<string>("query")
|
||||
var queryArgument = new Argument<string?>("query")
|
||||
{
|
||||
Description = "The question or query to ask the advisory assistant."
|
||||
};
|
||||
queryArgument.Arity = ArgumentArity.ZeroOrOne;
|
||||
|
||||
var fileOption = new Option<string?>("--file")
|
||||
{
|
||||
Description = "Read batch queries from newline-delimited JSON (.jsonl)."
|
||||
};
|
||||
|
||||
var imageOption = new Option<string?>("--image", new[] { "-i" })
|
||||
{
|
||||
@@ -95,6 +104,7 @@ internal static class AdviseChatCommandGroup
|
||||
conversationOption,
|
||||
noActionOption,
|
||||
evidenceOption,
|
||||
fileOption,
|
||||
formatOption,
|
||||
outputOption,
|
||||
tenantOption,
|
||||
@@ -104,13 +114,14 @@ internal static class AdviseChatCommandGroup
|
||||
|
||||
ask.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var query = parseResult.GetValue(queryArgument) ?? string.Empty;
|
||||
var query = parseResult.GetValue(queryArgument);
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var digest = parseResult.GetValue(digestOption);
|
||||
var env = parseResult.GetValue(envOption);
|
||||
var conversationId = parseResult.GetValue(conversationOption);
|
||||
var noAction = parseResult.GetValue(noActionOption);
|
||||
var evidence = parseResult.GetValue(evidenceOption);
|
||||
var filePath = parseResult.GetValue(fileOption);
|
||||
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
@@ -127,6 +138,7 @@ internal static class AdviseChatCommandGroup
|
||||
conversationId,
|
||||
noAction,
|
||||
evidence,
|
||||
filePath,
|
||||
format,
|
||||
outputPath,
|
||||
tenant,
|
||||
@@ -138,6 +150,85 @@ internal static class AdviseChatCommandGroup
|
||||
return ask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'advise export' command for exporting conversation history.
|
||||
/// </summary>
|
||||
public static Command BuildExportCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var conversationIdOption = new Option<string?>("--conversation-id", new[] { "-c" })
|
||||
{
|
||||
Description = "Export a single conversation id."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant context for listing conversations."
|
||||
};
|
||||
|
||||
var userOption = new Option<string?>("--user")
|
||||
{
|
||||
Description = "User context for listing conversations."
|
||||
};
|
||||
|
||||
var limitOption = new Option<int?>("--limit")
|
||||
{
|
||||
Description = "Maximum number of conversations to export (default: 100)."
|
||||
};
|
||||
limitOption.SetDefaultValue(100);
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: json, table, markdown (default: json)."
|
||||
};
|
||||
formatOption.SetDefaultValue("json");
|
||||
formatOption.FromAmong("json", "table", "markdown");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Write export output to file instead of stdout."
|
||||
};
|
||||
|
||||
var export = new Command("export", "Export advisory conversation history.")
|
||||
{
|
||||
conversationIdOption,
|
||||
tenantOption,
|
||||
userOption,
|
||||
limitOption,
|
||||
formatOption,
|
||||
outputOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
export.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var conversationId = parseResult.GetValue(conversationIdOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var user = parseResult.GetValue(userOption);
|
||||
var limit = parseResult.GetValue(limitOption);
|
||||
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
|
||||
var outputPath = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await HandleExportAsync(
|
||||
services,
|
||||
options,
|
||||
conversationId,
|
||||
tenant,
|
||||
user,
|
||||
limit,
|
||||
format,
|
||||
outputPath,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'advise doctor' command for chat diagnostics.
|
||||
/// </summary>
|
||||
@@ -467,16 +558,25 @@ internal static class AdviseChatCommandGroup
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record BatchQueryEntry(int LineNumber, string Query);
|
||||
|
||||
private sealed record BatchQueryResult(
|
||||
int LineNumber,
|
||||
string Query,
|
||||
ChatQueryResponse? Response,
|
||||
string? Error);
|
||||
|
||||
private static async Task HandleAskAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string query,
|
||||
string? query,
|
||||
string? image,
|
||||
string? digest,
|
||||
string? environment,
|
||||
string? conversationId,
|
||||
bool noAction,
|
||||
bool includeEvidence,
|
||||
string? filePath,
|
||||
ChatOutputFormat format,
|
||||
string? outputPath,
|
||||
string? tenant,
|
||||
@@ -502,9 +602,38 @@ internal static class AdviseChatCommandGroup
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandleAskBatchAsync(
|
||||
services,
|
||||
options,
|
||||
filePath,
|
||||
image,
|
||||
digest,
|
||||
environment,
|
||||
conversationId,
|
||||
noAction,
|
||||
includeEvidence,
|
||||
format,
|
||||
outputPath,
|
||||
tenant,
|
||||
user,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidOperationException)
|
||||
{
|
||||
Console.Error.WriteLine($"Batch query failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Console.Error.WriteLine("Error: Query cannot be empty.");
|
||||
Console.Error.WriteLine("Error: Query cannot be empty. Provide 'query' or '--file'.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -565,6 +694,235 @@ internal static class AdviseChatCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleAskBatchAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string filePath,
|
||||
string? image,
|
||||
string? digest,
|
||||
string? environment,
|
||||
string? conversationId,
|
||||
bool noAction,
|
||||
bool includeEvidence,
|
||||
ChatOutputFormat format,
|
||||
string? outputPath,
|
||||
string? tenant,
|
||||
string? user,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = CreateChatClient(services, options);
|
||||
var requests = await ReadBatchRequestsAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: Batch file contained no queries.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Processing {requests.Count} query entries from '{filePath}'.");
|
||||
}
|
||||
|
||||
var results = new List<BatchQueryResult>(requests.Count);
|
||||
foreach (var entry in requests)
|
||||
{
|
||||
var request = new ChatQueryRequest
|
||||
{
|
||||
Query = entry.Query,
|
||||
ImageReference = image,
|
||||
ArtifactDigest = digest,
|
||||
Environment = environment,
|
||||
ConversationId = conversationId,
|
||||
NoAction = noAction,
|
||||
IncludeEvidence = includeEvidence
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.QueryAsync(request, tenant, user, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(new BatchQueryResult(entry.LineNumber, entry.Query, response, null));
|
||||
}
|
||||
catch (ChatException ex)
|
||||
{
|
||||
results.Add(new BatchQueryResult(entry.LineNumber, entry.Query, null, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
await using var writer = GetOutputWriter(outputPath);
|
||||
if (format == ChatOutputFormat.Json)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
file = Path.GetFileName(filePath),
|
||||
count = results.Count,
|
||||
results = results.Select(static item => new
|
||||
{
|
||||
item.LineNumber,
|
||||
item.Query,
|
||||
Error = item.Error,
|
||||
Response = item.Response
|
||||
})
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in results)
|
||||
{
|
||||
await writer.WriteLineAsync($"# Batch line {item.LineNumber}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(item.Error))
|
||||
{
|
||||
await writer.WriteLineAsync($"Error: {item.Error}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync($"Query: {item.Query}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
await ChatRenderer.RenderQueryResponseAsync(item.Response!, format, writer, cancellationToken).ConfigureAwait(false);
|
||||
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<BatchQueryEntry>> ReadBatchRequestsAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
throw new InvalidOperationException("Batch file path must be provided.");
|
||||
}
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("Batch query file was not found.", filePath);
|
||||
}
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<BatchQueryEntry>(lines.Length);
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var lineNumber = i + 1;
|
||||
var line = lines[i]?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var query = root.GetString();
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
throw new InvalidOperationException($"Batch line {lineNumber} contains empty query.");
|
||||
}
|
||||
|
||||
results.Add(new BatchQueryEntry(lineNumber, query));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!TryGetStringProperty(root, "query", out var jsonQuery) ||
|
||||
string.IsNullOrWhiteSpace(jsonQuery))
|
||||
{
|
||||
throw new InvalidOperationException($"Batch line {lineNumber} must be a JSON string or object with non-empty 'query'.");
|
||||
}
|
||||
|
||||
results.Add(new BatchQueryEntry(lineNumber, jsonQuery!));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Batch line {lineNumber} is not valid JSON: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool TryGetStringProperty(JsonElement element, string name, out string? value)
|
||||
{
|
||||
value = null;
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase) &&
|
||||
property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
value = property.Value.GetString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task HandleExportAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
string? conversationId,
|
||||
string? tenant,
|
||||
string? user,
|
||||
int? limit,
|
||||
ChatOutputFormat format,
|
||||
string? outputPath,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = CreateChatClient(services, options);
|
||||
var conversations = new List<ChatConversationResponse>();
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Exporting conversation '{conversationId}'.");
|
||||
}
|
||||
|
||||
var single = await client.GetConversationAsync(conversationId, tenant, user, cancellationToken).ConfigureAwait(false);
|
||||
conversations.Add(single);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
Console.Error.WriteLine($"Listing conversations (limit: {limit ?? 100}).");
|
||||
}
|
||||
|
||||
var listed = await client.ListConversationsAsync(tenant, user, limit, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var summary in listed.Conversations
|
||||
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
|
||||
{
|
||||
conversations.Add(await client.GetConversationAsync(summary.ConversationId, tenant, user, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
var export = new ChatConversationExport
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
TenantId = tenant,
|
||||
UserId = user,
|
||||
ConversationCount = conversations.Count,
|
||||
Conversations = conversations
|
||||
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
|
||||
await using var writer = GetOutputWriter(outputPath);
|
||||
await ChatRenderer.RenderConversationExportAsync(export, format, writer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ChatException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Export failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleDoctorAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
|
||||
@@ -100,6 +100,33 @@ internal static class ChatRenderer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a chat conversation export.
|
||||
/// </summary>
|
||||
public static async Task RenderConversationExportAsync(
|
||||
ChatConversationExport export,
|
||||
ChatOutputFormat format,
|
||||
TextWriter writer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(export);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case ChatOutputFormat.Markdown:
|
||||
await RenderConversationExportMarkdownAsync(export, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case ChatOutputFormat.Table:
|
||||
await RenderConversationExportTableAsync(export, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case ChatOutputFormat.Json:
|
||||
default:
|
||||
await RenderConversationExportJsonAsync(export, writer, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RenderQueryJsonAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
@@ -429,4 +456,78 @@ internal static class ChatRenderer
|
||||
|
||||
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderConversationExportJsonAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(export, JsonOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderConversationExportTableAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== Advisory Conversation Export ===");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Generated: {export.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"Tenant: {export.TenantId ?? "(not set)"}");
|
||||
sb.AppendLine($"User: {export.UserId ?? "(not set)"}");
|
||||
sb.AppendLine($"Conversations: {export.ConversationCount}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var conversation in export.Conversations
|
||||
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"--- Conversation {conversation.ConversationId} ---");
|
||||
sb.AppendLine($"Created: {conversation.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"Updated: {conversation.UpdatedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"Turns: {conversation.Turns.Count}");
|
||||
foreach (var turn in conversation.Turns
|
||||
.OrderBy(static item => item.Timestamp)
|
||||
.ThenBy(static item => item.TurnId, StringComparer.Ordinal))
|
||||
{
|
||||
var role = string.IsNullOrWhiteSpace(turn.Role) ? "unknown" : turn.Role;
|
||||
sb.AppendLine($" [{turn.Timestamp:yyyy-MM-dd HH:mm:ss}] {role}: {turn.Content}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task RenderConversationExportMarkdownAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Advisory Conversation Export");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- Generated: {export.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"- Tenant: {export.TenantId ?? "(not set)"}");
|
||||
sb.AppendLine($"- User: {export.UserId ?? "(not set)"}");
|
||||
sb.AppendLine($"- Conversations: {export.ConversationCount}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var conversation in export.Conversations
|
||||
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"## {conversation.ConversationId}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- Created: {conversation.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"- Updated: {conversation.UpdatedAt:yyyy-MM-dd HH:mm:ss} UTC");
|
||||
sb.AppendLine($"- Turns: {conversation.Turns.Count}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var turn in conversation.Turns
|
||||
.OrderBy(static item => item.Timestamp)
|
||||
.ThenBy(static item => item.TurnId, StringComparer.Ordinal))
|
||||
{
|
||||
var role = string.IsNullOrWhiteSpace(turn.Role) ? "unknown" : turn.Role;
|
||||
sb.AppendLine($"- **{role}** ({turn.Timestamp:yyyy-MM-dd HH:mm:ss} UTC): {turn.Content}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4490,6 +4490,7 @@ flowchart TB
|
||||
advise.Add(AdviseChatCommandGroup.BuildAskCommand(services, options, verboseOption, cancellationToken));
|
||||
advise.Add(AdviseChatCommandGroup.BuildDoctorCommand(services, options, verboseOption, cancellationToken));
|
||||
advise.Add(AdviseChatCommandGroup.BuildSettingsCommand(services, options, verboseOption, cancellationToken));
|
||||
advise.Add(AdviseChatCommandGroup.BuildExportCommand(services, options, verboseOption, cancellationToken));
|
||||
|
||||
return advise;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CompareCommandBuilder.cs
|
||||
// Sprint: SPRINT_4200_0002_0004_cli_compare
|
||||
// Updated: SPRINT_20260208_029_Cli_baseline_selection_logic
|
||||
// Description: CLI commands for comparing scan snapshots.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services;
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -16,6 +18,7 @@ namespace StellaOps.Cli.Commands.Compare;
|
||||
/// <summary>
|
||||
/// Builds CLI commands for comparing scan snapshots.
|
||||
/// Per SPRINT_4200_0002_0004.
|
||||
/// Updated for baseline strategies per SPRINT_20260208_029.
|
||||
/// </summary>
|
||||
internal static class CompareCommandBuilder
|
||||
{
|
||||
@@ -34,10 +37,11 @@ internal static class CompareCommandBuilder
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseDigestOption = new Option<string>("--base", new[] { "-b" })
|
||||
// Base can now be optional when using baseline strategies
|
||||
var baseDigestOption = new Option<string?>("--base", new[] { "-b" })
|
||||
{
|
||||
Description = "Base snapshot digest (the 'before' state)",
|
||||
Required = true
|
||||
Description = "Base snapshot digest (the 'before' state). Optional when using --baseline-strategy.",
|
||||
Required = false
|
||||
};
|
||||
|
||||
var targetDigestOption = new Option<string>("--target", new[] { "-t" })
|
||||
@@ -46,6 +50,22 @@ internal static class CompareCommandBuilder
|
||||
Required = true
|
||||
};
|
||||
|
||||
// SPRINT_20260208_029: Baseline strategy options
|
||||
var baselineStrategyOption = new Option<string?>("--baseline-strategy")
|
||||
{
|
||||
Description = "Strategy for selecting baseline: 'explicit' (default, requires --base), 'last-green' (most recent passing), or 'previous-release' (previous release tag)"
|
||||
};
|
||||
|
||||
var artifactOption = new Option<string?>("--artifact", new[] { "-a" })
|
||||
{
|
||||
Description = "Artifact identifier (PURL, OCI reference, or path) for baseline resolution. Required when using non-explicit strategies."
|
||||
};
|
||||
|
||||
var currentVersionOption = new Option<string?>("--current-version")
|
||||
{
|
||||
Description = "Current version/tag for context (helps with previous-release strategy)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format (table, json, sarif)"
|
||||
@@ -71,33 +91,70 @@ internal static class CompareCommandBuilder
|
||||
Description = "Scanner WebService URL override"
|
||||
};
|
||||
|
||||
var verificationReportOption = new Option<string?>("--verification-report")
|
||||
{
|
||||
Description = "Path to JSON output from 'stella bundle verify --output json' for compare verification overlay"
|
||||
};
|
||||
|
||||
var reverifyBundleOption = new Option<string?>("--reverify-bundle")
|
||||
{
|
||||
Description = "Path to local evidence bundle directory to recompute hash/signature status inline with compare output"
|
||||
};
|
||||
|
||||
var determinismManifestOption = new Option<string?>("--determinism-manifest")
|
||||
{
|
||||
Description = "Path to determinism manifest JSON used to attach determinism score context to compare output"
|
||||
};
|
||||
|
||||
// compare diff - Full comparison
|
||||
var diffCommand = new Command("diff", "Compare two scan snapshots and show detailed diff.");
|
||||
diffCommand.Add(baseDigestOption);
|
||||
diffCommand.Add(targetDigestOption);
|
||||
diffCommand.Add(baselineStrategyOption);
|
||||
diffCommand.Add(artifactOption);
|
||||
diffCommand.Add(currentVersionOption);
|
||||
diffCommand.Add(outputOption);
|
||||
diffCommand.Add(outputFileOption);
|
||||
diffCommand.Add(includeUnchangedOption);
|
||||
diffCommand.Add(severityFilterOption);
|
||||
diffCommand.Add(backendUrlOption);
|
||||
diffCommand.Add(verificationReportOption);
|
||||
diffCommand.Add(reverifyBundleOption);
|
||||
diffCommand.Add(determinismManifestOption);
|
||||
diffCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption);
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
|
||||
var artifact = parseResult.GetValue(artifactOption);
|
||||
var currentVersion = parseResult.GetValue(currentVersionOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var outputFile = parseResult.GetValue(outputFileOption);
|
||||
var includeUnchanged = parseResult.GetValue(includeUnchangedOption);
|
||||
var severity = parseResult.GetValue(severityFilterOption);
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
var verificationReportPath = parseResult.GetValue(verificationReportOption);
|
||||
var reverifyBundlePath = parseResult.GetValue(reverifyBundleOption);
|
||||
var determinismManifestPath = parseResult.GetValue(determinismManifestOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
var baselineResolver = services.GetService<IBaselineResolver>();
|
||||
|
||||
// Resolve baseline using strategy
|
||||
var resolvedBase = await ResolveBaselineAsync(
|
||||
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
|
||||
|
||||
if (resolvedBase is null)
|
||||
{
|
||||
return; // Error already printed
|
||||
}
|
||||
|
||||
var request = new CompareRequest
|
||||
{
|
||||
BaseDigest = baseDigest,
|
||||
BaseDigest = resolvedBase,
|
||||
TargetDigest = targetDigest,
|
||||
IncludeUnchanged = includeUnchanged,
|
||||
SeverityFilter = severity,
|
||||
@@ -105,6 +162,16 @@ internal static class CompareCommandBuilder
|
||||
};
|
||||
|
||||
var result = await client.CompareAsync(request, cancellationToken);
|
||||
var verification = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath,
|
||||
reverifyBundlePath,
|
||||
determinismManifestPath,
|
||||
cancellationToken);
|
||||
|
||||
if (verification is not null)
|
||||
{
|
||||
result = result with { Verification = verification };
|
||||
}
|
||||
|
||||
await WriteOutputAsync(result, output, outputFile, renderer, verbose);
|
||||
});
|
||||
@@ -113,12 +180,18 @@ internal static class CompareCommandBuilder
|
||||
var summaryCommand = new Command("summary", "Show quick summary of changes between snapshots.");
|
||||
summaryCommand.Add(baseDigestOption);
|
||||
summaryCommand.Add(targetDigestOption);
|
||||
summaryCommand.Add(baselineStrategyOption);
|
||||
summaryCommand.Add(artifactOption);
|
||||
summaryCommand.Add(currentVersionOption);
|
||||
summaryCommand.Add(outputOption);
|
||||
summaryCommand.Add(backendUrlOption);
|
||||
summaryCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption);
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
|
||||
var artifact = parseResult.GetValue(artifactOption);
|
||||
var currentVersion = parseResult.GetValue(currentVersionOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
@@ -126,8 +199,18 @@ internal static class CompareCommandBuilder
|
||||
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
var baselineResolver = services.GetService<IBaselineResolver>();
|
||||
|
||||
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
|
||||
// Resolve baseline using strategy
|
||||
var resolvedBase = await ResolveBaselineAsync(
|
||||
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
|
||||
|
||||
if (resolvedBase is null)
|
||||
{
|
||||
return; // Error already printed
|
||||
}
|
||||
|
||||
var result = await client.GetSummaryAsync(resolvedBase, targetDigest, backendUrl, cancellationToken);
|
||||
|
||||
WriteSummary(result, output, renderer, verbose);
|
||||
});
|
||||
@@ -136,18 +219,35 @@ internal static class CompareCommandBuilder
|
||||
var canShipCommand = new Command("can-ship", "Check if target snapshot can ship relative to base.");
|
||||
canShipCommand.Add(baseDigestOption);
|
||||
canShipCommand.Add(targetDigestOption);
|
||||
canShipCommand.Add(baselineStrategyOption);
|
||||
canShipCommand.Add(artifactOption);
|
||||
canShipCommand.Add(currentVersionOption);
|
||||
canShipCommand.Add(backendUrlOption);
|
||||
canShipCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption);
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
|
||||
var artifact = parseResult.GetValue(artifactOption);
|
||||
var currentVersion = parseResult.GetValue(currentVersionOption);
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
var baselineResolver = services.GetService<IBaselineResolver>();
|
||||
|
||||
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
|
||||
// Resolve baseline using strategy
|
||||
var resolvedBase = await ResolveBaselineAsync(
|
||||
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
|
||||
|
||||
if (resolvedBase is null)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await client.GetSummaryAsync(resolvedBase, targetDigest, backendUrl, cancellationToken);
|
||||
|
||||
WriteCanShipResult(result, verbose);
|
||||
|
||||
@@ -161,13 +261,19 @@ internal static class CompareCommandBuilder
|
||||
var vulnsCommand = new Command("vulns", "List vulnerability changes between snapshots.");
|
||||
vulnsCommand.Add(baseDigestOption);
|
||||
vulnsCommand.Add(targetDigestOption);
|
||||
vulnsCommand.Add(baselineStrategyOption);
|
||||
vulnsCommand.Add(artifactOption);
|
||||
vulnsCommand.Add(currentVersionOption);
|
||||
vulnsCommand.Add(outputOption);
|
||||
vulnsCommand.Add(severityFilterOption);
|
||||
vulnsCommand.Add(backendUrlOption);
|
||||
vulnsCommand.SetAction(async parseResult =>
|
||||
{
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption)!;
|
||||
var baseDigest = parseResult.GetValue(baseDigestOption);
|
||||
var targetDigest = parseResult.GetValue(targetDigestOption)!;
|
||||
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
|
||||
var artifact = parseResult.GetValue(artifactOption);
|
||||
var currentVersion = parseResult.GetValue(currentVersionOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var severity = parseResult.GetValue(severityFilterOption);
|
||||
var backendUrl = parseResult.GetValue(backendUrlOption);
|
||||
@@ -176,10 +282,20 @@ internal static class CompareCommandBuilder
|
||||
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
|
||||
var client = services.GetService<ICompareClient>()
|
||||
?? new LocalCompareClient();
|
||||
var baselineResolver = services.GetService<IBaselineResolver>();
|
||||
|
||||
// Resolve baseline using strategy
|
||||
var resolvedBase = await ResolveBaselineAsync(
|
||||
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
|
||||
|
||||
if (resolvedBase is null)
|
||||
{
|
||||
return; // Error already printed
|
||||
}
|
||||
|
||||
var request = new CompareRequest
|
||||
{
|
||||
BaseDigest = baseDigest,
|
||||
BaseDigest = resolvedBase,
|
||||
TargetDigest = targetDigest,
|
||||
SeverityFilter = severity,
|
||||
BackendUrl = backendUrl
|
||||
@@ -263,6 +379,50 @@ internal static class CompareCommandBuilder
|
||||
{
|
||||
Console.WriteLine($"Policy Verdict: {result.TargetVerdict} (unchanged)");
|
||||
}
|
||||
|
||||
if (result.Verification is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Verification Overlay: {result.Verification.OverallStatus}");
|
||||
Console.WriteLine($" Source: {result.Verification.Source}");
|
||||
|
||||
if (result.Verification.Determinism is not null)
|
||||
{
|
||||
var score = result.Verification.Determinism.OverallScore.HasValue
|
||||
? result.Verification.Determinism.OverallScore.Value.ToString("0.000")
|
||||
: "n/a";
|
||||
var threshold = result.Verification.Determinism.Threshold.HasValue
|
||||
? result.Verification.Determinism.Threshold.Value.ToString("0.000")
|
||||
: "n/a";
|
||||
|
||||
Console.WriteLine($" Determinism: score={score}, threshold={threshold}, status={result.Verification.Determinism.Status}");
|
||||
}
|
||||
|
||||
if (result.Verification.Artifacts.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" Artifacts: (none)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" Artifacts:");
|
||||
foreach (var artifact in result.Verification.Artifacts)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" - {artifact.Artifact}: hash={FormatVerificationBadge(artifact.HashStatus)}; signature={FormatVerificationBadge(artifact.SignatureStatus)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose && result.Verification.Warnings.Count > 0)
|
||||
{
|
||||
Console.WriteLine(" Warnings:");
|
||||
foreach (var warning in result.Verification.Warnings)
|
||||
{
|
||||
Console.WriteLine($" - {warning}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteSummary(CompareSummary summary, string format, IOutputRenderer renderer, bool verbose)
|
||||
@@ -418,6 +578,135 @@ internal static class CompareCommandBuilder
|
||||
_ => "none"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatVerificationBadge(string state)
|
||||
{
|
||||
return state.ToLowerInvariant() switch
|
||||
{
|
||||
"pass" => "PASS",
|
||||
"fail" => "FAIL",
|
||||
"warning" => "WARN",
|
||||
_ => "UNKNOWN"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the baseline digest using the specified strategy.
|
||||
/// SPRINT_20260208_029: Baseline selection logic.
|
||||
/// </summary>
|
||||
private static async Task<string?> ResolveBaselineAsync(
|
||||
string? explicitBaseDigest,
|
||||
string? strategyRaw,
|
||||
string? artifact,
|
||||
string? currentVersion,
|
||||
string? backendUrl,
|
||||
IBaselineResolver? resolver,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Parse strategy (default to explicit if base is provided, otherwise last-green)
|
||||
var strategy = ParseStrategy(strategyRaw, explicitBaseDigest);
|
||||
|
||||
if (strategy == BaselineStrategy.Explicit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(explicitBaseDigest))
|
||||
{
|
||||
Console.Error.WriteLine("Error: --base is required when using explicit baseline strategy.");
|
||||
Console.Error.WriteLine(" Use --baseline-strategy=last-green or --baseline-strategy=previous-release for automatic resolution.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Using explicit baseline: {explicitBaseDigest}");
|
||||
}
|
||||
|
||||
return explicitBaseDigest;
|
||||
}
|
||||
|
||||
// For auto-resolution strategies, we need the resolver and artifact
|
||||
if (resolver is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: Baseline resolution service not available.");
|
||||
Console.Error.WriteLine(" Use --base with an explicit digest instead.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: --artifact is required when using {strategy} baseline strategy.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = artifact,
|
||||
Strategy = strategy,
|
||||
ExplicitDigest = explicitBaseDigest,
|
||||
CurrentVersion = currentVersion,
|
||||
BackendUrl = backendUrl
|
||||
};
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Resolving baseline using {strategy} strategy for artifact: {artifact}");
|
||||
}
|
||||
|
||||
var result = await resolver.ResolveAsync(request, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Failed to resolve baseline - {result.Error}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.Suggestion))
|
||||
{
|
||||
Console.Error.WriteLine($" Suggestion: {result.Suggestion}");
|
||||
}
|
||||
|
||||
// Offer suggestions
|
||||
var suggestions = await resolver.GetSuggestionsAsync(artifact, cancellationToken);
|
||||
if (suggestions.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine("\nAvailable baselines:");
|
||||
foreach (var suggestion in suggestions.Take(5))
|
||||
{
|
||||
var status = suggestion.IsPassing ? "[PASS]" : "[FAIL]";
|
||||
Console.Error.WriteLine($" {status} {suggestion.Digest[..Math.Min(12, suggestion.Digest.Length)]}... - {suggestion.Description}");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine($"Resolved baseline: {result.Digest}");
|
||||
}
|
||||
|
||||
return result.Digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the baseline strategy from the command line.
|
||||
/// </summary>
|
||||
private static BaselineStrategy ParseStrategy(string? raw, string? explicitDigest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
// Default: explicit if --base provided, otherwise last-green
|
||||
return string.IsNullOrWhiteSpace(explicitDigest)
|
||||
? BaselineStrategy.LastGreen
|
||||
: BaselineStrategy.Explicit;
|
||||
}
|
||||
|
||||
return raw.ToLowerInvariant() switch
|
||||
{
|
||||
"explicit" => BaselineStrategy.Explicit,
|
||||
"last-green" or "lastgreen" => BaselineStrategy.LastGreen,
|
||||
"previous-release" or "previousrelease" => BaselineStrategy.PreviousRelease,
|
||||
_ => BaselineStrategy.Explicit
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -445,6 +734,7 @@ public sealed record CompareResult
|
||||
public string? BaseVerdict { get; init; }
|
||||
public string? TargetVerdict { get; init; }
|
||||
public required IReadOnlyList<VulnChange> Vulnerabilities { get; init; }
|
||||
public CompareVerificationOverlay? Verification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Compare;
|
||||
|
||||
internal static class CompareVerificationOverlayBuilder
|
||||
{
|
||||
public static async Task<CompareVerificationOverlay?> BuildAsync(
|
||||
string? verificationReportPath,
|
||||
string? reverifyBundlePath,
|
||||
string? determinismManifestPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(verificationReportPath)
|
||||
&& string.IsNullOrWhiteSpace(reverifyBundlePath)
|
||||
&& string.IsNullOrWhiteSpace(determinismManifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var source = new List<string>();
|
||||
var warnings = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var artifacts = new Dictionary<string, ArtifactVerificationBuilder>(StringComparer.Ordinal);
|
||||
var overallFromReport = "UNKNOWN";
|
||||
var reverified = false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verificationReportPath))
|
||||
{
|
||||
source.Add("verification-report");
|
||||
await ApplyVerificationReportAsync(
|
||||
verificationReportPath,
|
||||
artifacts,
|
||||
warnings,
|
||||
value => overallFromReport = value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reverifyBundlePath))
|
||||
{
|
||||
source.Add("reverify-bundle");
|
||||
reverified = true;
|
||||
await ApplyBundleReverificationAsync(
|
||||
reverifyBundlePath,
|
||||
artifacts,
|
||||
warnings,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
CompareDeterminismVerification? determinism = null;
|
||||
if (!string.IsNullOrWhiteSpace(determinismManifestPath))
|
||||
{
|
||||
source.Add("determinism-manifest");
|
||||
determinism = await ParseDeterminismManifestAsync(
|
||||
determinismManifestPath,
|
||||
warnings,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var artifactList = artifacts.Values
|
||||
.Select(builder => builder.Build())
|
||||
.OrderBy(static item => item.Artifact, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var aggregateStatus = ComputeOverallStatus(artifactList, warnings, determinism);
|
||||
var overallStatus = MergeOverallStatus(overallFromReport, aggregateStatus);
|
||||
var sourceText = source.Count == 0 ? "none" : string.Join("+", source);
|
||||
|
||||
return new CompareVerificationOverlay
|
||||
{
|
||||
Source = sourceText,
|
||||
Reverified = reverified,
|
||||
OverallStatus = overallStatus,
|
||||
Artifacts = artifactList,
|
||||
Determinism = determinism,
|
||||
Warnings = warnings.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task ApplyVerificationReportAsync(
|
||||
string reportPath,
|
||||
IDictionary<string, ArtifactVerificationBuilder> artifacts,
|
||||
ISet<string> warnings,
|
||||
Action<string> setOverallStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(reportPath))
|
||||
{
|
||||
warnings.Add($"verification report not found: {reportPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(reportPath, cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (root.TryGetProperty("overallStatus", out var overallNode)
|
||||
&& overallNode.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
setOverallStatus(overallNode.GetString() ?? "UNKNOWN");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("checks", out var checksNode)
|
||||
|| checksNode.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add("verification report missing checks array");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var check in checksNode.EnumerateArray())
|
||||
{
|
||||
var name = check.TryGetProperty("name", out var nameNode) && nameNode.ValueKind == JsonValueKind.String
|
||||
? nameNode.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
var message = check.TryGetProperty("message", out var messageNode) && messageNode.ValueKind == JsonValueKind.String
|
||||
? messageNode.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
var passed = check.TryGetProperty("passed", out var passedNode) && passedNode.ValueKind == JsonValueKind.True;
|
||||
var severity = check.TryGetProperty("severity", out var severityNode) && severityNode.ValueKind == JsonValueKind.String
|
||||
? severityNode.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
var status = ToStatus(passed, severity);
|
||||
|
||||
if (name.StartsWith("checksum:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var artifact = NormalizePath(name["checksum:".Length..]);
|
||||
if (string.IsNullOrWhiteSpace(artifact))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GetArtifactBuilder(artifacts, artifact).SetHash(status, message);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.StartsWith("dsse:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var dssePath = NormalizePath(name["dsse:".Length..]);
|
||||
if (string.IsNullOrWhiteSpace(dssePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var artifact = ResolveSignatureArtifact(dssePath, artifacts.Keys);
|
||||
GetArtifactBuilder(artifacts, artifact).SetSignature(status, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"failed to parse verification report '{reportPath}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyBundleReverificationAsync(
|
||||
string bundlePath,
|
||||
IDictionary<string, ArtifactVerificationBuilder> artifacts,
|
||||
ISet<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(bundlePath))
|
||||
{
|
||||
warnings.Add($"reverify bundle path is not a directory: {bundlePath}");
|
||||
return;
|
||||
}
|
||||
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
warnings.Add($"reverify bundle missing manifest.json: {bundlePath}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(manifestJson);
|
||||
if (!document.RootElement.TryGetProperty("bundle", out var bundleNode)
|
||||
|| !bundleNode.TryGetProperty("artifacts", out var artifactsNode)
|
||||
|| artifactsNode.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add("reverify bundle manifest does not contain bundle.artifacts");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var artifactNode in artifactsNode.EnumerateArray())
|
||||
{
|
||||
var artifactPath = artifactNode.TryGetProperty("path", out var pathNode)
|
||||
&& pathNode.ValueKind == JsonValueKind.String
|
||||
? NormalizePath(pathNode.GetString() ?? string.Empty)
|
||||
: string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artifactPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var digest = artifactNode.TryGetProperty("digest", out var digestNode)
|
||||
&& digestNode.ValueKind == JsonValueKind.String
|
||||
? digestNode.GetString()
|
||||
: null;
|
||||
|
||||
var builder = GetArtifactBuilder(artifacts, artifactPath);
|
||||
var fullArtifactPath = Path.Combine(
|
||||
bundlePath,
|
||||
artifactPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (!File.Exists(fullArtifactPath))
|
||||
{
|
||||
builder.SetHash("fail", $"artifact not found: {artifactPath}");
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
builder.SetHash("warning", "manifest digest missing");
|
||||
}
|
||||
else if (!digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.SetHash("warning", $"unsupported digest algorithm: {digest}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var expected = digest["sha256:".Length..].Trim().ToLowerInvariant();
|
||||
var actual = await ComputeSha256Async(fullArtifactPath, cancellationToken).ConfigureAwait(false);
|
||||
var matches = string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
|
||||
builder.SetHash(matches ? "pass" : "fail", matches ? "hash matches" : "hash mismatch");
|
||||
}
|
||||
|
||||
var dssePath = FindSidecarDssePath(bundlePath, artifactPath);
|
||||
if (dssePath is null)
|
||||
{
|
||||
builder.SetSignature("warning", "dsse sidecar not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
var signatureStatus = await ReadSignatureStatusAsync(dssePath, cancellationToken).ConfigureAwait(false);
|
||||
builder.SetSignature(signatureStatus.Status, signatureStatus.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure standalone DSSE files appear even if not tied to artifact list.
|
||||
foreach (var dsseFile in Directory.GetFiles(bundlePath, "*.dsse.json", SearchOption.AllDirectories)
|
||||
.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
{
|
||||
var relativePath = NormalizePath(Path.GetRelativePath(bundlePath, dsseFile));
|
||||
var artifact = ResolveSignatureArtifact(relativePath, artifacts.Keys);
|
||||
var builder = GetArtifactBuilder(artifacts, artifact);
|
||||
|
||||
if (builder.SignatureStatus != "unknown")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var signatureStatus = await ReadSignatureStatusAsync(dsseFile, cancellationToken).ConfigureAwait(false);
|
||||
builder.SetSignature(signatureStatus.Status, signatureStatus.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"failed to reverify bundle '{bundlePath}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CompareDeterminismVerification?> ParseDeterminismManifestAsync(
|
||||
string manifestPath,
|
||||
ISet<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
warnings.Add($"determinism manifest not found: {manifestPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
var score = TryReadDouble(root, "overall_score", "overallScore");
|
||||
var threshold = root.TryGetProperty("thresholds", out var thresholdsNode)
|
||||
? TryReadDouble(thresholdsNode, "overall_min", "overallMin")
|
||||
: null;
|
||||
|
||||
var status = "unknown";
|
||||
if (score.HasValue && threshold.HasValue)
|
||||
{
|
||||
status = score.Value >= threshold.Value ? "pass" : "fail";
|
||||
}
|
||||
else if (score.HasValue)
|
||||
{
|
||||
status = "warning";
|
||||
}
|
||||
|
||||
var imageCount = root.TryGetProperty("images", out var imagesNode)
|
||||
&& imagesNode.ValueKind == JsonValueKind.Array
|
||||
? imagesNode.GetArrayLength()
|
||||
: 0;
|
||||
|
||||
return new CompareDeterminismVerification
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
OverallScore = score,
|
||||
Threshold = threshold,
|
||||
Status = status,
|
||||
ImageCount = imageCount
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"failed to parse determinism manifest '{manifestPath}': {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ArtifactVerificationBuilder GetArtifactBuilder(
|
||||
IDictionary<string, ArtifactVerificationBuilder> artifacts,
|
||||
string artifact)
|
||||
{
|
||||
if (artifacts.TryGetValue(artifact, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new ArtifactVerificationBuilder(artifact);
|
||||
artifacts[artifact] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
private static string ResolveSignatureArtifact(string dssePath, IEnumerable<string> knownArtifacts)
|
||||
{
|
||||
var normalized = NormalizePath(dssePath);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var candidate = normalized.EndsWith(".dsse.json", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized[..^10]
|
||||
: normalized;
|
||||
|
||||
foreach (var artifact in knownArtifacts)
|
||||
{
|
||||
if (string.Equals(artifact, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return artifact;
|
||||
}
|
||||
}
|
||||
|
||||
var candidateName = Path.GetFileNameWithoutExtension(candidate);
|
||||
foreach (var artifact in knownArtifacts)
|
||||
{
|
||||
if (string.Equals(
|
||||
Path.GetFileNameWithoutExtension(artifact),
|
||||
candidateName,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return artifact;
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string value)
|
||||
{
|
||||
return value.Replace('\\', '/').Trim();
|
||||
}
|
||||
|
||||
private static string? FindSidecarDssePath(string bundlePath, string artifactPath)
|
||||
{
|
||||
var artifactFilePath = Path.Combine(bundlePath, artifactPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
var candidates = new[]
|
||||
{
|
||||
$"{artifactFilePath}.dsse.json",
|
||||
Path.ChangeExtension(artifactFilePath, ".dsse.json"),
|
||||
Path.Combine(Path.GetDirectoryName(artifactFilePath) ?? bundlePath, $"{Path.GetFileName(artifactFilePath)}.dsse.json")
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
private static async Task<(string Status, string Message)> ReadSignatureStatusAsync(string dssePath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(dssePath, cancellationToken).ConfigureAwait(false);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("signatures", out var signaturesNode)
|
||||
|| signaturesNode.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return ("fail", "signatures array missing");
|
||||
}
|
||||
|
||||
return signaturesNode.GetArrayLength() > 0
|
||||
? ("pass", "signature(s) present")
|
||||
: ("fail", "no signatures present");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ("fail", $"failed to parse dsse file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ToStatus(bool passed, string severity)
|
||||
{
|
||||
if (passed)
|
||||
{
|
||||
return "pass";
|
||||
}
|
||||
|
||||
return string.Equals(severity, "warning", StringComparison.OrdinalIgnoreCase)
|
||||
? "warning"
|
||||
: "fail";
|
||||
}
|
||||
|
||||
private static double? TryReadDouble(JsonElement node, params string[] propertyNames)
|
||||
{
|
||||
foreach (var propertyName in propertyNames)
|
||||
{
|
||||
if (!node.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out var numeric))
|
||||
{
|
||||
return numeric;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputeOverallStatus(
|
||||
IReadOnlyCollection<CompareArtifactVerification> artifacts,
|
||||
IReadOnlyCollection<string> warnings,
|
||||
CompareDeterminismVerification? determinism)
|
||||
{
|
||||
if (artifacts.Any(static a => a.HashStatus == "fail" || a.SignatureStatus == "fail")
|
||||
|| string.Equals(determinism?.Status, "fail", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "FAILED";
|
||||
}
|
||||
|
||||
if (artifacts.Any(static a => a.HashStatus == "warning" || a.SignatureStatus == "warning")
|
||||
|| warnings.Count > 0
|
||||
|| string.Equals(determinism?.Status, "warning", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "PASSED_WITH_WARNINGS";
|
||||
}
|
||||
|
||||
return "PASSED";
|
||||
}
|
||||
|
||||
private static string MergeOverallStatus(string fromReport, string computed)
|
||||
{
|
||||
var reportRank = Rank(fromReport);
|
||||
var computedRank = Rank(computed);
|
||||
return reportRank >= computedRank ? NormalizeOverall(fromReport) : NormalizeOverall(computed);
|
||||
}
|
||||
|
||||
private static int Rank(string status)
|
||||
{
|
||||
return NormalizeOverall(status) switch
|
||||
{
|
||||
"FAILED" => 3,
|
||||
"PASSED_WITH_WARNINGS" => 2,
|
||||
"PASSED" => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeOverall(string status)
|
||||
{
|
||||
return status.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"FAILED" => "FAILED",
|
||||
"PASSED_WITH_WARNINGS" => "PASSED_WITH_WARNINGS",
|
||||
"PASSED" => "PASSED",
|
||||
_ => "UNKNOWN"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ArtifactVerificationBuilder
|
||||
{
|
||||
public ArtifactVerificationBuilder(string artifact)
|
||||
{
|
||||
Artifact = artifact;
|
||||
}
|
||||
|
||||
public string Artifact { get; }
|
||||
|
||||
public string HashStatus { get; private set; } = "unknown";
|
||||
|
||||
public string SignatureStatus { get; private set; } = "unknown";
|
||||
|
||||
public string? HashMessage { get; private set; }
|
||||
|
||||
public string? SignatureMessage { get; private set; }
|
||||
|
||||
public void SetHash(string status, string? message)
|
||||
{
|
||||
HashStatus = PromoteStatus(HashStatus, status);
|
||||
HashMessage = message;
|
||||
}
|
||||
|
||||
public void SetSignature(string status, string? message)
|
||||
{
|
||||
SignatureStatus = PromoteStatus(SignatureStatus, status);
|
||||
SignatureMessage = message;
|
||||
}
|
||||
|
||||
public CompareArtifactVerification Build()
|
||||
{
|
||||
return new CompareArtifactVerification
|
||||
{
|
||||
Artifact = Artifact,
|
||||
HashStatus = HashStatus,
|
||||
SignatureStatus = SignatureStatus,
|
||||
HashMessage = HashMessage,
|
||||
SignatureMessage = SignatureMessage
|
||||
};
|
||||
}
|
||||
|
||||
private static string PromoteStatus(string current, string incoming)
|
||||
{
|
||||
var currentRank = StatusRank(current);
|
||||
var incomingRank = StatusRank(incoming);
|
||||
|
||||
return incomingRank >= currentRank
|
||||
? incoming.ToLowerInvariant()
|
||||
: current.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static int StatusRank(string status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
_ when string.Equals(status, "fail", StringComparison.OrdinalIgnoreCase) => 3,
|
||||
_ when string.Equals(status, "warning", StringComparison.OrdinalIgnoreCase) => 2,
|
||||
_ when string.Equals(status, "pass", StringComparison.OrdinalIgnoreCase) => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CompareVerificationOverlay
|
||||
{
|
||||
public required string Source { get; init; }
|
||||
public bool Reverified { get; init; }
|
||||
public required string OverallStatus { get; init; }
|
||||
public required IReadOnlyList<CompareArtifactVerification> Artifacts { get; init; }
|
||||
public CompareDeterminismVerification? Determinism { get; init; }
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CompareArtifactVerification
|
||||
{
|
||||
public required string Artifact { get; init; }
|
||||
public required string HashStatus { get; init; }
|
||||
public required string SignatureStatus { get; init; }
|
||||
public string? HashMessage { get; init; }
|
||||
public string? SignatureMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CompareDeterminismVerification
|
||||
{
|
||||
public required string ManifestPath { get; init; }
|
||||
public double? OverallScore { get; init; }
|
||||
public double? Threshold { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int ImageCount { get; init; }
|
||||
}
|
||||
@@ -61,7 +61,11 @@ public static class EvidenceCommandGroup
|
||||
BuildReplayCommand(verboseOption),
|
||||
BuildProofCommand(verboseOption),
|
||||
BuildProvenanceCommand(verboseOption),
|
||||
BuildSealCommand(verboseOption)
|
||||
BuildSealCommand(verboseOption),
|
||||
|
||||
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
|
||||
EvidenceReferrerCommands.BuildPushReferrerCommand(services, verboseOption, cancellationToken),
|
||||
EvidenceReferrerCommands.BuildListReferrersCommand(services, verboseOption, cancellationToken)
|
||||
};
|
||||
|
||||
return evidence;
|
||||
|
||||
565
src/Cli/StellaOps.Cli/Commands/EvidenceReferrerCommands.cs
Normal file
565
src/Cli/StellaOps.Cli/Commands/EvidenceReferrerCommands.cs
Normal file
@@ -0,0 +1,565 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceReferrerCommands.cs
|
||||
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
|
||||
// Task: T1/T2 — push-referrer and list-referrers commands for OCI evidence storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands for pushing and listing OCI referrers for evidence storage.
|
||||
/// Enables `stella evidence push-referrer` and `stella evidence list-referrers`.
|
||||
/// </summary>
|
||||
public static class EvidenceReferrerCommands
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the `evidence push-referrer` command.
|
||||
/// Pushes an evidence artifact as an OCI referrer to a registry.
|
||||
/// </summary>
|
||||
public static Command BuildPushReferrerCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageOption = new Option<string>("--image")
|
||||
{
|
||||
Description = "OCI image reference to attach the referrer to (e.g., registry/repo@sha256:abc...).",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var artifactTypeOption = new Option<string>("--artifact-type")
|
||||
{
|
||||
Description = "OCI artifact type for the referrer (e.g., application/vnd.stellaops.verdict.attestation.v1+json).",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var fileOption = new Option<string>("--file")
|
||||
{
|
||||
Description = "Path to the evidence artifact file to push as a referrer.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var annotationOption = new Option<string[]?>("--annotation")
|
||||
{
|
||||
Description = "Key=value annotation to attach to the referrer manifest. Can be specified multiple times."
|
||||
};
|
||||
|
||||
var offlineOption = new Option<bool>("--offline")
|
||||
{
|
||||
Description = "Simulate push without connecting to a registry (for offline/air-gap testing)."
|
||||
};
|
||||
|
||||
var command = new Command("push-referrer", "Push an evidence artifact as an OCI referrer to a container registry.")
|
||||
{
|
||||
imageOption,
|
||||
artifactTypeOption,
|
||||
fileOption,
|
||||
annotationOption,
|
||||
offlineOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageOption)!;
|
||||
var artifactType = parseResult.GetValue(artifactTypeOption)!;
|
||||
var filePath = parseResult.GetValue(fileOption)!;
|
||||
var annotations = parseResult.GetValue(annotationOption);
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var loggerFactory = services.GetService<ILoggerFactory>() ?? new LoggerFactory();
|
||||
var logger = loggerFactory.CreateLogger(typeof(EvidenceReferrerCommands));
|
||||
|
||||
return await ExecutePushReferrerAsync(
|
||||
services, image, artifactType, filePath, annotations, offline, verbose, logger, ct);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the `evidence list-referrers` command.
|
||||
/// Lists all OCI referrers attached to an artifact digest.
|
||||
/// </summary>
|
||||
public static Command BuildListReferrersCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageOption = new Option<string>("--image")
|
||||
{
|
||||
Description = "OCI image reference to list referrers for.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var digestOption = new Option<string?>("--digest")
|
||||
{
|
||||
Description = "Specific digest to list referrers for. If omitted, resolves from image reference."
|
||||
};
|
||||
|
||||
var artifactTypeOption = new Option<string?>("--artifact-type")
|
||||
{
|
||||
Description = "Filter referrers by artifact type."
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Output format: table (default) or json."
|
||||
};
|
||||
formatOption.SetDefaultValue("table");
|
||||
|
||||
var offlineOption = new Option<bool>("--offline")
|
||||
{
|
||||
Description = "Use simulated data instead of connecting to a registry."
|
||||
};
|
||||
|
||||
var command = new Command("list-referrers", "List all OCI referrers attached to an artifact digest.")
|
||||
{
|
||||
imageOption,
|
||||
digestOption,
|
||||
artifactTypeOption,
|
||||
formatOption,
|
||||
offlineOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var image = parseResult.GetValue(imageOption)!;
|
||||
var digest = parseResult.GetValue(digestOption);
|
||||
var artifactType = parseResult.GetValue(artifactTypeOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "table";
|
||||
var offline = parseResult.GetValue(offlineOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var loggerFactory = services.GetService<ILoggerFactory>() ?? new LoggerFactory();
|
||||
var logger = loggerFactory.CreateLogger(typeof(EvidenceReferrerCommands));
|
||||
|
||||
return await ExecuteListReferrersAsync(
|
||||
services, image, digest, artifactType, format, offline, verbose, logger, ct);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
// ── Push referrer implementation ───────────────────────────────────
|
||||
|
||||
internal static async Task<int> ExecutePushReferrerAsync(
|
||||
IServiceProvider services,
|
||||
string image,
|
||||
string artifactType,
|
||||
string filePath,
|
||||
string[]? annotations,
|
||||
bool offline,
|
||||
bool verbose,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: file not found: {filePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||
var fileDigest = ComputeSha256Digest(fileBytes);
|
||||
var parsedAnnotations = ParseAnnotations(annotations);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation("Pushing referrer: image={Image}, artifactType={ArtifactType}, file={File}, digest={Digest}, size={Size}",
|
||||
image, artifactType, filePath, fileDigest, fileBytes.Length);
|
||||
}
|
||||
|
||||
if (offline)
|
||||
{
|
||||
return HandleOfflinePush(image, artifactType, filePath, fileDigest, fileBytes.Length, parsedAnnotations);
|
||||
}
|
||||
|
||||
var ociClient = services.GetService<IOciRegistryClient>();
|
||||
if (ociClient is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: OCI registry client not configured.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var reference = ParseImageReference(image);
|
||||
if (reference is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: could not parse image reference: {image}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve the subject digest if not provided
|
||||
var subjectDigest = reference.Digest;
|
||||
if (string.IsNullOrEmpty(subjectDigest))
|
||||
{
|
||||
subjectDigest = await ociClient.ResolveDigestAsync(reference, ct);
|
||||
}
|
||||
|
||||
// Build the referrer manifest
|
||||
var manifest = BuildReferrerManifest(artifactType, fileDigest, fileBytes.Length, subjectDigest, parsedAnnotations);
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
|
||||
Console.WriteLine($"Pushed referrer:");
|
||||
Console.WriteLine($" Subject: {reference.Repository}@{subjectDigest}");
|
||||
Console.WriteLine($" Artifact: {artifactType}");
|
||||
Console.WriteLine($" Layer digest: {fileDigest}");
|
||||
Console.WriteLine($" Layer size: {fileBytes.Length}");
|
||||
Console.WriteLine($" Manifest: {ComputeSha256Digest(Encoding.UTF8.GetBytes(manifestJson))}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to push referrer to {Image}", image);
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── List referrers implementation ──────────────────────────────────
|
||||
|
||||
internal static async Task<int> ExecuteListReferrersAsync(
|
||||
IServiceProvider services,
|
||||
string image,
|
||||
string? digest,
|
||||
string? artifactType,
|
||||
string format,
|
||||
bool offline,
|
||||
bool verbose,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation("Listing referrers: image={Image}, digest={Digest}, artifactType={ArtifactType}",
|
||||
image, digest, artifactType);
|
||||
}
|
||||
|
||||
if (offline)
|
||||
{
|
||||
return HandleOfflineList(image, digest, artifactType, format);
|
||||
}
|
||||
|
||||
var ociClient = services.GetService<IOciRegistryClient>();
|
||||
if (ociClient is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: OCI registry client not configured.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var reference = ParseImageReference(image);
|
||||
if (reference is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: could not parse image reference: {image}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resolvedDigest = digest ?? reference.Digest;
|
||||
if (string.IsNullOrEmpty(resolvedDigest))
|
||||
{
|
||||
resolvedDigest = await ociClient.ResolveDigestAsync(reference, ct);
|
||||
}
|
||||
|
||||
var referrers = await ociClient.GetReferrersAsync(
|
||||
reference.Registry, reference.Repository, resolvedDigest, artifactType, ct);
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(referrers, SerializerOptions);
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderReferrersTable(referrers, resolvedDigest);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to list referrers for {Image}", image);
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Offline handlers ───────────────────────────────────────────────
|
||||
|
||||
internal static int HandleOfflinePush(
|
||||
string image,
|
||||
string artifactType,
|
||||
string filePath,
|
||||
string fileDigest,
|
||||
int fileSize,
|
||||
Dictionary<string, string> annotations)
|
||||
{
|
||||
var manifest = BuildReferrerManifest(
|
||||
artifactType, fileDigest, fileSize, "sha256:offline-subject-digest", annotations);
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
|
||||
Console.WriteLine("[offline] Simulated push-referrer:");
|
||||
Console.WriteLine($" Image: {image}");
|
||||
Console.WriteLine($" Artifact: {artifactType}");
|
||||
Console.WriteLine($" File: {filePath}");
|
||||
Console.WriteLine($" Layer digest: {fileDigest}");
|
||||
Console.WriteLine($" Layer size: {fileSize}");
|
||||
Console.WriteLine($" Manifest:");
|
||||
Console.WriteLine(manifestJson);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static int HandleOfflineList(
|
||||
string image,
|
||||
string? digest,
|
||||
string? artifactType,
|
||||
string format)
|
||||
{
|
||||
var simulatedReferrers = new List<OciReferrerDescriptor>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaType = "application/vnd.oci.image.manifest.v2+json",
|
||||
ArtifactType = OciMediaTypes.VerdictAttestation,
|
||||
Digest = "sha256:aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb",
|
||||
Size = 1024,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["org.opencontainers.image.created"] = "2026-02-08T12:00:00Z",
|
||||
[OciAnnotations.StellaVerdictDecision] = "PASS"
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
MediaType = "application/vnd.oci.image.manifest.v2+json",
|
||||
ArtifactType = OciMediaTypes.SbomAttestation,
|
||||
Digest = "sha256:11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff",
|
||||
Size = 4096,
|
||||
Annotations = new Dictionary<string, string>
|
||||
{
|
||||
["org.opencontainers.image.created"] = "2026-02-08T12:00:01Z",
|
||||
[OciAnnotations.StellaSbomDigest] = "sha256:sbom-digest-example"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Apply artifact type filter
|
||||
if (!string.IsNullOrEmpty(artifactType))
|
||||
{
|
||||
simulatedReferrers = simulatedReferrers
|
||||
.Where(r => string.Equals(r.ArtifactType, artifactType, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(simulatedReferrers, SerializerOptions);
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
var resolvedDigest = digest ?? "sha256:offline-subject-digest";
|
||||
Console.WriteLine($"[offline] Simulated referrers for {image}:");
|
||||
RenderReferrersTable(simulatedReferrers, resolvedDigest);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
internal static OciImageReference? ParseImageReference(string image)
|
||||
{
|
||||
// Format: registry/repository@sha256:digest or registry/repository:tag
|
||||
var atIdx = image.IndexOf('@');
|
||||
string? digest = null;
|
||||
string? tag = null;
|
||||
string registryRepo;
|
||||
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
digest = image[(atIdx + 1)..];
|
||||
registryRepo = image[..atIdx];
|
||||
}
|
||||
else
|
||||
{
|
||||
var colonIdx = image.LastIndexOf(':');
|
||||
// Avoid splitting on port numbers (e.g., registry:5000/repo)
|
||||
if (colonIdx >= 0 && image[(colonIdx + 1)..].All(c => !char.IsDigit(c) || image[(colonIdx + 1)..].Contains('/')))
|
||||
{
|
||||
// Simple heuristic: if after : there's no /, it's a tag
|
||||
var afterColon = image[(colonIdx + 1)..];
|
||||
if (!afterColon.Contains('/'))
|
||||
{
|
||||
tag = afterColon;
|
||||
registryRepo = image[..colonIdx];
|
||||
}
|
||||
else
|
||||
{
|
||||
registryRepo = image;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
registryRepo = image;
|
||||
}
|
||||
}
|
||||
|
||||
var slashIdx = registryRepo.IndexOf('/');
|
||||
if (slashIdx < 0) return null;
|
||||
|
||||
return new OciImageReference
|
||||
{
|
||||
Registry = registryRepo[..slashIdx],
|
||||
Repository = registryRepo[(slashIdx + 1)..],
|
||||
Tag = tag,
|
||||
Digest = digest,
|
||||
Original = image
|
||||
};
|
||||
}
|
||||
|
||||
internal static ReferrerManifest BuildReferrerManifest(
|
||||
string artifactType,
|
||||
string layerDigest,
|
||||
int layerSize,
|
||||
string subjectDigest,
|
||||
Dictionary<string, string> annotations)
|
||||
{
|
||||
return new ReferrerManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
MediaType = "application/vnd.oci.image.manifest.v2+json",
|
||||
ArtifactType = artifactType,
|
||||
Config = new ManifestDescriptor
|
||||
{
|
||||
MediaType = "application/vnd.oci.empty.v1+json",
|
||||
Digest = "sha256:44136fa355b311bfa0680e70cf7b5b35e2b5615f2e49a8e9c5c7e2f5f1b1f7d0",
|
||||
Size = 2
|
||||
},
|
||||
Layers =
|
||||
[
|
||||
new ManifestDescriptor
|
||||
{
|
||||
MediaType = artifactType,
|
||||
Digest = layerDigest,
|
||||
Size = layerSize
|
||||
}
|
||||
],
|
||||
Subject = new ManifestDescriptor
|
||||
{
|
||||
MediaType = "application/vnd.oci.image.manifest.v2+json",
|
||||
Digest = subjectDigest,
|
||||
Size = 0
|
||||
},
|
||||
Annotations = annotations.Count > 0 ? annotations : null
|
||||
};
|
||||
}
|
||||
|
||||
internal static Dictionary<string, string> ParseAnnotations(string[]? annotations)
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
if (annotations is null) return result;
|
||||
|
||||
foreach (var annotation in annotations)
|
||||
{
|
||||
var eqIdx = annotation.IndexOf('=');
|
||||
if (eqIdx > 0)
|
||||
{
|
||||
result[annotation[..eqIdx]] = annotation[(eqIdx + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static void RenderReferrersTable(IReadOnlyList<OciReferrerDescriptor> referrers, string subjectDigest)
|
||||
{
|
||||
Console.WriteLine($"Subject: {subjectDigest}");
|
||||
Console.WriteLine($"Referrers: {referrers.Count}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (referrers.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (no referrers found)");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($" {"ARTIFACT TYPE",-55} {"DIGEST",-20} {"SIZE",8}");
|
||||
Console.WriteLine($" {new string('-', 55)} {new string('-', 20)} {new string('-', 8)}");
|
||||
|
||||
foreach (var r in referrers)
|
||||
{
|
||||
var shortDigest = r.Digest.Length > 19 ? r.Digest[..19] + "…" : r.Digest;
|
||||
Console.WriteLine($" {r.ArtifactType ?? "(unknown)",-55} {shortDigest,-20} {r.Size,8}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal DTOs ──────────────────────────────────────────────────
|
||||
|
||||
internal sealed record ReferrerManifest
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public ManifestDescriptor? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("layers")]
|
||||
public List<ManifestDescriptor> Layers { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public ManifestDescriptor? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public Dictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ManifestDescriptor
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "stellaops/cli/unknowns-export.schema.json",
|
||||
"title": "StellaOps Unknowns Export Envelope",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"exportedAt",
|
||||
"itemCount",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "string",
|
||||
"description": "Version marker for forward-compatible parsing.",
|
||||
"examples": ["unknowns.export.v1"]
|
||||
},
|
||||
"exportedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Deterministic export timestamp derived from payload contents."
|
||||
},
|
||||
"itemCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/unknown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"unknown": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"id",
|
||||
"packageId",
|
||||
"packageVersion",
|
||||
"band",
|
||||
"score",
|
||||
"reasonCode",
|
||||
"reasonCodeShort",
|
||||
"firstSeenAt",
|
||||
"lastEvaluatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"packageId": {
|
||||
"type": "string"
|
||||
},
|
||||
"packageVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"band": {
|
||||
"type": "string",
|
||||
"enum": ["hot", "warm", "cold"]
|
||||
},
|
||||
"score": {
|
||||
"type": "number"
|
||||
},
|
||||
"reasonCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasonCodeShort": {
|
||||
"type": "string"
|
||||
},
|
||||
"firstSeenAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"lastEvaluatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ namespace StellaOps.Cli.Commands;
|
||||
/// </summary>
|
||||
public static class UnknownsCommandGroup
|
||||
{
|
||||
private const string DefaultUnknownsExportSchemaVersion = "unknowns.export.v1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
@@ -395,6 +397,13 @@ public static class UnknownsCommandGroup
|
||||
Description = "Output format: json, csv, ndjson"
|
||||
};
|
||||
formatOption.SetDefaultValue("json");
|
||||
formatOption.FromAmong("json", "csv", "ndjson");
|
||||
|
||||
var schemaVersionOption = new Option<string>("--schema-version")
|
||||
{
|
||||
Description = "Schema version to stamp into exported artifacts."
|
||||
};
|
||||
schemaVersionOption.SetDefaultValue(DefaultUnknownsExportSchemaVersion);
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
@@ -404,6 +413,7 @@ public static class UnknownsCommandGroup
|
||||
var exportCommand = new Command("export", "Export unknowns with fingerprints and triggers for offline analysis");
|
||||
exportCommand.Add(bandOption);
|
||||
exportCommand.Add(formatOption);
|
||||
exportCommand.Add(schemaVersionOption);
|
||||
exportCommand.Add(outputOption);
|
||||
exportCommand.Add(verboseOption);
|
||||
|
||||
@@ -411,10 +421,11 @@ public static class UnknownsCommandGroup
|
||||
{
|
||||
var band = parseResult.GetValue(bandOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var schemaVersion = parseResult.GetValue(schemaVersionOption) ?? DefaultUnknownsExportSchemaVersion;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await HandleExportAsync(services, band, format, output, verbose, cancellationToken);
|
||||
return await HandleExportAsync(services, band, format, schemaVersion, output, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return exportCommand;
|
||||
@@ -1009,6 +1020,7 @@ public static class UnknownsCommandGroup
|
||||
IServiceProvider services,
|
||||
string? band,
|
||||
string format,
|
||||
string schemaVersion,
|
||||
string? outputPath,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
@@ -1052,9 +1064,15 @@ public static class UnknownsCommandGroup
|
||||
|
||||
// Deterministic ordering by band priority, then score descending
|
||||
var sorted = result.Items
|
||||
.OrderBy(u => u.Band switch { "hot" => 0, "warm" => 1, "cold" => 2, _ => 3 })
|
||||
.OrderBy(u => u.Band.ToLowerInvariant() switch { "hot" => 0, "warm" => 1, "cold" => 2, _ => 3 })
|
||||
.ThenByDescending(u => u.Score)
|
||||
.ThenBy(u => u.PackageId, StringComparer.Ordinal)
|
||||
.ThenBy(u => u.PackageVersion, StringComparer.Ordinal)
|
||||
.ThenBy(u => u.Id)
|
||||
.ToList();
|
||||
var exportedAt = sorted.Count == 0
|
||||
? DateTimeOffset.UnixEpoch
|
||||
: sorted.Max(u => u.LastEvaluatedAt).ToUniversalTime();
|
||||
|
||||
TextWriter writer = outputPath is not null
|
||||
? new StreamWriter(outputPath)
|
||||
@@ -1065,17 +1083,33 @@ public static class UnknownsCommandGroup
|
||||
switch (format.ToLowerInvariant())
|
||||
{
|
||||
case "csv":
|
||||
await WriteCsvAsync(writer, sorted);
|
||||
await WriteCsvAsync(writer, sorted, schemaVersion, exportedAt);
|
||||
break;
|
||||
case "ndjson":
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion,
|
||||
exportedAt,
|
||||
itemCount = sorted.Count
|
||||
}, JsonOptions));
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(item, JsonOptions));
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion,
|
||||
item
|
||||
}, JsonOptions));
|
||||
}
|
||||
break;
|
||||
case "json":
|
||||
default:
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(sorted, JsonOptions));
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(new UnknownsExportEnvelope
|
||||
{
|
||||
SchemaVersion = schemaVersion,
|
||||
ExportedAt = exportedAt,
|
||||
ItemCount = sorted.Count,
|
||||
Items = sorted
|
||||
}, JsonOptions));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1102,8 +1136,13 @@ public static class UnknownsCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteCsvAsync(TextWriter writer, IReadOnlyList<UnknownDto> items)
|
||||
private static async Task WriteCsvAsync(
|
||||
TextWriter writer,
|
||||
IReadOnlyList<UnknownDto> items,
|
||||
string schemaVersion,
|
||||
DateTimeOffset exportedAt)
|
||||
{
|
||||
await writer.WriteLineAsync($"# schema_version={schemaVersion}; exported_at={exportedAt:O}; item_count={items.Count}");
|
||||
// CSV header
|
||||
await writer.WriteLineAsync("id,package_id,package_version,band,score,reason_code,fingerprint_id,first_seen_at,last_evaluated_at");
|
||||
|
||||
@@ -1590,6 +1629,14 @@ public static class UnknownsCommandGroup
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
private sealed record UnknownsExportEnvelope
|
||||
{
|
||||
public string SchemaVersion { get; init; } = DefaultUnknownsExportSchemaVersion;
|
||||
public DateTimeOffset ExportedAt { get; init; }
|
||||
public int ItemCount { get; init; }
|
||||
public IReadOnlyList<UnknownDto> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
private sealed record UnknownDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
@@ -56,6 +56,12 @@ public sealed class StellaOpsCliOptions
|
||||
/// Directory containing offline kits when in offline mode.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default tenant identifier for multi-tenant operations.
|
||||
/// Falls back to "default" when not specified.
|
||||
/// </summary>
|
||||
public string? DefaultTenant { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliAuthorityOptions
|
||||
|
||||
@@ -211,6 +211,9 @@ internal static class Program
|
||||
// CLI-FORENSICS-54-001: Forensic verifier (local only, no HTTP)
|
||||
services.AddSingleton<IForensicVerifier, ForensicVerifier>();
|
||||
|
||||
// SPRINT_20260208_029: Baseline resolver for compare commands
|
||||
services.AddSingleton<IBaselineResolver, BaselineResolver>();
|
||||
|
||||
// CLI-FORENSICS-54-002: Attestation reader (local only, no HTTP)
|
||||
services.AddSingleton<IAttestationReader, AttestationReader>();
|
||||
|
||||
|
||||
329
src/Cli/StellaOps.Cli/Services/BaselineResolver.cs
Normal file
329
src/Cli/StellaOps.Cli/Services/BaselineResolver.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BaselineResolver.cs
|
||||
// Sprint: SPRINT_20260208_029_Cli_baseline_selection_logic
|
||||
// Description: Resolves baseline snapshots using configurable strategies.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves baseline snapshots using configurable strategies.
|
||||
/// </summary>
|
||||
internal sealed class BaselineResolver : IBaselineResolver
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IForensicSnapshotClient _forensicClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<BaselineResolver> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public BaselineResolver(
|
||||
IForensicSnapshotClient forensicClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<BaselineResolver> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_forensicClient = forensicClient ?? throw new ArgumentNullException(nameof(forensicClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<BaselineResolutionResult> ResolveAsync(
|
||||
BaselineResolutionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return request.Strategy switch
|
||||
{
|
||||
BaselineStrategy.Explicit => ResolveExplicit(request),
|
||||
BaselineStrategy.LastGreen => await ResolveLastGreenAsync(request, cancellationToken).ConfigureAwait(false),
|
||||
BaselineStrategy.PreviousRelease => await ResolvePreviousReleaseAsync(request, cancellationToken).ConfigureAwait(false),
|
||||
_ => new BaselineResolutionResult(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Strategy: request.Strategy,
|
||||
Error: $"Unknown baseline strategy: {request.Strategy}")
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BaselineSuggestion>> GetSuggestionsAsync(
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
|
||||
|
||||
var suggestions = new List<BaselineSuggestion>();
|
||||
var tenant = _options.DefaultTenant ?? "default";
|
||||
|
||||
try
|
||||
{
|
||||
// Query for passing snapshots
|
||||
var passingQuery = new ForensicSnapshotListQuery(
|
||||
Tenant: tenant,
|
||||
Status: ForensicSnapshotStatus.Ready,
|
||||
Tags: ["verdict:pass", $"artifact:{SanitizeTag(artifactId)}"],
|
||||
Limit: 3);
|
||||
|
||||
var passingResponse = await _forensicClient
|
||||
.ListSnapshotsAsync(passingQuery, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var snapshot in passingResponse.Snapshots.OrderByDescending(s => s.CreatedAt))
|
||||
{
|
||||
var digest = snapshot.Manifest?.Digest ?? snapshot.SnapshotId;
|
||||
var version = ExtractVersionFromTags(snapshot.Tags);
|
||||
|
||||
suggestions.Add(new BaselineSuggestion(
|
||||
Digest: digest,
|
||||
RecommendedStrategy: BaselineStrategy.LastGreen,
|
||||
Description: $"Passing snapshot from {snapshot.CreatedAt:yyyy-MM-dd}",
|
||||
Timestamp: snapshot.CreatedAt,
|
||||
Version: version,
|
||||
IsPassing: true));
|
||||
}
|
||||
|
||||
// Query for recent releases (regardless of verdict)
|
||||
var releaseQuery = new ForensicSnapshotListQuery(
|
||||
Tenant: tenant,
|
||||
Status: ForensicSnapshotStatus.Ready,
|
||||
Tags: [$"artifact:{SanitizeTag(artifactId)}", "release:true"],
|
||||
Limit: 3);
|
||||
|
||||
var releaseResponse = await _forensicClient
|
||||
.ListSnapshotsAsync(releaseQuery, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var snapshot in releaseResponse.Snapshots.OrderByDescending(s => s.CreatedAt))
|
||||
{
|
||||
var digest = snapshot.Manifest?.Digest ?? snapshot.SnapshotId;
|
||||
var version = ExtractVersionFromTags(snapshot.Tags);
|
||||
var isPassing = snapshot.Tags.Any(t =>
|
||||
t.Equals("verdict:pass", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Avoid duplicates
|
||||
if (suggestions.All(s => s.Digest != digest))
|
||||
{
|
||||
suggestions.Add(new BaselineSuggestion(
|
||||
Digest: digest,
|
||||
RecommendedStrategy: BaselineStrategy.PreviousRelease,
|
||||
Description: $"Release {version ?? "unknown"} from {snapshot.CreatedAt:yyyy-MM-dd}",
|
||||
Timestamp: snapshot.CreatedAt,
|
||||
Version: version,
|
||||
IsPassing: isPassing));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to retrieve baseline suggestions for {ArtifactId}", artifactId);
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private static BaselineResolutionResult ResolveExplicit(BaselineResolutionRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ExplicitDigest))
|
||||
{
|
||||
return new BaselineResolutionResult(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Strategy: BaselineStrategy.Explicit,
|
||||
Error: "Explicit baseline strategy requires a digest via --base option");
|
||||
}
|
||||
|
||||
return new BaselineResolutionResult(
|
||||
Success: true,
|
||||
Digest: request.ExplicitDigest,
|
||||
Strategy: BaselineStrategy.Explicit);
|
||||
}
|
||||
|
||||
private async Task<BaselineResolutionResult> ResolveLastGreenAsync(
|
||||
BaselineResolutionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = request.TenantId ?? _options.DefaultTenant ?? "default";
|
||||
|
||||
try
|
||||
{
|
||||
// Query for the most recent passing snapshot for this artifact
|
||||
var query = new ForensicSnapshotListQuery(
|
||||
Tenant: tenant,
|
||||
Status: ForensicSnapshotStatus.Ready,
|
||||
Tags: ["verdict:pass", $"artifact:{SanitizeTag(request.ArtifactId)}"],
|
||||
Limit: 1);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Resolving last-green baseline for artifact {ArtifactId} in tenant {Tenant}",
|
||||
request.ArtifactId,
|
||||
tenant);
|
||||
|
||||
var response = await _forensicClient
|
||||
.ListSnapshotsAsync(query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.Snapshots.Count == 0)
|
||||
{
|
||||
return new BaselineResolutionResult(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Strategy: BaselineStrategy.LastGreen,
|
||||
Suggestion: "No passing baselines found. Try --baseline-strategy=explicit with a known digest.",
|
||||
Error: $"No passing snapshot found for artifact '{request.ArtifactId}'");
|
||||
}
|
||||
|
||||
var snapshot = response.Snapshots[0];
|
||||
var digest = snapshot.Manifest?.Digest ?? snapshot.SnapshotId;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Resolved last-green baseline: {Digest} (from {Date})",
|
||||
digest,
|
||||
snapshot.CreatedAt);
|
||||
|
||||
return new BaselineResolutionResult(
|
||||
Success: true,
|
||||
Digest: digest,
|
||||
Strategy: BaselineStrategy.LastGreen);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resolve last-green baseline for {ArtifactId}", request.ArtifactId);
|
||||
|
||||
return new BaselineResolutionResult(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Strategy: BaselineStrategy.LastGreen,
|
||||
Error: $"Failed to query baseline: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BaselineResolutionResult> ResolvePreviousReleaseAsync(
|
||||
BaselineResolutionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = request.TenantId ?? _options.DefaultTenant ?? "default";
|
||||
|
||||
try
|
||||
{
|
||||
// For previous release, we query for releases tagged before the current version
|
||||
var tags = new List<string>
|
||||
{
|
||||
$"artifact:{SanitizeTag(request.ArtifactId)}",
|
||||
"release:true"
|
||||
};
|
||||
|
||||
var query = new ForensicSnapshotListQuery(
|
||||
Tenant: tenant,
|
||||
Status: ForensicSnapshotStatus.Ready,
|
||||
Tags: tags,
|
||||
Limit: 10); // Get more to filter by version
|
||||
|
||||
_logger.LogDebug(
|
||||
"Resolving previous-release baseline for artifact {ArtifactId} (current: {CurrentVersion})",
|
||||
request.ArtifactId,
|
||||
request.CurrentVersion ?? "unspecified");
|
||||
|
||||
var response = await _forensicClient
|
||||
.ListSnapshotsAsync(query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.Snapshots.Count == 0)
|
||||
{
|
||||
return new BaselineResolutionResult(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Strategy: BaselineStrategy.PreviousRelease,
|
||||
Suggestion: "No previous releases found. Try --baseline-strategy=explicit with a known digest.",
|
||||
Error: $"No release snapshots found for artifact '{request.ArtifactId}'");
|
||||
}
|
||||
|
||||
// Order by date descending and pick the first one that's not the current version
|
||||
var candidates = response.Snapshots
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
ForensicSnapshotDocument? selectedSnapshot = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.CurrentVersion))
|
||||
{
|
||||
// Find the first snapshot that doesn't match current version
|
||||
foreach (var snapshot in candidates)
|
||||
{
|
||||
var version = ExtractVersionFromTags(snapshot.Tags);
|
||||
if (!string.Equals(version, request.CurrentVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
selectedSnapshot = snapshot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No current version specified, just get the most recent
|
||||
selectedSnapshot = candidates.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (selectedSnapshot is null)
|
||||
{
|
||||
return new BaselineResolutionResult(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Strategy: BaselineStrategy.PreviousRelease,
|
||||
Suggestion: "All found releases match current version.",
|
||||
Error: "No previous release found distinct from current version");
|
||||
}
|
||||
|
||||
var digest = selectedSnapshot.Manifest?.Digest ?? selectedSnapshot.SnapshotId;
|
||||
var resolvedVersion = ExtractVersionFromTags(selectedSnapshot.Tags);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Resolved previous-release baseline: {Digest} (version {Version}, from {Date})",
|
||||
digest,
|
||||
resolvedVersion ?? "unknown",
|
||||
selectedSnapshot.CreatedAt);
|
||||
|
||||
return new BaselineResolutionResult(
|
||||
Success: true,
|
||||
Digest: digest,
|
||||
Strategy: BaselineStrategy.PreviousRelease);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resolve previous-release baseline for {ArtifactId}", request.ArtifactId);
|
||||
|
||||
return new BaselineResolutionResult(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Strategy: BaselineStrategy.PreviousRelease,
|
||||
Error: $"Failed to query releases: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeTag(string value)
|
||||
{
|
||||
// Tags may need sanitization for special characters
|
||||
return Uri.EscapeDataString(value);
|
||||
}
|
||||
|
||||
private static string? ExtractVersionFromTags(IReadOnlyList<string> tags)
|
||||
{
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (tag.StartsWith("version:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return tag["version:".Length..];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
@@ -118,22 +119,85 @@ internal sealed class ChatClient : IChatClient
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ChatConversationListResponse> ListConversationsAsync(
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
query.Add($"tenantId={Uri.EscapeDataString(tenantId)}");
|
||||
}
|
||||
|
||||
if (limit is > 0)
|
||||
{
|
||||
query.Add($"limit={limit.Value}");
|
||||
}
|
||||
|
||||
var suffix = query.Count == 0 ? string.Empty : "?" + string.Join("&", query);
|
||||
var url = BuildAdvisoryAiUrl($"/v1/advisory-ai/conversations{suffix}");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
AddHeaders(httpRequest, tenantId, userId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ChatConversationListResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? throw new InvalidOperationException("Conversation list returned null response.");
|
||||
}
|
||||
|
||||
public async Task<ChatConversationResponse> GetConversationAsync(
|
||||
string conversationId,
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
throw new ArgumentException("Conversation id must be provided.", nameof(conversationId));
|
||||
}
|
||||
|
||||
var escapedConversationId = Uri.EscapeDataString(conversationId.Trim());
|
||||
var url = BuildAdvisoryAiUrl($"/v1/advisory-ai/conversations/{escapedConversationId}");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
AddHeaders(httpRequest, tenantId, userId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ChatConversationResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return result ?? throw new InvalidOperationException("Conversation response returned null payload.");
|
||||
}
|
||||
|
||||
private string BuildUrl(string path)
|
||||
{
|
||||
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "http://localhost:5000";
|
||||
return $"{baseUrl}{path}";
|
||||
}
|
||||
|
||||
private string BuildAdvisoryAiUrl(string path)
|
||||
{
|
||||
var advisoryAiBase = string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl)
|
||||
? _options.BackendUrl
|
||||
: _options.AdvisoryAiUrl;
|
||||
var baseUrl = advisoryAiBase?.TrimEnd('/') ?? "http://localhost:5000";
|
||||
return $"{baseUrl}{path}";
|
||||
}
|
||||
|
||||
private static void AddHeaders(HttpRequestMessage request, string? tenantId, string? userId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId);
|
||||
request.Headers.TryAddWithoutValidation("X-StellaOps-Tenant", tenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-User-Id", userId);
|
||||
request.Headers.TryAddWithoutValidation("X-StellaOps-User", userId);
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -57,4 +57,22 @@ internal interface IChatClient
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists conversations available for the current tenant/user scope.
|
||||
/// </summary>
|
||||
Task<ChatConversationListResponse> ListConversationsAsync(
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single conversation with all turns.
|
||||
/// </summary>
|
||||
Task<ChatConversationResponse> GetConversationAsync(
|
||||
string conversationId,
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
116
src/Cli/StellaOps.Cli/Services/IBaselineResolver.cs
Normal file
116
src/Cli/StellaOps.Cli/Services/IBaselineResolver.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBaselineResolver.cs
|
||||
// Sprint: SPRINT_20260208_029_Cli_baseline_selection_logic
|
||||
// Description: Service for resolving baseline snapshots with different strategies.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for selecting a baseline snapshot.
|
||||
/// </summary>
|
||||
public enum BaselineStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Explicit baseline - requires a specific digest to be provided.
|
||||
/// </summary>
|
||||
Explicit = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Last green - selects the most recent snapshot with a passing verdict.
|
||||
/// </summary>
|
||||
LastGreen = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Previous release - resolves the previous release tag from SCM/registry metadata.
|
||||
/// </summary>
|
||||
PreviousRelease = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from baseline resolution.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether resolution succeeded.</param>
|
||||
/// <param name="Digest">The resolved snapshot digest (null if failed).</param>
|
||||
/// <param name="Strategy">The strategy used for resolution.</param>
|
||||
/// <param name="Suggestion">Suggested baseline if resolution failed.</param>
|
||||
/// <param name="Error">Error message if resolution failed.</param>
|
||||
public sealed record BaselineResolutionResult(
|
||||
bool Success,
|
||||
string? Digest,
|
||||
BaselineStrategy Strategy,
|
||||
string? Suggestion = null,
|
||||
string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request for baseline resolution.
|
||||
/// </summary>
|
||||
public sealed record BaselineResolutionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The target artifact (PURL, OCI reference, or path).
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Explicit baseline digest (used with Explicit strategy).
|
||||
/// </summary>
|
||||
public string? ExplicitDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Strategy to use for resolution.
|
||||
/// </summary>
|
||||
public required BaselineStrategy Strategy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current version/tag for context (helps with PreviousRelease strategy).
|
||||
/// </summary>
|
||||
public string? CurrentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for scoped queries.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backend URL override.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves baseline snapshots using configurable strategies.
|
||||
/// </summary>
|
||||
public interface IBaselineResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve a baseline snapshot based on the specified strategy.
|
||||
/// </summary>
|
||||
/// <param name="request">Resolution request with strategy and context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Resolution result with digest or error.</returns>
|
||||
Task<BaselineResolutionResult> ResolveAsync(
|
||||
BaselineResolutionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get suggestions for baselines when none is specified.
|
||||
/// </summary>
|
||||
/// <param name="artifactId">Target artifact identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of suggested baselines with metadata.</returns>
|
||||
Task<IReadOnlyList<BaselineSuggestion>> GetSuggestionsAsync(
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A suggested baseline for comparison.
|
||||
/// </summary>
|
||||
public sealed record BaselineSuggestion(
|
||||
string Digest,
|
||||
BaselineStrategy RecommendedStrategy,
|
||||
string Description,
|
||||
DateTimeOffset? Timestamp,
|
||||
string? Version,
|
||||
bool IsPassing);
|
||||
@@ -398,6 +398,126 @@ internal sealed record ChatToolSettingsUpdate
|
||||
public List<string>? AllowedTools { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conversation list response from AdvisoryAI conversation endpoints.
|
||||
/// </summary>
|
||||
internal sealed record ChatConversationListResponse
|
||||
{
|
||||
[JsonPropertyName("conversations")]
|
||||
public List<ChatConversationSummary> Conversations { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatConversationSummary
|
||||
{
|
||||
[JsonPropertyName("conversationId")]
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("turnCount")]
|
||||
public int TurnCount { get; init; }
|
||||
|
||||
[JsonPropertyName("preview")]
|
||||
public string? Preview { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatConversationResponse
|
||||
{
|
||||
[JsonPropertyName("conversationId")]
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public required string UserId { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("turns")]
|
||||
public List<ChatConversationTurn> Turns { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record ChatConversationTurn
|
||||
{
|
||||
[JsonPropertyName("turnId")]
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
[JsonPropertyName("role")]
|
||||
public required string Role { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public List<ChatConversationEvidenceLink>? EvidenceLinks { get; init; }
|
||||
|
||||
[JsonPropertyName("proposedActions")]
|
||||
public List<ChatConversationProposedAction>? ProposedActions { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatConversationEvidenceLink
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public string? Label { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double? Confidence { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatConversationProposedAction
|
||||
{
|
||||
[JsonPropertyName("actionType")]
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public required string Label { get; init; }
|
||||
|
||||
[JsonPropertyName("policyGate")]
|
||||
public string? PolicyGate { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresConfirmation")]
|
||||
public bool RequiresConfirmation { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ChatConversationExport
|
||||
{
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; init; }
|
||||
|
||||
[JsonPropertyName("conversationCount")]
|
||||
public int ConversationCount { get; init; }
|
||||
|
||||
[JsonPropertyName("conversations")]
|
||||
public List<ChatConversationResponse> Conversations { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error response from chat API.
|
||||
/// </summary>
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.Oci/StellaOps.Attestor.Oci.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.Timestamping/StellaOps.Attestor.Timestamping.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.TrustRepo/StellaOps.Attestor.TrustRepo.csproj" />
|
||||
|
||||
@@ -57,3 +57,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| TASK-032-005 | BLOCKED | Docs delivered; validation blocked pending stable API filters. |
|
||||
| TASK-033-007 | DONE | Updated CLI compatibility shims; CLI + plugins build (SPRINT_20260120_033). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260208_030-CORE | DONE | Added `stella advise ask --file` batch processing and `stella advise export` conversation history command surfaces (2026-02-08). |
|
||||
| SPRINT_20260208_033-CORE | DONE | Unknowns export schema/versioning envelope and CLI option integration completed (2026-02-08). |
|
||||
|
||||
| SPRINT_20260208_031-CORE | DONE | Compare verification overlay options, builder, and output/model integration completed (2026-02-08).
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.PolicyDsl;
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// CLI plugin module for policy DSL commands.
|
||||
/// Provides 'stella policy lint', 'stella policy compile', and 'stella policy simulate'.
|
||||
/// </summary>
|
||||
public sealed class PolicyCliCommandModule : ICliCommandModule
|
||||
{
|
||||
public string Name => "stellaops.cli.plugins.policy";
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public void RegisterCommands(
|
||||
RootCommand root,
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(verboseOption);
|
||||
|
||||
root.Add(BuildPolicyCommand(services, verboseOption, options, cancellationToken));
|
||||
}
|
||||
|
||||
private static Command BuildPolicyCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
StellaOpsCliOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = new Command("policy", "Policy DSL operations: lint, compile, and simulate.");
|
||||
|
||||
policy.Add(BuildLintCommand(services, verboseOption));
|
||||
policy.Add(BuildCompileCommand(services, verboseOption));
|
||||
policy.Add(BuildSimulateCommand(services, verboseOption));
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
private static Command BuildLintCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file to lint.");
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: text, json. Default: text"
|
||||
};
|
||||
|
||||
var lintCommand = new Command("lint", "Lint a policy DSL file for syntax and semantic errors.")
|
||||
{
|
||||
fileArg,
|
||||
outputOption
|
||||
};
|
||||
|
||||
lintCommand.SetHandler(async (file, output, verbose) =>
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found: {file.FullName}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await File.ReadAllTextAsync(file.FullName);
|
||||
var result = PolicyParser.Parse(source);
|
||||
|
||||
var outputFormat = output?.ToLowerInvariant() ?? "text";
|
||||
|
||||
if (outputFormat == "json")
|
||||
{
|
||||
var jsonResult = new
|
||||
{
|
||||
file = file.FullName,
|
||||
success = !result.Diagnostics.Any(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error),
|
||||
diagnostics = result.Diagnostics.Select(d => new
|
||||
{
|
||||
severity = d.Severity.ToString().ToLowerInvariant(),
|
||||
code = d.Code,
|
||||
message = d.Message,
|
||||
path = d.Path,
|
||||
location = d.Location is not null ? new
|
||||
{
|
||||
line = d.Location.Line,
|
||||
column = d.Location.Column
|
||||
} : null
|
||||
})
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(jsonResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
var errors = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error).ToList();
|
||||
var warnings = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Warning).ToList();
|
||||
var infos = result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Info).ToList();
|
||||
|
||||
if (errors.Count == 0 && warnings.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"✓ {file.Name}: No issues found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Linting {file.Name}:");
|
||||
foreach (var diag in result.Diagnostics.OrderBy(d => d.Location?.Line ?? 0))
|
||||
{
|
||||
var symbol = diag.Severity switch
|
||||
{
|
||||
StellaOps.Policy.PolicyIssueSeverity.Error => "✗",
|
||||
StellaOps.Policy.PolicyIssueSeverity.Warning => "⚠",
|
||||
_ => "ℹ"
|
||||
};
|
||||
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
|
||||
Console.WriteLine($" {symbol} [{diag.Code}]{location}: {diag.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Summary: {errors.Count} error(s), {warnings.Count} warning(s), {infos.Count} info(s)");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}, fileArg, outputOption, verboseOption);
|
||||
|
||||
return lintCommand;
|
||||
}
|
||||
|
||||
private static Command BuildCompileCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file to compile.");
|
||||
|
||||
var outputOption = new Option<FileInfo?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output path for compiled IR (.json). Default: stdout"
|
||||
};
|
||||
|
||||
var checksumOnlyOption = new Option<bool>("--checksum-only")
|
||||
{
|
||||
Description = "Only output the deterministic checksum."
|
||||
};
|
||||
|
||||
var compileCommand = new Command("compile", "Compile a policy DSL file to intermediate representation.")
|
||||
{
|
||||
fileArg,
|
||||
outputOption,
|
||||
checksumOnlyOption
|
||||
};
|
||||
|
||||
compileCommand.SetHandler(async (file, output, checksumOnly, verbose) =>
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: File not found: {file.FullName}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await File.ReadAllTextAsync(file.FullName);
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(source);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"Compilation failed for {file.Name}:");
|
||||
foreach (var diag in result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error))
|
||||
{
|
||||
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
|
||||
Console.Error.WriteLine($" ✗ [{diag.Code}]{location}: {diag.Message}");
|
||||
}
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (checksumOnly)
|
||||
{
|
||||
Console.WriteLine(result.Checksum);
|
||||
return;
|
||||
}
|
||||
|
||||
var irBytes = PolicyIrSerializer.Serialize(result.Document!);
|
||||
var irJson = Encoding.UTF8.GetString(irBytes.AsSpan());
|
||||
|
||||
if (output is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(output.FullName, irJson);
|
||||
Console.WriteLine($"✓ Compiled {file.Name} -> {output.Name}");
|
||||
Console.WriteLine($" Checksum: {result.Checksum}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(irJson);
|
||||
}
|
||||
}, fileArg, outputOption, checksumOnlyOption, verboseOption);
|
||||
|
||||
return compileCommand;
|
||||
}
|
||||
|
||||
private static Command BuildSimulateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
var fileArg = new Argument<FileInfo>("file", "Path to .stella policy file.");
|
||||
|
||||
var signalsOption = new Option<FileInfo?>("--signals", new[] { "-s" })
|
||||
{
|
||||
Description = "Path to signals context JSON file."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: text, json. Default: text"
|
||||
};
|
||||
|
||||
var simulateCommand = new Command("simulate", "Simulate policy evaluation with given signal context.")
|
||||
{
|
||||
fileArg,
|
||||
signalsOption,
|
||||
outputOption
|
||||
};
|
||||
|
||||
simulateCommand.SetHandler(async (file, signals, output, verbose) =>
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<PolicyCliCommandModule>>();
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Policy file not found: {file.FullName}");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await File.ReadAllTextAsync(file.FullName);
|
||||
var factory = new PolicyEngineFactory();
|
||||
var engineResult = factory.CreateFromSource(source);
|
||||
|
||||
if (engineResult.Engine is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Compilation failed. Run 'stella policy lint' for details.");
|
||||
foreach (var diag in engineResult.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error))
|
||||
{
|
||||
var location = diag.Location is not null ? $":{diag.Location.Line}:{diag.Location.Column}" : "";
|
||||
Console.Error.WriteLine($" ✗ [{diag.Code}]{location}: {diag.Message}");
|
||||
}
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load signals context
|
||||
SignalContext signalContext;
|
||||
if (signals is not null && signals.Exists)
|
||||
{
|
||||
var signalsJson = await File.ReadAllTextAsync(signals.FullName);
|
||||
var signalsDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(signalsJson);
|
||||
signalContext = new SignalContext(signalsDict ?? new Dictionary<string, object?>());
|
||||
}
|
||||
else
|
||||
{
|
||||
signalContext = new SignalContext();
|
||||
}
|
||||
|
||||
// Run simulation
|
||||
var engine = engineResult.Engine;
|
||||
var evaluationResult = engine.Evaluate(signalContext);
|
||||
|
||||
var outputFormat = output?.ToLowerInvariant() ?? "text";
|
||||
var verdict = evaluationResult.MatchedRules.Length > 0 ? "match" : "no-match";
|
||||
|
||||
if (outputFormat == "json")
|
||||
{
|
||||
var jsonResult = new
|
||||
{
|
||||
policy = evaluationResult.PolicyName,
|
||||
policyChecksum = evaluationResult.PolicyChecksum,
|
||||
verdict,
|
||||
matchedRules = evaluationResult.MatchedRules,
|
||||
actions = evaluationResult.Actions.Select(a => new
|
||||
{
|
||||
ruleName = a.RuleName,
|
||||
action = a.Action.ActionName,
|
||||
wasElseBranch = a.WasElseBranch
|
||||
})
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(jsonResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Policy: {evaluationResult.PolicyName}");
|
||||
Console.WriteLine($"Checksum: {evaluationResult.PolicyChecksum}");
|
||||
Console.WriteLine();
|
||||
|
||||
if (evaluationResult.MatchedRules.Length > 0)
|
||||
{
|
||||
Console.WriteLine($"✓ Matched Rules ({evaluationResult.MatchedRules.Length}):");
|
||||
foreach (var rule in evaluationResult.MatchedRules)
|
||||
{
|
||||
Console.WriteLine($" - {rule}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No rules matched.");
|
||||
}
|
||||
|
||||
if (evaluationResult.Actions.Length > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Actions ({evaluationResult.Actions.Length}):");
|
||||
foreach (var action in evaluationResult.Actions)
|
||||
{
|
||||
var branch = action.WasElseBranch ? " (else)" : "";
|
||||
Console.WriteLine($" - [{action.RuleName}]{branch}: {action.Action.ActionName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, fileArg, signalsOption, outputOption, verboseOption);
|
||||
|
||||
return simulateCommand;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- StellaOps.Cli.Plugins.Policy: CLI plugin for policy DSL operations (lint, compile, simulate) -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Policy\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\StellaOps.PolicyDsl\StellaOps.PolicyDsl.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Advise;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.AdviseParity.Tests;
|
||||
|
||||
public sealed class AdviseParityIsolationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public AdviseParityIsolationTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"advise-parity-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ask_WithFile_ProcessesBatchQueriesAsJson()
|
||||
{
|
||||
var chatClient = new FakeChatClient();
|
||||
chatClient.QueryResponseFactory = request => CreateQueryResponse($"resp-{request.Query.Replace(' ', '-')}", request.Query);
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(chatClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsCliOptions();
|
||||
var command = AdviseChatCommandGroup.BuildAskCommand(
|
||||
services,
|
||||
options,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var batchPath = Path.Combine(_tempRoot, "queries.jsonl");
|
||||
await File.WriteAllTextAsync(
|
||||
batchPath,
|
||||
"""
|
||||
{"query":"first question"}
|
||||
"second question"
|
||||
"""
|
||||
);
|
||||
|
||||
var output = new StringWriter();
|
||||
var original = Console.Out;
|
||||
try
|
||||
{
|
||||
Console.SetOut(output);
|
||||
await root.Parse($"ask --file \"{batchPath}\" --format json").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(original);
|
||||
}
|
||||
|
||||
var json = output.ToString();
|
||||
Assert.Contains("\"count\": 2", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"query\": \"first question\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"query\": \"second question\"", json, StringComparison.Ordinal);
|
||||
Assert.Equal(2, chatClient.QueryCalls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithoutConversationId_ListsAndExportsSortedConversations()
|
||||
{
|
||||
var chatClient = new FakeChatClient();
|
||||
chatClient.ListResponse = new ChatConversationListResponse
|
||||
{
|
||||
TotalCount = 2,
|
||||
Conversations =
|
||||
[
|
||||
new ChatConversationSummary
|
||||
{
|
||||
ConversationId = "conv-b",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-02T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-02T01:00:00Z"),
|
||||
TurnCount = 1
|
||||
},
|
||||
new ChatConversationSummary
|
||||
{
|
||||
ConversationId = "conv-a",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-01T01:00:00Z"),
|
||||
TurnCount = 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
chatClient.ConversationById["conv-a"] = CreateConversation("conv-a", "Tenant-1", "User-1", "hello a");
|
||||
chatClient.ConversationById["conv-b"] = CreateConversation("conv-b", "Tenant-1", "User-1", "hello b");
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(chatClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsCliOptions();
|
||||
var command = AdviseChatCommandGroup.BuildExportCommand(
|
||||
services,
|
||||
options,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var output = new StringWriter();
|
||||
var original = Console.Out;
|
||||
try
|
||||
{
|
||||
Console.SetOut(output);
|
||||
await root.Parse("export --format json --tenant tenant-1 --user user-1").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(original);
|
||||
}
|
||||
|
||||
var json = output.ToString();
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var rootNode = document.RootElement;
|
||||
Assert.Equal(2, rootNode.GetProperty("conversationCount").GetInt32());
|
||||
|
||||
var conversations = rootNode.GetProperty("conversations");
|
||||
Assert.Equal("conv-a", conversations[0].GetProperty("conversationId").GetString());
|
||||
Assert.Equal("conv-b", conversations[1].GetProperty("conversationId").GetString());
|
||||
Assert.Equal(2, chatClient.GetConversationCalls.Count);
|
||||
}
|
||||
|
||||
private static ChatQueryResponse CreateQueryResponse(string responseId, string summary)
|
||||
{
|
||||
return new ChatQueryResponse
|
||||
{
|
||||
ResponseId = responseId,
|
||||
Intent = "triage",
|
||||
GeneratedAt = DateTimeOffset.Parse("2026-01-15T09:30:00Z"),
|
||||
Summary = summary,
|
||||
Confidence = new ChatConfidence
|
||||
{
|
||||
Overall = 0.9,
|
||||
EvidenceQuality = 0.8,
|
||||
ModelCertainty = 0.85
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatConversationResponse CreateConversation(string id, string tenant, string user, string content)
|
||||
{
|
||||
return new ChatConversationResponse
|
||||
{
|
||||
ConversationId = id,
|
||||
TenantId = tenant,
|
||||
UserId = user,
|
||||
CreatedAt = DateTimeOffset.Parse("2026-01-15T09:30:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-01-15T09:31:00Z"),
|
||||
Turns =
|
||||
[
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = $"{id}-1",
|
||||
Role = "user",
|
||||
Content = content,
|
||||
Timestamp = DateTimeOffset.Parse("2026-01-15T09:30:00Z")
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeChatClient : IChatClient
|
||||
{
|
||||
public List<ChatQueryRequest> QueryCalls { get; } = [];
|
||||
|
||||
public List<string> GetConversationCalls { get; } = [];
|
||||
|
||||
public Func<ChatQueryRequest, ChatQueryResponse>? QueryResponseFactory { get; set; }
|
||||
|
||||
public ChatConversationListResponse ListResponse { get; set; } = new();
|
||||
|
||||
public Dictionary<string, ChatConversationResponse> ConversationById { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ChatQueryResponse> QueryAsync(ChatQueryRequest request, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
QueryCalls.Add(request);
|
||||
return Task.FromResult(QueryResponseFactory?.Invoke(request) ?? CreateQueryResponse("resp-default", request.Query));
|
||||
}
|
||||
|
||||
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ListResponse);
|
||||
|
||||
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetConversationCalls.Add(conversationId);
|
||||
return Task.FromResult(ConversationById[conversationId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/Cli/__Tests/StellaOps.Cli.AdviseParity.Tests/CompatStubs.cs
Normal file
104
src/Cli/__Tests/StellaOps.Cli.AdviseParity.Tests/CompatStubs.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
|
||||
namespace StellaOps.Cli.Configuration
|
||||
{
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
public AdvisoryAiOptions AdvisoryAi { get; } = new();
|
||||
|
||||
public sealed class AdvisoryAiOptions
|
||||
{
|
||||
public bool Configured { get; set; } = true;
|
||||
|
||||
public bool HasConfiguredProvider() => Configured;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellaOps.Cli.Services.Chat
|
||||
{
|
||||
internal class ChatException : Exception
|
||||
{
|
||||
public ChatException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatGuardrailException : ChatException
|
||||
{
|
||||
public ChatGuardrailException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
ErrorResponse = errorResponse;
|
||||
}
|
||||
|
||||
public ChatErrorResponse? ErrorResponse { get; }
|
||||
}
|
||||
|
||||
internal sealed class ChatToolDeniedException : ChatException
|
||||
{
|
||||
public ChatToolDeniedException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatQuotaExceededException : ChatException
|
||||
{
|
||||
public ChatQuotaExceededException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatServiceUnavailableException : ChatException
|
||||
{
|
||||
public ChatServiceUnavailableException(string message, ChatErrorResponse? errorResponse = null) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChatClient : IChatClient
|
||||
{
|
||||
public ChatClient(HttpClient httpClient, StellaOps.Cli.Configuration.StellaOpsCliOptions options)
|
||||
{
|
||||
}
|
||||
|
||||
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatQueryResponse> QueryAsync(ChatQueryRequest request, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
namespace System.CommandLine
|
||||
{
|
||||
// Compatibility shims for the API shape expected by AdviseChatCommandGroup.
|
||||
public static class OptionCompatExtensions
|
||||
{
|
||||
public static Option<T> SetDefaultValue<T>(this Option<T> option, T value)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
|
||||
public static Option<T> FromAmong<T>(this Option<T> option, params T[] values)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<RootNamespace>StellaOps.Cli.AdviseParity.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\Advise\AdviseChatCommandGroup.cs" Link="Commands\AdviseChatCommandGroup.cs" />
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\Advise\ChatRenderer.cs" Link="Commands\ChatRenderer.cs" />
|
||||
<Compile Include="..\..\StellaOps.Cli\Services\Chat\IChatClient.cs" Link="Services\IChatClient.cs" />
|
||||
<Compile Include="..\..\StellaOps.Cli\Services\Models\Chat\ChatModels.cs" Link="Services\ChatModels.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cli.Commands.Compare;
|
||||
|
||||
namespace StellaOps.Cli.CompareOverlay.Tests;
|
||||
|
||||
public sealed class CompareVerificationOverlayBuilderIsolationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public CompareVerificationOverlayBuilderIsolationTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"compare-overlay-isolated-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ParsesVerificationReportChecks()
|
||||
{
|
||||
var reportPath = Path.Combine(_tempRoot, "verify-report.json");
|
||||
await File.WriteAllTextAsync(
|
||||
reportPath,
|
||||
"""
|
||||
{
|
||||
"overallStatus": "PASSED_WITH_WARNINGS",
|
||||
"checks": [
|
||||
{ "name": "checksum:inputs/sbom.cdx.json", "passed": true, "message": "Hash matches", "severity": "info" },
|
||||
{ "name": "dsse:inputs/sbom.cdx.json.dsse.json", "passed": false, "message": "No signatures found", "severity": "error" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
reportPath,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(overlay);
|
||||
Assert.Equal("verification-report", overlay.Source);
|
||||
Assert.Equal("FAILED", overlay.OverallStatus);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("fail", artifact.SignatureStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ReverifyBundle_ComputesHashAndSignatureStatus()
|
||||
{
|
||||
var bundleDir = Path.Combine(_tempRoot, "bundle");
|
||||
var inputsDir = Path.Combine(bundleDir, "inputs");
|
||||
Directory.CreateDirectory(inputsDir);
|
||||
|
||||
var artifactPath = Path.Combine(inputsDir, "sbom.cdx.json");
|
||||
await File.WriteAllTextAsync(artifactPath, """{"bomFormat":"CycloneDX"}""");
|
||||
var digest = ComputeSha256Hex(await File.ReadAllTextAsync(artifactPath));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "manifest.json"),
|
||||
$$"""
|
||||
{
|
||||
"bundle": {
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "inputs/sbom.cdx.json",
|
||||
"digest": "sha256:{{digest}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(inputsDir, "sbom.cdx.json.dsse.json"),
|
||||
"""
|
||||
{
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": [
|
||||
{ "keyid": "test-key", "sig": "dGVzdA==" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: bundleDir,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(overlay);
|
||||
Assert.True(overlay.Reverified);
|
||||
Assert.Equal("reverify-bundle", overlay.Source);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("pass", artifact.SignatureStatus);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_AttachesDeterminismManifestSummary()
|
||||
{
|
||||
var manifestPath = Path.Combine(_tempRoot, "determinism.json");
|
||||
await File.WriteAllTextAsync(
|
||||
manifestPath,
|
||||
"""
|
||||
{
|
||||
"overall_score": 0.973,
|
||||
"thresholds": {
|
||||
"overall_min": 0.950
|
||||
},
|
||||
"images": [
|
||||
{ "digest": "sha256:aaa" },
|
||||
{ "digest": "sha256:bbb" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: manifestPath,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(overlay);
|
||||
Assert.NotNull(overlay.Determinism);
|
||||
Assert.Equal("determinism-manifest", overlay.Source);
|
||||
Assert.Equal(0.973, overlay.Determinism.OverallScore);
|
||||
Assert.Equal(0.950, overlay.Determinism.Threshold);
|
||||
Assert.Equal("pass", overlay.Determinism.Status);
|
||||
Assert.Equal(2, overlay.Determinism.ImageCount);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<RootNamespace>StellaOps.Cli.CompareOverlay.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\Compare\CompareVerificationOverlayBuilder.cs" Link="Compare\CompareVerificationOverlayBuilder.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -2,12 +2,17 @@
|
||||
// Licensed under the BUSL-1.1 license.
|
||||
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Advise;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Chat;
|
||||
using StellaOps.Cli.Services.Models.Chat;
|
||||
using Xunit;
|
||||
|
||||
@@ -252,6 +257,124 @@ public sealed class AdviseChatCommandTests
|
||||
Assert.Contains("Query: What vulnerabilities affect my image?", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderConversationExport_Json_RendersConversationsPayload()
|
||||
{
|
||||
var export = CreateSampleConversationExport();
|
||||
var sb = new StringBuilder();
|
||||
await using var writer = new StringWriter(sb);
|
||||
|
||||
await ChatRenderer.RenderConversationExportAsync(export, ChatOutputFormat.Json, writer, CancellationToken.None);
|
||||
var output = sb.ToString();
|
||||
|
||||
Assert.Contains("\"conversationCount\"", output);
|
||||
Assert.Contains("\"conv-001\"", output);
|
||||
Assert.Contains("\"turns\"", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAskCommand_AllowsFileOptionWithoutQuery()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var command = AdviseChatCommandGroup.BuildAskCommand(
|
||||
services,
|
||||
new StellaOpsCliOptions(),
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
var parseResult = command.Parse("--file queries.jsonl");
|
||||
Assert.Empty(parseResult.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportCommand_HasExpectedOptions()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var command = AdviseChatCommandGroup.BuildExportCommand(
|
||||
services,
|
||||
new StellaOpsCliOptions(),
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("export", command.Name);
|
||||
Assert.Contains(command.Options, static option => option.Name == "--conversation-id");
|
||||
Assert.Contains(command.Options, static option => option.Name == "--limit");
|
||||
Assert.Contains(command.Options, static option => option.Name == "--format");
|
||||
Assert.Contains(command.Options, static option => option.Name == "--output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AskCommand_FileBatch_InvokesClientAndWritesJson()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"advise-batch-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var batchPath = Path.Combine(tempDir, "queries.jsonl");
|
||||
var outputPath = Path.Combine(tempDir, "output.json");
|
||||
await File.WriteAllTextAsync(batchPath, """
|
||||
{"query":"What changed in stage?"}
|
||||
"List high severity CVEs"
|
||||
""");
|
||||
|
||||
var fakeClient = new FakeChatClient();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(fakeClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
AdvisoryAi = new StellaOpsCliAdvisoryAiOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OpenAi = new StellaOpsCliLlmProviderOptions { ApiKey = "test-key" }
|
||||
}
|
||||
};
|
||||
|
||||
var command = AdviseChatCommandGroup.BuildAskCommand(
|
||||
services,
|
||||
options,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
var parseResult = command.Parse($"--file \"{batchPath}\" --format json --output \"{outputPath}\"");
|
||||
var exitCode = await parseResult.InvokeAsync();
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(2, fakeClient.Queries.Count);
|
||||
using var outputJson = JsonDocument.Parse(await File.ReadAllTextAsync(outputPath));
|
||||
Assert.Equal(2, outputJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.Contains("What changed in stage?", outputJson.RootElement.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportCommand_UsesConversationEndpointsAndWritesJson()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"advise-export-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var outputPath = Path.Combine(tempDir, "conversation-export.json");
|
||||
|
||||
var fakeClient = new FakeChatClient();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IChatClient>(fakeClient)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var command = AdviseChatCommandGroup.BuildExportCommand(
|
||||
services,
|
||||
new StellaOpsCliOptions(),
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
|
||||
var parseResult = command.Parse($"--tenant tenant-001 --user user-001 --format json --output \"{outputPath}\"");
|
||||
var exitCode = await parseResult.InvokeAsync();
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(1, fakeClient.ListCalls);
|
||||
Assert.Equal(1, fakeClient.GetCalls);
|
||||
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
Assert.Contains("\"conversationCount\": 1", json);
|
||||
Assert.Contains("\"conversationId\": \"conv-001\"", json);
|
||||
}
|
||||
|
||||
private static ChatQueryResponse CreateSampleQueryResponse()
|
||||
{
|
||||
return new ChatQueryResponse
|
||||
@@ -401,4 +524,136 @@ public sealed class AdviseChatCommandTests
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatConversationExport CreateSampleConversationExport()
|
||||
{
|
||||
return new ChatConversationExport
|
||||
{
|
||||
GeneratedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
TenantId = "tenant-001",
|
||||
UserId = "user-001",
|
||||
ConversationCount = 1,
|
||||
Conversations =
|
||||
[
|
||||
new ChatConversationResponse
|
||||
{
|
||||
ConversationId = "conv-001",
|
||||
TenantId = "tenant-001",
|
||||
UserId = "user-001",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
|
||||
Turns =
|
||||
[
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-1",
|
||||
Role = "user",
|
||||
Content = "What changed?",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:10Z")
|
||||
},
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-2",
|
||||
Role = "assistant",
|
||||
Content = "Two vulnerabilities were remediated.",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:20Z")
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeChatClient : IChatClient
|
||||
{
|
||||
public List<string> Queries { get; } = [];
|
||||
|
||||
public int ListCalls { get; private set; }
|
||||
|
||||
public int GetCalls { get; private set; }
|
||||
|
||||
public Task<ChatQueryResponse> QueryAsync(
|
||||
ChatQueryRequest request,
|
||||
string? tenantId = null,
|
||||
string? userId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queries.Add(request.Query);
|
||||
return Task.FromResult(new ChatQueryResponse
|
||||
{
|
||||
ResponseId = $"resp-{Queries.Count}",
|
||||
Intent = "test",
|
||||
GeneratedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
Summary = $"Handled {request.Query}",
|
||||
Confidence = new ChatConfidence
|
||||
{
|
||||
Overall = 1,
|
||||
EvidenceQuality = 1,
|
||||
ModelCertainty = 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ChatDoctorResponse> GetDoctorAsync(string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> GetSettingsAsync(string scope = "effective", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatSettingsResponse> UpdateSettingsAsync(ChatSettingsUpdateRequest request, string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task ClearSettingsAsync(string scope = "user", string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ChatConversationListResponse> ListConversationsAsync(string? tenantId = null, string? userId = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ListCalls++;
|
||||
return Task.FromResult(new ChatConversationListResponse
|
||||
{
|
||||
TotalCount = 1,
|
||||
Conversations =
|
||||
[
|
||||
new ChatConversationSummary
|
||||
{
|
||||
ConversationId = "conv-001",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
|
||||
TurnCount = 2,
|
||||
Preview = "What changed?"
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ChatConversationResponse> GetConversationAsync(string conversationId, string? tenantId = null, string? userId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetCalls++;
|
||||
return Task.FromResult(new ChatConversationResponse
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
TenantId = tenantId ?? "tenant-001",
|
||||
UserId = userId ?? "user-001",
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-08T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-08T00:01:00Z"),
|
||||
Turns =
|
||||
[
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-1",
|
||||
Role = "user",
|
||||
Content = "What changed?",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:10Z")
|
||||
},
|
||||
new ChatConversationTurn
|
||||
{
|
||||
TurnId = "turn-2",
|
||||
Role = "assistant",
|
||||
Content = "Two updates were deployed.",
|
||||
Timestamp = DateTimeOffset.Parse("2026-02-08T00:00:20Z")
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,17 @@ public sealed class CommandFactoryTests
|
||||
Assert.Contains(sbom.Subcommands, command => string.Equals(command.Name, "upload", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesAdviseExportCommand()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var advise = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "advise", StringComparison.Ordinal));
|
||||
Assert.Contains(advise.Subcommands, command => string.Equals(command.Name, "export", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesTimestampCommands()
|
||||
{
|
||||
|
||||
@@ -204,6 +204,51 @@ public class CompareCommandTests
|
||||
Assert.NotNull(backendUrlOption);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_HasVerificationReportOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
|
||||
|
||||
// Act
|
||||
var option = diffCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "--verification-report" || o.Aliases.Contains("--verification-report"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(option);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_HasReverifyBundleOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
|
||||
|
||||
// Act
|
||||
var option = diffCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "--reverify-bundle" || o.Aliases.Contains("--reverify-bundle"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(option);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_HasDeterminismManifestOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var diffCommand = command.Subcommands.First(c => c.Name == "diff");
|
||||
|
||||
// Act
|
||||
var option = diffCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "--determinism-manifest" || o.Aliases.Contains("--determinism-manifest"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(option);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parse Tests
|
||||
@@ -307,7 +352,7 @@ public class CompareCommandTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareDiff_FailsWithoutBase()
|
||||
public void CompareDiff_ParsesWithoutBase()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
@@ -317,7 +362,22 @@ public class CompareCommandTests
|
||||
var result = root.Parse("compare diff -t sha256:def456");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareDiff_ParsesWithVerificationOverlayOptions()
|
||||
{
|
||||
// Arrange
|
||||
var command = CompareCommandBuilder.BuildCompareCommand(_services, _verboseOption, _cancellationToken);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
// Act
|
||||
var result = root.Parse(
|
||||
"compare diff -b sha256:abc123 -t sha256:def456 --verification-report verify.json --reverify-bundle ./bundle --determinism-manifest determinism.json");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Cli.Commands.Compare;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class CompareVerificationOverlayBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public CompareVerificationOverlayBuilderTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"compare-overlay-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ParsesVerificationReportChecks()
|
||||
{
|
||||
// Arrange
|
||||
var reportPath = Path.Combine(_tempRoot, "verify-report.json");
|
||||
await File.WriteAllTextAsync(
|
||||
reportPath,
|
||||
"""
|
||||
{
|
||||
"overallStatus": "PASSED_WITH_WARNINGS",
|
||||
"checks": [
|
||||
{ "name": "checksum:inputs/sbom.cdx.json", "passed": true, "message": "Hash matches", "severity": "info" },
|
||||
{ "name": "dsse:inputs/sbom.cdx.json.dsse.json", "passed": false, "message": "No signatures found", "severity": "warning" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
reportPath,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(overlay);
|
||||
Assert.Equal("verification-report", overlay.Source);
|
||||
Assert.Equal("PASSED_WITH_WARNINGS", overlay.OverallStatus);
|
||||
Assert.Single(overlay.Artifacts);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("warning", artifact.SignatureStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_ReverifyBundle_ComputesHashAndSignatureStatus()
|
||||
{
|
||||
// Arrange
|
||||
var bundleDir = Path.Combine(_tempRoot, "bundle");
|
||||
var inputsDir = Path.Combine(bundleDir, "inputs");
|
||||
Directory.CreateDirectory(inputsDir);
|
||||
|
||||
var artifactPath = Path.Combine(inputsDir, "sbom.cdx.json");
|
||||
await File.WriteAllTextAsync(artifactPath, """{"bomFormat":"CycloneDX"}""");
|
||||
var digest = ComputeSha256Hex(await File.ReadAllTextAsync(artifactPath));
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "manifest.json"),
|
||||
$$"""
|
||||
{
|
||||
"bundle": {
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "inputs/sbom.cdx.json",
|
||||
"digest": "sha256:{{digest}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(inputsDir, "sbom.cdx.json.dsse.json"),
|
||||
"""
|
||||
{
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": [
|
||||
{ "keyid": "test-key", "sig": "dGVzdA==" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: bundleDir,
|
||||
determinismManifestPath: null,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(overlay);
|
||||
Assert.True(overlay.Reverified);
|
||||
Assert.Equal("reverify-bundle", overlay.Source);
|
||||
Assert.Single(overlay.Artifacts);
|
||||
|
||||
var artifact = Assert.Single(overlay.Artifacts);
|
||||
Assert.Equal("inputs/sbom.cdx.json", artifact.Artifact);
|
||||
Assert.Equal("pass", artifact.HashStatus);
|
||||
Assert.Equal("pass", artifact.SignatureStatus);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildAsync_AttachesDeterminismManifestSummary()
|
||||
{
|
||||
// Arrange
|
||||
var manifestPath = Path.Combine(_tempRoot, "determinism.json");
|
||||
await File.WriteAllTextAsync(
|
||||
manifestPath,
|
||||
"""
|
||||
{
|
||||
"overall_score": 0.973,
|
||||
"thresholds": {
|
||||
"overall_min": 0.950
|
||||
},
|
||||
"images": [
|
||||
{ "digest": "sha256:aaa" },
|
||||
{ "digest": "sha256:bbb" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
// Act
|
||||
var overlay = await CompareVerificationOverlayBuilder.BuildAsync(
|
||||
verificationReportPath: null,
|
||||
reverifyBundlePath: null,
|
||||
determinismManifestPath: manifestPath,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(overlay);
|
||||
Assert.NotNull(overlay.Determinism);
|
||||
Assert.Equal("determinism-manifest", overlay.Source);
|
||||
Assert.Equal(0.973, overlay.Determinism.OverallScore);
|
||||
Assert.Equal(0.950, overlay.Determinism.Threshold);
|
||||
Assert.Equal("pass", overlay.Determinism.Status);
|
||||
Assert.Equal(2, overlay.Determinism.ImageCount);
|
||||
Assert.Equal("PASSED", overlay.OverallStatus);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EvidenceReferrerCommandTests.cs
|
||||
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
|
||||
// Description: Unit tests for push-referrer and list-referrers CLI commands.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EvidenceReferrerCommandTests
|
||||
{
|
||||
// ── ParseImageReference ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseImageReference_WithDigest_ParsesCorrectly()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseImageReference(
|
||||
"registry.example.com/repo/image@sha256:abcdef1234567890");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Registry.Should().Be("registry.example.com");
|
||||
result.Repository.Should().Be("repo/image");
|
||||
result.Digest.Should().Be("sha256:abcdef1234567890");
|
||||
result.Tag.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageReference_NoSlash_ReturnsNull()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseImageReference("noslash");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseImageReference_WithTag_ParsesCorrectly()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseImageReference(
|
||||
"registry.example.com/repo:latest");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Registry.Should().Be("registry.example.com");
|
||||
result.Repository.Should().Be("repo");
|
||||
result.Tag.Should().Be("latest");
|
||||
}
|
||||
|
||||
// ── ParseAnnotations ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_Null_ReturnsEmpty()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(null);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_ValidPairs_ParsesAll()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(
|
||||
["key1=value1", "key2=value2"]);
|
||||
|
||||
result.Should().HaveCount(2);
|
||||
result["key1"].Should().Be("value1");
|
||||
result["key2"].Should().Be("value2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_NoEquals_Skipped()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(
|
||||
["valid=yes", "noequalssign"]);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result["valid"].Should().Be("yes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAnnotations_ValueWithEquals_PreservesFullValue()
|
||||
{
|
||||
var result = EvidenceReferrerCommands.ParseAnnotations(
|
||||
["key=value=with=equals"]);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result["key"].Should().Be("value=with=equals");
|
||||
}
|
||||
|
||||
// ── BuildReferrerManifest ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildReferrerManifest_ProducesValidOciManifest()
|
||||
{
|
||||
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
|
||||
"application/vnd.stellaops.verdict.attestation.v1+json",
|
||||
"sha256:deadbeef",
|
||||
1024,
|
||||
"sha256:cafebabe",
|
||||
new Dictionary<string, string> { ["key"] = "value" });
|
||||
|
||||
manifest.SchemaVersion.Should().Be(2);
|
||||
manifest.MediaType.Should().Be("application/vnd.oci.image.manifest.v2+json");
|
||||
manifest.ArtifactType.Should().Be("application/vnd.stellaops.verdict.attestation.v1+json");
|
||||
manifest.Layers.Should().HaveCount(1);
|
||||
manifest.Layers[0].Digest.Should().Be("sha256:deadbeef");
|
||||
manifest.Layers[0].Size.Should().Be(1024);
|
||||
manifest.Subject.Should().NotBeNull();
|
||||
manifest.Subject!.Digest.Should().Be("sha256:cafebabe");
|
||||
manifest.Config.Should().NotBeNull();
|
||||
manifest.Config!.MediaType.Should().Be("application/vnd.oci.empty.v1+json");
|
||||
manifest.Annotations.Should().ContainKey("key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReferrerManifest_NoAnnotations_NullAnnotations()
|
||||
{
|
||||
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
|
||||
"test/type", "sha256:abc", 100, "sha256:def",
|
||||
new Dictionary<string, string>());
|
||||
|
||||
manifest.Annotations.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReferrerManifest_SerializesToValidJson()
|
||||
{
|
||||
var manifest = EvidenceReferrerCommands.BuildReferrerManifest(
|
||||
OciMediaTypes.SbomAttestation, "sha256:1234", 2048, "sha256:5678",
|
||||
new Dictionary<string, string>());
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("schemaVersion").GetInt32().Should().Be(2);
|
||||
doc.RootElement.GetProperty("artifactType").GetString()
|
||||
.Should().Be(OciMediaTypes.SbomAttestation);
|
||||
doc.RootElement.GetProperty("layers").GetArrayLength().Should().Be(1);
|
||||
}
|
||||
|
||||
// ── HandleOfflinePush ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflinePush_ReturnsZero()
|
||||
{
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflinePush(
|
||||
"registry/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
"/tmp/artifact.json",
|
||||
"sha256:deadbeef",
|
||||
1024,
|
||||
new Dictionary<string, string>());
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
// ── HandleOfflineList ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflineList_TableFormat_ReturnsZero()
|
||||
{
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
|
||||
"registry/repo@sha256:abc", null, null, "table");
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflineList_JsonFormat_ReturnsZero()
|
||||
{
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
|
||||
"registry/repo@sha256:abc", null, null, "json");
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleOfflineList_FilterByArtifactType_FiltersResults()
|
||||
{
|
||||
// Simulate by checking the offline handler doesn't crash with filter
|
||||
var exitCode = EvidenceReferrerCommands.HandleOfflineList(
|
||||
"registry/repo@sha256:abc",
|
||||
null,
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
"table");
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
// ── ExecutePushReferrerAsync ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_FileNotFound_ReturnsError()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
"/nonexistent/file.json",
|
||||
null,
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_OfflineMode_ReturnsSuccess()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(tempDir, "evidence.json");
|
||||
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
filePath,
|
||||
["org.opencontainers.image.created=2026-01-01"],
|
||||
offline: true,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_NoOciClient_ReturnsError()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(tempDir, "evidence.json");
|
||||
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
|
||||
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
filePath,
|
||||
null,
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1); // No IOciRegistryClient registered
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecutePushReferrer_InvalidImageRef_ReturnsError()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(tempDir, "evidence.json");
|
||||
await File.WriteAllTextAsync(filePath, """{"type":"test"}""");
|
||||
|
||||
// Register a mock OCI client so we get past the null check
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IOciRegistryClient>(new FakeOciClient())
|
||||
.BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecutePushReferrerAsync(
|
||||
services,
|
||||
"noslash", // invalid — no registry/repo split
|
||||
OciMediaTypes.VerdictAttestation,
|
||||
filePath,
|
||||
null,
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ExecuteListReferrersAsync ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteListReferrers_OfflineMode_ReturnsSuccess()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
null, null, "table",
|
||||
offline: true,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteListReferrers_NoOciClient_ReturnsError()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
|
||||
services,
|
||||
"registry.example.com/repo@sha256:abc",
|
||||
null, null, "table",
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteListReferrers_InvalidImageRef_ReturnsError()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IOciRegistryClient>(new FakeOciClient())
|
||||
.BuildServiceProvider();
|
||||
var logger = NullLogger.Instance;
|
||||
|
||||
var exitCode = await EvidenceReferrerCommands.ExecuteListReferrersAsync(
|
||||
services,
|
||||
"noslash",
|
||||
null, null, "table",
|
||||
offline: false,
|
||||
verbose: false,
|
||||
logger,
|
||||
CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
// ── Fake OCI client for testing ────────────────────────────────────
|
||||
|
||||
private sealed class FakeOciClient : IOciRegistryClient
|
||||
{
|
||||
public Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult("sha256:fakedigest0000000000000000000000000000000000000000000000000000");
|
||||
|
||||
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult("sha256:fakedigest0000000000000000000000000000000000000000000000000000");
|
||||
|
||||
public Task<OciReferrersResponse> ListReferrersAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new OciReferrersResponse());
|
||||
|
||||
public Task<IReadOnlyList<OciReferrerDescriptor>> GetReferrersAsync(string registry, string repository, string digest, string? artifactType = null, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<OciReferrerDescriptor>>([]);
|
||||
|
||||
public Task<OciManifest> GetManifestAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new OciManifest());
|
||||
|
||||
public Task<byte[]> GetBlobAsync(OciImageReference reference, string digest, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Array.Empty<byte>());
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,17 @@ public class Sprint3500_0004_0001_CommandTests
|
||||
Assert.NotNull(resolveCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsCommand_HasExportSubcommand()
|
||||
{
|
||||
// Act
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
|
||||
var exportCommand = command.Subcommands.FirstOrDefault(c => c.Name == "export");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(exportCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsList_ParsesWithBandOption()
|
||||
{
|
||||
@@ -279,6 +290,34 @@ public class Sprint3500_0004_0001_CommandTests
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsExport_ParsesSchemaVersionOption()
|
||||
{
|
||||
// Arrange
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
// Act
|
||||
var result = root.Parse("unknowns export --format json --schema-version unknowns.export.v2");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsExport_InvalidFormat_ReturnsParseError()
|
||||
{
|
||||
// Arrange
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, _verboseOption, _cancellationToken);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
// Act
|
||||
var result = root.Parse("unknowns export --format xml");
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ScanGraphCommandGroup Tests
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -259,6 +260,135 @@ public class UnknownsGreyQueueCommandTests
|
||||
return value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsExport_Json_IncludesSchemaEnvelopeAndDeterministicMetadata()
|
||||
{
|
||||
// Arrange
|
||||
SetupPolicyUnknownsResponse("""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"packageId": "pkg:npm/a",
|
||||
"packageVersion": "1.0.0",
|
||||
"band": "warm",
|
||||
"score": 65.5,
|
||||
"reasonCode": "Reachability",
|
||||
"reasonCodeShort": "U-RCH",
|
||||
"firstSeenAt": "2026-01-10T12:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-15T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"packageId": "pkg:npm/b",
|
||||
"packageVersion": "2.0.0",
|
||||
"band": "hot",
|
||||
"score": 90.0,
|
||||
"reasonCode": "PolicyConflict",
|
||||
"reasonCodeShort": "U-POL",
|
||||
"firstSeenAt": "2026-01-09T12:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-15T09:30:00Z"
|
||||
}
|
||||
],
|
||||
"totalCount": 2
|
||||
}
|
||||
""");
|
||||
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns export --format json --schema-version unknowns.export.v2").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
using var doc = JsonDocument.Parse(writer.ToString());
|
||||
var rootElement = doc.RootElement;
|
||||
|
||||
Assert.Equal("unknowns.export.v2", rootElement.GetProperty("schemaVersion").GetString());
|
||||
Assert.Equal(2, rootElement.GetProperty("itemCount").GetInt32());
|
||||
|
||||
var exportedAt = rootElement.GetProperty("exportedAt").GetDateTimeOffset();
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-01-15T09:30:00+00:00"), exportedAt);
|
||||
|
||||
var items = rootElement.GetProperty("items");
|
||||
Assert.Equal(2, items.GetArrayLength());
|
||||
Assert.Equal("hot", items[0].GetProperty("band").GetString());
|
||||
Assert.Equal("warm", items[1].GetProperty("band").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsExport_Csv_IncludesSchemaHeaderLine()
|
||||
{
|
||||
// Arrange
|
||||
SetupPolicyUnknownsResponse("""
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"packageId": "pkg:npm/c",
|
||||
"packageVersion": "3.0.0",
|
||||
"band": "cold",
|
||||
"score": 10.0,
|
||||
"reasonCode": "None",
|
||||
"reasonCodeShort": "U-NA",
|
||||
"firstSeenAt": "2026-01-01T00:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"totalCount": 1
|
||||
}
|
||||
""");
|
||||
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(_services, new Option<bool>("--verbose"), CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
using var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns export --format csv --schema-version unknowns.export.v9").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, exitCode);
|
||||
var output = writer.ToString();
|
||||
Assert.Contains("# schema_version=unknowns.export.v9;", output);
|
||||
Assert.Contains("item_count=1", output);
|
||||
Assert.Contains("id,package_id,package_version,band,score", output);
|
||||
}
|
||||
|
||||
private void SetupPolicyUnknownsResponse(string json)
|
||||
{
|
||||
_httpHandlerMock
|
||||
.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(request =>
|
||||
request.Method == HttpMethod.Get &&
|
||||
request.RequestUri != null &&
|
||||
request.RequestUri.ToString().Contains("/api/v1/policy/unknowns", StringComparison.Ordinal)),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
});
|
||||
}
|
||||
|
||||
// Test DTOs matching the CLI internal types
|
||||
private sealed record TestUnknownsSummaryResponse
|
||||
{
|
||||
|
||||
193
src/Cli/__Tests/StellaOps.Cli.Tests/PolicyCliIntegrationTests.cs
Normal file
193
src/Cli/__Tests/StellaOps.Cli.Tests/PolicyCliIntegrationTests.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Policy.Tests;
|
||||
|
||||
public class PolicyCliIntegrationTests
|
||||
{
|
||||
private const string ValidPolicySource = @"
|
||||
policy ""Test Policy"" syntax ""stella-dsl@1"" {
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
version: ""1.0.0""
|
||||
}
|
||||
|
||||
settings {
|
||||
default_action: ""allow""
|
||||
}
|
||||
|
||||
rule allow_all (10) {
|
||||
when true
|
||||
then {
|
||||
allow()
|
||||
}
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
private const string InvalidPolicySource = @"
|
||||
policy ""Invalid Policy""
|
||||
// Missing syntax declaration
|
||||
{
|
||||
metadata {
|
||||
author: ""test@example.com""
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
[Fact]
|
||||
public void PolicyParser_ParsesValidPolicy_ReturnsSuccess()
|
||||
{
|
||||
// Act
|
||||
var result = PolicyParser.Parse(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error)
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyParser_ParsesInvalidPolicy_ReturnsDiagnostics()
|
||||
{
|
||||
// Act
|
||||
var result = PolicyParser.Parse(InvalidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Diagnostics.Where(d => d.Severity == StellaOps.Policy.PolicyIssueSeverity.Error)
|
||||
.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCompiler_CompilesValidPolicy_ReturnsChecksum()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
|
||||
// Act
|
||||
var result = compiler.Compile(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Checksum.Should().NotBeNullOrEmpty();
|
||||
result.Checksum.Should().HaveLength(64); // SHA-256 hex
|
||||
result.Document.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCompiler_CompilesInvalidPolicy_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
|
||||
// Act
|
||||
var result = compiler.Compile(InvalidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyCompiler_IsDeterministic_SameInputProducesSameChecksum()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
|
||||
// Act
|
||||
var result1 = compiler.Compile(ValidPolicySource);
|
||||
var result2 = compiler.Compile(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result1.Success.Should().BeTrue();
|
||||
result2.Success.Should().BeTrue();
|
||||
result1.Checksum.Should().Be(result2.Checksum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyEngineFactory_CreatesEngineFromSource()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEngineFactory();
|
||||
|
||||
// Act
|
||||
var result = factory.CreateFromSource(ValidPolicySource);
|
||||
|
||||
// Assert
|
||||
result.Engine.Should().NotBeNull();
|
||||
result.Engine!.Name.Should().Be("Test Policy");
|
||||
result.Engine.Syntax.Should().Be("stella-dsl@1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyEngine_EvaluatesAgainstEmptyContext()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEngineFactory();
|
||||
var engineResult = factory.CreateFromSource(ValidPolicySource);
|
||||
var engine = engineResult.Engine!;
|
||||
var context = new SignalContext();
|
||||
|
||||
// Act
|
||||
var result = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
result.PolicyName.Should().Be("Test Policy");
|
||||
result.PolicyChecksum.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyEngine_EvaluatesAgainstSignalContext()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEngineFactory();
|
||||
var engineResult = factory.CreateFromSource(ValidPolicySource);
|
||||
var engine = engineResult.Engine!;
|
||||
var context = new SignalContext()
|
||||
.SetSignal("cvss.score", 8.5)
|
||||
.SetSignal("cve.reachable", true);
|
||||
|
||||
// Act
|
||||
var result = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.MatchedRules.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalContext_StoresAndRetrievesValues()
|
||||
{
|
||||
// Arrange
|
||||
var context = new SignalContext();
|
||||
|
||||
// Act
|
||||
context.SetSignal("test.string", "hello");
|
||||
context.SetSignal("test.number", 42);
|
||||
context.SetSignal("test.boolean", true);
|
||||
|
||||
// Assert
|
||||
context.HasSignal("test.string").Should().BeTrue();
|
||||
context.GetSignal<string>("test.string").Should().Be("hello");
|
||||
context.GetSignal<int>("test.number").Should().Be(42);
|
||||
context.GetSignal<bool>("test.boolean").Should().BeTrue();
|
||||
context.HasSignal("nonexistent").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyIrSerializer_SerializesToDeterministicBytes()
|
||||
{
|
||||
// Arrange
|
||||
var compiler = new PolicyCompiler();
|
||||
var result = compiler.Compile(ValidPolicySource);
|
||||
|
||||
// Act
|
||||
var bytes1 = PolicyIrSerializer.Serialize(result.Document!);
|
||||
var bytes2 = PolicyIrSerializer.Serialize(result.Document!);
|
||||
|
||||
// Assert
|
||||
bytes1.SequenceEqual(bytes2).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BaselineResolverTests.cs
|
||||
// Sprint: SPRINT_20260208_029_Cli_baseline_selection_logic
|
||||
// Description: Unit tests for baseline resolution service.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Services;
|
||||
|
||||
public sealed class BaselineResolverTests
|
||||
{
|
||||
private readonly IForensicSnapshotClient _forensicClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<BaselineResolver> _logger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly BaselineResolver _resolver;
|
||||
|
||||
public BaselineResolverTests()
|
||||
{
|
||||
_forensicClient = Substitute.For<IForensicSnapshotClient>();
|
||||
_options = new StellaOpsCliOptions { DefaultTenant = "test-tenant" };
|
||||
_logger = Substitute.For<ILogger<BaselineResolver>>();
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
_resolver = new BaselineResolver(
|
||||
_forensicClient,
|
||||
_options,
|
||||
_logger,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExplicitStrategy_ReturnsProvidedDigest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp@sha256:abc123",
|
||||
Strategy = BaselineStrategy.Explicit,
|
||||
ExplicitDigest = "sha256:cafebabe"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("sha256:cafebabe", result.Digest);
|
||||
Assert.Equal(BaselineStrategy.Explicit, result.Strategy);
|
||||
Assert.Null(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExplicitStrategy_WithoutDigest_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp@sha256:abc123",
|
||||
Strategy = BaselineStrategy.Explicit,
|
||||
ExplicitDigest = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("requires a digest", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_LastGreen_ReturnsLatestPassingSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen
|
||||
};
|
||||
|
||||
var snapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Tags = ["verdict:pass", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-001",
|
||||
Digest = "sha256:lastgreen123"
|
||||
}
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [snapshot],
|
||||
Total = 1
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("sha256:lastgreen123", result.Digest);
|
||||
Assert.Equal(BaselineStrategy.LastGreen, result.Strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_LastGreen_NoPassingSnapshots_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [],
|
||||
Total = 0
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("No passing snapshot found", result.Error);
|
||||
Assert.NotNull(result.Suggestion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreviousRelease_ReturnsOlderRelease()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.PreviousRelease,
|
||||
CurrentVersion = "v2.0.0"
|
||||
};
|
||||
|
||||
var v2Snapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-002",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Tags = ["release:true", "version:v2.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-002",
|
||||
Digest = "sha256:v2digest"
|
||||
}
|
||||
};
|
||||
|
||||
var v1Snapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
Tags = ["release:true", "version:v1.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-001",
|
||||
Digest = "sha256:v1digest"
|
||||
}
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [v2Snapshot, v1Snapshot],
|
||||
Total = 2
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("sha256:v1digest", result.Digest);
|
||||
Assert.Equal(BaselineStrategy.PreviousRelease, result.Strategy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PreviousRelease_NoReleases_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.PreviousRelease
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse
|
||||
{
|
||||
Snapshots = [],
|
||||
Total = 0
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("No release snapshots found", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ClientException_ReturnsFalseWithError()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new HttpRequestException("Connection refused"));
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Digest);
|
||||
Assert.Contains("Connection refused", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_ReturnsSuggestionsFromMultipleSources()
|
||||
{
|
||||
// Arrange
|
||||
var passingSnapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-pass-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Tags = ["verdict:pass", "version:v1.0.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-001",
|
||||
Digest = "sha256:passing123"
|
||||
}
|
||||
};
|
||||
|
||||
var releaseSnapshot = new ForensicSnapshotDocument
|
||||
{
|
||||
SnapshotId = "snap-rel-001",
|
||||
CaseId = "case-001",
|
||||
Tenant = "test-tenant",
|
||||
Status = ForensicSnapshotStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
Tags = ["release:true", "version:v0.9.0", "artifact:pkg%3Aoci%2Fmyapp"],
|
||||
Manifest = new ForensicSnapshotManifest
|
||||
{
|
||||
ManifestId = "manifest-002",
|
||||
Digest = "sha256:release123"
|
||||
}
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new ForensicSnapshotListResponse { Snapshots = [passingSnapshot], Total = 1 },
|
||||
new ForensicSnapshotListResponse { Snapshots = [releaseSnapshot], Total = 1 });
|
||||
|
||||
// Act
|
||||
var suggestions = await _resolver.GetSuggestionsAsync("pkg:oci/myapp");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, suggestions.Count);
|
||||
Assert.Contains(suggestions, s => s.Digest == "sha256:passing123" && s.RecommendedStrategy == BaselineStrategy.LastGreen);
|
||||
Assert.Contains(suggestions, s => s.Digest == "sha256:release123" && s.RecommendedStrategy == BaselineStrategy.PreviousRelease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UsesDefaultTenant_WhenNotProvided()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen,
|
||||
TenantId = null
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse { Snapshots = [], Total = 0 });
|
||||
|
||||
// Act
|
||||
await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
await _forensicClient.Received(1).ListSnapshotsAsync(
|
||||
Arg.Is<ForensicSnapshotListQuery>(q => q.Tenant == "test-tenant"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UsesTenantFromRequest_WhenProvided()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = BaselineStrategy.LastGreen,
|
||||
TenantId = "custom-tenant"
|
||||
};
|
||||
|
||||
_forensicClient
|
||||
.ListSnapshotsAsync(Arg.Any<ForensicSnapshotListQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ForensicSnapshotListResponse { Snapshots = [], Total = 0 });
|
||||
|
||||
// Act
|
||||
await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
await _forensicClient.Received(1).ListSnapshotsAsync(
|
||||
Arg.Is<ForensicSnapshotListQuery>(q => q.Tenant == "custom-tenant"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UnknownStrategy_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var request = new BaselineResolutionRequest
|
||||
{
|
||||
ArtifactId = "pkg:oci/myapp",
|
||||
Strategy = (BaselineStrategy)99 // Invalid strategy
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _resolver.ResolveAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Unknown baseline strategy", result.Error);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Spectre.Console.Testing" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# CLI Tests Task Board
|
||||
# CLI Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
@@ -34,3 +34,14 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| ATT-005 | DONE | SPRINT_20260119_010 - Timestamp CLI workflow tests added. |
|
||||
| TASK-032-004 | DONE | SPRINT_20260120_032 - Analytics CLI tests and fixtures added. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260208_030-TESTS | DONE | Added isolated advise parity validation in StellaOps.Cli.AdviseParity.Tests; command passed (2 tests, 2026-02-08).
|
||||
| SPRINT_20260208_033-TESTS | DONE | Added isolated Unknowns export deterministic validation in StellaOps.Cli.UnknownsExport.Tests; command passed (3 tests, 2026-02-08).
|
||||
|
||||
| SPRINT_20260208_031-TESTS | DONE | Isolated compare overlay deterministic validation added in StellaOps.Cli.CompareOverlay.Tests; command passed (3 tests, 2026-02-08).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Cli.Extensions
|
||||
{
|
||||
// Isolated test project stub namespace for UnknownsCommandGroup compile.
|
||||
internal static class CompatStubs
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Models
|
||||
{
|
||||
// Isolated test project stubs to satisfy UnknownsCommandGroup compile.
|
||||
public sealed record UnknownPlaceholder;
|
||||
}
|
||||
|
||||
namespace System.CommandLine
|
||||
{
|
||||
// Compatibility shims for the System.CommandLine API shape expected by UnknownsCommandGroup.
|
||||
public static class OptionCompatExtensions
|
||||
{
|
||||
public static Option<T> SetDefaultValue<T>(this Option<T> option, T value)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
|
||||
public static Option<T> FromAmong<T>(this Option<T> option, params T[] values)
|
||||
{
|
||||
return option;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<RootNamespace>StellaOps.Cli.UnknownsExport.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\StellaOps.Cli\Commands\UnknownsCommandGroup.cs" Link="Commands\UnknownsCommandGroup.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
|
||||
namespace StellaOps.Cli.UnknownsExport.Tests;
|
||||
|
||||
public sealed class UnknownsExportIsolationTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnknownsExport_ParsesSchemaVersionAndFormat()
|
||||
{
|
||||
var services = BuildServices([]);
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var result = root.Parse("unknowns export --format json --schema-version unknowns.export.v9");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownsExport_DefaultFormat_Parses()
|
||||
{
|
||||
var services = BuildServices([]);
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var result = root.Parse("unknowns export");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsExport_Json_IncludesSchemaEnvelopeAndMetadata()
|
||||
{
|
||||
var payload = """
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"packageId": "pkg:npm/a@1.0.0",
|
||||
"packageVersion": "1.0.0",
|
||||
"band": "hot",
|
||||
"score": 0.99,
|
||||
"reasonCode": "r1",
|
||||
"reasonCodeShort": "r1",
|
||||
"firstSeenAt": "2026-01-01T00:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-15T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"packageId": "pkg:npm/b@1.0.0",
|
||||
"packageVersion": "1.0.0",
|
||||
"band": "warm",
|
||||
"score": 0.55,
|
||||
"reasonCode": "r2",
|
||||
"reasonCodeShort": "r2",
|
||||
"firstSeenAt": "2026-01-01T00:00:00Z",
|
||||
"lastEvaluatedAt": "2026-01-14T09:30:00Z"
|
||||
}
|
||||
],
|
||||
"totalCount": 2
|
||||
}
|
||||
""";
|
||||
|
||||
var services = BuildServices([("/api/v1/policy/unknowns?limit=10000", payload)]);
|
||||
var command = UnknownsCommandGroup.BuildUnknownsCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None);
|
||||
var root = new RootCommand { command };
|
||||
|
||||
var writer = new StringWriter();
|
||||
var originalOut = Console.Out;
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
Console.SetOut(writer);
|
||||
exitCode = await root.Parse("unknowns export --format json --schema-version unknowns.export.v2").InvokeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
}
|
||||
|
||||
var output = writer.ToString();
|
||||
Assert.True(exitCode == 0, $"ExitCode={exitCode}; Output={output}");
|
||||
Assert.Contains("\"schemaVersion\": \"unknowns.export.v2\"", output, StringComparison.Ordinal);
|
||||
Assert.Contains("\"itemCount\": 2", output, StringComparison.Ordinal);
|
||||
Assert.Contains("\"exportedAt\": \"2026-01-15T09:30:00+00:00\"", output, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildServices(IReadOnlyList<(string Path, string Json)> payloads)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(static builder => builder.SetMinimumLevel(LogLevel.Warning));
|
||||
services.AddHttpClient("PolicyApi")
|
||||
.ConfigureHttpClient(static client => client.BaseAddress = new Uri("http://localhost"))
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new TestHandler(payloads));
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private sealed class TestHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, string> _responses;
|
||||
|
||||
public TestHandler(IReadOnlyList<(string Path, string Json)> payloads)
|
||||
{
|
||||
_responses = payloads.ToDictionary(
|
||||
static x => x.Path,
|
||||
static x => x.Json,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = request.RequestUri?.PathAndQuery ?? string.Empty;
|
||||
if (_responses.TryGetValue(key, out var json))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent("""{"error":"not found"}""", Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user