audit, advisories and doctors/setup work
This commit is contained in:
@@ -8,6 +8,7 @@ using StellaOps.Cli.Commands.Budget;
|
||||
using StellaOps.Cli.Commands.Chain;
|
||||
using StellaOps.Cli.Commands.DeltaSig;
|
||||
using StellaOps.Cli.Commands.Proof;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Plugins;
|
||||
@@ -44,6 +45,7 @@ internal static class CommandFactory
|
||||
|
||||
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(ImageCommandGroup.BuildImageCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildRubyCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPhpCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPythonCommand(services, verboseOption, cancellationToken));
|
||||
@@ -141,6 +143,9 @@ internal static class CommandFactory
|
||||
// Sprint: Doctor Diagnostics System
|
||||
root.Add(DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: Setup Wizard - Settings Store Integration
|
||||
root.Add(Setup.SetupCommandGroup.BuildSetupCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Add scan graph subcommand to existing scan command
|
||||
var scanCommand = root.Children.OfType<Command>().FirstOrDefault(c => c.Name == "scan");
|
||||
if (scanCommand is not null)
|
||||
@@ -423,6 +428,10 @@ internal static class CommandFactory
|
||||
var recipe = LayerSbomCommandGroup.BuildRecipeCommand(services, options, verboseOption, cancellationToken);
|
||||
scan.Add(recipe);
|
||||
|
||||
// Binary diff command (Sprint: SPRINT_20260113_001_003_CLI_binary_diff_command)
|
||||
var diff = BinaryDiffCommandGroup.BuildDiffCommand(services, verboseOption, cancellationToken);
|
||||
scan.Add(diff);
|
||||
|
||||
// Patch verification command (Sprint: SPRINT_20260111_001_004_CLI_verify_patches)
|
||||
var verifyPatches = PatchVerifyCommandGroup.BuildVerifyPatchesCommand(services, verboseOption, cancellationToken);
|
||||
scan.Add(verifyPatches);
|
||||
|
||||
330
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Image.cs
Normal file
330
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Image.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static partial class CommandHandlers
|
||||
{
|
||||
internal static async Task<int> HandleInspectImageAsync(
|
||||
IServiceProvider services,
|
||||
string reference,
|
||||
bool resolveIndex,
|
||||
bool printLayers,
|
||||
string? platformFilter,
|
||||
string output,
|
||||
int timeoutSeconds,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("image-inspect");
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (!OfflineModeGuard.IsNetworkAllowed(options, "image inspect"))
|
||||
{
|
||||
WriteInspectError("Offline mode enabled. Image inspection requires network access.", output);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
WriteInspectError("Image reference is required.", output);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!TryParseOutput(output, out var normalizedOutput, out var outputError))
|
||||
{
|
||||
WriteInspectError(outputError, output);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!TryValidatePlatformFilter(platformFilter, out var platformError))
|
||||
{
|
||||
WriteInspectError(platformError, normalizedOutput);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = OciImageReferenceParser.Parse(reference);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
WriteInspectError($"Invalid image reference: {ex.Message}", normalizedOutput);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var inspector = scope.ServiceProvider.GetRequiredService<IOciImageInspector>();
|
||||
var inspectOptions = new ImageInspectionOptions
|
||||
{
|
||||
ResolveIndex = resolveIndex,
|
||||
IncludeLayers = printLayers,
|
||||
PlatformFilter = platformFilter,
|
||||
Timeout = timeoutSeconds > 0 ? TimeSpan.FromSeconds(timeoutSeconds) : null
|
||||
};
|
||||
|
||||
var result = await inspector.InspectAsync(reference, inspectOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
WriteInspectError($"Image not found: {reference}", normalizedOutput);
|
||||
Environment.ExitCode = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (IsAuthWarning(result))
|
||||
{
|
||||
WriteInspectError($"Authentication required for {result.Registry}.", normalizedOutput);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (normalizedOutput == "json")
|
||||
{
|
||||
Console.Out.WriteLine(CanonicalJsonSerializer.Serialize(result));
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteInspectTable(console, result, printLayers, verbose);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
return 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
WriteInspectError("Request timed out.", normalizedOutput);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "Network error while inspecting {Reference}", reference);
|
||||
WriteInspectError($"Network error: {ex.Message}", normalizedOutput);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Image inspection failed for {Reference}", reference);
|
||||
WriteInspectError($"Error: {ex.Message}", normalizedOutput);
|
||||
Environment.ExitCode = 2;
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteInspectTable(
|
||||
IAnsiConsole console,
|
||||
ImageInspectionResult result,
|
||||
bool includeLayers,
|
||||
bool verbose)
|
||||
{
|
||||
console.MarkupLine($"Image: [bold]{Markup.Escape(result.Reference)}[/]");
|
||||
console.MarkupLine($"Resolved Digest: [bold]{Markup.Escape(result.ResolvedDigest)}[/]");
|
||||
console.MarkupLine($"Media Type: {Markup.Escape(result.MediaType)}");
|
||||
console.MarkupLine($"Multi-Arch: {(result.IsMultiArch ? "Yes" : "No")} ({result.Platforms.Length.ToString(CultureInfo.InvariantCulture)} platforms)");
|
||||
console.MarkupLine($"Registry: {Markup.Escape(result.Registry)}");
|
||||
console.MarkupLine($"Repository: {Markup.Escape(result.Repository)}");
|
||||
console.MarkupLine($"Inspected At: {result.InspectedAt.ToString("O", CultureInfo.InvariantCulture)}");
|
||||
console.MarkupLine($"Inspector Version: {Markup.Escape(result.InspectorVersion)}");
|
||||
console.WriteLine();
|
||||
|
||||
if (result.Platforms.IsEmpty)
|
||||
{
|
||||
console.MarkupLine("[yellow]No platform manifests found.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table().Border(TableBorder.Ascii);
|
||||
table.AddColumn("OS");
|
||||
table.AddColumn("Architecture");
|
||||
table.AddColumn("Variant");
|
||||
table.AddColumn("Layers");
|
||||
table.AddColumn("Total Size");
|
||||
table.AddColumn("Manifest");
|
||||
|
||||
foreach (var platform in result.Platforms)
|
||||
{
|
||||
var variant = string.IsNullOrWhiteSpace(platform.Variant) ? "-" : platform.Variant;
|
||||
var layerCount = includeLayers
|
||||
? platform.Layers.Length.ToString(CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
var totalSize = includeLayers
|
||||
? FormatImageSize(platform.TotalSize)
|
||||
: "-";
|
||||
|
||||
table.AddRow(
|
||||
platform.Os,
|
||||
platform.Architecture,
|
||||
variant,
|
||||
layerCount,
|
||||
totalSize,
|
||||
TruncateImageDigest(platform.ManifestDigest));
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
|
||||
if (includeLayers)
|
||||
{
|
||||
foreach (var platform in result.Platforms)
|
||||
{
|
||||
if (platform.Layers.IsEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
console.WriteLine();
|
||||
console.MarkupLine($"Layers ({Markup.Escape(FormatPlatform(platform))}):");
|
||||
|
||||
var layerTable = new Table().Border(TableBorder.Ascii);
|
||||
layerTable.AddColumn("Order");
|
||||
layerTable.AddColumn("Size");
|
||||
layerTable.AddColumn("Digest");
|
||||
layerTable.AddColumn("Type");
|
||||
|
||||
foreach (var layer in platform.Layers.OrderBy(l => l.Order))
|
||||
{
|
||||
layerTable.AddRow(
|
||||
layer.Order.ToString(CultureInfo.InvariantCulture),
|
||||
FormatImageSize(layer.Size),
|
||||
TruncateImageDigest(layer.Digest),
|
||||
layer.MediaType);
|
||||
}
|
||||
|
||||
console.Write(layerTable);
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose && result.Warnings.Length > 0)
|
||||
{
|
||||
console.WriteLine();
|
||||
console.MarkupLine("[yellow]Warnings:[/]");
|
||||
foreach (var warning in result.Warnings)
|
||||
{
|
||||
console.MarkupLine($"- {Markup.Escape(warning)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteInspectError(string message, string output)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
if (string.Equals(output, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var payload = new Dictionary<string, string>
|
||||
{
|
||||
["status"] = "error",
|
||||
["message"] = message
|
||||
};
|
||||
Console.Out.WriteLine(CanonicalJsonSerializer.Serialize(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
console.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
|
||||
}
|
||||
|
||||
private static bool TryParseOutput(string value, out string normalized, out string error)
|
||||
{
|
||||
normalized = value?.Trim().ToLowerInvariant() ?? "table";
|
||||
error = string.Empty;
|
||||
|
||||
if (normalized is "table" or "json")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"Unsupported output format '{value}'. Use table or json.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryValidatePlatformFilter(string? value, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts = value.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2 || parts.Length > 3)
|
||||
{
|
||||
error = "Platform filter must be in the form os/arch or os/arch/variant.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsAuthWarning(ImageInspectionResult result)
|
||||
{
|
||||
foreach (var warning in result.Warnings)
|
||||
{
|
||||
if (warning.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) ||
|
||||
warning.Contains("Forbidden", StringComparison.OrdinalIgnoreCase) ||
|
||||
warning.Contains("401", StringComparison.OrdinalIgnoreCase) ||
|
||||
warning.Contains("403", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FormatPlatform(PlatformManifest platform)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(platform.Variant)
|
||||
? $"{platform.Os}/{platform.Architecture}"
|
||||
: $"{platform.Os}/{platform.Architecture}/{platform.Variant}";
|
||||
}
|
||||
|
||||
private static string TruncateImageDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
const int limit = 12;
|
||||
return digest.Length > limit ? digest[..limit] + "..." : digest;
|
||||
}
|
||||
|
||||
private static string FormatImageSize(long size)
|
||||
{
|
||||
const double kilo = 1024;
|
||||
const double mega = kilo * 1024;
|
||||
const double giga = mega * 1024;
|
||||
|
||||
if (size < kilo)
|
||||
{
|
||||
return $"{size.ToString(CultureInfo.InvariantCulture)} B";
|
||||
}
|
||||
|
||||
if (size < mega)
|
||||
{
|
||||
return $"{(size / kilo).ToString("0.0", CultureInfo.InvariantCulture)} KB";
|
||||
}
|
||||
|
||||
if (size < giga)
|
||||
{
|
||||
return $"{(size / mega).ToString("0.0", CultureInfo.InvariantCulture)} MB";
|
||||
}
|
||||
|
||||
return $"{(size / giga).ToString("0.0", CultureInfo.InvariantCulture)} GB";
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Doctor.Engine;
|
||||
using StellaOps.Doctor.Export;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Output;
|
||||
using StellaOps.Doctor.Packs;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -17,6 +28,9 @@ namespace StellaOps.Cli.Commands;
|
||||
/// </summary>
|
||||
internal static class DoctorCommandGroup
|
||||
{
|
||||
private const int MaxFixOutputChars = 2000;
|
||||
private static readonly Regex PlaceholderPattern = new(@"\{[A-Z][A-Z0-9_]*\}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
internal static Command BuildDoctorCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
@@ -24,10 +38,23 @@ internal static class DoctorCommandGroup
|
||||
{
|
||||
var doctor = new Command("doctor", "Run diagnostic checks on Stella Ops installation and environment.");
|
||||
|
||||
var rootRunOptions = CreateRunOptions();
|
||||
AddRunOptions(doctor, rootRunOptions, verboseOption);
|
||||
doctor.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
await RunDoctorFromParseResultAsync(
|
||||
parseResult,
|
||||
rootRunOptions,
|
||||
verboseOption,
|
||||
services,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
// Sub-commands
|
||||
doctor.Add(BuildRunCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(BuildFixCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return doctor;
|
||||
}
|
||||
@@ -36,6 +63,26 @@ internal static class DoctorCommandGroup
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var runOptions = CreateRunOptions();
|
||||
|
||||
var run = new Command("run", "Execute diagnostic checks.");
|
||||
AddRunOptions(run, runOptions, verboseOption);
|
||||
|
||||
run.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
await RunDoctorFromParseResultAsync(
|
||||
parseResult,
|
||||
runOptions,
|
||||
verboseOption,
|
||||
services,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
private static DoctorRunCommandOptions CreateRunOptions()
|
||||
{
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
@@ -84,8 +131,7 @@ internal static class DoctorCommandGroup
|
||||
Description = "Exit with non-zero code on warnings (default: only fail on errors)"
|
||||
};
|
||||
|
||||
var run = new Command("run", "Execute diagnostic checks.")
|
||||
{
|
||||
return new DoctorRunCommandOptions(
|
||||
formatOption,
|
||||
modeOption,
|
||||
categoryOption,
|
||||
@@ -94,39 +140,57 @@ internal static class DoctorCommandGroup
|
||||
parallelOption,
|
||||
timeoutOption,
|
||||
outputOption,
|
||||
failOnWarnOption,
|
||||
verboseOption
|
||||
};
|
||||
failOnWarnOption);
|
||||
}
|
||||
|
||||
run.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "text";
|
||||
var mode = parseResult.GetValue(modeOption);
|
||||
var category = parseResult.GetValue(categoryOption);
|
||||
var tags = parseResult.GetValue(tagOption) ?? [];
|
||||
var checkId = parseResult.GetValue(checkOption);
|
||||
var parallel = parseResult.GetValue(parallelOption) ?? 4;
|
||||
var timeout = parseResult.GetValue(timeoutOption) ?? 30;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var failOnWarn = parseResult.GetValue(failOnWarnOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
private static void AddRunOptions(
|
||||
Command command,
|
||||
DoctorRunCommandOptions options,
|
||||
Option<bool> verboseOption)
|
||||
{
|
||||
command.Add(options.FormatOption);
|
||||
command.Add(options.ModeOption);
|
||||
command.Add(options.CategoryOption);
|
||||
command.Add(options.TagOption);
|
||||
command.Add(options.CheckOption);
|
||||
command.Add(options.ParallelOption);
|
||||
command.Add(options.TimeoutOption);
|
||||
command.Add(options.OutputOption);
|
||||
command.Add(options.FailOnWarnOption);
|
||||
command.Add(verboseOption);
|
||||
}
|
||||
|
||||
await RunDoctorAsync(
|
||||
services,
|
||||
format,
|
||||
mode,
|
||||
category,
|
||||
tags,
|
||||
checkId,
|
||||
parallel,
|
||||
timeout,
|
||||
output,
|
||||
failOnWarn,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
private static async Task RunDoctorFromParseResultAsync(
|
||||
ParseResult parseResult,
|
||||
DoctorRunCommandOptions options,
|
||||
Option<bool> verboseOption,
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var format = parseResult.GetValue(options.FormatOption) ?? "text";
|
||||
var mode = parseResult.GetValue(options.ModeOption);
|
||||
var category = parseResult.GetValue(options.CategoryOption);
|
||||
var tags = parseResult.GetValue(options.TagOption) ?? [];
|
||||
var checkId = parseResult.GetValue(options.CheckOption);
|
||||
var parallel = parseResult.GetValue(options.ParallelOption) ?? 4;
|
||||
var timeout = parseResult.GetValue(options.TimeoutOption) ?? 30;
|
||||
var output = parseResult.GetValue(options.OutputOption);
|
||||
var failOnWarn = parseResult.GetValue(options.FailOnWarnOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return run;
|
||||
await RunDoctorAsync(
|
||||
services,
|
||||
format,
|
||||
mode,
|
||||
category,
|
||||
tags,
|
||||
checkId,
|
||||
parallel,
|
||||
timeout,
|
||||
output,
|
||||
failOnWarn,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(
|
||||
@@ -169,10 +233,10 @@ internal static class DoctorCommandGroup
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output ZIP file path",
|
||||
IsRequired = true
|
||||
Arity = ArgumentArity.ExactlyOne
|
||||
};
|
||||
|
||||
var includeLogsOption = new Option<bool>("--include-logs")
|
||||
@@ -221,6 +285,41 @@ internal static class DoctorCommandGroup
|
||||
return export;
|
||||
}
|
||||
|
||||
private static Command BuildFixCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fromOption = new Option<string>("--from")
|
||||
{
|
||||
Description = "Path to JSON report file with fix commands",
|
||||
Arity = ArgumentArity.ExactlyOne
|
||||
};
|
||||
|
||||
var applyOption = new Option<bool>("--apply")
|
||||
{
|
||||
Description = "Execute non-destructive fixes (default: dry-run preview)"
|
||||
};
|
||||
|
||||
var fix = new Command("fix", "Apply non-destructive fixes from a doctor report.")
|
||||
{
|
||||
fromOption,
|
||||
applyOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
fix.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var reportPath = parseResult.GetValue(fromOption);
|
||||
var apply = parseResult.GetValue(applyOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await ApplyFixesAsync(services, reportPath, apply, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return fix;
|
||||
}
|
||||
|
||||
private static async Task ExportDiagnosticBundleAsync(
|
||||
IServiceProvider services,
|
||||
string outputPath,
|
||||
@@ -324,7 +423,8 @@ internal static class DoctorCommandGroup
|
||||
Tags = tags.Length > 0 ? tags : null,
|
||||
CheckIds = checkId != null ? [checkId] : null,
|
||||
Parallelism = parallel,
|
||||
Timeout = TimeSpan.FromSeconds(timeout)
|
||||
Timeout = TimeSpan.FromSeconds(timeout),
|
||||
DoctorCommand = Environment.CommandLine
|
||||
};
|
||||
|
||||
// Progress reporting for verbose mode
|
||||
@@ -358,6 +458,581 @@ internal static class DoctorCommandGroup
|
||||
SetExitCode(report, failOnWarn);
|
||||
}
|
||||
|
||||
private static async Task ApplyFixesAsync(
|
||||
IServiceProvider services,
|
||||
string? reportPath,
|
||||
bool apply,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reportPath))
|
||||
{
|
||||
Console.Error.WriteLine("Error: --from is required.");
|
||||
Environment.ExitCode = CliExitCodes.MissingRequiredOption;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(reportPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Report file not found: {reportPath}");
|
||||
Environment.ExitCode = CliExitCodes.InputFileNotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<DoctorFixStep> steps;
|
||||
try
|
||||
{
|
||||
steps = await LoadFixStepsAsync(reportPath, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Unable to parse doctor report: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Unable to read doctor report: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
|
||||
var orderedSteps = steps
|
||||
.Where(step => !string.IsNullOrWhiteSpace(step.Command))
|
||||
.OrderBy(step => step.CheckId, StringComparer.Ordinal)
|
||||
.ThenBy(step => step.Order)
|
||||
.ThenBy(step => step.Command, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (orderedSteps.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No fix commands found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var plan = BuildFixPlan(orderedSteps);
|
||||
var distinctChecks = plan
|
||||
.Select(entry => entry.Step.CheckId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count();
|
||||
var safeCount = plan.Count(entry => entry.IsSafe);
|
||||
var manualCount = plan.Count - safeCount;
|
||||
|
||||
Console.WriteLine($"Found {plan.Count} fix command(s) across {distinctChecks} check(s).");
|
||||
Console.WriteLine($"Safe commands: {safeCount}. Manual commands: {manualCount}.");
|
||||
|
||||
if (!apply)
|
||||
{
|
||||
Console.WriteLine("Dry-run preview. Use --apply to execute safe commands.");
|
||||
PrintFixPlan(plan, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
if (safeCount == 0)
|
||||
{
|
||||
Console.WriteLine("No safe commands to apply.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Applying safe fix commands...");
|
||||
var runner = services.GetRequiredService<IDoctorPackCommandRunner>();
|
||||
var context = BuildFixContext(services);
|
||||
|
||||
var applied = 0;
|
||||
var skipped = 0;
|
||||
var failed = 0;
|
||||
|
||||
foreach (var entry in plan)
|
||||
{
|
||||
if (!entry.IsSafe)
|
||||
{
|
||||
skipped++;
|
||||
WritePlanEntry("SKIP", entry, verbose);
|
||||
continue;
|
||||
}
|
||||
|
||||
WritePlanEntry("RUN", entry, verbose);
|
||||
var result = await runner.RunAsync(
|
||||
new DoctorPackCommand(entry.Step.Command),
|
||||
context,
|
||||
ct);
|
||||
|
||||
if (result.ExitCode == 0 && string.IsNullOrWhiteSpace(result.Error))
|
||||
{
|
||||
applied++;
|
||||
if (verbose && !string.IsNullOrWhiteSpace(result.StdOut))
|
||||
{
|
||||
Console.WriteLine(TrimOutput(result.StdOut));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
Console.WriteLine($"[FAIL] {FormatStep(entry.Step)}");
|
||||
if (!string.IsNullOrWhiteSpace(result.Error))
|
||||
{
|
||||
Console.WriteLine($" Error: {result.Error}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(result.StdErr))
|
||||
{
|
||||
Console.WriteLine($" Stderr: {TrimOutput(result.StdErr)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Applied: {applied}. Skipped: {skipped}. Failed: {failed}.");
|
||||
if (failed > 0)
|
||||
{
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<DoctorFixPlanEntry> BuildFixPlan(IReadOnlyList<DoctorFixStep> steps)
|
||||
{
|
||||
var plan = new List<DoctorFixPlanEntry>(steps.Count);
|
||||
foreach (var step in steps)
|
||||
{
|
||||
var isSafe = IsSafeCommand(step, out var reason);
|
||||
plan.Add(new DoctorFixPlanEntry(step, isSafe, isSafe ? string.Empty : reason));
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static void PrintFixPlan(IReadOnlyList<DoctorFixPlanEntry> plan, bool verbose)
|
||||
{
|
||||
foreach (var entry in plan)
|
||||
{
|
||||
var label = entry.IsSafe ? "SAFE" : "MANUAL";
|
||||
WritePlanEntry(label, entry, verbose);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WritePlanEntry(string label, DoctorFixPlanEntry entry, bool verbose)
|
||||
{
|
||||
var reasonSuffix = entry.IsSafe || string.IsNullOrWhiteSpace(entry.Reason)
|
||||
? string.Empty
|
||||
: $" ({entry.Reason})";
|
||||
|
||||
Console.WriteLine($"[{label}] {FormatStep(entry.Step)}: {entry.Step.Command}{reasonSuffix}");
|
||||
|
||||
if (!verbose)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Step.Description))
|
||||
{
|
||||
Console.WriteLine($" {entry.Step.Description}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Step.SafetyNote))
|
||||
{
|
||||
Console.WriteLine($" Safety: {entry.Step.SafetyNote}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatStep(DoctorFixStep step)
|
||||
{
|
||||
return $"{step.CheckId} step {step.Order.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DoctorFixStep>> LoadFixStepsAsync(
|
||||
string reportPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(reportPath);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
|
||||
return ExtractFixSteps(doc.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return await LoadFixStepsFromJsonLinesAsync(reportPath, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DoctorFixStep>> LoadFixStepsFromJsonLinesAsync(
|
||||
string reportPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var steps = new List<DoctorFixStep>();
|
||||
|
||||
using var stream = new FileStream(reportPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) is not null)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
steps.AddRange(ExtractFixSteps(doc.RootElement));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DoctorFixStep> ExtractFixSteps(JsonElement root)
|
||||
{
|
||||
var steps = new List<DoctorFixStep>();
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (TryGetProperty(root, "results", out var results) ||
|
||||
TryGetProperty(root, "checks", out results))
|
||||
{
|
||||
AppendFixStepsFromResults(results, steps);
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendFixStepsFromResult(root, steps);
|
||||
}
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
AppendFixStepsFromResults(root, steps);
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private static void AppendFixStepsFromResults(JsonElement results, List<DoctorFixStep> steps)
|
||||
{
|
||||
if (results.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var result in results.EnumerateArray())
|
||||
{
|
||||
AppendFixStepsFromResult(result, steps);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendFixStepsFromResult(JsonElement result, List<DoctorFixStep> steps)
|
||||
{
|
||||
var checkId = GetString(result, "checkId") ?? "unknown";
|
||||
|
||||
if (TryGetProperty(result, "remediation", out var remediation))
|
||||
{
|
||||
AppendFixStepsFromRemediation(remediation, checkId, steps);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryGetProperty(result, "how_to_fix", out var howToFix) ||
|
||||
TryGetProperty(result, "howToFix", out howToFix))
|
||||
{
|
||||
AppendFixStepsFromHowToFix(howToFix, checkId, steps);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendFixStepsFromRemediation(
|
||||
JsonElement remediation,
|
||||
string checkId,
|
||||
List<DoctorFixStep> steps)
|
||||
{
|
||||
var requiresBackup = GetBool(remediation, "requiresBackup");
|
||||
var safetyNote = GetString(remediation, "safetyNote");
|
||||
|
||||
if (TryGetProperty(remediation, "steps", out var stepArray) &&
|
||||
stepArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var fallbackOrder = 0;
|
||||
foreach (var stepElement in stepArray.EnumerateArray())
|
||||
{
|
||||
fallbackOrder++;
|
||||
var command = GetString(stepElement, "command");
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var order = GetInt(stepElement, "order") ?? fallbackOrder;
|
||||
if (order <= 0)
|
||||
{
|
||||
order = fallbackOrder;
|
||||
}
|
||||
|
||||
var description = GetString(stepElement, "description");
|
||||
var commandType = GetString(stepElement, "commandType");
|
||||
if (string.IsNullOrWhiteSpace(commandType))
|
||||
{
|
||||
commandType = "shell";
|
||||
}
|
||||
|
||||
steps.Add(new DoctorFixStep(
|
||||
checkId,
|
||||
order,
|
||||
command.Trim(),
|
||||
commandType,
|
||||
description,
|
||||
requiresBackup,
|
||||
safetyNote));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryGetProperty(remediation, "commands", out var commands))
|
||||
{
|
||||
AppendFixStepsFromCommands(
|
||||
checkId,
|
||||
commands,
|
||||
"shell",
|
||||
null,
|
||||
requiresBackup,
|
||||
safetyNote,
|
||||
steps);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendFixStepsFromHowToFix(
|
||||
JsonElement howToFix,
|
||||
string checkId,
|
||||
List<DoctorFixStep> steps)
|
||||
{
|
||||
if (TryGetProperty(howToFix, "commands", out var commands))
|
||||
{
|
||||
AppendFixStepsFromCommands(
|
||||
checkId,
|
||||
commands,
|
||||
"shell",
|
||||
null,
|
||||
requiresBackup: false,
|
||||
safetyNote: null,
|
||||
steps);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendFixStepsFromCommands(
|
||||
string checkId,
|
||||
JsonElement commands,
|
||||
string commandType,
|
||||
string? description,
|
||||
bool requiresBackup,
|
||||
string? safetyNote,
|
||||
List<DoctorFixStep> steps)
|
||||
{
|
||||
if (commands.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var order = 0;
|
||||
foreach (var commandValue in commands.EnumerateArray())
|
||||
{
|
||||
if (commandValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var command = commandValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
order++;
|
||||
steps.Add(new DoctorFixStep(
|
||||
checkId,
|
||||
order,
|
||||
command.Trim(),
|
||||
commandType,
|
||||
description,
|
||||
requiresBackup,
|
||||
safetyNote));
|
||||
}
|
||||
}
|
||||
|
||||
private static DoctorPluginContext BuildFixContext(IServiceProvider services)
|
||||
{
|
||||
var configuration = services.GetRequiredService<IConfiguration>();
|
||||
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("DoctorFix");
|
||||
var timeProvider = services.GetRequiredService<TimeProvider>();
|
||||
var environment = services.GetService<IHostEnvironment>();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = services,
|
||||
Configuration = configuration,
|
||||
TimeProvider = timeProvider,
|
||||
Logger = logger,
|
||||
EnvironmentName = environment?.EnvironmentName ?? Environments.Production,
|
||||
PluginConfig = configuration.GetSection("Doctor:Packs")
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSafeCommand(DoctorFixStep step, out string reason)
|
||||
{
|
||||
if (!IsShellCommand(step.CommandType))
|
||||
{
|
||||
reason = "unsupported command type";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (step.RequiresBackup)
|
||||
{
|
||||
reason = "requires backup";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsStellaCommand(step.Command))
|
||||
{
|
||||
reason = "non-stella command";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ContainsPlaceholders(step.Command))
|
||||
{
|
||||
reason = "has placeholders";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsShellCommand(string? commandType)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(commandType) ||
|
||||
string.Equals(commandType.Trim(), "shell", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsStellaCommand(string command)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = command.TrimStart();
|
||||
var token = trimmed
|
||||
.Split(new[] { ' ', '\t', '\r', '\n' }, 2, StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
token = token.Trim('"', '\'');
|
||||
|
||||
return string.Equals(token, "stella", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(token, "stella.exe", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(token, "stella.cmd", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(token, "stella.ps1", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(token, "./stella", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(token, "./stella.exe", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(token, ".\\stella", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(token, ".\\stella.exe", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ContainsPlaceholders(string command)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(command) &&
|
||||
PlaceholderPattern.IsMatch(command);
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
value = default;
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => null,
|
||||
_ => value.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool GetBool(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number &&
|
||||
value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TrimOutput(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length <= MaxFixOutputChars)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed[..MaxFixOutputChars] + "...(truncated)";
|
||||
}
|
||||
|
||||
private static async Task ListChecksAsync(
|
||||
IServiceProvider services,
|
||||
string? category,
|
||||
@@ -438,4 +1113,29 @@ internal static class DoctorCommandGroup
|
||||
Environment.ExitCode = exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record DoctorRunCommandOptions(
|
||||
Option<string> FormatOption,
|
||||
Option<string?> ModeOption,
|
||||
Option<string?> CategoryOption,
|
||||
Option<string[]> TagOption,
|
||||
Option<string?> CheckOption,
|
||||
Option<int?> ParallelOption,
|
||||
Option<int?> TimeoutOption,
|
||||
Option<string?> OutputOption,
|
||||
Option<bool> FailOnWarnOption);
|
||||
|
||||
private sealed record DoctorFixStep(
|
||||
string CheckId,
|
||||
int Order,
|
||||
string Command,
|
||||
string CommandType,
|
||||
string? Description,
|
||||
bool RequiresBackup,
|
||||
string? SafetyNote);
|
||||
|
||||
private sealed record DoctorFixPlanEntry(
|
||||
DoctorFixStep Step,
|
||||
bool IsSafe,
|
||||
string Reason);
|
||||
}
|
||||
|
||||
95
src/Cli/StellaOps.Cli/Commands/ImageCommandGroup.cs
Normal file
95
src/Cli/StellaOps.Cli/Commands/ImageCommandGroup.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class ImageCommandGroup
|
||||
{
|
||||
internal static Command BuildImageCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var image = new Command("image", "OCI image operations");
|
||||
image.Add(BuildInspectCommand(services, options, verboseOption, cancellationToken));
|
||||
return image;
|
||||
}
|
||||
|
||||
private static Command BuildInspectCommand(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var referenceArg = new Argument<string>("reference")
|
||||
{
|
||||
Description = "Image reference (e.g., nginx:latest, ghcr.io/org/app@sha256:...)"
|
||||
};
|
||||
|
||||
var resolveIndexOption = new Option<bool>("--resolve-index", new[] { "-r" })
|
||||
{
|
||||
Description = "Resolve multi-arch index to platform manifests"
|
||||
};
|
||||
resolveIndexOption.SetDefaultValue(true);
|
||||
|
||||
var printLayersOption = new Option<bool>("--print-layers", new[] { "-l" })
|
||||
{
|
||||
Description = "Include layer details in output"
|
||||
};
|
||||
printLayersOption.SetDefaultValue(true);
|
||||
|
||||
var platformOption = new Option<string?>("--platform", new[] { "-p" })
|
||||
{
|
||||
Description = "Filter to specific platform (e.g., linux/amd64)"
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json"
|
||||
};
|
||||
outputOption.SetDefaultValue("table");
|
||||
outputOption.FromAmong("table", "json");
|
||||
|
||||
var timeoutOption = new Option<int>("--timeout")
|
||||
{
|
||||
Description = "Request timeout in seconds (default: 60)"
|
||||
};
|
||||
timeoutOption.SetDefaultValue(60);
|
||||
|
||||
var inspect = new Command("inspect", "Inspect OCI image manifest and layers")
|
||||
{
|
||||
referenceArg,
|
||||
resolveIndexOption,
|
||||
printLayersOption,
|
||||
platformOption,
|
||||
outputOption,
|
||||
timeoutOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
inspect.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var reference = parseResult.GetValue(referenceArg) ?? string.Empty;
|
||||
var resolveIndex = parseResult.GetValue(resolveIndexOption);
|
||||
var printLayers = parseResult.GetValue(printLayersOption);
|
||||
var platform = parseResult.GetValue(platformOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? "table";
|
||||
var timeoutSeconds = parseResult.GetValue(timeoutOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await CommandHandlers.HandleInspectImageAsync(
|
||||
services,
|
||||
reference,
|
||||
resolveIndex,
|
||||
printLayers,
|
||||
platform,
|
||||
output,
|
||||
timeoutSeconds,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return inspect;
|
||||
}
|
||||
}
|
||||
355
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffCommandGroup.cs
Normal file
355
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffCommandGroup.cs
Normal file
@@ -0,0 +1,355 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Cli.Extensions;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal static class BinaryDiffCommandGroup
|
||||
{
|
||||
internal static Command BuildDiffCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseOption = new Option<string>("--base", new[] { "-b" })
|
||||
{
|
||||
Description = "Base image reference (tag or @digest)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var targetOption = new Option<string>("--target", new[] { "-t" })
|
||||
{
|
||||
Description = "Target image reference (tag or @digest)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var modeOption = new Option<string>("--mode", new[] { "-m" })
|
||||
{
|
||||
Description = "Analysis mode: elf, pe, auto (default: auto)"
|
||||
}.SetDefaultValue("auto").FromAmong("elf", "pe", "auto");
|
||||
|
||||
var emitDsseOption = new Option<string?>("--emit-dsse", new[] { "-d" })
|
||||
{
|
||||
Description = "Directory for DSSE attestation output"
|
||||
};
|
||||
|
||||
var signingKeyOption = new Option<string?>("--signing-key")
|
||||
{
|
||||
Description = "Path to ECDSA private key (PEM) for DSSE signing"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: table, json, summary (default: table)"
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "summary");
|
||||
|
||||
var platformOption = new Option<string?>("--platform", new[] { "-p" })
|
||||
{
|
||||
Description = "Platform filter (e.g., linux/amd64)"
|
||||
};
|
||||
|
||||
var includeUnchangedOption = new Option<bool>("--include-unchanged")
|
||||
{
|
||||
Description = "Include unchanged binaries in output"
|
||||
};
|
||||
|
||||
var sectionsOption = new Option<string[]>("--sections")
|
||||
{
|
||||
Description = "Sections to analyze (comma-separated or repeatable)"
|
||||
};
|
||||
sectionsOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var registryAuthOption = new Option<string?>("--registry-auth")
|
||||
{
|
||||
Description = "Path to Docker config for registry authentication"
|
||||
};
|
||||
|
||||
var timeoutOption = new Option<int>("--timeout")
|
||||
{
|
||||
Description = "Timeout in seconds for operations (default: 300)"
|
||||
}.SetDefaultValue(300);
|
||||
|
||||
var command = new Command("diff", GetCommandDescription())
|
||||
{
|
||||
baseOption,
|
||||
targetOption,
|
||||
modeOption,
|
||||
emitDsseOption,
|
||||
signingKeyOption,
|
||||
formatOption,
|
||||
platformOption,
|
||||
includeUnchangedOption,
|
||||
sectionsOption,
|
||||
registryAuthOption,
|
||||
timeoutOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var baseRef = parseResult.GetValue(baseOption) ?? string.Empty;
|
||||
var targetRef = parseResult.GetValue(targetOption) ?? string.Empty;
|
||||
var modeValue = parseResult.GetValue(modeOption) ?? "auto";
|
||||
var emitDsse = parseResult.GetValue(emitDsseOption);
|
||||
var signingKeyPath = parseResult.GetValue(signingKeyOption);
|
||||
var formatValue = parseResult.GetValue(formatOption) ?? "table";
|
||||
var platformValue = parseResult.GetValue(platformOption);
|
||||
var includeUnchanged = parseResult.GetValue(includeUnchangedOption);
|
||||
var sectionsValue = parseResult.GetValue(sectionsOption) ?? Array.Empty<string>();
|
||||
var registryAuthPath = parseResult.GetValue(registryAuthOption);
|
||||
var timeoutSeconds = parseResult.GetValue(timeoutOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!TryParseMode(modeValue, out var mode, out var modeError))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {modeError}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!TryParseFormat(formatValue, out var format, out var formatError))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {formatError}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!TryParsePlatform(platformValue, out var platform, out var platformError))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {platformError}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sections = NormalizeSections(sectionsValue);
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
|
||||
if (timeoutSeconds > 0)
|
||||
{
|
||||
linkedCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
}
|
||||
|
||||
var showProgress = format != BinaryDiffOutputFormat.Json || verbose;
|
||||
IProgress<BinaryDiffProgress>? progress = showProgress
|
||||
? new Progress<BinaryDiffProgress>(ReportProgress)
|
||||
: null;
|
||||
|
||||
var diffService = services.GetRequiredService<IBinaryDiffService>();
|
||||
var renderer = services.GetRequiredService<IBinaryDiffRenderer>();
|
||||
var signer = services.GetRequiredService<IBinaryDiffDsseSigner>();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await diffService.ComputeDiffAsync(
|
||||
new BinaryDiffRequest
|
||||
{
|
||||
BaseImageRef = baseRef,
|
||||
TargetImageRef = targetRef,
|
||||
Mode = mode,
|
||||
Platform = platform,
|
||||
Sections = sections,
|
||||
IncludeUnchanged = includeUnchanged,
|
||||
RegistryAuthPath = registryAuthPath
|
||||
},
|
||||
progress,
|
||||
linkedCts.Token).ConfigureAwait(false);
|
||||
|
||||
if (result.Summary.TotalBinaries == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Warning: No ELF binaries found in images.");
|
||||
}
|
||||
|
||||
BinaryDiffDsseOutputResult? dsseOutput = null;
|
||||
if (!string.IsNullOrWhiteSpace(emitDsse))
|
||||
{
|
||||
if (result.Predicate is null)
|
||||
{
|
||||
Console.Error.WriteLine("Error: DSSE output requested but predicate is missing.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var signingKey = BinaryDiffKeyLoader.LoadSigningKey(signingKeyPath ?? string.Empty);
|
||||
var dsse = await signer.SignAsync(result.Predicate, signingKey, linkedCts.Token).ConfigureAwait(false);
|
||||
dsseOutput = await BinaryDiffDsseOutputWriter.WriteAsync(
|
||||
emitDsse,
|
||||
result.Platform,
|
||||
dsse,
|
||||
linkedCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await renderer.RenderAsync(result, format, Console.Out, linkedCts.Token).ConfigureAwait(false);
|
||||
|
||||
if (format == BinaryDiffOutputFormat.Summary && dsseOutput is not null)
|
||||
{
|
||||
Console.Out.WriteLine($"DSSE Attestation: {dsseOutput.EnvelopePath}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (BinaryDiffException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return ex.ExitCode;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Operation timed out after {timeoutSeconds.ToString(CultureInfo.InvariantCulture)}s");
|
||||
return 124;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Network error: {ex.Message}");
|
||||
return 5;
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsAuthFailure(ex))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Registry authentication failed: {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static string GetCommandDescription()
|
||||
{
|
||||
return "Compare binaries between two images using section hashes.\n\nExamples:\n" +
|
||||
" stella scan diff --base image1 --target image2\n" +
|
||||
" stella scan diff --base docker://repo/app:1.0.0 --target docker://repo/app:1.0.1 --mode=elf\n" +
|
||||
" stella scan diff --base image1 --target image2 --emit-dsse=./attestations --signing-key=signing-key.pem\n" +
|
||||
" stella scan diff --base image1 --target image2 --format=json > diff.json\n" +
|
||||
" stella scan diff --base image1 --target image2 --platform=linux/amd64";
|
||||
}
|
||||
|
||||
private static void ReportProgress(BinaryDiffProgress progress)
|
||||
{
|
||||
if (progress.Total > 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[{progress.Phase}] {progress.CurrentItem} ({progress.Current}/{progress.Total})");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[{progress.Phase}] {progress.CurrentItem} ({progress.Current})");
|
||||
}
|
||||
|
||||
private static bool TryParseMode(string value, out BinaryDiffMode mode, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
mode = BinaryDiffMode.Auto;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
error = "Mode is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (value.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "elf":
|
||||
mode = BinaryDiffMode.Elf;
|
||||
return true;
|
||||
case "pe":
|
||||
mode = BinaryDiffMode.Pe;
|
||||
return true;
|
||||
case "auto":
|
||||
mode = BinaryDiffMode.Auto;
|
||||
return true;
|
||||
default:
|
||||
error = $"Unsupported mode '{value}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseFormat(string value, out BinaryDiffOutputFormat format, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
format = BinaryDiffOutputFormat.Table;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
error = "Format is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (value.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "table":
|
||||
format = BinaryDiffOutputFormat.Table;
|
||||
return true;
|
||||
case "json":
|
||||
format = BinaryDiffOutputFormat.Json;
|
||||
return true;
|
||||
case "summary":
|
||||
format = BinaryDiffOutputFormat.Summary;
|
||||
return true;
|
||||
default:
|
||||
error = $"Unsupported format '{value}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParsePlatform(string? value, out BinaryDiffPlatform? platform, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
platform = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts = value.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2 || parts.Length > 3)
|
||||
{
|
||||
error = "Platform must be in the form os/arch or os/arch/variant.";
|
||||
return false;
|
||||
}
|
||||
|
||||
platform = new BinaryDiffPlatform
|
||||
{
|
||||
Os = parts[0],
|
||||
Architecture = parts[1],
|
||||
Variant = parts.Length == 3 ? parts[2] : null
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeSections(string[] sections)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in sections)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = entry.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
set.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return set
|
||||
.OrderBy(section => section, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsAuthFailure(InvalidOperationException ex)
|
||||
{
|
||||
return ex.Message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
60
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffDsseOutput.cs
Normal file
60
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffDsseOutput.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal sealed record BinaryDiffDsseOutputResult
|
||||
{
|
||||
public required string EnvelopePath { get; init; }
|
||||
public required string PayloadPath { get; init; }
|
||||
}
|
||||
|
||||
internal static class BinaryDiffDsseOutputWriter
|
||||
{
|
||||
public static async Task<BinaryDiffDsseOutputResult> WriteAsync(
|
||||
string outputDirectory,
|
||||
BinaryDiffPlatform platform,
|
||||
BinaryDiffDsseResult result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
throw new ArgumentException("Output directory is required.", nameof(outputDirectory));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
var platformSuffix = FormatPlatformForFile(platform);
|
||||
var baseName = $"{platformSuffix}-binarydiff";
|
||||
var envelopePath = Path.Combine(outputDirectory, $"{baseName}.dsse.json");
|
||||
var payloadPath = Path.Combine(outputDirectory, $"{baseName}.payload.json");
|
||||
|
||||
await File.WriteAllTextAsync(envelopePath, result.EnvelopeJson, cancellationToken).ConfigureAwait(false);
|
||||
var payloadJson = Encoding.UTF8.GetString(result.Payload);
|
||||
await File.WriteAllTextAsync(payloadPath, payloadJson, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new BinaryDiffDsseOutputResult
|
||||
{
|
||||
EnvelopePath = envelopePath,
|
||||
PayloadPath = payloadPath
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatPlatformForFile(BinaryDiffPlatform platform)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
platform.Os,
|
||||
platform.Architecture
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(platform.Variant))
|
||||
{
|
||||
parts.Add(platform.Variant);
|
||||
}
|
||||
|
||||
return string.Join("-", parts)
|
||||
.ToLowerInvariant()
|
||||
.Replace('/', '-')
|
||||
.Replace('\\', '-');
|
||||
}
|
||||
}
|
||||
29
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffErrors.cs
Normal file
29
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffErrors.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal enum BinaryDiffErrorCode
|
||||
{
|
||||
InvalidReference,
|
||||
AuthFailed,
|
||||
PlatformNotFound,
|
||||
UnsupportedMode,
|
||||
RegistryAuthInvalid,
|
||||
SigningKeyInvalid
|
||||
}
|
||||
|
||||
internal sealed class BinaryDiffException : Exception
|
||||
{
|
||||
public BinaryDiffException(BinaryDiffErrorCode code, string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public BinaryDiffErrorCode Code { get; }
|
||||
|
||||
public int ExitCode => Code switch
|
||||
{
|
||||
BinaryDiffErrorCode.AuthFailed => 2,
|
||||
BinaryDiffErrorCode.PlatformNotFound => 3,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
57
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffKeyLoader.cs
Normal file
57
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffKeyLoader.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal static class BinaryDiffKeyLoader
|
||||
{
|
||||
public static EnvelopeKey LoadSigningKey(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.SigningKeyInvalid,
|
||||
"Signing key path is required for DSSE output.");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.SigningKeyInvalid,
|
||||
$"Signing key file not found: {path}");
|
||||
}
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
using var ecdsa = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
ecdsa.ImportFromPem(pem);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.SigningKeyInvalid,
|
||||
"Failed to load ECDSA private key from PEM.",
|
||||
ex);
|
||||
}
|
||||
|
||||
var keySize = ecdsa.KeySize;
|
||||
var parameters = ecdsa.ExportParameters(true);
|
||||
var algorithm = ResolveEcdsaAlgorithm(keySize);
|
||||
return EnvelopeKey.CreateEcdsaSigner(algorithm, parameters);
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithm(int keySize)
|
||||
{
|
||||
return keySize switch
|
||||
{
|
||||
256 => SignatureAlgorithms.Es256,
|
||||
384 => SignatureAlgorithms.Es384,
|
||||
521 => SignatureAlgorithms.Es512,
|
||||
_ => throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.SigningKeyInvalid,
|
||||
$"Unsupported ECDSA key size: {keySize}.")
|
||||
};
|
||||
}
|
||||
}
|
||||
62
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffModels.cs
Normal file
62
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffModels.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal enum BinaryDiffMode
|
||||
{
|
||||
Auto,
|
||||
Elf,
|
||||
Pe
|
||||
}
|
||||
|
||||
internal enum BinaryDiffOutputFormat
|
||||
{
|
||||
Table,
|
||||
Json,
|
||||
Summary
|
||||
}
|
||||
|
||||
internal sealed record BinaryDiffRequest
|
||||
{
|
||||
public required string BaseImageRef { get; init; }
|
||||
public required string TargetImageRef { get; init; }
|
||||
public required BinaryDiffMode Mode { get; init; }
|
||||
public BinaryDiffPlatform? Platform { get; init; }
|
||||
public ImmutableArray<string> Sections { get; init; } = ImmutableArray<string>.Empty;
|
||||
public bool IncludeUnchanged { get; init; }
|
||||
public string? RegistryAuthPath { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BinaryDiffResult
|
||||
{
|
||||
public required BinaryDiffImageReference Base { get; init; }
|
||||
public required BinaryDiffImageReference Target { get; init; }
|
||||
public required BinaryDiffPlatform Platform { get; init; }
|
||||
public required BinaryDiffMode Mode { get; init; }
|
||||
public required ImmutableArray<BinaryDiffFinding> Findings { get; init; }
|
||||
public required BinaryDiffSummary Summary { get; init; }
|
||||
public required BinaryDiffMetadata Metadata { get; init; }
|
||||
public BinaryDiffPredicate? Predicate { get; init; }
|
||||
public OciImageReference? BaseReference { get; init; }
|
||||
public OciImageReference? TargetReference { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BinaryDiffSummary
|
||||
{
|
||||
public required int TotalBinaries { get; init; }
|
||||
public required int Modified { get; init; }
|
||||
public required int Added { get; init; }
|
||||
public required int Removed { get; init; }
|
||||
public required int Unchanged { get; init; }
|
||||
public required ImmutableDictionary<string, int> Verdicts { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BinaryDiffProgress
|
||||
{
|
||||
public required string Phase { get; init; }
|
||||
public required string CurrentItem { get; init; }
|
||||
public required int Current { get; init; }
|
||||
public required int Total { get; init; }
|
||||
}
|
||||
247
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRegistryAuth.cs
Normal file
247
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRegistryAuth.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal sealed record RegistryAuthCredentials(string Username, string Password);
|
||||
|
||||
internal sealed class RegistryAuthConfig
|
||||
{
|
||||
public RegistryAuthConfig(Dictionary<string, RegistryAuthCredentials> auths)
|
||||
{
|
||||
Auths = auths;
|
||||
}
|
||||
|
||||
public Dictionary<string, RegistryAuthCredentials> Auths { get; }
|
||||
}
|
||||
|
||||
internal sealed class RegistryAuthScope : IDisposable
|
||||
{
|
||||
private readonly string? _previousUser;
|
||||
private readonly string? _previousPassword;
|
||||
private bool _disposed;
|
||||
|
||||
private RegistryAuthScope(string? previousUser, string? previousPassword)
|
||||
{
|
||||
_previousUser = previousUser;
|
||||
_previousPassword = previousPassword;
|
||||
}
|
||||
|
||||
public static RegistryAuthScope? Apply(RegistryAuthConfig? config, string registry)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!TryResolveCredentials(config, registry, out var credentials))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var previousUser = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME");
|
||||
var previousPassword = Environment.GetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD");
|
||||
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME", credentials!.Username);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD", credentials.Password);
|
||||
|
||||
return new RegistryAuthScope(previousUser, previousPassword);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_USERNAME", _previousUser);
|
||||
Environment.SetEnvironmentVariable("STELLAOPS_REGISTRY_PASSWORD", _previousPassword);
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static bool TryResolveCredentials(
|
||||
RegistryAuthConfig config,
|
||||
string registry,
|
||||
out RegistryAuthCredentials? credentials)
|
||||
{
|
||||
credentials = null;
|
||||
var normalized = NormalizeRegistryKey(registry);
|
||||
|
||||
if (config.Auths.TryGetValue(normalized, out var resolved))
|
||||
{
|
||||
credentials = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeRegistryKey(string registry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(registry))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = registry.Trim();
|
||||
if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
|
||||
{
|
||||
trimmed = uri.Authority;
|
||||
}
|
||||
}
|
||||
|
||||
trimmed = trimmed.TrimEnd('/');
|
||||
if (string.Equals(trimmed, "index.docker.io", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(trimmed, "registry-1.docker.io", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "docker.io";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RegistryAuthConfigLoader
|
||||
{
|
||||
public static RegistryAuthConfig? Load(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.RegistryAuthInvalid,
|
||||
$"Registry auth file not found: {path}");
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("auths", out var authsElement) ||
|
||||
authsElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.RegistryAuthInvalid,
|
||||
"Registry auth file does not contain an auths section.");
|
||||
}
|
||||
|
||||
var auths = new Dictionary<string, RegistryAuthCredentials>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var authEntry in authsElement.EnumerateObject())
|
||||
{
|
||||
var key = authEntry.Name;
|
||||
if (authEntry.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseAuthEntry(authEntry.Value, out var credentials))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = NormalizeRegistryKey(key);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
auths[normalized] = credentials!;
|
||||
}
|
||||
|
||||
return new RegistryAuthConfig(auths);
|
||||
}
|
||||
|
||||
private static bool TryParseAuthEntry(JsonElement authEntry, out RegistryAuthCredentials? credentials)
|
||||
{
|
||||
credentials = null;
|
||||
|
||||
if (authEntry.TryGetProperty("auth", out var authValue) &&
|
||||
authValue.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var decoded = TryDecodeBasicAuth(authValue.GetString());
|
||||
if (decoded is not null)
|
||||
{
|
||||
credentials = decoded;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (authEntry.TryGetProperty("username", out var userValue) &&
|
||||
authEntry.TryGetProperty("password", out var passwordValue) &&
|
||||
userValue.ValueKind == JsonValueKind.String &&
|
||||
passwordValue.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var username = userValue.GetString();
|
||||
var password = passwordValue.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
credentials = new RegistryAuthCredentials(username!, password!);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static RegistryAuthCredentials? TryDecodeBasicAuth(string? encoded)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(encoded))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
|
||||
var parts = decoded.Split(':', 2);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RegistryAuthCredentials(parts[0], parts[1]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRegistryKey(string registry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(registry))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = registry.Trim();
|
||||
if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
|
||||
{
|
||||
trimmed = uri.Authority;
|
||||
}
|
||||
}
|
||||
|
||||
trimmed = trimmed.TrimEnd('/');
|
||||
if (string.Equals(trimmed, "index.docker.io", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(trimmed, "registry-1.docker.io", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "docker.io";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
292
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRenderer.cs
Normal file
292
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffRenderer.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal interface IBinaryDiffRenderer
|
||||
{
|
||||
Task RenderAsync(
|
||||
BinaryDiffResult result,
|
||||
BinaryDiffOutputFormat format,
|
||||
TextWriter writer,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class BinaryDiffRenderer : IBinaryDiffRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public Task RenderAsync(
|
||||
BinaryDiffResult result,
|
||||
BinaryDiffOutputFormat format,
|
||||
TextWriter writer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
return format switch
|
||||
{
|
||||
BinaryDiffOutputFormat.Json => RenderJsonAsync(result, writer, cancellationToken),
|
||||
BinaryDiffOutputFormat.Summary => RenderSummaryAsync(result, writer, cancellationToken),
|
||||
_ => RenderTableAsync(result, writer, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
private static Task RenderTableAsync(BinaryDiffResult result, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
writer.WriteLine($"Binary Diff: {FormatReference(result.Base)} -> {FormatReference(result.Target)}");
|
||||
writer.WriteLine($"Platform: {FormatPlatform(result.Platform)}");
|
||||
writer.WriteLine($"Analysis Mode: {FormatMode(result.Mode)}");
|
||||
writer.WriteLine();
|
||||
|
||||
var rows = result.Findings
|
||||
.OrderBy(finding => finding.Path, StringComparer.Ordinal)
|
||||
.Select(finding => new TableRow(
|
||||
finding.Path,
|
||||
finding.ChangeType.ToString().ToLowerInvariant(),
|
||||
FormatVerdict(finding.Verdict),
|
||||
FormatConfidence(finding.Confidence),
|
||||
FormatSections(finding.SectionDeltas)))
|
||||
.ToList();
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
writer.WriteLine("No ELF binaries found.");
|
||||
writer.WriteLine();
|
||||
WriteSummary(writer, result.Summary);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var widths = ComputeWidths(rows);
|
||||
WriteHeader(writer, widths);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
writer.WriteLine($"{row.Path.PadRight(widths.Path)} {row.Change.PadRight(widths.Change)} {row.Verdict.PadRight(widths.Verdict)} {row.Confidence.PadRight(widths.Confidence)} {row.Sections}");
|
||||
}
|
||||
|
||||
writer.WriteLine();
|
||||
WriteSummary(writer, result.Summary);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task RenderSummaryAsync(BinaryDiffResult result, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
writer.WriteLine("Binary Diff Summary");
|
||||
writer.WriteLine("-------------------");
|
||||
writer.WriteLine($"Base: {FormatReference(result.Base)}");
|
||||
writer.WriteLine($"Target: {FormatReference(result.Target)}");
|
||||
writer.WriteLine($"Platform: {FormatPlatform(result.Platform)}");
|
||||
writer.WriteLine();
|
||||
writer.WriteLine($"Binaries: {result.Summary.TotalBinaries} total, {result.Summary.Modified} modified, {result.Summary.Unchanged} unchanged");
|
||||
writer.WriteLine($"Added: {result.Summary.Added}, Removed: {result.Summary.Removed}");
|
||||
|
||||
if (result.Summary.Verdicts.Count > 0)
|
||||
{
|
||||
var verdicts = string.Join(", ", result.Summary.Verdicts
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
|
||||
writer.WriteLine($"Verdicts: {verdicts}");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task RenderJsonAsync(BinaryDiffResult result, TextWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var report = new BinaryDiffReport
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Base = new BinaryDiffReportImage
|
||||
{
|
||||
Reference = result.Base.Reference,
|
||||
Digest = result.Base.Digest
|
||||
},
|
||||
Target = new BinaryDiffReportImage
|
||||
{
|
||||
Reference = result.Target.Reference,
|
||||
Digest = result.Target.Digest
|
||||
},
|
||||
Platform = result.Platform,
|
||||
AnalysisMode = result.Mode.ToString().ToLowerInvariant(),
|
||||
Timestamp = result.Metadata.AnalysisTimestamp,
|
||||
Findings = result.Findings,
|
||||
Summary = new BinaryDiffReportSummary
|
||||
{
|
||||
TotalBinaries = result.Summary.TotalBinaries,
|
||||
Modified = result.Summary.Modified,
|
||||
Unchanged = result.Summary.Unchanged,
|
||||
Added = result.Summary.Added,
|
||||
Removed = result.Summary.Removed,
|
||||
Verdicts = result.Summary.Verdicts
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(report, JsonOptions);
|
||||
var canonical = JsonCanonicalizer.Canonicalize(json);
|
||||
writer.WriteLine(canonical);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void WriteHeader(TextWriter writer, TableWidths widths)
|
||||
{
|
||||
writer.WriteLine($"{Pad("PATH", widths.Path)} {Pad("CHANGE", widths.Change)} {Pad("VERDICT", widths.Verdict)} {Pad("CONFIDENCE", widths.Confidence)} SECTIONS CHANGED");
|
||||
writer.WriteLine($"{new string('-', widths.Path)} {new string('-', widths.Change)} {new string('-', widths.Verdict)} {new string('-', widths.Confidence)} {new string('-', 16)}");
|
||||
}
|
||||
|
||||
private static void WriteSummary(TextWriter writer, BinaryDiffSummary summary)
|
||||
{
|
||||
writer.WriteLine($"Summary: {summary.TotalBinaries} binaries analyzed, {summary.Modified} modified, {summary.Unchanged} unchanged");
|
||||
writer.WriteLine($" Added: {summary.Added}, Removed: {summary.Removed}");
|
||||
|
||||
if (summary.Verdicts.Count > 0)
|
||||
{
|
||||
var verdicts = string.Join(", ", summary.Verdicts
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
|
||||
writer.WriteLine($" Verdicts: {verdicts}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatReference(BinaryDiffImageReference reference)
|
||||
{
|
||||
return reference.Reference ?? reference.Digest;
|
||||
}
|
||||
|
||||
private static string FormatPlatform(BinaryDiffPlatform platform)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform.Variant))
|
||||
{
|
||||
return $"{platform.Os}/{platform.Architecture}";
|
||||
}
|
||||
|
||||
return $"{platform.Os}/{platform.Architecture}/{platform.Variant}";
|
||||
}
|
||||
|
||||
private static string FormatMode(BinaryDiffMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
BinaryDiffMode.Elf => "ELF section hashes",
|
||||
BinaryDiffMode.Pe => "PE section hashes",
|
||||
_ => "ELF section hashes"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatVerdict(StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict? verdict)
|
||||
{
|
||||
return verdict is null ? "-" : verdict.Value.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string FormatConfidence(double? confidence)
|
||||
{
|
||||
return confidence.HasValue
|
||||
? confidence.Value.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string FormatSections(ImmutableArray<SectionDelta> deltas)
|
||||
{
|
||||
if (deltas.IsDefaultOrEmpty)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
var sections = deltas
|
||||
.Where(delta => delta.Status != SectionStatus.Identical)
|
||||
.Select(delta => delta.Section)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(section => section, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return sections.Length == 0 ? "-" : string.Join(", ", sections);
|
||||
}
|
||||
|
||||
private static TableWidths ComputeWidths(IEnumerable<TableRow> rows)
|
||||
{
|
||||
var pathWidth = Math.Max(4, rows.Max(row => row.Path.Length));
|
||||
var changeWidth = Math.Max(6, rows.Max(row => row.Change.Length));
|
||||
var verdictWidth = Math.Max(7, rows.Max(row => row.Verdict.Length));
|
||||
var confidenceWidth = Math.Max(10, rows.Max(row => row.Confidence.Length));
|
||||
|
||||
return new TableWidths(pathWidth, changeWidth, verdictWidth, confidenceWidth);
|
||||
}
|
||||
|
||||
private static string Pad(string value, int width) => value.PadRight(width);
|
||||
|
||||
private sealed record TableRow(string Path, string Change, string Verdict, string Confidence, string Sections);
|
||||
|
||||
private sealed record TableWidths(int Path, int Change, int Verdict, int Confidence);
|
||||
|
||||
private sealed record BinaryDiffReport
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("base")]
|
||||
public required BinaryDiffReportImage Base { get; init; }
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public required BinaryDiffReportImage Target { get; init; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public required BinaryDiffPlatform Platform { get; init; }
|
||||
|
||||
[JsonPropertyName("analysisMode")]
|
||||
public required string AnalysisMode { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("findings")]
|
||||
public required ImmutableArray<BinaryDiffFinding> Findings { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public required BinaryDiffReportSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BinaryDiffReportImage
|
||||
{
|
||||
[JsonPropertyName("reference")]
|
||||
public string? Reference { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BinaryDiffReportSummary
|
||||
{
|
||||
[JsonPropertyName("totalBinaries")]
|
||||
public required int TotalBinaries { get; init; }
|
||||
|
||||
[JsonPropertyName("modified")]
|
||||
public required int Modified { get; init; }
|
||||
|
||||
[JsonPropertyName("unchanged")]
|
||||
public required int Unchanged { get; init; }
|
||||
|
||||
[JsonPropertyName("added")]
|
||||
public required int Added { get; init; }
|
||||
|
||||
[JsonPropertyName("removed")]
|
||||
public required int Removed { get; init; }
|
||||
|
||||
[JsonPropertyName("verdicts")]
|
||||
public required ImmutableDictionary<string, int> Verdicts { get; init; }
|
||||
}
|
||||
}
|
||||
862
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffService.cs
Normal file
862
src/Cli/StellaOps.Cli/Commands/Scan/BinaryDiffService.cs
Normal file
@@ -0,0 +1,862 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using BinaryDiffVerdict = StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Scanner.Analyzers.Native;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Scan;
|
||||
|
||||
internal interface IBinaryDiffService
|
||||
{
|
||||
Task<BinaryDiffResult> ComputeDiffAsync(
|
||||
BinaryDiffRequest request,
|
||||
IProgress<BinaryDiffProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class BinaryDiffService : IBinaryDiffService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly IElfSectionHashExtractor _elfExtractor;
|
||||
private readonly IOptions<BinaryDiffOptions> _diffOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<BinaryDiffService> _logger;
|
||||
|
||||
public BinaryDiffService(
|
||||
IOciRegistryClient registryClient,
|
||||
IElfSectionHashExtractor elfExtractor,
|
||||
IOptions<BinaryDiffOptions> diffOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<BinaryDiffService> logger)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_elfExtractor = elfExtractor ?? throw new ArgumentNullException(nameof(elfExtractor));
|
||||
_diffOptions = diffOptions ?? throw new ArgumentNullException(nameof(diffOptions));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BinaryDiffResult> ComputeDiffAsync(
|
||||
BinaryDiffRequest request,
|
||||
IProgress<BinaryDiffProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (request.Mode == BinaryDiffMode.Pe)
|
||||
{
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.UnsupportedMode,
|
||||
"PE mode is not supported yet.");
|
||||
}
|
||||
|
||||
var baseReference = ParseReference(request.BaseImageRef);
|
||||
var targetReference = ParseReference(request.TargetImageRef);
|
||||
var registryAuth = RegistryAuthConfigLoader.Load(request.RegistryAuthPath);
|
||||
var analyzedSections = NormalizeSections(request.Sections);
|
||||
|
||||
ResolvedManifest baseResolved;
|
||||
string baseDigest;
|
||||
Dictionary<string, BinaryDiffFileEntry> baseFiles;
|
||||
using (RegistryAuthScope.Apply(registryAuth, baseReference.Registry))
|
||||
{
|
||||
baseDigest = await _registryClient.ResolveDigestAsync(baseReference, cancellationToken).ConfigureAwait(false);
|
||||
baseResolved = await ResolvePlatformManifestAsync(
|
||||
baseReference,
|
||||
baseDigest,
|
||||
request.Platform,
|
||||
progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
baseFiles = await ExtractElfFilesAsync(
|
||||
baseReference,
|
||||
baseResolved.Manifest,
|
||||
analyzedSections,
|
||||
progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ResolvedManifest targetResolved;
|
||||
string targetDigest;
|
||||
Dictionary<string, BinaryDiffFileEntry> targetFiles;
|
||||
using (RegistryAuthScope.Apply(registryAuth, targetReference.Registry))
|
||||
{
|
||||
targetDigest = await _registryClient.ResolveDigestAsync(targetReference, cancellationToken).ConfigureAwait(false);
|
||||
targetResolved = await ResolvePlatformManifestAsync(
|
||||
targetReference,
|
||||
targetDigest,
|
||||
request.Platform,
|
||||
progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
targetFiles = await ExtractElfFilesAsync(
|
||||
targetReference,
|
||||
targetResolved.Manifest,
|
||||
analyzedSections,
|
||||
progress,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var platform = ResolvePlatform(request.Platform, baseResolved.Platform, targetResolved.Platform);
|
||||
var allFindings = ComputeFindings(baseFiles, targetFiles);
|
||||
var summary = ComputeSummary(allFindings);
|
||||
var outputFindings = request.IncludeUnchanged
|
||||
? allFindings
|
||||
: allFindings.Where(finding => finding.ChangeType != ChangeType.Unchanged).ToImmutableArray();
|
||||
var predicate = BuildPredicate(
|
||||
baseReference,
|
||||
targetReference,
|
||||
baseDigest,
|
||||
targetDigest,
|
||||
baseResolved.ManifestDigest,
|
||||
targetResolved.ManifestDigest,
|
||||
platform,
|
||||
allFindings,
|
||||
summary,
|
||||
analyzedSections);
|
||||
|
||||
return new BinaryDiffResult
|
||||
{
|
||||
Base = new BinaryDiffImageReference
|
||||
{
|
||||
Reference = request.BaseImageRef,
|
||||
Digest = baseDigest,
|
||||
ManifestDigest = baseResolved.ManifestDigest,
|
||||
Platform = platform
|
||||
},
|
||||
Target = new BinaryDiffImageReference
|
||||
{
|
||||
Reference = request.TargetImageRef,
|
||||
Digest = targetDigest,
|
||||
ManifestDigest = targetResolved.ManifestDigest,
|
||||
Platform = platform
|
||||
},
|
||||
Platform = platform,
|
||||
Mode = request.Mode == BinaryDiffMode.Auto ? BinaryDiffMode.Elf : request.Mode,
|
||||
Findings = outputFindings,
|
||||
Summary = summary,
|
||||
Metadata = predicate.Metadata,
|
||||
Predicate = predicate,
|
||||
BaseReference = baseReference,
|
||||
TargetReference = targetReference
|
||||
};
|
||||
}
|
||||
|
||||
private static OciImageReference ParseReference(string reference)
|
||||
{
|
||||
try
|
||||
{
|
||||
return OciImageReferenceParser.Parse(reference);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new BinaryDiffException(BinaryDiffErrorCode.InvalidReference, ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string>? NormalizeSections(ImmutableArray<string> sections)
|
||||
{
|
||||
if (sections.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
|
||||
foreach (var section in sections)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(section))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(section.Trim());
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? null : builder.ToImmutable();
|
||||
}
|
||||
|
||||
private BinaryDiffPredicate BuildPredicate(
|
||||
OciImageReference baseReference,
|
||||
OciImageReference targetReference,
|
||||
string baseDigest,
|
||||
string targetDigest,
|
||||
string? baseManifestDigest,
|
||||
string? targetManifestDigest,
|
||||
BinaryDiffPlatform platform,
|
||||
ImmutableArray<BinaryDiffFinding> findings,
|
||||
BinaryDiffSummary summary,
|
||||
ImmutableHashSet<string>? analyzedSections)
|
||||
{
|
||||
var builder = new BinaryDiffPredicateBuilder(_diffOptions, _timeProvider);
|
||||
builder.WithSubject(targetReference.Original, targetDigest, platform)
|
||||
.WithInputs(
|
||||
new BinaryDiffImageReference
|
||||
{
|
||||
Reference = baseReference.Original,
|
||||
Digest = baseDigest,
|
||||
ManifestDigest = baseManifestDigest,
|
||||
Platform = platform
|
||||
},
|
||||
new BinaryDiffImageReference
|
||||
{
|
||||
Reference = targetReference.Original,
|
||||
Digest = targetDigest,
|
||||
ManifestDigest = targetManifestDigest,
|
||||
Platform = platform
|
||||
});
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
builder.AddFinding(finding);
|
||||
}
|
||||
|
||||
builder.WithMetadata(metadataBuilder =>
|
||||
{
|
||||
metadataBuilder
|
||||
.WithToolVersion(ResolveToolVersion())
|
||||
.WithAnalysisTimestamp(_timeProvider.GetUtcNow())
|
||||
.WithTotals(summary.TotalBinaries, summary.Modified);
|
||||
|
||||
if (analyzedSections is { Count: > 0 })
|
||||
{
|
||||
metadataBuilder.WithAnalyzedSections(analyzedSections);
|
||||
}
|
||||
});
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private async Task<ResolvedManifest> ResolvePlatformManifestAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
BinaryDiffPlatform? platformFilter,
|
||||
IProgress<BinaryDiffProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
progress?.Report(new BinaryDiffProgress
|
||||
{
|
||||
Phase = "pulling",
|
||||
CurrentItem = $"manifest:{digest}",
|
||||
Current = 1,
|
||||
Total = 1
|
||||
});
|
||||
|
||||
var manifest = await _registryClient.GetManifestAsync(reference, digest, cancellationToken).ConfigureAwait(false);
|
||||
if (manifest.Manifests is { Count: > 0 })
|
||||
{
|
||||
var selected = SelectManifestDescriptor(manifest.Manifests, platformFilter);
|
||||
if (selected is null)
|
||||
{
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.PlatformNotFound,
|
||||
platformFilter is null
|
||||
? "Platform is required when image index contains multiple manifests."
|
||||
: $"Platform '{FormatPlatform(platformFilter)}' not found in image index.");
|
||||
}
|
||||
|
||||
var platform = BuildPlatform(selected.Platform) ?? platformFilter ?? UnknownPlatform();
|
||||
var resolved = await _registryClient.GetManifestAsync(reference, selected.Digest, cancellationToken).ConfigureAwait(false);
|
||||
return new ResolvedManifest(resolved, selected.Digest, platform);
|
||||
}
|
||||
|
||||
var inferredPlatform = platformFilter ?? await TryResolvePlatformFromConfigAsync(reference, manifest, cancellationToken).ConfigureAwait(false)
|
||||
?? UnknownPlatform();
|
||||
return new ResolvedManifest(manifest, digest, inferredPlatform);
|
||||
}
|
||||
|
||||
private static OciIndexDescriptor? SelectManifestDescriptor(
|
||||
IReadOnlyList<OciIndexDescriptor> manifests,
|
||||
BinaryDiffPlatform? platformFilter)
|
||||
{
|
||||
var candidates = manifests
|
||||
.Where(descriptor => descriptor.Platform is not null)
|
||||
.OrderBy(descriptor => descriptor.Platform!.Os, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(descriptor => descriptor.Platform!.Architecture, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(descriptor => descriptor.Platform!.Variant, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (platformFilter is null)
|
||||
{
|
||||
return candidates.Count == 1 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(descriptor =>
|
||||
IsPlatformMatch(platformFilter, descriptor.Platform));
|
||||
}
|
||||
|
||||
private static bool IsPlatformMatch(BinaryDiffPlatform platform, OciPlatform? candidate)
|
||||
{
|
||||
if (candidate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var osMatch = string.Equals(platform.Os, candidate.Os, StringComparison.OrdinalIgnoreCase);
|
||||
var archMatch = string.Equals(platform.Architecture, candidate.Architecture, StringComparison.OrdinalIgnoreCase);
|
||||
if (!osMatch || !archMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(platform.Variant))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(platform.Variant, candidate.Variant, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static BinaryDiffPlatform? BuildPlatform(OciPlatform? platform)
|
||||
{
|
||||
if (platform is null ||
|
||||
string.IsNullOrWhiteSpace(platform.Os) ||
|
||||
string.IsNullOrWhiteSpace(platform.Architecture))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BinaryDiffPlatform
|
||||
{
|
||||
Os = platform.Os!,
|
||||
Architecture = platform.Architecture!,
|
||||
Variant = string.IsNullOrWhiteSpace(platform.Variant) ? null : platform.Variant
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<BinaryDiffPlatform?> TryResolvePlatformFromConfigAsync(
|
||||
OciImageReference reference,
|
||||
OciManifest manifest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (manifest.Config?.Digest is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var blob = await _registryClient.GetBlobAsync(reference, manifest.Config.Digest, cancellationToken).ConfigureAwait(false);
|
||||
var config = JsonSerializer.Deserialize<OciImageConfig>(blob, JsonOptions);
|
||||
if (config is null ||
|
||||
string.IsNullOrWhiteSpace(config.Os) ||
|
||||
string.IsNullOrWhiteSpace(config.Architecture))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BinaryDiffPlatform
|
||||
{
|
||||
Os = config.Os,
|
||||
Architecture = config.Architecture,
|
||||
Variant = string.IsNullOrWhiteSpace(config.Variant) ? null : config.Variant
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read image config for platform detection.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static BinaryDiffPlatform ResolvePlatform(
|
||||
BinaryDiffPlatform? requested,
|
||||
BinaryDiffPlatform? basePlatform,
|
||||
BinaryDiffPlatform? targetPlatform)
|
||||
{
|
||||
if (requested is not null)
|
||||
{
|
||||
return requested;
|
||||
}
|
||||
|
||||
if (basePlatform is not null && targetPlatform is not null)
|
||||
{
|
||||
var osMatch = string.Equals(basePlatform.Os, targetPlatform.Os, StringComparison.OrdinalIgnoreCase);
|
||||
var archMatch = string.Equals(basePlatform.Architecture, targetPlatform.Architecture, StringComparison.OrdinalIgnoreCase);
|
||||
if (osMatch && archMatch)
|
||||
{
|
||||
return basePlatform;
|
||||
}
|
||||
|
||||
throw new BinaryDiffException(
|
||||
BinaryDiffErrorCode.PlatformNotFound,
|
||||
$"Base platform '{FormatPlatform(basePlatform)}' does not match target platform '{FormatPlatform(targetPlatform)}'.");
|
||||
}
|
||||
|
||||
return basePlatform ?? targetPlatform ?? UnknownPlatform();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, BinaryDiffFileEntry>> ExtractElfFilesAsync(
|
||||
OciImageReference reference,
|
||||
OciManifest manifest,
|
||||
ImmutableHashSet<string>? sections,
|
||||
IProgress<BinaryDiffProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var files = new Dictionary<string, BinaryDiffFileEntry>(StringComparer.Ordinal);
|
||||
var layers = manifest.Layers ?? new List<OciDescriptor>();
|
||||
var totalLayers = layers.Count;
|
||||
|
||||
for (var index = 0; index < totalLayers; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var layer = layers[index];
|
||||
progress?.Report(new BinaryDiffProgress
|
||||
{
|
||||
Phase = "pulling",
|
||||
CurrentItem = layer.Digest,
|
||||
Current = index + 1,
|
||||
Total = totalLayers
|
||||
});
|
||||
|
||||
var blob = await _registryClient.GetBlobAsync(reference, layer.Digest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress?.Report(new BinaryDiffProgress
|
||||
{
|
||||
Phase = "extracting",
|
||||
CurrentItem = layer.Digest,
|
||||
Current = index + 1,
|
||||
Total = totalLayers
|
||||
});
|
||||
|
||||
await using var layerStream = OpenLayerStream(layer.MediaType, blob);
|
||||
using var tarReader = new TarReader(layerStream, leaveOpen: false);
|
||||
TarEntry? entry;
|
||||
while ((entry = tarReader.GetNextEntry()) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (entry.EntryType is TarEntryType.Directory or TarEntryType.SymbolicLink or TarEntryType.HardLink)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = NormalizeTarPath(entry.Name);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryApplyWhiteout(files, path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsRegularFile(entry.EntryType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.DataStream is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await ReadEntryAsync(entry.DataStream, cancellationToken).ConfigureAwait(false);
|
||||
if (!IsElf(content.Span))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hashSet = await _elfExtractor.ExtractFromBytesAsync(content, path, cancellationToken).ConfigureAwait(false);
|
||||
if (hashSet is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = MapSectionHashSet(hashSet, sections);
|
||||
files[path] = new BinaryDiffFileEntry
|
||||
{
|
||||
Path = path,
|
||||
Hashes = normalized,
|
||||
LayerDigest = layer.Digest
|
||||
};
|
||||
|
||||
progress?.Report(new BinaryDiffProgress
|
||||
{
|
||||
Phase = "analyzing",
|
||||
CurrentItem = path,
|
||||
Current = files.Count,
|
||||
Total = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static bool IsRegularFile(TarEntryType entryType)
|
||||
{
|
||||
return entryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile;
|
||||
}
|
||||
|
||||
private static MemoryStream OpenLayerStream(string? mediaType, byte[] blob)
|
||||
{
|
||||
var input = new MemoryStream(blob, writable: false);
|
||||
if (mediaType is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
if (mediaType.Contains("gzip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new MemoryStream(DecompressGzip(input));
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
private static byte[] DecompressGzip(Stream input)
|
||||
{
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<ReadOnlyMemory<byte>> ReadEntryAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsElf(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
return bytes.Length >= 4 &&
|
||||
bytes[0] == 0x7F &&
|
||||
bytes[1] == (byte)'E' &&
|
||||
bytes[2] == (byte)'L' &&
|
||||
bytes[3] == (byte)'F';
|
||||
}
|
||||
|
||||
private static bool TryApplyWhiteout(Dictionary<string, BinaryDiffFileEntry> files, string path)
|
||||
{
|
||||
var fileName = GetFileName(path);
|
||||
if (string.Equals(fileName, ".wh..wh..opq", StringComparison.Ordinal))
|
||||
{
|
||||
var directory = GetDirectoryName(path);
|
||||
var prefix = directory.EndsWith("/", StringComparison.Ordinal)
|
||||
? directory
|
||||
: directory + "/";
|
||||
var keysToRemove = files.Keys
|
||||
.Where(key => key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
files.Remove(key);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fileName.StartsWith(".wh.", StringComparison.Ordinal))
|
||||
{
|
||||
var directory = GetDirectoryName(path);
|
||||
var target = CombinePath(directory, fileName[4..]);
|
||||
files.Remove(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeTarPath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = path.Replace('\\', '/');
|
||||
while (normalized.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized[2..];
|
||||
}
|
||||
|
||||
normalized = normalized.Trim('/');
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return "/" + normalized;
|
||||
}
|
||||
|
||||
private static string GetFileName(string path)
|
||||
{
|
||||
var index = path.LastIndexOf("/", StringComparison.Ordinal);
|
||||
return index >= 0 ? path[(index + 1)..] : path;
|
||||
}
|
||||
|
||||
private static string GetDirectoryName(string path)
|
||||
{
|
||||
var index = path.LastIndexOf("/", StringComparison.Ordinal);
|
||||
if (index <= 0)
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
return path[..index];
|
||||
}
|
||||
|
||||
private static string CombinePath(string directory, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory) || directory == "/")
|
||||
{
|
||||
return "/" + fileName;
|
||||
}
|
||||
|
||||
return directory + "/" + fileName;
|
||||
}
|
||||
|
||||
private static SectionHashSet MapSectionHashSet(ElfSectionHashSet hashSet, ImmutableHashSet<string>? filter)
|
||||
{
|
||||
var sections = ImmutableDictionary.CreateBuilder<string, SectionInfo>(StringComparer.Ordinal);
|
||||
foreach (var section in hashSet.Sections.OrderBy(section => section.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (filter is not null && !filter.Contains(section.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sections[section.Name] = new SectionInfo
|
||||
{
|
||||
Sha256 = section.Sha256,
|
||||
Blake3 = section.Blake3,
|
||||
Size = section.Size
|
||||
};
|
||||
}
|
||||
|
||||
return new SectionHashSet
|
||||
{
|
||||
BuildId = hashSet.BuildId,
|
||||
FileHash = hashSet.FileHash,
|
||||
Sections = sections.ToImmutable()
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<BinaryDiffFinding> ComputeFindings(
|
||||
Dictionary<string, BinaryDiffFileEntry> baseFiles,
|
||||
Dictionary<string, BinaryDiffFileEntry> targetFiles)
|
||||
{
|
||||
var paths = baseFiles.Keys
|
||||
.Union(targetFiles.Keys, StringComparer.Ordinal)
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var findings = ImmutableArray.CreateBuilder<BinaryDiffFinding>(paths.Count);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
baseFiles.TryGetValue(path, out var baseEntry);
|
||||
targetFiles.TryGetValue(path, out var targetEntry);
|
||||
|
||||
var deltas = ComputeSectionDeltas(baseEntry?.Hashes, targetEntry?.Hashes);
|
||||
var changeType = ResolveChangeType(baseEntry, targetEntry, deltas);
|
||||
var verdict = changeType == ChangeType.Unchanged ? BinaryDiffVerdict.Vanilla : BinaryDiffVerdict.Unknown;
|
||||
var confidence = ComputeConfidence(baseEntry?.Hashes, targetEntry?.Hashes, deltas);
|
||||
|
||||
findings.Add(new BinaryDiffFinding
|
||||
{
|
||||
Path = path,
|
||||
ChangeType = changeType,
|
||||
BinaryFormat = BinaryFormat.Elf,
|
||||
LayerDigest = targetEntry?.LayerDigest ?? baseEntry?.LayerDigest,
|
||||
BaseHashes = baseEntry?.Hashes,
|
||||
TargetHashes = targetEntry?.Hashes,
|
||||
SectionDeltas = deltas,
|
||||
Confidence = confidence,
|
||||
Verdict = verdict
|
||||
});
|
||||
}
|
||||
|
||||
return findings.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<SectionDelta> ComputeSectionDeltas(
|
||||
SectionHashSet? baseHashes,
|
||||
SectionHashSet? targetHashes)
|
||||
{
|
||||
if (baseHashes is null && targetHashes is null)
|
||||
{
|
||||
return ImmutableArray<SectionDelta>.Empty;
|
||||
}
|
||||
|
||||
var baseSections = baseHashes?.Sections ?? ImmutableDictionary<string, SectionInfo>.Empty;
|
||||
var targetSections = targetHashes?.Sections ?? ImmutableDictionary<string, SectionInfo>.Empty;
|
||||
|
||||
var sectionNames = baseSections.Keys
|
||||
.Union(targetSections.Keys, StringComparer.Ordinal)
|
||||
.OrderBy(name => name, StringComparer.Ordinal);
|
||||
|
||||
var deltas = new List<SectionDelta>();
|
||||
foreach (var name in sectionNames)
|
||||
{
|
||||
var hasBase = baseSections.TryGetValue(name, out var baseInfo);
|
||||
var hasTarget = targetSections.TryGetValue(name, out var targetInfo);
|
||||
|
||||
if (hasBase && hasTarget)
|
||||
{
|
||||
var identical = string.Equals(baseInfo!.Sha256, targetInfo!.Sha256, StringComparison.Ordinal);
|
||||
if (!identical)
|
||||
{
|
||||
deltas.Add(new SectionDelta
|
||||
{
|
||||
Section = name,
|
||||
Status = SectionStatus.Modified,
|
||||
BaseSha256 = baseInfo.Sha256,
|
||||
TargetSha256 = targetInfo.Sha256,
|
||||
SizeDelta = targetInfo.Size - baseInfo.Size
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (hasBase)
|
||||
{
|
||||
deltas.Add(new SectionDelta
|
||||
{
|
||||
Section = name,
|
||||
Status = SectionStatus.Removed,
|
||||
BaseSha256 = baseInfo!.Sha256,
|
||||
TargetSha256 = null,
|
||||
SizeDelta = -baseInfo.Size
|
||||
});
|
||||
}
|
||||
else if (hasTarget)
|
||||
{
|
||||
deltas.Add(new SectionDelta
|
||||
{
|
||||
Section = name,
|
||||
Status = SectionStatus.Added,
|
||||
BaseSha256 = null,
|
||||
TargetSha256 = targetInfo!.Sha256,
|
||||
SizeDelta = targetInfo.Size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return deltas.OrderBy(delta => delta.Section, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ChangeType ResolveChangeType(
|
||||
BinaryDiffFileEntry? baseEntry,
|
||||
BinaryDiffFileEntry? targetEntry,
|
||||
ImmutableArray<SectionDelta> deltas)
|
||||
{
|
||||
if (baseEntry is null && targetEntry is not null)
|
||||
{
|
||||
return ChangeType.Added;
|
||||
}
|
||||
|
||||
if (baseEntry is not null && targetEntry is null)
|
||||
{
|
||||
return ChangeType.Removed;
|
||||
}
|
||||
|
||||
if (deltas.Length == 0)
|
||||
{
|
||||
return ChangeType.Unchanged;
|
||||
}
|
||||
|
||||
return ChangeType.Modified;
|
||||
}
|
||||
|
||||
private static double? ComputeConfidence(
|
||||
SectionHashSet? baseHashes,
|
||||
SectionHashSet? targetHashes,
|
||||
ImmutableArray<SectionDelta> deltas)
|
||||
{
|
||||
if (baseHashes is null || targetHashes is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var totalSections = baseHashes.Sections.Count + targetHashes.Sections.Count;
|
||||
if (totalSections == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var changedCount = deltas.Length;
|
||||
var ratio = 1.0 - (changedCount / (double)totalSections);
|
||||
return Math.Clamp(Math.Round(ratio, 4, MidpointRounding.ToZero), 0.0, 1.0);
|
||||
}
|
||||
|
||||
private static BinaryDiffSummary ComputeSummary(ImmutableArray<BinaryDiffFinding> findings)
|
||||
{
|
||||
var total = findings.Length;
|
||||
var added = findings.Count(f => f.ChangeType == ChangeType.Added);
|
||||
var removed = findings.Count(f => f.ChangeType == ChangeType.Removed);
|
||||
var unchanged = findings.Count(f => f.ChangeType == ChangeType.Unchanged);
|
||||
var modified = total - unchanged;
|
||||
|
||||
var verdicts = ImmutableDictionary.CreateBuilder<string, int>(StringComparer.Ordinal);
|
||||
foreach (var verdict in findings.Select(finding => finding.Verdict).Where(v => v.HasValue))
|
||||
{
|
||||
var key = verdict!.Value.ToString().ToLowerInvariant();
|
||||
verdicts[key] = verdicts.TryGetValue(key, out var count) ? count + 1 : 1;
|
||||
}
|
||||
|
||||
return new BinaryDiffSummary
|
||||
{
|
||||
TotalBinaries = total,
|
||||
Modified = modified,
|
||||
Added = added,
|
||||
Removed = removed,
|
||||
Unchanged = unchanged,
|
||||
Verdicts = verdicts.ToImmutable()
|
||||
};
|
||||
}
|
||||
|
||||
private static BinaryDiffPlatform UnknownPlatform()
|
||||
{
|
||||
return new BinaryDiffPlatform
|
||||
{
|
||||
Os = "unknown",
|
||||
Architecture = "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatPlatform(BinaryDiffPlatform platform)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform.Variant))
|
||||
{
|
||||
return $"{platform.Os}/{platform.Architecture}";
|
||||
}
|
||||
|
||||
return $"{platform.Os}/{platform.Architecture}/{platform.Variant}";
|
||||
}
|
||||
|
||||
private static string ResolveToolVersion()
|
||||
{
|
||||
var version = typeof(BinaryDiffService).Assembly.GetName().Version?.ToString();
|
||||
return string.IsNullOrWhiteSpace(version) ? "stellaops-cli" : version;
|
||||
}
|
||||
|
||||
private sealed record ResolvedManifest(OciManifest Manifest, string ManifestDigest, BinaryDiffPlatform Platform);
|
||||
|
||||
private sealed record BinaryDiffFileEntry
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required SectionHashSet Hashes { get; init; }
|
||||
public required string LayerDigest { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciImageConfig
|
||||
{
|
||||
public string? Os { get; init; }
|
||||
public string? Architecture { get; init; }
|
||||
public string? Variant { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Parses setup configuration from YAML files.
|
||||
/// </summary>
|
||||
public interface ISetupConfigParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse a setup configuration file.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to the YAML configuration file.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Parsed setup configuration.</returns>
|
||||
Task<SetupConfig> ParseAsync(string path, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate a setup configuration file.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to the YAML configuration file.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Validation result with any errors.</returns>
|
||||
Task<SetupConfigValidationResult> ValidateAsync(string path, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed setup configuration.
|
||||
/// </summary>
|
||||
public sealed record SetupConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration version for compatibility checking.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Steps to skip during setup.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SkipSteps { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Steps to include (if specified, only these steps run).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> IncludeSteps { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Database configuration.
|
||||
/// </summary>
|
||||
public DatabaseConfig? Database { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache (Valkey/Redis) configuration.
|
||||
/// </summary>
|
||||
public CacheConfig? Cache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vault/secrets configuration.
|
||||
/// </summary>
|
||||
public VaultConfig? Vault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Settings store configuration.
|
||||
/// </summary>
|
||||
public SettingsStoreConfig? SettingsStore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container registry configuration.
|
||||
/// </summary>
|
||||
public RegistryConfig? Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SCM (source control) configuration.
|
||||
/// </summary>
|
||||
public ScmConfig? Scm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CI system configuration.
|
||||
/// </summary>
|
||||
public CiConfig? Ci { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry/observability configuration.
|
||||
/// </summary>
|
||||
public TelemetryConfig? Telemetry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional custom key-value configuration.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Custom { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Database configuration section.
|
||||
/// </summary>
|
||||
public sealed record DatabaseConfig
|
||||
{
|
||||
public string? Host { get; init; }
|
||||
public int? Port { get; init; }
|
||||
public string? Database { get; init; }
|
||||
public string? User { get; init; }
|
||||
public string? Password { get; init; }
|
||||
public bool? Ssl { get; init; }
|
||||
public string? ConnectionString { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache (Valkey/Redis) configuration section.
|
||||
/// </summary>
|
||||
public sealed record CacheConfig
|
||||
{
|
||||
public string? Host { get; init; }
|
||||
public int? Port { get; init; }
|
||||
public string? Password { get; init; }
|
||||
public bool? Ssl { get; init; }
|
||||
public int? Database { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vault/secrets configuration section.
|
||||
/// </summary>
|
||||
public sealed record VaultConfig
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public string? RoleId { get; init; }
|
||||
public string? SecretId { get; init; }
|
||||
public string? MountPath { get; init; }
|
||||
public string? Namespace { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings store configuration section.
|
||||
/// </summary>
|
||||
public sealed record SettingsStoreConfig
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public string? Prefix { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public bool? ReloadOnChange { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container registry configuration section.
|
||||
/// </summary>
|
||||
public sealed record RegistryConfig
|
||||
{
|
||||
public string? Url { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? Password { get; init; }
|
||||
public bool? Insecure { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SCM (source control) configuration section.
|
||||
/// </summary>
|
||||
public sealed record ScmConfig
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public string? Organization { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI system configuration section.
|
||||
/// </summary>
|
||||
public sealed record CiConfig
|
||||
{
|
||||
public string? Provider { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public string? Token { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry/observability configuration section.
|
||||
/// </summary>
|
||||
public sealed record TelemetryConfig
|
||||
{
|
||||
public string? OtlpEndpoint { get; init; }
|
||||
public string? ServiceName { get; init; }
|
||||
public bool? EnableTracing { get; init; }
|
||||
public bool? EnableMetrics { get; init; }
|
||||
public bool? EnableLogging { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a setup configuration file.
|
||||
/// </summary>
|
||||
public sealed record SetupConfigValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the configuration is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||
|
||||
public static SetupConfigValidationResult Valid() =>
|
||||
new() { IsValid = true };
|
||||
|
||||
public static SetupConfigValidationResult Invalid(
|
||||
IReadOnlyList<string> errors,
|
||||
IReadOnlyList<string>? warnings = null) =>
|
||||
new()
|
||||
{
|
||||
IsValid = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings ?? []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Config;
|
||||
|
||||
/// <summary>
|
||||
/// YAML-based implementation of setup configuration parsing.
|
||||
/// </summary>
|
||||
public sealed class YamlSetupConfigParser : ISetupConfigParser
|
||||
{
|
||||
private readonly IDeserializer _deserializer;
|
||||
|
||||
public YamlSetupConfigParser()
|
||||
{
|
||||
_deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task<SetupConfig> ParseAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"Configuration file not found: {path}", path);
|
||||
}
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(path, ct);
|
||||
var rawConfig = _deserializer.Deserialize<RawSetupConfig>(yaml);
|
||||
|
||||
return MapToSetupConfig(rawConfig);
|
||||
}
|
||||
|
||||
public async Task<SetupConfigValidationResult> ValidateAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
errors.Add("Configuration file path is required.");
|
||||
return SetupConfigValidationResult.Invalid(errors);
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
errors.Add($"Configuration file not found: {path}");
|
||||
return SetupConfigValidationResult.Invalid(errors);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var yaml = await File.ReadAllTextAsync(path, ct);
|
||||
var rawConfig = _deserializer.Deserialize<RawSetupConfig>(yaml);
|
||||
|
||||
// Validate version
|
||||
if (string.IsNullOrEmpty(rawConfig?.Version))
|
||||
{
|
||||
warnings.Add("No version specified in configuration file. Assuming version 1.");
|
||||
}
|
||||
|
||||
// Validate database config
|
||||
if (rawConfig?.Database != null)
|
||||
{
|
||||
ValidateDatabaseConfig(rawConfig.Database, errors, warnings);
|
||||
}
|
||||
|
||||
// Validate cache config
|
||||
if (rawConfig?.Cache != null)
|
||||
{
|
||||
ValidateCacheConfig(rawConfig.Cache, errors, warnings);
|
||||
}
|
||||
|
||||
// Validate vault config
|
||||
if (rawConfig?.Vault != null)
|
||||
{
|
||||
ValidateVaultConfig(rawConfig.Vault, errors, warnings);
|
||||
}
|
||||
|
||||
// Validate registry config
|
||||
if (rawConfig?.Registry != null)
|
||||
{
|
||||
ValidateRegistryConfig(rawConfig.Registry, errors, warnings);
|
||||
}
|
||||
|
||||
// Validate SCM config
|
||||
if (rawConfig?.Scm != null)
|
||||
{
|
||||
ValidateScmConfig(rawConfig.Scm, errors, warnings);
|
||||
}
|
||||
|
||||
// Check for conflicting step settings
|
||||
if (rawConfig?.SkipSteps?.Count > 0 && rawConfig?.IncludeSteps?.Count > 0)
|
||||
{
|
||||
var overlap = new HashSet<string>(rawConfig.SkipSteps, StringComparer.OrdinalIgnoreCase);
|
||||
overlap.IntersectWith(rawConfig.IncludeSteps);
|
||||
if (overlap.Count > 0)
|
||||
{
|
||||
errors.Add($"Steps cannot be both skipped and included: {string.Join(", ", overlap)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (YamlDotNet.Core.YamlException ex)
|
||||
{
|
||||
errors.Add($"YAML parsing error at line {ex.Start.Line}, column {ex.Start.Column}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Error reading configuration file: {ex.Message}");
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? SetupConfigValidationResult.Invalid(errors, warnings)
|
||||
: new SetupConfigValidationResult { IsValid = true, Warnings = warnings };
|
||||
}
|
||||
|
||||
private static void ValidateDatabaseConfig(
|
||||
RawDatabaseConfig config,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.ConnectionString))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.Host) || config.Port.HasValue)
|
||||
{
|
||||
warnings.Add("Database: connectionString provided; host/port will be ignored.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(config.Host))
|
||||
{
|
||||
warnings.Add("Database: host not specified; will use default (localhost).");
|
||||
}
|
||||
|
||||
if (config.Port.HasValue && (config.Port < 1 || config.Port > 65535))
|
||||
{
|
||||
errors.Add($"Database: invalid port number: {config.Port}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCacheConfig(
|
||||
RawCacheConfig config,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
if (string.IsNullOrEmpty(config.Host))
|
||||
{
|
||||
warnings.Add("Cache: host not specified; will use default (localhost).");
|
||||
}
|
||||
|
||||
if (config.Port.HasValue && (config.Port < 1 || config.Port > 65535))
|
||||
{
|
||||
errors.Add($"Cache: invalid port number: {config.Port}");
|
||||
}
|
||||
|
||||
if (config.Database.HasValue && config.Database < 0)
|
||||
{
|
||||
errors.Add($"Cache: invalid database number: {config.Database}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateVaultConfig(
|
||||
RawVaultConfig config,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
var validProviders = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"hashicorp", "azure", "aws", "gcp"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(config.Provider) && !validProviders.Contains(config.Provider))
|
||||
{
|
||||
warnings.Add($"Vault: unknown provider '{config.Provider}'. Valid providers: {string.Join(", ", validProviders)}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(config.Address) && config.Provider == "hashicorp")
|
||||
{
|
||||
errors.Add("Vault: address is required for HashiCorp Vault.");
|
||||
}
|
||||
|
||||
// Check for authentication
|
||||
var hasToken = !string.IsNullOrEmpty(config.Token);
|
||||
var hasAppRole = !string.IsNullOrEmpty(config.RoleId) && !string.IsNullOrEmpty(config.SecretId);
|
||||
|
||||
if (!hasToken && !hasAppRole && config.Provider == "hashicorp")
|
||||
{
|
||||
warnings.Add("Vault: no authentication configured. Either token or roleId+secretId required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateRegistryConfig(
|
||||
RawRegistryConfig config,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
if (string.IsNullOrEmpty(config.Url))
|
||||
{
|
||||
errors.Add("Registry: url is required.");
|
||||
}
|
||||
else if (!Uri.TryCreate(config.Url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
errors.Add($"Registry: invalid URL: {config.Url}");
|
||||
}
|
||||
else if (uri.Scheme != "http" && uri.Scheme != "https")
|
||||
{
|
||||
errors.Add($"Registry: URL must use http or https scheme: {config.Url}");
|
||||
}
|
||||
|
||||
if (config.Insecure == true)
|
||||
{
|
||||
warnings.Add("Registry: insecure mode enabled. Use only for development.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateScmConfig(
|
||||
RawScmConfig config,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
var validProviders = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"github", "gitlab", "gitea", "bitbucket", "azure"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(config.Provider) && !validProviders.Contains(config.Provider))
|
||||
{
|
||||
warnings.Add($"SCM: unknown provider '{config.Provider}'. Valid providers: {string.Join(", ", validProviders)}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(config.Url) && config.Provider != "github")
|
||||
{
|
||||
errors.Add("SCM: url is required for non-GitHub providers.");
|
||||
}
|
||||
}
|
||||
|
||||
private static SetupConfig MapToSetupConfig(RawSetupConfig? raw)
|
||||
{
|
||||
if (raw == null)
|
||||
{
|
||||
return new SetupConfig();
|
||||
}
|
||||
|
||||
return new SetupConfig
|
||||
{
|
||||
Version = raw.Version,
|
||||
SkipSteps = raw.SkipSteps ?? [],
|
||||
IncludeSteps = raw.IncludeSteps ?? [],
|
||||
Database = raw.Database != null
|
||||
? new DatabaseConfig
|
||||
{
|
||||
Host = raw.Database.Host,
|
||||
Port = raw.Database.Port,
|
||||
Database = raw.Database.Database,
|
||||
User = raw.Database.User,
|
||||
Password = raw.Database.Password,
|
||||
Ssl = raw.Database.Ssl,
|
||||
ConnectionString = raw.Database.ConnectionString
|
||||
}
|
||||
: null,
|
||||
Cache = raw.Cache != null
|
||||
? new CacheConfig
|
||||
{
|
||||
Host = raw.Cache.Host,
|
||||
Port = raw.Cache.Port,
|
||||
Password = raw.Cache.Password,
|
||||
Ssl = raw.Cache.Ssl,
|
||||
Database = raw.Cache.Database
|
||||
}
|
||||
: null,
|
||||
Vault = raw.Vault != null
|
||||
? new VaultConfig
|
||||
{
|
||||
Provider = raw.Vault.Provider,
|
||||
Address = raw.Vault.Address,
|
||||
Token = raw.Vault.Token,
|
||||
RoleId = raw.Vault.RoleId,
|
||||
SecretId = raw.Vault.SecretId,
|
||||
MountPath = raw.Vault.MountPath,
|
||||
Namespace = raw.Vault.Namespace
|
||||
}
|
||||
: null,
|
||||
SettingsStore = raw.SettingsStore != null
|
||||
? new SettingsStoreConfig
|
||||
{
|
||||
Provider = raw.SettingsStore.Provider,
|
||||
Address = raw.SettingsStore.Address,
|
||||
Prefix = raw.SettingsStore.Prefix,
|
||||
Token = raw.SettingsStore.Token,
|
||||
ReloadOnChange = raw.SettingsStore.ReloadOnChange
|
||||
}
|
||||
: null,
|
||||
Registry = raw.Registry != null
|
||||
? new RegistryConfig
|
||||
{
|
||||
Url = raw.Registry.Url,
|
||||
Username = raw.Registry.Username,
|
||||
Password = raw.Registry.Password,
|
||||
Insecure = raw.Registry.Insecure
|
||||
}
|
||||
: null,
|
||||
Scm = raw.Scm != null
|
||||
? new ScmConfig
|
||||
{
|
||||
Provider = raw.Scm.Provider,
|
||||
Url = raw.Scm.Url,
|
||||
Token = raw.Scm.Token,
|
||||
Organization = raw.Scm.Organization
|
||||
}
|
||||
: null,
|
||||
Ci = raw.Ci != null
|
||||
? new CiConfig
|
||||
{
|
||||
Provider = raw.Ci.Provider,
|
||||
Url = raw.Ci.Url,
|
||||
Token = raw.Ci.Token
|
||||
}
|
||||
: null,
|
||||
Telemetry = raw.Telemetry != null
|
||||
? new TelemetryConfig
|
||||
{
|
||||
OtlpEndpoint = raw.Telemetry.OtlpEndpoint,
|
||||
ServiceName = raw.Telemetry.ServiceName,
|
||||
EnableTracing = raw.Telemetry.EnableTracing,
|
||||
EnableMetrics = raw.Telemetry.EnableMetrics,
|
||||
EnableLogging = raw.Telemetry.EnableLogging
|
||||
}
|
||||
: null,
|
||||
Custom = raw.Custom ?? new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
// Raw config classes for YAML deserialization
|
||||
private sealed class RawSetupConfig
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
public List<string>? SkipSteps { get; set; }
|
||||
public List<string>? IncludeSteps { get; set; }
|
||||
public RawDatabaseConfig? Database { get; set; }
|
||||
public RawCacheConfig? Cache { get; set; }
|
||||
public RawVaultConfig? Vault { get; set; }
|
||||
public RawSettingsStoreConfig? SettingsStore { get; set; }
|
||||
public RawRegistryConfig? Registry { get; set; }
|
||||
public RawScmConfig? Scm { get; set; }
|
||||
public RawCiConfig? Ci { get; set; }
|
||||
public RawTelemetryConfig? Telemetry { get; set; }
|
||||
public Dictionary<string, string>? Custom { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawDatabaseConfig
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
public int? Port { get; set; }
|
||||
public string? Database { get; set; }
|
||||
public string? User { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public bool? Ssl { get; set; }
|
||||
public string? ConnectionString { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawCacheConfig
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
public int? Port { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public bool? Ssl { get; set; }
|
||||
public int? Database { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawVaultConfig
|
||||
{
|
||||
public string? Provider { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public string? RoleId { get; set; }
|
||||
public string? SecretId { get; set; }
|
||||
public string? MountPath { get; set; }
|
||||
public string? Namespace { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawSettingsStoreConfig
|
||||
{
|
||||
public string? Provider { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Prefix { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public bool? ReloadOnChange { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawRegistryConfig
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public bool? Insecure { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawScmConfig
|
||||
{
|
||||
public string? Provider { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public string? Organization { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawCiConfig
|
||||
{
|
||||
public string? Provider { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public string? Token { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawTelemetryConfig
|
||||
{
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
public string? ServiceName { get; set; }
|
||||
public bool? EnableTracing { get; set; }
|
||||
public bool? EnableMetrics { get; set; }
|
||||
public bool? EnableLogging { get; set; }
|
||||
}
|
||||
}
|
||||
50
src/Cli/StellaOps.Cli/Commands/Setup/ISetupCommandHandler.cs
Normal file
50
src/Cli/StellaOps.Cli/Commands/Setup/ISetupCommandHandler.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for setup wizard commands.
|
||||
/// </summary>
|
||||
public interface ISetupCommandHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the setup wizard with the specified options.
|
||||
/// </summary>
|
||||
Task RunAsync(SetupRunOptions options, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resume an interrupted setup session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Optional session ID to resume. Uses latest if not specified.</param>
|
||||
/// <param name="verbose">Enable verbose output.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ResumeAsync(string? sessionId, bool verbose, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Show current setup status and completed steps.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Optional session ID to check. Uses latest if not specified.</param>
|
||||
/// <param name="json">Output in JSON format.</param>
|
||||
/// <param name="verbose">Enable verbose output.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StatusAsync(string? sessionId, bool json, bool verbose, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reset setup state for specific steps or all steps.
|
||||
/// </summary>
|
||||
/// <param name="step">Reset only the specified step.</param>
|
||||
/// <param name="all">Reset all setup state.</param>
|
||||
/// <param name="force">Skip confirmation prompts.</param>
|
||||
/// <param name="verbose">Enable verbose output.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ResetAsync(string? step, bool all, bool force, bool verbose, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate setup configuration without running setup.
|
||||
/// </summary>
|
||||
/// <param name="configPath">Path to YAML configuration file.</param>
|
||||
/// <param name="verbose">Enable verbose output.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ValidateConfigAsync(string configPath, bool verbose, CancellationToken ct = default);
|
||||
}
|
||||
235
src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandGroup.cs
Normal file
235
src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandGroup.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Doctor.Detection;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for the setup wizard.
|
||||
/// Provides interactive and non-interactive setup for StellaOps components.
|
||||
/// </summary>
|
||||
internal static class SetupCommandGroup
|
||||
{
|
||||
public static Command BuildSetupCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var setupCommand = new Command("setup", "Interactive setup wizard for StellaOps components.");
|
||||
|
||||
// Global setup options
|
||||
var configOption = new Option<string?>("--config", new[] { "-c" })
|
||||
{
|
||||
Description = "Path to YAML configuration file for automated setup."
|
||||
};
|
||||
|
||||
var nonInteractiveOption = new Option<bool>("--non-interactive", new[] { "-y" })
|
||||
{
|
||||
Description = "Run in non-interactive mode using defaults or config file values."
|
||||
};
|
||||
|
||||
setupCommand.Add(configOption);
|
||||
setupCommand.Add(nonInteractiveOption);
|
||||
|
||||
// Add subcommands
|
||||
setupCommand.Add(BuildRunCommand(services, verboseOption, configOption, nonInteractiveOption, cancellationToken));
|
||||
setupCommand.Add(BuildResumeCommand(services, verboseOption, cancellationToken));
|
||||
setupCommand.Add(BuildStatusCommand(services, verboseOption, cancellationToken));
|
||||
setupCommand.Add(BuildResetCommand(services, verboseOption, cancellationToken));
|
||||
setupCommand.Add(BuildValidateCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return setupCommand;
|
||||
}
|
||||
|
||||
private static Command BuildRunCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<string?> configOption,
|
||||
Option<bool> nonInteractiveOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var runCommand = new Command("run", "Run the setup wizard from the beginning or continue from last checkpoint.");
|
||||
|
||||
var stepOption = new Option<string?>("--step", new[] { "-s" })
|
||||
{
|
||||
Description = "Run a specific step only (e.g., database, vault, registry)."
|
||||
};
|
||||
|
||||
var skipOption = new Option<string[]>("--skip")
|
||||
{
|
||||
Description = "Skip specified steps (can be specified multiple times).",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var dryRunOption = new Option<bool>("--dry-run")
|
||||
{
|
||||
Description = "Validate configuration without making changes."
|
||||
};
|
||||
|
||||
var forceOption = new Option<bool>("--force", new[] { "-f" })
|
||||
{
|
||||
Description = "Force re-run of already completed steps."
|
||||
};
|
||||
|
||||
runCommand.Add(stepOption);
|
||||
runCommand.Add(skipOption);
|
||||
runCommand.Add(dryRunOption);
|
||||
runCommand.Add(forceOption);
|
||||
|
||||
runCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var config = parseResult.GetValue(configOption);
|
||||
var nonInteractive = parseResult.GetValue(nonInteractiveOption);
|
||||
var step = parseResult.GetValue(stepOption);
|
||||
var skip = parseResult.GetValue(skipOption) ?? Array.Empty<string>();
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
|
||||
var handler = services.GetRequiredService<ISetupCommandHandler>();
|
||||
await handler.RunAsync(new SetupRunOptions
|
||||
{
|
||||
ConfigPath = config,
|
||||
NonInteractive = nonInteractive,
|
||||
SpecificStep = step,
|
||||
SkipSteps = skip,
|
||||
DryRun = dryRun,
|
||||
Force = force,
|
||||
Verbose = verbose
|
||||
}, cancellationToken);
|
||||
});
|
||||
|
||||
return runCommand;
|
||||
}
|
||||
|
||||
private static Command BuildResumeCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resumeCommand = new Command("resume", "Resume an interrupted setup from the last checkpoint.");
|
||||
|
||||
var sessionOption = new Option<string?>("--session")
|
||||
{
|
||||
Description = "Specific session ID to resume (uses latest if not specified)."
|
||||
};
|
||||
|
||||
resumeCommand.Add(sessionOption);
|
||||
|
||||
resumeCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var sessionId = parseResult.GetValue(sessionOption);
|
||||
|
||||
var handler = services.GetRequiredService<ISetupCommandHandler>();
|
||||
await handler.ResumeAsync(sessionId, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return resumeCommand;
|
||||
}
|
||||
|
||||
private static Command BuildStatusCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCommand = new Command("status", "Show current setup status and completed steps.");
|
||||
|
||||
var sessionOption = new Option<string?>("--session")
|
||||
{
|
||||
Description = "Specific session ID to check (uses latest if not specified)."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output status in JSON format."
|
||||
};
|
||||
|
||||
statusCommand.Add(sessionOption);
|
||||
statusCommand.Add(jsonOption);
|
||||
|
||||
statusCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var sessionId = parseResult.GetValue(sessionOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
|
||||
var handler = services.GetRequiredService<ISetupCommandHandler>();
|
||||
await handler.StatusAsync(sessionId, json, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return statusCommand;
|
||||
}
|
||||
|
||||
private static Command BuildResetCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resetCommand = new Command("reset", "Reset setup state for specific steps or all steps.");
|
||||
|
||||
var stepOption = new Option<string?>("--step", new[] { "-s" })
|
||||
{
|
||||
Description = "Reset only the specified step."
|
||||
};
|
||||
|
||||
var allOption = new Option<bool>("--all")
|
||||
{
|
||||
Description = "Reset all setup state (requires confirmation)."
|
||||
};
|
||||
|
||||
var forceOption = new Option<bool>("--force", new[] { "-f" })
|
||||
{
|
||||
Description = "Skip confirmation prompts."
|
||||
};
|
||||
|
||||
resetCommand.Add(stepOption);
|
||||
resetCommand.Add(allOption);
|
||||
resetCommand.Add(forceOption);
|
||||
|
||||
resetCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var step = parseResult.GetValue(stepOption);
|
||||
var all = parseResult.GetValue(allOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
|
||||
var handler = services.GetRequiredService<ISetupCommandHandler>();
|
||||
await handler.ResetAsync(step, all, force, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return resetCommand;
|
||||
}
|
||||
|
||||
private static Command BuildValidateCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validateCommand = new Command("validate", "Validate setup configuration without running setup.");
|
||||
|
||||
var configOption = new Option<string>("--config", new[] { "-c" })
|
||||
{
|
||||
Description = "Path to YAML configuration file to validate.",
|
||||
Arity = ArgumentArity.ExactlyOne
|
||||
};
|
||||
|
||||
validateCommand.Add(configOption);
|
||||
|
||||
validateCommand.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var config = parseResult.GetValue(configOption);
|
||||
|
||||
var handler = services.GetRequiredService<ISetupCommandHandler>();
|
||||
await handler.ValidateConfigAsync(config!, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
return validateCommand;
|
||||
}
|
||||
}
|
||||
745
src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandHandler.cs
Normal file
745
src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandHandler.cs
Normal file
@@ -0,0 +1,745 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands.Setup.Config;
|
||||
using StellaOps.Cli.Commands.Setup.State;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
using StellaOps.Doctor.Detection;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Handles setup wizard command execution.
|
||||
/// </summary>
|
||||
public sealed class SetupCommandHandler : ISetupCommandHandler
|
||||
{
|
||||
private readonly ISetupStateStore _stateStore;
|
||||
private readonly ISetupConfigParser _configParser;
|
||||
private readonly SetupStepCatalog _stepCatalog;
|
||||
private readonly IRuntimeDetector _runtimeDetector;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SetupCommandHandler> _logger;
|
||||
|
||||
public SetupCommandHandler(
|
||||
ISetupStateStore stateStore,
|
||||
ISetupConfigParser configParser,
|
||||
SetupStepCatalog stepCatalog,
|
||||
IRuntimeDetector runtimeDetector,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SetupCommandHandler> logger)
|
||||
{
|
||||
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||
_configParser = configParser ?? throw new ArgumentNullException(nameof(configParser));
|
||||
_stepCatalog = stepCatalog ?? throw new ArgumentNullException(nameof(stepCatalog));
|
||||
_runtimeDetector = runtimeDetector ?? throw new ArgumentNullException(nameof(runtimeDetector));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task RunAsync(SetupRunOptions options, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
Console.WriteLine("StellaOps Setup Wizard");
|
||||
Console.WriteLine("======================");
|
||||
Console.WriteLine();
|
||||
|
||||
// Detect runtime environment
|
||||
var runtime = _runtimeDetector.Detect();
|
||||
Console.WriteLine($"Detected runtime: {runtime}");
|
||||
Console.WriteLine();
|
||||
|
||||
// Load configuration if provided
|
||||
SetupConfig? config = null;
|
||||
if (!string.IsNullOrEmpty(options.ConfigPath))
|
||||
{
|
||||
Console.WriteLine($"Loading configuration from: {options.ConfigPath}");
|
||||
var validation = await _configParser.ValidateAsync(options.ConfigPath, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
Console.WriteLine("Configuration validation failed:");
|
||||
foreach (var error in validation.Errors)
|
||||
{
|
||||
Console.WriteLine($" ERROR: {error}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var warning in validation.Warnings)
|
||||
{
|
||||
Console.WriteLine($" WARNING: {warning}");
|
||||
}
|
||||
|
||||
config = await _configParser.ParseAsync(options.ConfigPath, ct);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Create or resume session
|
||||
SetupSession session;
|
||||
IReadOnlyDictionary<string, SetupStepResult> completedSteps;
|
||||
|
||||
var existingSession = await _stateStore.GetLatestSessionAsync(ct);
|
||||
if (existingSession != null && existingSession.Status == SetupSessionStatus.InProgress && !options.Force)
|
||||
{
|
||||
Console.WriteLine($"Found existing session from {existingSession.CreatedAt:g}");
|
||||
if (!options.NonInteractive)
|
||||
{
|
||||
Console.Write("Resume? [Y/n] ");
|
||||
var response = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
if (response != "N" && response != "NO")
|
||||
{
|
||||
session = existingSession;
|
||||
completedSteps = await _stateStore.GetStepResultsAsync(session.Id, ct);
|
||||
Console.WriteLine($"Resuming session {session.Id}");
|
||||
}
|
||||
else
|
||||
{
|
||||
session = await _stateStore.CreateSessionAsync(runtime, ct);
|
||||
completedSteps = new Dictionary<string, SetupStepResult>();
|
||||
Console.WriteLine($"Created new session {session.Id}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
session = existingSession;
|
||||
completedSteps = await _stateStore.GetStepResultsAsync(session.Id, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
session = await _stateStore.CreateSessionAsync(runtime, ct);
|
||||
completedSteps = new Dictionary<string, SetupStepResult>();
|
||||
Console.WriteLine($"Created new session {session.Id}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// Determine steps to run
|
||||
var allSteps = _stepCatalog.GetStepsInOrder();
|
||||
var stepsToRun = DetermineStepsToRun(allSteps, options, config, completedSteps);
|
||||
|
||||
if (stepsToRun.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No steps to run.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Steps to run:");
|
||||
foreach (var step in stepsToRun)
|
||||
{
|
||||
var status = completedSteps.TryGetValue(step.Id, out var result)
|
||||
? $"[{result.Status}]"
|
||||
: "[Pending]";
|
||||
Console.WriteLine($" {step.Order}. {step.Name} {status}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
Console.WriteLine("Dry run mode - no changes will be made.");
|
||||
await ValidateStepsAsync(stepsToRun, session, config, completedSteps, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute steps
|
||||
await ExecuteStepsAsync(stepsToRun, session, runtime, options, config, completedSteps, ct);
|
||||
|
||||
// Mark session complete
|
||||
var finalResults = await _stateStore.GetStepResultsAsync(session.Id, ct);
|
||||
var allCompleted = stepsToRun.All(s =>
|
||||
{
|
||||
if (finalResults.TryGetValue(s.Id, out var r))
|
||||
{
|
||||
return r.Status == SetupStepStatus.Completed || r.Status == SetupStepStatus.Skipped;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (allCompleted)
|
||||
{
|
||||
await _stateStore.CompleteSessionAsync(session.Id, ct);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Setup completed successfully!");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Setup incomplete. Run 'stella setup resume' to continue.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ResumeAsync(string? sessionId, bool verbose, CancellationToken ct = default)
|
||||
{
|
||||
SetupSession? session;
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
session = await _stateStore.GetSessionAsync(sessionId, ct);
|
||||
if (session == null)
|
||||
{
|
||||
Console.WriteLine($"Session not found: {sessionId}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
session = await _stateStore.GetLatestSessionAsync(ct);
|
||||
if (session == null)
|
||||
{
|
||||
Console.WriteLine("No sessions found. Run 'stella setup run' to start a new session.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (session.Status == SetupSessionStatus.Completed)
|
||||
{
|
||||
Console.WriteLine($"Session {session.Id} is already completed.");
|
||||
Console.WriteLine("Run 'stella setup run --force' to start over.");
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(new SetupRunOptions
|
||||
{
|
||||
Verbose = verbose,
|
||||
NonInteractive = true
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public async Task StatusAsync(string? sessionId, bool json, bool verbose, CancellationToken ct = default)
|
||||
{
|
||||
SetupSession? session;
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
session = await _stateStore.GetSessionAsync(sessionId, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
session = await _stateStore.GetLatestSessionAsync(ct);
|
||||
}
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
if (json)
|
||||
{
|
||||
Console.WriteLine("{\"status\": \"no_session\"}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No setup session found.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var stepResults = await _stateStore.GetStepResultsAsync(session.Id, ct);
|
||||
var allSteps = _stepCatalog.GetStepsInOrder();
|
||||
|
||||
if (json)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
sessionId = session.Id,
|
||||
status = session.Status.ToString(),
|
||||
runtime = session.Runtime.ToString(),
|
||||
createdAt = session.CreatedAt.ToString("o", CultureInfo.InvariantCulture),
|
||||
updatedAt = session.UpdatedAt?.ToString("o", CultureInfo.InvariantCulture),
|
||||
completedAt = session.CompletedAt?.ToString("o", CultureInfo.InvariantCulture),
|
||||
completedSteps = session.CompletedStepCount,
|
||||
totalSteps = allSteps.Count,
|
||||
steps = allSteps.Select(s => new
|
||||
{
|
||||
id = s.Id,
|
||||
name = s.Name,
|
||||
category = s.Category.ToString(),
|
||||
required = s.IsRequired,
|
||||
status = stepResults.TryGetValue(s.Id, out var r) ? r.Status.ToString() : "Pending"
|
||||
})
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(output, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Session ID: {session.Id}");
|
||||
Console.WriteLine($"Status: {session.Status}");
|
||||
Console.WriteLine($"Runtime: {session.Runtime}");
|
||||
Console.WriteLine($"Created: {session.CreatedAt:g}");
|
||||
if (session.UpdatedAt.HasValue)
|
||||
{
|
||||
Console.WriteLine($"Updated: {session.UpdatedAt:g}");
|
||||
}
|
||||
if (session.CompletedAt.HasValue)
|
||||
{
|
||||
Console.WriteLine($"Completed: {session.CompletedAt:g}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("Steps:");
|
||||
foreach (var step in allSteps)
|
||||
{
|
||||
var status = stepResults.TryGetValue(step.Id, out var r) ? r.Status : SetupStepStatus.Pending;
|
||||
var statusSymbol = status switch
|
||||
{
|
||||
SetupStepStatus.Completed => "[OK]",
|
||||
SetupStepStatus.Skipped => "[SKIP]",
|
||||
SetupStepStatus.Failed => "[FAIL]",
|
||||
SetupStepStatus.Running => "[...]",
|
||||
_ => "[ ]"
|
||||
};
|
||||
Console.WriteLine($" {statusSymbol} {step.Name} ({step.Category})");
|
||||
if (verbose && r != null && !string.IsNullOrEmpty(r.Message))
|
||||
{
|
||||
Console.WriteLine($" {r.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ResetAsync(string? step, bool all, bool force, bool verbose, CancellationToken ct = default)
|
||||
{
|
||||
if (all)
|
||||
{
|
||||
if (!force)
|
||||
{
|
||||
Console.Write("This will delete all setup sessions. Are you sure? [y/N] ");
|
||||
var response = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
if (response != "Y" && response != "YES")
|
||||
{
|
||||
Console.WriteLine("Cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _stateStore.DeleteAllSessionsAsync(ct);
|
||||
Console.WriteLine("All setup sessions deleted.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(step))
|
||||
{
|
||||
var session = await _stateStore.GetLatestSessionAsync(ct);
|
||||
if (session == null)
|
||||
{
|
||||
Console.WriteLine("No active session found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var stepDef = _stepCatalog.GetStep(step);
|
||||
if (stepDef == null)
|
||||
{
|
||||
Console.WriteLine($"Step not found: {step}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force)
|
||||
{
|
||||
Console.Write($"Reset step '{step}'? [y/N] ");
|
||||
var response = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
if (response != "Y" && response != "YES")
|
||||
{
|
||||
Console.WriteLine("Cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _stateStore.ResetStepAsync(session.Id, step, ct);
|
||||
Console.WriteLine($"Step '{step}' reset.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Specify --step <step-id> or --all to reset.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ValidateConfigAsync(string configPath, bool verbose, CancellationToken ct = default)
|
||||
{
|
||||
Console.WriteLine($"Validating configuration: {configPath}");
|
||||
Console.WriteLine();
|
||||
|
||||
var result = await _configParser.ValidateAsync(configPath, ct);
|
||||
|
||||
if (result.IsValid)
|
||||
{
|
||||
Console.WriteLine("Configuration is valid.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Configuration is invalid:");
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
Console.WriteLine($" ERROR: {error}");
|
||||
}
|
||||
|
||||
foreach (var warning in result.Warnings)
|
||||
{
|
||||
Console.WriteLine($" WARNING: {warning}");
|
||||
}
|
||||
|
||||
if (verbose && result.IsValid)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Parsed configuration:");
|
||||
var config = await _configParser.ParseAsync(configPath, ct);
|
||||
Console.WriteLine(JsonSerializer.Serialize(config, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ISetupStep> DetermineStepsToRun(
|
||||
IReadOnlyList<ISetupStep> allSteps,
|
||||
SetupRunOptions options,
|
||||
SetupConfig? config,
|
||||
IReadOnlyDictionary<string, SetupStepResult> completedSteps)
|
||||
{
|
||||
var skipSteps = new HashSet<string>(
|
||||
options.SkipSteps.Concat(config?.SkipSteps ?? []),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var includeSteps = config?.IncludeSteps != null && config.IncludeSteps.Count > 0
|
||||
? new HashSet<string>(config.IncludeSteps, StringComparer.OrdinalIgnoreCase)
|
||||
: null;
|
||||
|
||||
return allSteps
|
||||
.Where(s =>
|
||||
{
|
||||
// If specific step requested, only run that step
|
||||
if (!string.IsNullOrEmpty(options.SpecificStep))
|
||||
{
|
||||
return s.Id.Equals(options.SpecificStep, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// If include list specified, only run those steps
|
||||
if (includeSteps != null && !includeSteps.Contains(s.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if in skip list
|
||||
if (skipSteps.Contains(s.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if already completed (unless force)
|
||||
if (!options.Force &&
|
||||
completedSteps.TryGetValue(s.Id, out var result) &&
|
||||
result.Status == SetupStepStatus.Completed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task ValidateStepsAsync(
|
||||
IReadOnlyList<ISetupStep> steps,
|
||||
SetupSession session,
|
||||
SetupConfig? config,
|
||||
IReadOnlyDictionary<string, SetupStepResult> completedSteps,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Console.WriteLine("Validating steps...");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
Console.WriteLine($"Checking: {step.Name}");
|
||||
|
||||
// Run Doctor checks for this step
|
||||
// Note: Doctor checks integration requires DoctorPluginContext which needs full DI setup.
|
||||
// For now, we list the check IDs and recommend running `stella doctor run` after setup.
|
||||
if (step.ValidationChecks.Count > 0)
|
||||
{
|
||||
Console.WriteLine(" Validation checks (run 'stella doctor run' after setup):");
|
||||
foreach (var checkId in step.ValidationChecks)
|
||||
{
|
||||
Console.WriteLine($" - {checkId}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" (No validation checks defined)");
|
||||
}
|
||||
await Task.CompletedTask; // Keep method async
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteStepsAsync(
|
||||
IReadOnlyList<ISetupStep> steps,
|
||||
SetupSession session,
|
||||
RuntimeEnvironment runtime,
|
||||
SetupRunOptions options,
|
||||
SetupConfig? config,
|
||||
IReadOnlyDictionary<string, SetupStepResult> completedSteps,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var configValues = config != null ? FlattenConfig(config) : new Dictionary<string, string>();
|
||||
var mutableCompletedSteps = new Dictionary<string, SetupStepResult>(completedSteps);
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
Console.WriteLine("Setup cancelled.");
|
||||
break;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Running: {step.Name}");
|
||||
Console.WriteLine($" {step.Description}");
|
||||
Console.WriteLine();
|
||||
|
||||
var context = new SetupStepContext
|
||||
{
|
||||
SessionId = session.Id,
|
||||
Runtime = runtime,
|
||||
NonInteractive = options.NonInteractive,
|
||||
DryRun = options.DryRun,
|
||||
Verbose = options.Verbose,
|
||||
ConfigValues = configValues,
|
||||
RuntimeValues = _runtimeDetector.GetContextValues(),
|
||||
CompletedSteps = mutableCompletedSteps,
|
||||
Output = msg => Console.WriteLine($" {msg}"),
|
||||
OutputWarning = msg => Console.WriteLine($" WARNING: {msg}"),
|
||||
OutputError = msg => Console.Error.WriteLine($" ERROR: {msg}"),
|
||||
PromptForInput = (prompt, defaultValue) =>
|
||||
{
|
||||
if (options.NonInteractive)
|
||||
{
|
||||
return defaultValue ?? string.Empty;
|
||||
}
|
||||
Console.Write($" {prompt}");
|
||||
if (!string.IsNullOrEmpty(defaultValue))
|
||||
{
|
||||
Console.Write($" [{defaultValue}]");
|
||||
}
|
||||
Console.Write(": ");
|
||||
var input = Console.ReadLine()?.Trim();
|
||||
return string.IsNullOrEmpty(input) ? defaultValue ?? string.Empty : input;
|
||||
},
|
||||
PromptForConfirmation = (prompt, defaultValue) =>
|
||||
{
|
||||
if (options.NonInteractive)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
Console.Write($" {prompt} [{(defaultValue ? "Y/n" : "y/N")}] ");
|
||||
var input = Console.ReadLine()?.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
return input == "Y" || input == "YES";
|
||||
},
|
||||
PromptForSelection = (prompt, optionsList) =>
|
||||
{
|
||||
if (options.NonInteractive)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
Console.WriteLine($" {prompt}");
|
||||
for (var i = 0; i < optionsList.Count; i++)
|
||||
{
|
||||
Console.WriteLine($" {i + 1}. {optionsList[i]}");
|
||||
}
|
||||
Console.Write(" Selection: ");
|
||||
var input = Console.ReadLine()?.Trim();
|
||||
if (int.TryParse(input, out var selection) && selection >= 1 && selection <= optionsList.Count)
|
||||
{
|
||||
return selection - 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
PromptForSecret = prompt =>
|
||||
{
|
||||
if (options.NonInteractive)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
Console.Write($" {prompt}: ");
|
||||
var password = string.Empty;
|
||||
while (true)
|
||||
{
|
||||
var key = Console.ReadKey(true);
|
||||
if (key.Key == ConsoleKey.Enter)
|
||||
{
|
||||
Console.WriteLine();
|
||||
break;
|
||||
}
|
||||
if (key.Key == ConsoleKey.Backspace && password.Length > 0)
|
||||
{
|
||||
password = password[..^1];
|
||||
}
|
||||
else if (!char.IsControl(key.KeyChar))
|
||||
{
|
||||
password += key.KeyChar;
|
||||
}
|
||||
}
|
||||
return password;
|
||||
}
|
||||
};
|
||||
|
||||
// Check prerequisites
|
||||
var prereqResult = await step.CheckPrerequisitesAsync(context, ct);
|
||||
if (!prereqResult.Met)
|
||||
{
|
||||
Console.WriteLine($" Prerequisites not met: {prereqResult.Message}");
|
||||
foreach (var missing in prereqResult.MissingPrerequisites)
|
||||
{
|
||||
Console.WriteLine($" - {missing}");
|
||||
}
|
||||
foreach (var suggestion in prereqResult.Suggestions)
|
||||
{
|
||||
Console.WriteLine($" Suggestion: {suggestion}");
|
||||
}
|
||||
|
||||
var result = SetupStepResult.Failed(prereqResult.Message ?? "Prerequisites not met", canRetry: true);
|
||||
await _stateStore.SaveStepResultAsync(session.Id, step.Id, result with
|
||||
{
|
||||
StartedAt = _timeProvider.GetUtcNow(),
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
}, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute step
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
SetupStepResult stepResult;
|
||||
|
||||
try
|
||||
{
|
||||
stepResult = await step.ExecuteAsync(context, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Step {StepId} failed with exception", step.Id);
|
||||
stepResult = SetupStepResult.Failed($"Exception: {ex.Message}", ex, canRetry: true);
|
||||
}
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
stepResult = stepResult with
|
||||
{
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt
|
||||
};
|
||||
|
||||
await _stateStore.SaveStepResultAsync(session.Id, step.Id, stepResult, ct);
|
||||
mutableCompletedSteps[step.Id] = stepResult;
|
||||
|
||||
// Report result
|
||||
Console.WriteLine();
|
||||
switch (stepResult.Status)
|
||||
{
|
||||
case SetupStepStatus.Completed:
|
||||
Console.WriteLine($" [OK] {step.Name} completed");
|
||||
break;
|
||||
case SetupStepStatus.Skipped:
|
||||
Console.WriteLine($" [SKIP] {step.Name} skipped: {stepResult.Message}");
|
||||
break;
|
||||
case SetupStepStatus.Failed:
|
||||
Console.WriteLine($" [FAIL] {step.Name} failed: {stepResult.Error}");
|
||||
if (stepResult.CanRetry)
|
||||
{
|
||||
Console.WriteLine(" You can retry this step with 'stella setup run --step " + step.Id + "'");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate step if completed
|
||||
if (stepResult.Status == SetupStepStatus.Completed)
|
||||
{
|
||||
var validationResult = await step.ValidateAsync(context, ct);
|
||||
if (!validationResult.Valid)
|
||||
{
|
||||
Console.WriteLine($" Validation failed: {validationResult.Message}");
|
||||
foreach (var error in validationResult.Errors)
|
||||
{
|
||||
Console.WriteLine($" ERROR: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> FlattenConfig(SetupConfig config)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (config.Database != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.Database.Host))
|
||||
result["database.host"] = config.Database.Host;
|
||||
if (config.Database.Port.HasValue)
|
||||
result["database.port"] = config.Database.Port.Value.ToString(CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(config.Database.Database))
|
||||
result["database.database"] = config.Database.Database;
|
||||
if (!string.IsNullOrEmpty(config.Database.User))
|
||||
result["database.user"] = config.Database.User;
|
||||
if (!string.IsNullOrEmpty(config.Database.Password))
|
||||
result["database.password"] = config.Database.Password;
|
||||
if (!string.IsNullOrEmpty(config.Database.ConnectionString))
|
||||
result["database.connectionString"] = config.Database.ConnectionString;
|
||||
}
|
||||
|
||||
if (config.Cache != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.Cache.Host))
|
||||
result["cache.host"] = config.Cache.Host;
|
||||
if (config.Cache.Port.HasValue)
|
||||
result["cache.port"] = config.Cache.Port.Value.ToString(CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(config.Cache.Password))
|
||||
result["cache.password"] = config.Cache.Password;
|
||||
}
|
||||
|
||||
if (config.Vault != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.Vault.Provider))
|
||||
result["vault.provider"] = config.Vault.Provider;
|
||||
if (!string.IsNullOrEmpty(config.Vault.Address))
|
||||
result["vault.address"] = config.Vault.Address;
|
||||
}
|
||||
|
||||
if (config.SettingsStore != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.SettingsStore.Provider))
|
||||
result["settingsStore.provider"] = config.SettingsStore.Provider;
|
||||
if (!string.IsNullOrEmpty(config.SettingsStore.Address))
|
||||
result["settingsStore.address"] = config.SettingsStore.Address;
|
||||
}
|
||||
|
||||
if (config.Registry != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.Registry.Url))
|
||||
result["registry.url"] = config.Registry.Url;
|
||||
if (!string.IsNullOrEmpty(config.Registry.Username))
|
||||
result["registry.username"] = config.Registry.Username;
|
||||
}
|
||||
|
||||
if (config.Scm != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.Scm.Provider))
|
||||
result["scm.provider"] = config.Scm.Provider;
|
||||
if (!string.IsNullOrEmpty(config.Scm.Url))
|
||||
result["scm.url"] = config.Scm.Url;
|
||||
}
|
||||
|
||||
foreach (var kv in config.Custom)
|
||||
{
|
||||
result[$"custom.{kv.Key}"] = kv.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
45
src/Cli/StellaOps.Cli/Commands/Setup/SetupRunOptions.cs
Normal file
45
src/Cli/StellaOps.Cli/Commands/Setup/SetupRunOptions.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the setup run command.
|
||||
/// </summary>
|
||||
public sealed record SetupRunOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to YAML configuration file for automated setup.
|
||||
/// </summary>
|
||||
public string? ConfigPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run in non-interactive mode using defaults or config file values.
|
||||
/// </summary>
|
||||
public bool NonInteractive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run a specific step only.
|
||||
/// </summary>
|
||||
public string? SpecificStep { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Steps to skip during setup.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SkipSteps { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Validate configuration without making changes.
|
||||
/// </summary>
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Force re-run of already completed steps.
|
||||
/// </summary>
|
||||
public bool Force { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable verbose output.
|
||||
/// </summary>
|
||||
public bool Verbose { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Cli.Commands.Setup.Config;
|
||||
using StellaOps.Cli.Commands.Setup.State;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering setup services.
|
||||
/// </summary>
|
||||
public static class SetupServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add setup wizard services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSetupWizard(this IServiceCollection services)
|
||||
{
|
||||
// Core services
|
||||
services.TryAddSingleton<ISetupStateStore>(sp =>
|
||||
new FileSetupStateStore(sp.GetRequiredService<TimeProvider>()));
|
||||
|
||||
services.TryAddSingleton<ISetupConfigParser, YamlSetupConfigParser>();
|
||||
|
||||
// Step catalog
|
||||
services.TryAddSingleton<SetupStepCatalog>(sp =>
|
||||
{
|
||||
var catalog = new SetupStepCatalog();
|
||||
// Register steps from DI
|
||||
foreach (var step in sp.GetServices<ISetupStep>())
|
||||
{
|
||||
catalog.Register(step);
|
||||
}
|
||||
return catalog;
|
||||
});
|
||||
|
||||
// Command handler
|
||||
services.TryAddSingleton<ISetupCommandHandler, SetupCommandHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a setup step.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSetupStep<TStep>(this IServiceCollection services)
|
||||
where TStep : class, ISetupStep
|
||||
{
|
||||
services.AddSingleton<ISetupStep, TStep>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
using StellaOps.Doctor.Detection;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.State;
|
||||
|
||||
/// <summary>
|
||||
/// File-based implementation of setup state storage.
|
||||
/// Stores state in ~/.stellaops/setup/ directory.
|
||||
/// </summary>
|
||||
public sealed class FileSetupStateStore : ISetupStateStore
|
||||
{
|
||||
private readonly string _baseDir;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
private const string SessionsFileName = "sessions.json";
|
||||
private const string StepResultsFileName = "steps.json";
|
||||
private const string ConfigValuesFileName = "config.json";
|
||||
|
||||
public FileSetupStateStore(TimeProvider timeProvider, string? baseDir = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_baseDir = baseDir ?? GetDefaultBaseDir();
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetDefaultBaseDir()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(home, ".stellaops", "setup");
|
||||
}
|
||||
|
||||
public async Task<SetupSession> CreateSessionAsync(
|
||||
RuntimeEnvironment runtime,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sessions = await LoadSessionsAsync(ct);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var session = new SetupSession
|
||||
{
|
||||
Id = GenerateSessionId(now),
|
||||
CreatedAt = now,
|
||||
Runtime = runtime,
|
||||
Status = SetupSessionStatus.InProgress,
|
||||
TotalStepCount = 0,
|
||||
CompletedStepCount = 0
|
||||
};
|
||||
|
||||
sessions.Add(session);
|
||||
await SaveSessionsAsync(sessions, ct);
|
||||
|
||||
// Create session directory
|
||||
var sessionDir = GetSessionDir(session.Id);
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<SetupSession?> GetLatestSessionAsync(CancellationToken ct = default)
|
||||
{
|
||||
var sessions = await LoadSessionsAsync(ct);
|
||||
return sessions
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<SetupSession?> GetSessionAsync(string sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var sessions = await LoadSessionsAsync(ct);
|
||||
return sessions.FirstOrDefault(s => s.Id == sessionId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SetupSession>> ListSessionsAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await LoadSessionsAsync(ct);
|
||||
}
|
||||
|
||||
public async Task SaveStepResultAsync(
|
||||
string sessionId,
|
||||
string stepId,
|
||||
SetupStepResult result,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sessionDir = GetSessionDir(sessionId);
|
||||
EnsureDirectoryExists(sessionDir);
|
||||
|
||||
var stepsPath = Path.Combine(sessionDir, StepResultsFileName);
|
||||
var steps = await LoadStepResultsAsync(stepsPath, ct);
|
||||
|
||||
steps[stepId] = result;
|
||||
|
||||
await SaveJsonAsync(stepsPath, steps, ct);
|
||||
|
||||
// Update session metadata
|
||||
var sessions = await LoadSessionsAsync(ct);
|
||||
var session = sessions.FirstOrDefault(s => s.Id == sessionId);
|
||||
if (session != null)
|
||||
{
|
||||
var index = sessions.IndexOf(session);
|
||||
sessions[index] = session with
|
||||
{
|
||||
UpdatedAt = _timeProvider.GetUtcNow(),
|
||||
LastStepId = stepId,
|
||||
CompletedStepCount = steps.Count(s =>
|
||||
s.Value.Status == SetupStepStatus.Completed ||
|
||||
s.Value.Status == SetupStepStatus.Skipped)
|
||||
};
|
||||
await SaveSessionsAsync(sessions, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, SetupStepResult>> GetStepResultsAsync(
|
||||
string sessionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var stepsPath = Path.Combine(GetSessionDir(sessionId), StepResultsFileName);
|
||||
return await LoadStepResultsAsync(stepsPath, ct);
|
||||
}
|
||||
|
||||
public async Task CompleteSessionAsync(string sessionId, CancellationToken ct = default)
|
||||
{
|
||||
await UpdateSessionStatusAsync(
|
||||
sessionId,
|
||||
SetupSessionStatus.Completed,
|
||||
null,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task FailSessionAsync(string sessionId, string error, CancellationToken ct = default)
|
||||
{
|
||||
await UpdateSessionStatusAsync(
|
||||
sessionId,
|
||||
SetupSessionStatus.Failed,
|
||||
error,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task ResetStepAsync(string sessionId, string stepId, CancellationToken ct = default)
|
||||
{
|
||||
var stepsPath = Path.Combine(GetSessionDir(sessionId), StepResultsFileName);
|
||||
var steps = await LoadStepResultsAsync(stepsPath, ct);
|
||||
|
||||
if (steps.Remove(stepId))
|
||||
{
|
||||
await SaveJsonAsync(stepsPath, steps, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteSessionAsync(string sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var sessions = await LoadSessionsAsync(ct);
|
||||
var session = sessions.FirstOrDefault(s => s.Id == sessionId);
|
||||
if (session != null)
|
||||
{
|
||||
sessions.Remove(session);
|
||||
await SaveSessionsAsync(sessions, ct);
|
||||
}
|
||||
|
||||
var sessionDir = GetSessionDir(sessionId);
|
||||
if (Directory.Exists(sessionDir))
|
||||
{
|
||||
Directory.Delete(sessionDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAllSessionsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var sessions = await LoadSessionsAsync(ct);
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var sessionDir = GetSessionDir(session.Id);
|
||||
if (Directory.Exists(sessionDir))
|
||||
{
|
||||
Directory.Delete(sessionDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
await SaveSessionsAsync(new List<SetupSession>(), ct);
|
||||
}
|
||||
|
||||
public async Task SaveConfigValuesAsync(
|
||||
string sessionId,
|
||||
IReadOnlyDictionary<string, string> values,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sessionDir = GetSessionDir(sessionId);
|
||||
EnsureDirectoryExists(sessionDir);
|
||||
|
||||
var configPath = Path.Combine(sessionDir, ConfigValuesFileName);
|
||||
await SaveJsonAsync(configPath, values, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetConfigValuesAsync(
|
||||
string sessionId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var configPath = Path.Combine(GetSessionDir(sessionId), ConfigValuesFileName);
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
return await LoadJsonAsync<Dictionary<string, string>>(configPath, ct)
|
||||
?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
private async Task UpdateSessionStatusAsync(
|
||||
string sessionId,
|
||||
SetupSessionStatus status,
|
||||
string? error,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sessions = await LoadSessionsAsync(ct);
|
||||
var session = sessions.FirstOrDefault(s => s.Id == sessionId);
|
||||
if (session != null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var index = sessions.IndexOf(session);
|
||||
sessions[index] = session with
|
||||
{
|
||||
Status = status,
|
||||
UpdatedAt = now,
|
||||
CompletedAt = status == SetupSessionStatus.Completed ? now : session.CompletedAt,
|
||||
Error = error
|
||||
};
|
||||
await SaveSessionsAsync(sessions, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSessionDir(string sessionId)
|
||||
{
|
||||
return Path.Combine(_baseDir, sessionId);
|
||||
}
|
||||
|
||||
private string GetSessionsFilePath()
|
||||
{
|
||||
return Path.Combine(_baseDir, SessionsFileName);
|
||||
}
|
||||
|
||||
private async Task<List<SetupSession>> LoadSessionsAsync(CancellationToken ct)
|
||||
{
|
||||
var path = GetSessionsFilePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new List<SetupSession>();
|
||||
}
|
||||
|
||||
return await LoadJsonAsync<List<SetupSession>>(path, ct)
|
||||
?? new List<SetupSession>();
|
||||
}
|
||||
|
||||
private async Task SaveSessionsAsync(List<SetupSession> sessions, CancellationToken ct)
|
||||
{
|
||||
EnsureDirectoryExists(_baseDir);
|
||||
await SaveJsonAsync(GetSessionsFilePath(), sessions, ct);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, SetupStepResult>> LoadStepResultsAsync(
|
||||
string path,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new Dictionary<string, SetupStepResult>();
|
||||
}
|
||||
|
||||
return await LoadJsonAsync<Dictionary<string, SetupStepResult>>(path, ct)
|
||||
?? new Dictionary<string, SetupStepResult>();
|
||||
}
|
||||
|
||||
private async Task<T?> LoadJsonAsync<T>(string path, CancellationToken ct)
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(path, ct);
|
||||
return JsonSerializer.Deserialize<T>(json, _jsonOptions);
|
||||
}
|
||||
|
||||
private async Task SaveJsonAsync<T>(string path, T value, CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, _jsonOptions);
|
||||
await File.WriteAllTextAsync(path, json, ct);
|
||||
}
|
||||
|
||||
private static void EnsureDirectoryExists(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateSessionId(DateTimeOffset timestamp)
|
||||
{
|
||||
return $"setup-{timestamp.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}-{Guid.NewGuid().ToString("N")[..8]}";
|
||||
}
|
||||
}
|
||||
174
src/Cli/StellaOps.Cli/Commands/Setup/State/ISetupStateStore.cs
Normal file
174
src/Cli/StellaOps.Cli/Commands/Setup/State/ISetupStateStore.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
using StellaOps.Doctor.Detection;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.State;
|
||||
|
||||
/// <summary>
|
||||
/// Stores setup wizard progress for resumability.
|
||||
/// </summary>
|
||||
public interface ISetupStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new setup session.
|
||||
/// </summary>
|
||||
Task<SetupSession> CreateSessionAsync(
|
||||
RuntimeEnvironment runtime,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the most recent session.
|
||||
/// </summary>
|
||||
Task<SetupSession?> GetLatestSessionAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific session by ID.
|
||||
/// </summary>
|
||||
Task<SetupSession?> GetSessionAsync(string sessionId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List all sessions.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SetupSession>> ListSessionsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save step result to the session.
|
||||
/// </summary>
|
||||
Task SaveStepResultAsync(
|
||||
string sessionId,
|
||||
string stepId,
|
||||
SetupStepResult result,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get results for all completed steps in a session.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, SetupStepResult>> GetStepResultsAsync(
|
||||
string sessionId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Mark a session as completed.
|
||||
/// </summary>
|
||||
Task CompleteSessionAsync(string sessionId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Mark a session as failed.
|
||||
/// </summary>
|
||||
Task FailSessionAsync(string sessionId, string error, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reset a specific step in a session.
|
||||
/// </summary>
|
||||
Task ResetStepAsync(string sessionId, string stepId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a session and all its data.
|
||||
/// </summary>
|
||||
Task DeleteSessionAsync(string sessionId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete all sessions.
|
||||
/// </summary>
|
||||
Task DeleteAllSessionsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Store configuration values for a session.
|
||||
/// </summary>
|
||||
Task SaveConfigValuesAsync(
|
||||
string sessionId,
|
||||
IReadOnlyDictionary<string, string> values,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get stored configuration values for a session.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, string>> GetConfigValuesAsync(
|
||||
string sessionId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a setup wizard session.
|
||||
/// </summary>
|
||||
public sealed record SetupSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique session ID.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the session was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the session was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the session was completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected runtime environment.
|
||||
/// </summary>
|
||||
public required RuntimeEnvironment Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status of the session.
|
||||
/// </summary>
|
||||
public required SetupSessionStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the session failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the last step that was executed.
|
||||
/// </summary>
|
||||
public string? LastStepId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of completed steps.
|
||||
/// </summary>
|
||||
public int CompletedStepCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of steps in the session.
|
||||
/// </summary>
|
||||
public int TotalStepCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a setup session.
|
||||
/// </summary>
|
||||
public enum SetupSessionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Session is in progress.
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// Session completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// Session failed.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Session was cancelled.
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
84
src/Cli/StellaOps.Cli/Commands/Setup/Steps/ISetupStep.cs
Normal file
84
src/Cli/StellaOps.Cli/Commands/Setup/Steps/ISetupStep.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// A step in the setup wizard.
|
||||
/// </summary>
|
||||
public interface ISetupStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this step.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the step.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this step configures.
|
||||
/// </summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Category for grouping related steps.
|
||||
/// </summary>
|
||||
SetupCategory Category { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Order within the category (lower runs first).
|
||||
/// </summary>
|
||||
int Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this step is required for a minimal setup.
|
||||
/// </summary>
|
||||
bool IsRequired { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this step can be skipped.
|
||||
/// </summary>
|
||||
bool IsSkippable { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of steps that must complete before this step.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> Dependencies { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Doctor check IDs used to validate this step.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> ValidationChecks { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Check if prerequisites for this step are met.
|
||||
/// </summary>
|
||||
Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Execute the setup step.
|
||||
/// </summary>
|
||||
Task<SetupStepResult> ExecuteAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate that the step completed successfully.
|
||||
/// </summary>
|
||||
Task<SetupStepValidationResult> ValidateAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Rollback changes made by this step if possible.
|
||||
/// </summary>
|
||||
Task<SetupStepRollbackResult> RollbackAsync(
|
||||
SetupStepContext context,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
37
src/Cli/StellaOps.Cli/Commands/Setup/Steps/SetupCategory.cs
Normal file
37
src/Cli/StellaOps.Cli/Commands/Setup/Steps/SetupCategory.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Categories for grouping setup steps.
|
||||
/// </summary>
|
||||
public enum SetupCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Core infrastructure (database, cache).
|
||||
/// </summary>
|
||||
Infrastructure = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Security and secrets management.
|
||||
/// </summary>
|
||||
Security = 1,
|
||||
|
||||
/// <summary>
|
||||
/// External integrations (SCM, CI, registry).
|
||||
/// </summary>
|
||||
Integration = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Settings store and configuration.
|
||||
/// </summary>
|
||||
Configuration = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Observability (telemetry, logging).
|
||||
/// </summary>
|
||||
Observability = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Optional features and enhancements.
|
||||
/// </summary>
|
||||
Optional = 5
|
||||
}
|
||||
179
src/Cli/StellaOps.Cli/Commands/Setup/Steps/SetupStepCatalog.cs
Normal file
179
src/Cli/StellaOps.Cli/Commands/Setup/Steps/SetupStepCatalog.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Catalog of available setup steps.
|
||||
/// </summary>
|
||||
public sealed class SetupStepCatalog
|
||||
{
|
||||
private readonly Dictionary<string, ISetupStep> _steps = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// All registered steps.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ISetupStep> AllSteps => _steps.Values.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Register a setup step.
|
||||
/// </summary>
|
||||
public void Register(ISetupStep step)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(step);
|
||||
_steps[step.Id] = step;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a step by ID.
|
||||
/// </summary>
|
||||
public ISetupStep? GetStep(string id)
|
||||
{
|
||||
return _steps.TryGetValue(id, out var step) ? step : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get steps by category, ordered by their Order property.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ISetupStep> GetStepsByCategory(SetupCategory category)
|
||||
{
|
||||
return _steps.Values
|
||||
.Where(s => s.Category == category)
|
||||
.OrderBy(s => s.Order)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all steps in execution order (category order, then step order).
|
||||
/// </summary>
|
||||
public IReadOnlyList<ISetupStep> GetStepsInOrder()
|
||||
{
|
||||
return _steps.Values
|
||||
.OrderBy(s => (int)s.Category)
|
||||
.ThenBy(s => s.Order)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get required steps in execution order.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ISetupStep> GetRequiredSteps()
|
||||
{
|
||||
return GetStepsInOrder()
|
||||
.Where(s => s.IsRequired)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get steps that depend on the given step.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ISetupStep> GetDependentSteps(string stepId)
|
||||
{
|
||||
return _steps.Values
|
||||
.Where(s => s.Dependencies.Contains(stepId, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve execution order respecting dependencies.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ISetupStep> ResolveExecutionOrder(IEnumerable<string>? stepIds = null)
|
||||
{
|
||||
var result = new List<ISetupStep>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var visiting = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var steps = stepIds != null
|
||||
? stepIds.Select(GetStep).Where(s => s != null).Cast<ISetupStep>().ToList()
|
||||
: GetStepsInOrder().ToList();
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
Visit(step, visited, visiting, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void Visit(
|
||||
ISetupStep step,
|
||||
HashSet<string> visited,
|
||||
HashSet<string> visiting,
|
||||
List<ISetupStep> result)
|
||||
{
|
||||
if (visited.Contains(step.Id))
|
||||
return;
|
||||
|
||||
if (visiting.Contains(step.Id))
|
||||
throw new InvalidOperationException($"Circular dependency detected at step '{step.Id}'.");
|
||||
|
||||
visiting.Add(step.Id);
|
||||
|
||||
foreach (var depId in step.Dependencies)
|
||||
{
|
||||
var dep = GetStep(depId);
|
||||
if (dep != null)
|
||||
{
|
||||
Visit(dep, visited, visiting, result);
|
||||
}
|
||||
}
|
||||
|
||||
visiting.Remove(step.Id);
|
||||
visited.Add(step.Id);
|
||||
result.Add(step);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a setup step for display purposes.
|
||||
/// </summary>
|
||||
public sealed record SetupStepInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Step ID.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category.
|
||||
/// </summary>
|
||||
public required SetupCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this step is required.
|
||||
/// </summary>
|
||||
public required bool IsRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this step can be skipped.
|
||||
/// </summary>
|
||||
public required bool IsSkippable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of dependency steps.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Dependencies { get; init; }
|
||||
|
||||
public static SetupStepInfo FromStep(ISetupStep step) =>
|
||||
new()
|
||||
{
|
||||
Id = step.Id,
|
||||
Name = step.Name,
|
||||
Description = step.Description,
|
||||
Category = step.Category,
|
||||
IsRequired = step.IsRequired,
|
||||
IsSkippable = step.IsSkippable,
|
||||
Dependencies = step.Dependencies
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Doctor.Detection;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Context passed to setup steps during execution.
|
||||
/// </summary>
|
||||
public sealed class SetupStepContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique session ID for this setup run.
|
||||
/// </summary>
|
||||
public required string SessionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected runtime environment.
|
||||
/// </summary>
|
||||
public required RuntimeEnvironment Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the setup is running in non-interactive mode.
|
||||
/// </summary>
|
||||
public bool NonInteractive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a dry run (no changes made).
|
||||
/// </summary>
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether verbose output is enabled.
|
||||
/// </summary>
|
||||
public bool Verbose { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration values from YAML config file or user input.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> ConfigValues { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Context values from runtime detector.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> RuntimeValues { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Results from previously completed steps.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, SetupStepResult> CompletedSteps { get; init; } = new Dictionary<string, SetupStepResult>();
|
||||
|
||||
/// <summary>
|
||||
/// Function to prompt user for input (non-interactive mode returns defaults).
|
||||
/// </summary>
|
||||
public Func<string, string?, string> PromptForInput { get; init; } = (_, defaultValue) => defaultValue ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Function to prompt user for confirmation (non-interactive mode returns true).
|
||||
/// </summary>
|
||||
public Func<string, bool, bool> PromptForConfirmation { get; init; } = (_, defaultValue) => defaultValue;
|
||||
|
||||
/// <summary>
|
||||
/// Function to prompt user to select from options (non-interactive mode returns first option).
|
||||
/// </summary>
|
||||
public Func<string, IReadOnlyList<string>, int> PromptForSelection { get; init; } = (_, _) => 0;
|
||||
|
||||
/// <summary>
|
||||
/// Function to prompt user for a secret (non-interactive mode returns empty).
|
||||
/// </summary>
|
||||
public Func<string, string> PromptForSecret { get; init; } = _ => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Function to output a message to the console.
|
||||
/// </summary>
|
||||
public Action<string> Output { get; init; } = Console.WriteLine;
|
||||
|
||||
/// <summary>
|
||||
/// Function to output a warning to the console.
|
||||
/// </summary>
|
||||
public Action<string> OutputWarning { get; init; } = msg => Console.WriteLine($"WARNING: {msg}");
|
||||
|
||||
/// <summary>
|
||||
/// Function to output an error to the console.
|
||||
/// </summary>
|
||||
public Action<string> OutputError { get; init; } = msg => Console.Error.WriteLine($"ERROR: {msg}");
|
||||
}
|
||||
267
src/Cli/StellaOps.Cli/Commands/Setup/Steps/SetupStepResults.cs
Normal file
267
src/Cli/StellaOps.Cli/Commands/Setup/Steps/SetupStepResults.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a setup step prerequisite check.
|
||||
/// </summary>
|
||||
public sealed record SetupStepPrerequisiteResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether prerequisites are met.
|
||||
/// </summary>
|
||||
public required bool Met { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Message explaining the result.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of missing prerequisites.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> MissingPrerequisites { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Suggested remediation steps.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Suggestions { get; init; } = Array.Empty<string>();
|
||||
|
||||
public static SetupStepPrerequisiteResult Success(string? message = null) =>
|
||||
new() { Met = true, Message = message };
|
||||
|
||||
public static SetupStepPrerequisiteResult Failed(
|
||||
string message,
|
||||
IReadOnlyList<string>? missing = null,
|
||||
IReadOnlyList<string>? suggestions = null) =>
|
||||
new()
|
||||
{
|
||||
Met = false,
|
||||
Message = message,
|
||||
MissingPrerequisites = missing ?? Array.Empty<string>(),
|
||||
Suggestions = suggestions ?? Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a setup step execution.
|
||||
/// </summary>
|
||||
public enum SetupStepStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Step not started.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Step is currently running.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// Step completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// Step was skipped by user or configuration.
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// Step failed during execution.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Step was rolled back.
|
||||
/// </summary>
|
||||
RolledBack
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a setup step execution.
|
||||
/// </summary>
|
||||
public sealed record SetupStepResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Status of the step.
|
||||
/// </summary>
|
||||
public required SetupStepStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Message describing the result.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if the step failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exception if the step threw.
|
||||
/// </summary>
|
||||
public Exception? Exception { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the step started.
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the step completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the step execution.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration => CompletedAt.HasValue && StartedAt.HasValue
|
||||
? CompletedAt.Value - StartedAt.Value
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Output values from the step for use by subsequent steps.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> OutputValues { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Configuration values that were applied.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> AppliedConfig { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the step can be retried.
|
||||
/// </summary>
|
||||
public bool CanRetry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the step can be rolled back.
|
||||
/// </summary>
|
||||
public bool CanRollback { get; init; }
|
||||
|
||||
public static SetupStepResult Success(
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, string>? outputValues = null,
|
||||
IReadOnlyDictionary<string, string>? appliedConfig = null) =>
|
||||
new()
|
||||
{
|
||||
Status = SetupStepStatus.Completed,
|
||||
Message = message,
|
||||
OutputValues = outputValues ?? new Dictionary<string, string>(),
|
||||
AppliedConfig = appliedConfig ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
public static SetupStepResult Skipped(string? message = null) =>
|
||||
new()
|
||||
{
|
||||
Status = SetupStepStatus.Skipped,
|
||||
Message = message
|
||||
};
|
||||
|
||||
public static SetupStepResult Failed(
|
||||
string error,
|
||||
Exception? exception = null,
|
||||
bool canRetry = true,
|
||||
bool canRollback = false) =>
|
||||
new()
|
||||
{
|
||||
Status = SetupStepStatus.Failed,
|
||||
Error = error,
|
||||
Exception = exception,
|
||||
CanRetry = canRetry,
|
||||
CanRollback = canRollback
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a setup step validation.
|
||||
/// </summary>
|
||||
public sealed record SetupStepValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether validation passed.
|
||||
/// </summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation message.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of validation errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// List of validation warnings.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
public static SetupStepValidationResult Success(string? message = null) =>
|
||||
new() { Valid = true, Message = message };
|
||||
|
||||
public static SetupStepValidationResult Failed(
|
||||
string message,
|
||||
IReadOnlyList<string>? errors = null,
|
||||
IReadOnlyList<string>? warnings = null) =>
|
||||
new()
|
||||
{
|
||||
Valid = false,
|
||||
Message = message,
|
||||
Errors = errors ?? Array.Empty<string>(),
|
||||
Warnings = warnings ?? Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a setup step rollback.
|
||||
/// </summary>
|
||||
public sealed record SetupStepRollbackResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether rollback succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rollback message.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if rollback failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether manual intervention is required.
|
||||
/// </summary>
|
||||
public bool RequiresManualIntervention { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Instructions for manual intervention.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ManualSteps { get; init; } = Array.Empty<string>();
|
||||
|
||||
public static SetupStepRollbackResult Succeeded(string? message = null) =>
|
||||
new() { Success = true, Message = message };
|
||||
|
||||
public static SetupStepRollbackResult NotSupported() =>
|
||||
new() { Success = false, Message = "Rollback not supported for this step." };
|
||||
|
||||
public static SetupStepRollbackResult Failed(
|
||||
string error,
|
||||
bool requiresManualIntervention = false,
|
||||
IReadOnlyList<string>? manualSteps = null) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
RequiresManualIntervention = requiresManualIntervention,
|
||||
ManualSteps = manualSteps ?? Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -13,6 +15,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Facet;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -63,6 +66,24 @@ internal static class VexGenCommandGroup
|
||||
};
|
||||
statusOption.SetDefaultValue("under_investigation");
|
||||
|
||||
var linkEvidenceOption = new Option<bool>("--link-evidence")
|
||||
{
|
||||
Description = "Include evidence links in output when available."
|
||||
};
|
||||
linkEvidenceOption.SetDefaultValue(true);
|
||||
|
||||
var evidenceThresholdOption = new Option<double>("--evidence-threshold")
|
||||
{
|
||||
Description = "Minimum confidence for evidence links."
|
||||
};
|
||||
evidenceThresholdOption.SetDefaultValue(0.8);
|
||||
|
||||
var showEvidenceUriOption = new Option<bool>("--show-evidence-uri")
|
||||
{
|
||||
Description = "Show full evidence URIs in console output."
|
||||
};
|
||||
showEvidenceUriOption.SetDefaultValue(false);
|
||||
|
||||
var gen = new Command("gen", "Generate VEX statements from drift analysis.");
|
||||
gen.Add(fromDriftOption);
|
||||
gen.Add(imageOption);
|
||||
@@ -70,6 +91,9 @@ internal static class VexGenCommandGroup
|
||||
gen.Add(outputOption);
|
||||
gen.Add(formatOption);
|
||||
gen.Add(statusOption);
|
||||
gen.Add(linkEvidenceOption);
|
||||
gen.Add(evidenceThresholdOption);
|
||||
gen.Add(showEvidenceUriOption);
|
||||
gen.Add(verboseOption);
|
||||
|
||||
gen.SetAction(parseResult =>
|
||||
@@ -80,6 +104,9 @@ internal static class VexGenCommandGroup
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption)!;
|
||||
var status = parseResult.GetValue(statusOption)!;
|
||||
var linkEvidence = parseResult.GetValue(linkEvidenceOption);
|
||||
var evidenceThreshold = parseResult.GetValue(evidenceThresholdOption);
|
||||
var showEvidenceUri = parseResult.GetValue(showEvidenceUriOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!fromDrift)
|
||||
@@ -95,6 +122,9 @@ internal static class VexGenCommandGroup
|
||||
output,
|
||||
format,
|
||||
status,
|
||||
linkEvidence,
|
||||
evidenceThreshold,
|
||||
showEvidenceUri,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
@@ -109,6 +139,9 @@ internal static class VexGenCommandGroup
|
||||
string? outputPath,
|
||||
string format,
|
||||
string status,
|
||||
bool linkEvidence,
|
||||
double evidenceThreshold,
|
||||
bool showEvidenceUri,
|
||||
bool verbose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -175,6 +208,25 @@ internal static class VexGenCommandGroup
|
||||
return 0;
|
||||
}
|
||||
|
||||
var evidenceSummaries = ImmutableArray<EvidenceSummary>.Empty;
|
||||
if (linkEvidence)
|
||||
{
|
||||
var evidenceLinker = scope.ServiceProvider.GetService<IVexEvidenceLinker>();
|
||||
if (evidenceLinker is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Evidence linking unavailable; IVexEvidenceLinker not configured.[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
var threshold = NormalizeEvidenceThreshold(evidenceThreshold);
|
||||
(vexDocument, evidenceSummaries) = await AttachEvidenceLinksAsync(
|
||||
vexDocument,
|
||||
evidenceLinker,
|
||||
threshold,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
var vexJson = JsonSerializer.Serialize(vexDocument, new JsonSerializerOptions
|
||||
{
|
||||
@@ -204,6 +256,18 @@ internal static class VexGenCommandGroup
|
||||
}
|
||||
}
|
||||
|
||||
if (!evidenceSummaries.IsDefaultOrEmpty && evidenceSummaries.Length > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold]Evidence Summary:[/]");
|
||||
foreach (var summary in evidenceSummaries)
|
||||
{
|
||||
var uri = showEvidenceUri ? summary.EvidenceUri : TruncateEvidenceUri(summary.EvidenceUri);
|
||||
var confidence = summary.Confidence.ToString("F2", CultureInfo.InvariantCulture);
|
||||
AnsiConsole.MarkupLine($" {summary.StatementId}: {summary.Type} ({confidence}) {uri}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -221,18 +285,20 @@ internal static class VexGenCommandGroup
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var docId = Guid.NewGuid();
|
||||
var timestamp = now.ToString("O", CultureInfo.InvariantCulture);
|
||||
var docId = BuildDeterministicId("vex:drift:", imageDigest, timestamp);
|
||||
var statements = new List<OpenVexStatement>();
|
||||
|
||||
foreach (var drift in report.FacetDrifts.Where(d =>
|
||||
d.QuotaVerdict == QuotaVerdict.RequiresVex ||
|
||||
d.QuotaVerdict == QuotaVerdict.Warning))
|
||||
{
|
||||
var statementId = BuildDeterministicId("vex:drift-statement:", docId, drift.FacetId ?? "unknown");
|
||||
statements.Add(new OpenVexStatement
|
||||
{
|
||||
Id = $"vex:{Guid.NewGuid()}",
|
||||
Id = statementId,
|
||||
Status = status,
|
||||
Timestamp = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Timestamp = timestamp,
|
||||
Products =
|
||||
[
|
||||
new OpenVexProduct
|
||||
@@ -255,12 +321,96 @@ internal static class VexGenCommandGroup
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = $"https://stellaops.io/vex/{docId}",
|
||||
Author = "StellaOps CLI",
|
||||
Timestamp = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
Timestamp = timestamp,
|
||||
Version = 1,
|
||||
Statements = [.. statements]
|
||||
};
|
||||
}
|
||||
|
||||
internal static async Task<(OpenVexDocument Document, ImmutableArray<EvidenceSummary> Summaries)> AttachEvidenceLinksAsync(
|
||||
OpenVexDocument document,
|
||||
IVexEvidenceLinker evidenceLinker,
|
||||
double evidenceThreshold,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var statements = ImmutableArray.CreateBuilder<OpenVexStatement>(document.Statements.Length);
|
||||
var summaries = ImmutableArray.CreateBuilder<EvidenceSummary>();
|
||||
|
||||
foreach (var statement in document.Statements)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var links = await evidenceLinker.GetLinksAsync(statement.Id, ct).ConfigureAwait(false);
|
||||
var selected = SelectEvidenceLink(links, evidenceThreshold);
|
||||
if (selected is null)
|
||||
{
|
||||
statements.Add(statement);
|
||||
continue;
|
||||
}
|
||||
|
||||
var evidence = new OpenVexEvidence
|
||||
{
|
||||
Type = selected.EvidenceType.ToString().ToLowerInvariant(),
|
||||
Uri = selected.EvidenceUri,
|
||||
Confidence = selected.Confidence,
|
||||
PredicateType = selected.PredicateType,
|
||||
EnvelopeDigest = selected.EnvelopeDigest,
|
||||
ValidatedSignature = selected.SignatureValidated,
|
||||
RekorIndex = selected.RekorLogIndex,
|
||||
Signer = selected.SignerIdentity
|
||||
};
|
||||
|
||||
statements.Add(statement with { Evidence = evidence });
|
||||
summaries.Add(new EvidenceSummary(statement.Id, evidence.Type, selected.Confidence, selected.EvidenceUri));
|
||||
}
|
||||
|
||||
return (document with { Statements = statements.ToImmutable() }, summaries.ToImmutable());
|
||||
}
|
||||
|
||||
private static VexEvidenceLink? SelectEvidenceLink(VexEvidenceLinkSet linkSet, double threshold)
|
||||
{
|
||||
if (linkSet.PrimaryLink is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return linkSet.MaxConfidence >= threshold ? linkSet.PrimaryLink : null;
|
||||
}
|
||||
|
||||
private static double NormalizeEvidenceThreshold(double threshold)
|
||||
{
|
||||
if (double.IsNaN(threshold) || double.IsInfinity(threshold))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Clamp(threshold, 0, 1);
|
||||
}
|
||||
|
||||
private static string TruncateEvidenceUri(string uri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uri))
|
||||
{
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
var trimmed = uri.Trim();
|
||||
if (trimmed.Length <= 48)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed[..24] + "..." + trimmed[^16..];
|
||||
}
|
||||
|
||||
private static string BuildDeterministicId(string prefix, params string[] parts)
|
||||
{
|
||||
var payload = string.Join("|", parts.Select(part => part?.Trim() ?? string.Empty));
|
||||
var bytes = Encoding.UTF8.GetBytes(payload);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return prefix + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string TruncateHash(string? hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash)) return "(none)";
|
||||
@@ -314,6 +464,10 @@ internal sealed record OpenVexStatement
|
||||
|
||||
[JsonPropertyName("action_statement")]
|
||||
public string? ActionStatement { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public OpenVexEvidence? Evidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -336,3 +490,40 @@ internal sealed record OpenVexIdentifiers
|
||||
[JsonPropertyName("facet")]
|
||||
public string? Facet { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record OpenVexEvidence
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("envelopeDigest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? EnvelopeDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("validatedSignature")]
|
||||
public required bool ValidatedSignature { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorIndex")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RekorIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("signer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Signer { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record EvidenceSummary(
|
||||
string StatementId,
|
||||
string Type,
|
||||
double Confidence,
|
||||
string EvidenceUri);
|
||||
|
||||
@@ -5,20 +5,26 @@ using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
using StellaOps.ExportCenter.Core.EvidenceCache;
|
||||
using StellaOps.Verdict;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Scanner.Storage.Oci;
|
||||
using StellaOps.Scanner.PatchVerification.DependencyInjection;
|
||||
using StellaOps.Scanner.Analyzers.Native;
|
||||
using StellaOps.Doctor.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins.Core.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugins.Database.DependencyInjection;
|
||||
@@ -184,6 +190,7 @@ internal static class Program
|
||||
services.AddSingleton<MigrationCommandService>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IEvidenceCacheService, LocalEvidenceCacheService>();
|
||||
services.AddVexEvidenceLinking(configuration);
|
||||
|
||||
// Doctor diagnostics engine
|
||||
services.AddDoctorEngine();
|
||||
@@ -270,6 +277,14 @@ internal static class Program
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Cli/verify-image");
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "oci-registry");
|
||||
|
||||
services.AddOciImageInspector(configuration.GetSection("OciRegistry"));
|
||||
|
||||
// CLI-DIFF-0001: Binary diff predicates and native analyzer support
|
||||
services.AddBinaryDiffPredicates();
|
||||
services.AddNativeAnalyzer(configuration);
|
||||
services.AddSingleton<IBinaryDiffService, BinaryDiffService>();
|
||||
services.AddSingleton<IBinaryDiffRenderer, BinaryDiffRenderer>();
|
||||
|
||||
services.AddSingleton<ITrustPolicyLoader, TrustPolicyLoader>();
|
||||
services.AddSingleton<IDsseSignatureVerifier, DsseSignatureVerifier>();
|
||||
services.AddSingleton<IImageAttestationVerifier, ImageAttestationVerifier>();
|
||||
|
||||
@@ -44,6 +44,9 @@ public sealed record OciManifest
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
[JsonPropertyName("manifests")]
|
||||
public List<OciIndexDescriptor>? Manifests { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public OciDescriptor? Config { get; init; }
|
||||
|
||||
@@ -54,6 +57,36 @@ public sealed record OciManifest
|
||||
public Dictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciIndexDescriptor
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public OciPlatform? Platform { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public Dictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciPlatform
|
||||
{
|
||||
[JsonPropertyName("os")]
|
||||
public string? Os { get; init; }
|
||||
|
||||
[JsonPropertyName("architecture")]
|
||||
public string? Architecture { get; init; }
|
||||
|
||||
[JsonPropertyName("variant")]
|
||||
public string? Variant { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OciDescriptor
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
|
||||
@@ -13,7 +13,8 @@ internal static class OciImageReferenceParser
|
||||
|
||||
reference = reference.Trim();
|
||||
if (reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
reference.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||
reference.StartsWith("docker://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseUri(reference);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -59,6 +60,7 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.csproj" />
|
||||
@@ -80,6 +82,7 @@
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Persistence/StellaOps.Concelier.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Persistence/StellaOps.Policy.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/StellaOps.ExportCenter.Client.csproj" />
|
||||
@@ -87,12 +90,14 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="../../Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<!-- Binary Delta Signatures (SPRINT_20260102_001_BE) -->
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
|
||||
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.csproj" />
|
||||
<!-- Binary Call Graph (SPRINT_20260104_001_CLI) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
|
||||
<ProjectReference Include="../../Scanner/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<!-- Secrets Bundle CLI (SPRINT_20260104_003_SCANNER) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<!-- Replay Infrastructure (SPRINT_20260105_002_001_REPLAY) -->
|
||||
|
||||
@@ -8,3 +8,25 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0137-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0137-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0137-A | TODO | Revalidated 2026-01-06 (open findings: determinism, HttpClient usage, ASCII output, monolith). |
|
||||
| CLI-DIFF-COMMAND-0001 | DONE | SPRINT_20260113_001_003 - Implement stella scan diff command group. |
|
||||
| CLI-DIFF-OPTIONS-0001 | DONE | SPRINT_20260113_001_003 - Add diff options and defaults. |
|
||||
| CLI-DIFF-SERVICE-0001 | DONE | SPRINT_20260113_001_003 - Compute ELF diff and predicate. |
|
||||
| CLI-DIFF-RENDERER-0001 | DONE | SPRINT_20260113_001_003 - Table/json/summary output. |
|
||||
| CLI-DIFF-DSSE-OUTPUT-0001 | DONE | SPRINT_20260113_001_003 - Emit DSSE envelopes. |
|
||||
| CLI-DIFF-PROGRESS-0001 | DONE | SPRINT_20260113_001_003 - Progress reporting. |
|
||||
| CLI-DIFF-DI-0001 | DONE | SPRINT_20260113_001_003 - Register services in Program.cs. |
|
||||
| CLI-DIFF-HELP-0001 | DONE | SPRINT_20260113_001_003 - Help text and completions. |
|
||||
| CLI-DIFF-TESTS-0001 | DONE | SPRINT_20260113_001_003 - Binary diff command/service/renderer unit coverage added. |
|
||||
| CLI-DIFF-INTEGRATION-0001 | DONE | SPRINT_20260113_001_003 - Synthetic OCI ELF diff integration test added. |
|
||||
| CLI-IMAGE-GROUP-0001 | DONE | SPRINT_20260113_002_002 - Add image command group. |
|
||||
| CLI-IMAGE-INSPECT-0001 | DONE | SPRINT_20260113_002_002 - Implement image inspect options. |
|
||||
| CLI-IMAGE-HANDLER-0001 | DONE | SPRINT_20260113_002_002 - Handler uses IOciImageInspector. |
|
||||
| CLI-IMAGE-OUTPUT-TABLE-0001 | DONE | SPRINT_20260113_002_002 - Table output for platforms and layers. |
|
||||
| CLI-IMAGE-OUTPUT-JSON-0001 | DONE | SPRINT_20260113_002_002 - Canonical JSON output. |
|
||||
| CLI-IMAGE-REGISTER-0001 | DONE | SPRINT_20260113_002_002 - Register command and DI. |
|
||||
| CLI-IMAGE-TESTS-0001 | DONE | SPRINT_20260113_002_002 - Unit tests for inspect command. |
|
||||
| CLI-IMAGE-GOLDEN-0001 | DONE | SPRINT_20260113_002_002 - Golden output determinism tests. |
|
||||
| CLI-VEX-EVIDENCE-OPT-0001 | DONE | SPRINT_20260113_003_002 - VEX evidence options. |
|
||||
| CLI-VEX-EVIDENCE-HANDLER-0001 | DONE | SPRINT_20260113_003_002 - Evidence linking in VEX handler. |
|
||||
| CLI-VEX-EVIDENCE-JSON-0001 | DONE | SPRINT_20260113_003_002 - JSON evidence output. |
|
||||
| CLI-VEX-EVIDENCE-TABLE-0001 | DONE | SPRINT_20260113_003_002 - Table evidence summary. |
|
||||
|
||||
1
src/Cli/StellaOps.Cli/tmpclaude-889e-cwd
Normal file
1
src/Cli/StellaOps.Cli/tmpclaude-889e-cwd
Normal file
@@ -0,0 +1 @@
|
||||
/c/dev/New folder/git.stella-ops.org/src/Cli/StellaOps.Cli
|
||||
@@ -0,0 +1,370 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cli.Commands.Setup.Config;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Tests.Config;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class YamlSetupConfigParserTests
|
||||
{
|
||||
private readonly YamlSetupConfigParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ThrowsOnNullPath()
|
||||
{
|
||||
// Act
|
||||
var action = () => _parser.ParseAsync(null!);
|
||||
|
||||
// Assert
|
||||
await action.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ThrowsOnEmptyPath()
|
||||
{
|
||||
// Act
|
||||
var action = () => _parser.ParseAsync("");
|
||||
|
||||
// Assert
|
||||
await action.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ThrowsOnNonExistentFile()
|
||||
{
|
||||
// Act
|
||||
var action = () => _parser.ParseAsync("/nonexistent/file.yaml");
|
||||
|
||||
// Assert
|
||||
await action.Should().ThrowAsync<FileNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ParsesValidConfig()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
version: "1"
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
database: stellaops
|
||||
user: admin
|
||||
cache:
|
||||
host: localhost
|
||||
port: 6379
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var config = await _parser.ParseAsync(path);
|
||||
|
||||
// Assert
|
||||
config.Version.Should().Be("1");
|
||||
config.Database.Should().NotBeNull();
|
||||
config.Database!.Host.Should().Be("localhost");
|
||||
config.Database.Port.Should().Be(5432);
|
||||
config.Database.Database.Should().Be("stellaops");
|
||||
config.Database.User.Should().Be("admin");
|
||||
config.Cache.Should().NotBeNull();
|
||||
config.Cache!.Host.Should().Be("localhost");
|
||||
config.Cache.Port.Should().Be(6379);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ParsesSkipSteps()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
skipSteps:
|
||||
- vault
|
||||
- telemetry
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var config = await _parser.ParseAsync(path);
|
||||
|
||||
// Assert
|
||||
config.SkipSteps.Should().HaveCount(2);
|
||||
config.SkipSteps.Should().Contain("vault");
|
||||
config.SkipSteps.Should().Contain("telemetry");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ParsesVaultConfig()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
vault:
|
||||
provider: hashicorp
|
||||
address: https://vault.example.com
|
||||
token: hvs.xxx
|
||||
mountPath: secret
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var config = await _parser.ParseAsync(path);
|
||||
|
||||
// Assert
|
||||
config.Vault.Should().NotBeNull();
|
||||
config.Vault!.Provider.Should().Be("hashicorp");
|
||||
config.Vault.Address.Should().Be("https://vault.example.com");
|
||||
config.Vault.Token.Should().Be("hvs.xxx");
|
||||
config.Vault.MountPath.Should().Be("secret");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_ParsesSettingsStoreConfig()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
settingsStore:
|
||||
provider: consul
|
||||
address: http://localhost:8500
|
||||
prefix: stellaops/config
|
||||
reloadOnChange: true
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var config = await _parser.ParseAsync(path);
|
||||
|
||||
// Assert
|
||||
config.SettingsStore.Should().NotBeNull();
|
||||
config.SettingsStore!.Provider.Should().Be("consul");
|
||||
config.SettingsStore.Address.Should().Be("http://localhost:8500");
|
||||
config.SettingsStore.Prefix.Should().Be("stellaops/config");
|
||||
config.SettingsStore.ReloadOnChange.Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsValidForWellFormedConfig()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
version: "1"
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync(path);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsInvalidForNonExistentFile()
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync("/nonexistent/file.yaml");
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().ContainSingle(e => e.Contains("not found"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsInvalidForMalformedYaml()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
database:
|
||||
host: localhost
|
||||
cache:
|
||||
- this is wrong
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync(path);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().ContainSingle(e => e.Contains("YAML parsing error"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsWarningForMissingVersion()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
database:
|
||||
host: localhost
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync(path);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().ContainSingle(w => w.Contains("version"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsErrorForInvalidDatabasePort()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
database:
|
||||
port: 99999
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync(path);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().ContainSingle(e => e.Contains("invalid port"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsErrorForConflictingStepSettings()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
skipSteps:
|
||||
- database
|
||||
includeSteps:
|
||||
- database
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync(path);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().ContainSingle(e =>
|
||||
e.Contains("both skipped and included"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsErrorForMissingRegistryUrl()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
registry:
|
||||
username: admin
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync(path);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().ContainSingle(e => e.Contains("url is required"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsWarningForInsecureRegistry()
|
||||
{
|
||||
// Arrange
|
||||
var yaml = """
|
||||
registry:
|
||||
url: http://localhost:5000
|
||||
insecure: true
|
||||
""";
|
||||
var path = CreateTempYamlFile(yaml);
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = await _parser.ValidateAsync(path);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Warnings.Should().ContainSingle(w => w.Contains("insecure mode"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTempYamlFile(string content)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test-config-{Guid.NewGuid():N}.yaml");
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,209 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Cli.Commands.Setup.Steps;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Commands.Setup.Tests.Steps;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SetupStepCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_AddsStepToCatalog()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var step = new TestSetupStep("test-step");
|
||||
|
||||
// Act
|
||||
catalog.Register(step);
|
||||
|
||||
// Assert
|
||||
catalog.AllSteps.Should().ContainSingle();
|
||||
catalog.GetStep("test-step").Should().Be(step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStep_ReturnsNull_WhenStepNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
|
||||
// Act
|
||||
var result = catalog.GetStep("nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStep_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var step = new TestSetupStep("TestStep");
|
||||
catalog.Register(step);
|
||||
|
||||
// Act
|
||||
var result = catalog.GetStep("teststep");
|
||||
|
||||
// Assert
|
||||
result.Should().Be(step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStepsByCategory_ReturnsMatchingSteps()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var infraStep = new TestSetupStep("infra", SetupCategory.Infrastructure);
|
||||
var securityStep = new TestSetupStep("security", SetupCategory.Security);
|
||||
catalog.Register(infraStep);
|
||||
catalog.Register(securityStep);
|
||||
|
||||
// Act
|
||||
var result = catalog.GetStepsByCategory(SetupCategory.Infrastructure);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainSingle();
|
||||
result[0].Should().Be(infraStep);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStepsInOrder_ReturnsStepsOrderedByCategoryAndOrder()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var step1 = new TestSetupStep("step1", SetupCategory.Security, 2);
|
||||
var step2 = new TestSetupStep("step2", SetupCategory.Infrastructure, 1);
|
||||
var step3 = new TestSetupStep("step3", SetupCategory.Security, 1);
|
||||
catalog.Register(step1);
|
||||
catalog.Register(step2);
|
||||
catalog.Register(step3);
|
||||
|
||||
// Act
|
||||
var result = catalog.GetStepsInOrder();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result[0].Id.Should().Be("step2"); // Infrastructure first
|
||||
result[1].Id.Should().Be("step3"); // Security order 1
|
||||
result[2].Id.Should().Be("step1"); // Security order 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRequiredSteps_ReturnsOnlyRequiredSteps()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var requiredStep = new TestSetupStep("required", isRequired: true);
|
||||
var optionalStep = new TestSetupStep("optional", isRequired: false);
|
||||
catalog.Register(requiredStep);
|
||||
catalog.Register(optionalStep);
|
||||
|
||||
// Act
|
||||
var result = catalog.GetRequiredSteps();
|
||||
|
||||
// Assert
|
||||
result.Should().ContainSingle();
|
||||
result[0].Id.Should().Be("required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDependentSteps_ReturnsStepsThatDependOnGivenStep()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var baseStep = new TestSetupStep("base");
|
||||
var dependentStep = new TestSetupStep("dependent", dependencies: ["base"]);
|
||||
var independentStep = new TestSetupStep("independent");
|
||||
catalog.Register(baseStep);
|
||||
catalog.Register(dependentStep);
|
||||
catalog.Register(independentStep);
|
||||
|
||||
// Act
|
||||
var result = catalog.GetDependentSteps("base");
|
||||
|
||||
// Assert
|
||||
result.Should().ContainSingle();
|
||||
result[0].Id.Should().Be("dependent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveExecutionOrder_RespectsDepe()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var stepA = new TestSetupStep("a");
|
||||
var stepB = new TestSetupStep("b", dependencies: ["a"]);
|
||||
var stepC = new TestSetupStep("c", dependencies: ["b"]);
|
||||
catalog.Register(stepC);
|
||||
catalog.Register(stepA);
|
||||
catalog.Register(stepB);
|
||||
|
||||
// Act
|
||||
var result = catalog.ResolveExecutionOrder();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result[0].Id.Should().Be("a");
|
||||
result[1].Id.Should().Be("b");
|
||||
result[2].Id.Should().Be("c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveExecutionOrder_ThrowsOnCircularDependency()
|
||||
{
|
||||
// Arrange
|
||||
var catalog = new SetupStepCatalog();
|
||||
var stepA = new TestSetupStep("a", dependencies: ["b"]);
|
||||
var stepB = new TestSetupStep("b", dependencies: ["a"]);
|
||||
catalog.Register(stepA);
|
||||
catalog.Register(stepB);
|
||||
|
||||
// Act
|
||||
var action = () => catalog.ResolveExecutionOrder();
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*Circular dependency*");
|
||||
}
|
||||
|
||||
private sealed class TestSetupStep : ISetupStep
|
||||
{
|
||||
public TestSetupStep(
|
||||
string id,
|
||||
SetupCategory category = SetupCategory.Infrastructure,
|
||||
int order = 0,
|
||||
bool isRequired = true,
|
||||
IReadOnlyList<string>? dependencies = null)
|
||||
{
|
||||
Id = id;
|
||||
Category = category;
|
||||
Order = order;
|
||||
IsRequired = isRequired;
|
||||
Dependencies = dependencies ?? [];
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public string Name => Id;
|
||||
public string Description => $"Test step {Id}";
|
||||
public SetupCategory Category { get; }
|
||||
public int Order { get; }
|
||||
public bool IsRequired { get; }
|
||||
public bool IsSkippable => !IsRequired;
|
||||
public IReadOnlyList<string> Dependencies { get; }
|
||||
public IReadOnlyList<string> ValidationChecks => [];
|
||||
|
||||
public Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(SetupStepContext context, CancellationToken ct = default)
|
||||
=> Task.FromResult(SetupStepPrerequisiteResult.Success());
|
||||
|
||||
public Task<SetupStepResult> ExecuteAsync(SetupStepContext context, CancellationToken ct = default)
|
||||
=> Task.FromResult(SetupStepResult.Success());
|
||||
|
||||
public Task<SetupStepValidationResult> ValidateAsync(SetupStepContext context, CancellationToken ct = default)
|
||||
=> Task.FromResult(SetupStepValidationResult.Success());
|
||||
|
||||
public Task<SetupStepRollbackResult> RollbackAsync(SetupStepContext context, CancellationToken ct = default)
|
||||
=> Task.FromResult(SetupStepRollbackResult.NotSupported());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class BinaryDiffCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
public BinaryDiffCommandTests()
|
||||
{
|
||||
_services = new ServiceCollection().BuildServiceProvider();
|
||||
_verboseOption = new Option<bool>("--verbose", new[] { "-v" })
|
||||
{
|
||||
Description = "Enable verbose output"
|
||||
};
|
||||
_cancellationToken = CancellationToken.None;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDiffCommand_HasRequiredOptions()
|
||||
{
|
||||
var command = BuildDiffCommand();
|
||||
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--base", "-b"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--target", "-t"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--mode", "-m"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--emit-dsse", "-d"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--signing-key"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--format", "-f"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--platform", "-p"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--include-unchanged"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--sections"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--registry-auth"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--timeout"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--verbose", "-v"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDiffCommand_RequiresBaseAndTarget()
|
||||
{
|
||||
var command = BuildDiffCommand();
|
||||
var baseOption = FindOption(command, "--base");
|
||||
var targetOption = FindOption(command, "--target");
|
||||
|
||||
Assert.NotNull(baseOption);
|
||||
Assert.NotNull(targetOption);
|
||||
Assert.Equal(1, baseOption!.Arity.MinimumNumberOfValues);
|
||||
Assert.Equal(1, targetOption!.Arity.MinimumNumberOfValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_ParsesMinimalArgs()
|
||||
{
|
||||
var root = BuildRoot(out _);
|
||||
|
||||
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_FailsWhenBaseMissing()
|
||||
{
|
||||
var root = BuildRoot(out _);
|
||||
|
||||
var result = root.Parse("scan diff --target registry.example.com/app:2");
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_AcceptsSectionsTokens()
|
||||
{
|
||||
var root = BuildRoot(out var diffCommand);
|
||||
|
||||
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2 --sections .text,.rodata --sections .data");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
|
||||
var sectionsOption = diffCommand.Options
|
||||
.OfType<Option<string[]>>()
|
||||
.Single(option => HasAlias(option, "--sections"));
|
||||
|
||||
Assert.True(sectionsOption.AllowMultipleArgumentsPerToken);
|
||||
}
|
||||
|
||||
private Command BuildDiffCommand()
|
||||
{
|
||||
return BinaryDiffCommandGroup.BuildDiffCommand(_services, _verboseOption, _cancellationToken);
|
||||
}
|
||||
|
||||
private RootCommand BuildRoot(out Command diffCommand)
|
||||
{
|
||||
diffCommand = BuildDiffCommand();
|
||||
var scan = new Command("scan", "Scanner operations")
|
||||
{
|
||||
diffCommand
|
||||
};
|
||||
return new RootCommand { scan };
|
||||
}
|
||||
|
||||
private static Option? FindOption(Command command, string alias)
|
||||
{
|
||||
return command.Options.FirstOrDefault(option =>
|
||||
option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Aliases.Contains(alias));
|
||||
}
|
||||
|
||||
private static bool HasAlias(Option option, params string[] aliases)
|
||||
{
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
if (option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Aliases.Contains(alias))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class BinaryDiffCommandTests
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly Option<bool> _verboseOption;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
public BinaryDiffCommandTests()
|
||||
{
|
||||
_services = new ServiceCollection().BuildServiceProvider();
|
||||
_verboseOption = new Option<bool>("--verbose", new[] { "-v" })
|
||||
{
|
||||
Description = "Enable verbose output"
|
||||
};
|
||||
_cancellationToken = CancellationToken.None;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDiffCommand_HasRequiredOptions()
|
||||
{
|
||||
var command = BuildDiffCommand();
|
||||
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--base", "-b"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--target", "-t"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--mode", "-m"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--emit-dsse", "-d"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--signing-key"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--format", "-f"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--platform", "-p"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--include-unchanged"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--sections"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--registry-auth"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--timeout"));
|
||||
Assert.Contains(command.Options, option => HasAlias(option, "--verbose", "-v"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDiffCommand_RequiresBaseAndTarget()
|
||||
{
|
||||
var command = BuildDiffCommand();
|
||||
var baseOption = FindOption(command, "--base");
|
||||
var targetOption = FindOption(command, "--target");
|
||||
|
||||
Assert.NotNull(baseOption);
|
||||
Assert.NotNull(targetOption);
|
||||
Assert.True(baseOption!.IsRequired);
|
||||
Assert.True(targetOption!.IsRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_ParsesMinimalArgs()
|
||||
{
|
||||
var root = BuildRoot(out _);
|
||||
|
||||
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_FailsWhenBaseMissing()
|
||||
{
|
||||
var root = BuildRoot(out _);
|
||||
|
||||
var result = root.Parse("scan diff --target registry.example.com/app:2");
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffCommand_ParsesSectionsValues()
|
||||
{
|
||||
var root = BuildRoot(out var diffCommand);
|
||||
|
||||
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2 --sections .text,.rodata --sections .data");
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
|
||||
var sectionsOption = diffCommand.Options
|
||||
.OfType<Option<string[]>>()
|
||||
.Single(option => HasAlias(option, "--sections"));
|
||||
var values = result.GetValueForOption(sectionsOption);
|
||||
|
||||
Assert.Contains(".text,.rodata", values);
|
||||
Assert.Contains(".data", values);
|
||||
Assert.True(sectionsOption.AllowMultipleArgumentsPerToken);
|
||||
}
|
||||
|
||||
private Command BuildDiffCommand()
|
||||
{
|
||||
return BinaryDiffCommandGroup.BuildDiffCommand(_services, _verboseOption, _cancellationToken);
|
||||
}
|
||||
|
||||
private RootCommand BuildRoot(out Command diffCommand)
|
||||
{
|
||||
diffCommand = BuildDiffCommand();
|
||||
var scan = new Command("scan", "Scanner operations")
|
||||
{
|
||||
diffCommand
|
||||
};
|
||||
return new RootCommand { scan };
|
||||
}
|
||||
|
||||
private static Option? FindOption(Command command, string alias)
|
||||
{
|
||||
return command.Options.FirstOrDefault(option =>
|
||||
option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Aliases.Contains(alias));
|
||||
}
|
||||
|
||||
private static bool HasAlias(Option option, params string[] aliases)
|
||||
{
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
if (option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
|
||||
option.Aliases.Contains(alias))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class BinaryDiffRendererTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp =
|
||||
new(2026, 1, 13, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task RenderJson_WritesCanonicalOutput()
|
||||
{
|
||||
var renderer = new BinaryDiffRenderer();
|
||||
var result = CreateResult();
|
||||
|
||||
var first = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Json);
|
||||
var second = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Json);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
|
||||
using var document = JsonDocument.Parse(first);
|
||||
Assert.Equal("1.0.0", document.RootElement.GetProperty("schemaVersion").GetString());
|
||||
Assert.Equal("elf", document.RootElement.GetProperty("analysisMode").GetString());
|
||||
Assert.Equal("/usr/bin/app", document.RootElement.GetProperty("findings")[0].GetProperty("path").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderTable_IncludesFindingsAndSummary()
|
||||
{
|
||||
var renderer = new BinaryDiffRenderer();
|
||||
var result = CreateResult();
|
||||
|
||||
var output = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Table);
|
||||
|
||||
Assert.Contains("Binary Diff:", output);
|
||||
Assert.Contains("Analysis Mode: ELF section hashes", output);
|
||||
Assert.Contains("/usr/bin/app", output);
|
||||
Assert.Contains("Summary:", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderSummary_ReportsTotals()
|
||||
{
|
||||
var renderer = new BinaryDiffRenderer();
|
||||
var result = CreateResult();
|
||||
|
||||
var output = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Summary);
|
||||
|
||||
Assert.Contains("Binary Diff Summary", output);
|
||||
Assert.Contains("Binaries: 2 total, 1 modified, 1 unchanged", output);
|
||||
Assert.Contains("Added: 0, Removed: 0", output);
|
||||
}
|
||||
|
||||
private static async Task<string> RenderAsync(
|
||||
BinaryDiffRenderer renderer,
|
||||
BinaryDiffResult result,
|
||||
BinaryDiffOutputFormat format)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
await renderer.RenderAsync(result, format, writer, CancellationToken.None);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static BinaryDiffResult CreateResult()
|
||||
{
|
||||
var verdicts = ImmutableDictionary.CreateRange(
|
||||
StringComparer.Ordinal,
|
||||
new[]
|
||||
{
|
||||
new KeyValuePair<string, int>("unknown", 1)
|
||||
});
|
||||
|
||||
var findings = ImmutableArray.Create(
|
||||
new BinaryDiffFinding
|
||||
{
|
||||
Path = "/usr/bin/app",
|
||||
ChangeType = ChangeType.Modified,
|
||||
BinaryFormat = BinaryFormat.Elf,
|
||||
LayerDigest = "sha256:layer",
|
||||
SectionDeltas = ImmutableArray.Create(
|
||||
new SectionDelta
|
||||
{
|
||||
Section = ".text",
|
||||
Status = SectionStatus.Modified,
|
||||
BaseSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
TargetSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
SizeDelta = 32
|
||||
}),
|
||||
Confidence = 0.75,
|
||||
Verdict = StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict.Unknown
|
||||
},
|
||||
new BinaryDiffFinding
|
||||
{
|
||||
Path = "/usr/lib/libfoo.so",
|
||||
ChangeType = ChangeType.Unchanged,
|
||||
BinaryFormat = BinaryFormat.Elf,
|
||||
LayerDigest = "sha256:layer",
|
||||
SectionDeltas = ImmutableArray<SectionDelta>.Empty,
|
||||
Confidence = 1.0,
|
||||
Verdict = StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict.Vanilla
|
||||
});
|
||||
|
||||
var summary = new BinaryDiffSummary
|
||||
{
|
||||
TotalBinaries = 2,
|
||||
Modified = 1,
|
||||
Added = 0,
|
||||
Removed = 0,
|
||||
Unchanged = 1,
|
||||
Verdicts = verdicts
|
||||
};
|
||||
|
||||
return new BinaryDiffResult
|
||||
{
|
||||
Base = new BinaryDiffImageReference
|
||||
{
|
||||
Reference = "registry.example.com/app:1",
|
||||
Digest = "sha256:base",
|
||||
ManifestDigest = "sha256:manifest-base",
|
||||
Platform = new BinaryDiffPlatform
|
||||
{
|
||||
Os = "linux",
|
||||
Architecture = "amd64"
|
||||
}
|
||||
},
|
||||
Target = new BinaryDiffImageReference
|
||||
{
|
||||
Reference = "registry.example.com/app:2",
|
||||
Digest = "sha256:target",
|
||||
ManifestDigest = "sha256:manifest-target",
|
||||
Platform = new BinaryDiffPlatform
|
||||
{
|
||||
Os = "linux",
|
||||
Architecture = "amd64"
|
||||
}
|
||||
},
|
||||
Platform = new BinaryDiffPlatform
|
||||
{
|
||||
Os = "linux",
|
||||
Architecture = "amd64"
|
||||
},
|
||||
Mode = BinaryDiffMode.Elf,
|
||||
Findings = findings,
|
||||
Summary = summary,
|
||||
Metadata = new BinaryDiffMetadata
|
||||
{
|
||||
ToolVersion = "test",
|
||||
AnalysisTimestamp = FixedTimestamp,
|
||||
TotalBinaries = summary.TotalBinaries,
|
||||
ModifiedBinaries = summary.Modified,
|
||||
AnalyzedSections = ImmutableArray<string>.Empty
|
||||
},
|
||||
Predicate = null,
|
||||
BaseReference = null,
|
||||
TargetReference = null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Scanner.Analyzers.Native;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class BinaryDiffServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ComputeDiffAsync_InvalidReference_ThrowsBinaryDiffException()
|
||||
{
|
||||
var service = CreateService(new TestOciRegistryClient(), new TestElfSectionHashExtractor(), TimeProvider.System);
|
||||
|
||||
var request = new BinaryDiffRequest
|
||||
{
|
||||
BaseImageRef = "",
|
||||
TargetImageRef = "registry.example.com/app:2",
|
||||
Mode = BinaryDiffMode.Elf
|
||||
};
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BinaryDiffException>(() =>
|
||||
service.ComputeDiffAsync(request, null, CancellationToken.None));
|
||||
|
||||
Assert.Equal(BinaryDiffErrorCode.InvalidReference, exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDiffAsync_ExcludesUnchanged_WhenIncludeUnchangedFalse()
|
||||
{
|
||||
var baseRef = "registry.example.com/app:1";
|
||||
var targetRef = "registry.example.com/app:2";
|
||||
var baseLayer = CreateLayer(
|
||||
("usr/bin/alpha", CreateElfBytes('a')),
|
||||
("usr/bin/beta", CreateElfBytes('a')));
|
||||
var targetLayer = CreateLayer(
|
||||
("usr/bin/alpha", CreateElfBytes('a')),
|
||||
("usr/bin/beta", CreateElfBytes('b')),
|
||||
("usr/bin/gamma", CreateElfBytes('b')));
|
||||
|
||||
var registry = new TestOciRegistryClient();
|
||||
registry.AddImage(baseRef, "sha256:base", CreateManifest("sha256:layer-base", baseLayer.Length));
|
||||
registry.AddImage(targetRef, "sha256:target", CreateManifest("sha256:layer-target", targetLayer.Length));
|
||||
registry.AddBlob("sha256:layer-base", baseLayer);
|
||||
registry.AddBlob("sha256:layer-target", targetLayer);
|
||||
|
||||
var service = CreateService(registry, new TestElfSectionHashExtractor(), TimeProvider.System);
|
||||
|
||||
var result = await service.ComputeDiffAsync(
|
||||
new BinaryDiffRequest
|
||||
{
|
||||
BaseImageRef = baseRef,
|
||||
TargetImageRef = targetRef,
|
||||
Mode = BinaryDiffMode.Elf,
|
||||
IncludeUnchanged = false,
|
||||
Platform = new BinaryDiffPlatform { Os = "linux", Architecture = "amd64" }
|
||||
},
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Findings.Length);
|
||||
Assert.All(result.Findings, finding => Assert.NotEqual(ChangeType.Unchanged, finding.ChangeType));
|
||||
|
||||
var paths = result.Findings.Select(finding => finding.Path).ToArray();
|
||||
var sortedPaths = paths.OrderBy(path => path, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(sortedPaths, paths);
|
||||
|
||||
Assert.Equal(3, result.Summary.TotalBinaries);
|
||||
Assert.Equal(2, result.Summary.Modified);
|
||||
Assert.Equal(1, result.Summary.Unchanged);
|
||||
Assert.Equal(1, result.Summary.Added);
|
||||
Assert.Equal(0, result.Summary.Removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDiffAsync_UsesTimeProviderForMetadata()
|
||||
{
|
||||
var baseRef = "registry.example.com/app:1";
|
||||
var targetRef = "registry.example.com/app:2";
|
||||
var baseLayer = CreateLayer(("usr/bin/app", CreateElfBytes('a')));
|
||||
var targetLayer = CreateLayer(("usr/bin/app", CreateElfBytes('b')));
|
||||
|
||||
var registry = new TestOciRegistryClient();
|
||||
registry.AddImage(baseRef, "sha256:base", CreateManifest("sha256:layer-base", baseLayer.Length));
|
||||
registry.AddImage(targetRef, "sha256:target", CreateManifest("sha256:layer-target", targetLayer.Length));
|
||||
registry.AddBlob("sha256:layer-base", baseLayer);
|
||||
registry.AddBlob("sha256:layer-target", targetLayer);
|
||||
|
||||
var fixedTime = new DateTimeOffset(2026, 1, 13, 1, 0, 0, TimeSpan.Zero);
|
||||
var service = CreateService(registry, new TestElfSectionHashExtractor(), new FixedTimeProvider(fixedTime));
|
||||
|
||||
var result = await service.ComputeDiffAsync(
|
||||
new BinaryDiffRequest
|
||||
{
|
||||
BaseImageRef = baseRef,
|
||||
TargetImageRef = targetRef,
|
||||
Mode = BinaryDiffMode.Elf
|
||||
},
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(fixedTime, result.Metadata.AnalysisTimestamp);
|
||||
}
|
||||
|
||||
private static BinaryDiffService CreateService(
|
||||
IOciRegistryClient registryClient,
|
||||
IElfSectionHashExtractor extractor,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var options = Options.Create(new BinaryDiffOptions { ToolVersion = "test" });
|
||||
return new BinaryDiffService(
|
||||
registryClient,
|
||||
extractor,
|
||||
options,
|
||||
timeProvider,
|
||||
NullLogger<BinaryDiffService>.Instance);
|
||||
}
|
||||
|
||||
private static OciManifest CreateManifest(string layerDigest, long size)
|
||||
{
|
||||
return new OciManifest
|
||||
{
|
||||
Layers = new List<OciDescriptor>
|
||||
{
|
||||
new OciDescriptor
|
||||
{
|
||||
Digest = layerDigest,
|
||||
Size = size,
|
||||
MediaType = "application/vnd.oci.image.layer.v1.tar+gzip"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateLayer(params (string Path, byte[] Content)[] entries)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax))
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.Path)
|
||||
{
|
||||
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
|
||||
ModificationTime = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero),
|
||||
DataStream = new MemoryStream(entry.Content, writable: false)
|
||||
};
|
||||
tarWriter.WriteEntry(tarEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreateElfBytes(char marker)
|
||||
{
|
||||
return
|
||||
[
|
||||
0x7F,
|
||||
(byte)'E',
|
||||
(byte)'L',
|
||||
(byte)'F',
|
||||
(byte)marker
|
||||
];
|
||||
}
|
||||
|
||||
private static ElfSectionHashSet CreateHashSet(string path, char marker)
|
||||
{
|
||||
var hash = new string(marker, 64);
|
||||
var section = new ElfSectionHash
|
||||
{
|
||||
Name = ".text",
|
||||
Offset = 0,
|
||||
Size = 16,
|
||||
Sha256 = hash,
|
||||
Blake3 = null,
|
||||
SectionType = ElfSectionType.ProgBits,
|
||||
Flags = ElfSectionFlags.Alloc
|
||||
};
|
||||
|
||||
return new ElfSectionHashSet
|
||||
{
|
||||
FilePath = path,
|
||||
FileHash = hash,
|
||||
BuildId = "build-" + marker,
|
||||
Sections = ImmutableArray.Create(section),
|
||||
ExtractedAt = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero),
|
||||
ExtractorVersion = "test"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOciRegistryClient : IOciRegistryClient
|
||||
{
|
||||
private readonly Dictionary<string, string> _digestsByReference = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, OciManifest> _manifestsByDigest = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, byte[]> _blobsByDigest = new(StringComparer.Ordinal);
|
||||
|
||||
public void AddImage(string reference, string digest, OciManifest manifest)
|
||||
{
|
||||
_digestsByReference[reference] = digest;
|
||||
_manifestsByDigest[digest] = manifest;
|
||||
}
|
||||
|
||||
public void AddBlob(string digest, byte[] blob)
|
||||
{
|
||||
_blobsByDigest[digest] = blob;
|
||||
}
|
||||
|
||||
public Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_digestsByReference.TryGetValue(reference.Original, out var digest))
|
||||
{
|
||||
return Task.FromResult(digest);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Digest not configured for {reference.Original}");
|
||||
}
|
||||
|
||||
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException("ResolveTagAsync is not used by these tests.");
|
||||
}
|
||||
|
||||
public Task<OciReferrersResponse> ListReferrersAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException("ListReferrersAsync is not used by these tests.");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OciReferrerDescriptor>> GetReferrersAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
string? artifactType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException("GetReferrersAsync is not used by these tests.");
|
||||
}
|
||||
|
||||
public Task<OciManifest> GetManifestAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_manifestsByDigest.TryGetValue(digest, out var manifest))
|
||||
{
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Manifest not configured for {digest}");
|
||||
}
|
||||
|
||||
public Task<byte[]> GetBlobAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_blobsByDigest.TryGetValue(digest, out var blob))
|
||||
{
|
||||
return Task.FromResult(blob);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Blob not configured for {digest}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestElfSectionHashExtractor : IElfSectionHashExtractor
|
||||
{
|
||||
public Task<ElfSectionHashSet?> ExtractAsync(string elfPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(elfPath);
|
||||
return ExtractFromBytesAsync(bytes, elfPath, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ElfSectionHashSet?> ExtractFromBytesAsync(
|
||||
ReadOnlyMemory<byte> elfBytes,
|
||||
string virtualPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (elfBytes.Length < 5)
|
||||
{
|
||||
return Task.FromResult<ElfSectionHashSet?>(null);
|
||||
}
|
||||
|
||||
var marker = (char)elfBytes.Span[4];
|
||||
var normalized = marker switch
|
||||
{
|
||||
'a' => 'a',
|
||||
'b' => 'b',
|
||||
_ => 'c'
|
||||
};
|
||||
|
||||
return Task.FromResult<ElfSectionHashSet?>(CreateHashSet(virtualPath, normalized));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime)
|
||||
{
|
||||
_fixedTime = fixedTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
}
|
||||
@@ -77,10 +77,42 @@ public sealed class DoctorCommandGroupTests
|
||||
listCommand!.Description.Should().Contain("List");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDoctorCommand_HasFixSubcommand()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateTestServices();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var fixCommand = command.Subcommands.FirstOrDefault(c => c.Name == "fix");
|
||||
fixCommand.Should().NotBeNull();
|
||||
fixCommand!.Description.Should().Contain("fix");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Run Subcommand Options Tests
|
||||
|
||||
[Fact]
|
||||
public void RootCommand_HasFormatOption()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateTestServices();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var formatOption = command.Options.FirstOrDefault(o =>
|
||||
o.Name == "format" || o.Aliases.Contains("--format") || o.Aliases.Contains("-f"));
|
||||
formatOption.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunCommand_HasFormatOption()
|
||||
{
|
||||
@@ -274,6 +306,44 @@ public sealed class DoctorCommandGroupTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fix Subcommand Options Tests
|
||||
|
||||
[Fact]
|
||||
public void FixCommand_HasFromOption()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateTestServices();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
|
||||
var fixCommand = command.Subcommands.First(c => c.Name == "fix");
|
||||
|
||||
// Assert
|
||||
var fromOption = fixCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "from" || o.Name == "--from" || o.Aliases.Contains("--from"));
|
||||
fromOption.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixCommand_HasApplyOption()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateTestServices();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
|
||||
var fixCommand = command.Subcommands.First(c => c.Name == "fix");
|
||||
|
||||
// Assert
|
||||
var applyOption = fixCommand.Options.FirstOrDefault(o =>
|
||||
o.Name == "apply" || o.Name == "--apply" || o.Aliases.Contains("--apply"));
|
||||
applyOption.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exit Codes Tests
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.CommandLine;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class ImageCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_ExposesImageInspectCommand()
|
||||
{
|
||||
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 image = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "image", StringComparison.Ordinal));
|
||||
var inspect = Assert.Single(image.Subcommands, command => string.Equals(command.Name, "inspect", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(inspect.Options, option => option.Name == "--resolve-index" || option.Aliases.Contains("--resolve-index"));
|
||||
Assert.Contains(inspect.Options, option => option.Name == "--print-layers" || option.Aliases.Contains("--print-layers"));
|
||||
Assert.Contains(inspect.Options, option => option.Name == "--platform" || option.Aliases.Contains("--platform"));
|
||||
Assert.Contains(inspect.Options, option => option.Name == "--output" || option.Aliases.Contains("--output"));
|
||||
Assert.Contains(inspect.Options, option => option.Name == "--timeout" || option.Aliases.Contains("--timeout"));
|
||||
Assert.Contains(inspect.Arguments, argument => string.Equals(argument.Name, "reference", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class ImageInspectHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleInspectImageAsync_ValidResult_ReturnsZero()
|
||||
{
|
||||
var result = CreateResult();
|
||||
var provider = BuildServices(new StubInspector(result));
|
||||
var originalExit = Environment.ExitCode;
|
||||
|
||||
try
|
||||
{
|
||||
await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/app:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "json",
|
||||
timeoutSeconds: 60,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
});
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleInspectImageAsync_NotFound_ReturnsOne()
|
||||
{
|
||||
var provider = BuildServices(new StubInspector(null));
|
||||
var originalExit = Environment.ExitCode;
|
||||
|
||||
try
|
||||
{
|
||||
await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/missing:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "table",
|
||||
timeoutSeconds: 60,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
});
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleInspectImageAsync_InvalidReference_ReturnsTwo()
|
||||
{
|
||||
var provider = BuildServices(new StubInspector(CreateResult()));
|
||||
var originalExit = Environment.ExitCode;
|
||||
|
||||
try
|
||||
{
|
||||
await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
string.Empty,
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "table",
|
||||
timeoutSeconds: 60,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
});
|
||||
|
||||
Assert.Equal(2, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleInspectImageAsync_AuthWarning_ReturnsTwo()
|
||||
{
|
||||
var result = CreateResult(warnings: ImmutableArray.Create("Manifest GET returned Unauthorized."));
|
||||
var provider = BuildServices(new StubInspector(result));
|
||||
var originalExit = Environment.ExitCode;
|
||||
|
||||
try
|
||||
{
|
||||
await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/app:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "table",
|
||||
timeoutSeconds: 60,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
});
|
||||
|
||||
Assert.Equal(2, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleInspectImageAsync_JsonOutput_IsValidJson()
|
||||
{
|
||||
var result = CreateResult();
|
||||
var provider = BuildServices(new StubInspector(result));
|
||||
|
||||
var output = await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/app:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "json",
|
||||
timeoutSeconds: 60,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
});
|
||||
|
||||
var action = () => JsonDocument.Parse(output);
|
||||
action();
|
||||
}
|
||||
|
||||
private static ImageInspectionResult CreateResult(ImmutableArray<string>? warnings = null)
|
||||
{
|
||||
var layer = new LayerInfo
|
||||
{
|
||||
Order = 0,
|
||||
Digest = "sha256:layer",
|
||||
MediaType = "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
Size = 100
|
||||
};
|
||||
|
||||
var platform = new PlatformManifest
|
||||
{
|
||||
Os = "linux",
|
||||
Architecture = "amd64",
|
||||
Variant = null,
|
||||
OsVersion = null,
|
||||
ManifestDigest = "sha256:manifest",
|
||||
ManifestMediaType = OciMediaTypes.ImageManifest,
|
||||
ConfigDigest = "sha256:config",
|
||||
Layers = ImmutableArray.Create(layer),
|
||||
TotalSize = 100
|
||||
};
|
||||
|
||||
return new ImageInspectionResult
|
||||
{
|
||||
Reference = "registry.example/demo/app:1.0",
|
||||
ResolvedDigest = "sha256:manifest",
|
||||
MediaType = OciMediaTypes.ImageManifest,
|
||||
IsMultiArch = false,
|
||||
Platforms = ImmutableArray.Create(platform),
|
||||
InspectedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
|
||||
InspectorVersion = "1.0.0",
|
||||
Registry = "registry.example",
|
||||
Repository = "demo/app",
|
||||
Warnings = warnings ?? ImmutableArray<string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildServices(IOciImageInspector inspector)
|
||||
{
|
||||
OfflineModeGuard.IsOffline = false;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
services.AddSingleton(new StellaOpsCliOptions());
|
||||
services.AddSingleton(inspector);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static async Task<string> CaptureConsoleAsync(Func<TestConsole, Task> action)
|
||||
{
|
||||
var testConsole = new TestConsole();
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
var originalOut = Console.Out;
|
||||
using var writer = new StringWriter();
|
||||
|
||||
try
|
||||
{
|
||||
AnsiConsole.Console = testConsole;
|
||||
Console.SetOut(writer);
|
||||
await action(testConsole).ConfigureAwait(false);
|
||||
var output = testConsole.Output.ToString();
|
||||
if (string.IsNullOrEmpty(output))
|
||||
{
|
||||
output = writer.ToString();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
AnsiConsole.Console = originalConsole;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubInspector : IOciImageInspector
|
||||
{
|
||||
private readonly ImageInspectionResult? _result;
|
||||
|
||||
public StubInspector(ImageInspectionResult? result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<ImageInspectionResult?> InspectAsync(
|
||||
string reference,
|
||||
ImageInspectionOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
@@ -105,8 +106,9 @@ public sealed class OfflineCommandHandlersTests
|
||||
}
|
||||
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson));
|
||||
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBase64);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
var pae = DssePreAuthenticationEncoding.Compute("application/vnd.in-toto+json", payloadBytes);
|
||||
var signature = Convert.ToBase64String(rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
|
||||
|
||||
var dssePath = Path.Combine(bundleDir, "statement.dsse.json");
|
||||
@@ -278,31 +280,6 @@ public sealed class OfflineCommandHandlersTests
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static byte[] BuildDssePae(string payloadType, string payloadBase64)
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
var payloadText = Encoding.UTF8.GetString(payloadBytes);
|
||||
var parts = new[]
|
||||
{
|
||||
"DSSEv1",
|
||||
payloadType,
|
||||
payloadText
|
||||
};
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("PAE:");
|
||||
builder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
builder.Append(' ');
|
||||
builder.Append(part.Length);
|
||||
builder.Append(' ');
|
||||
builder.Append(part);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(builder.ToString());
|
||||
}
|
||||
|
||||
private static string WrapPem(string label, byte[] derBytes)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(derBytes);
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
@@ -191,8 +192,9 @@ public sealed class VerifyOfflineCommandHandlersTests
|
||||
predicate = new { }
|
||||
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBase64);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(statementJson);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
var pae = DssePreAuthenticationEncoding.Compute("application/vnd.in-toto+json", payloadBytes);
|
||||
var signature = Convert.ToBase64String(signingKey.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
|
||||
|
||||
var envelopeJson = JsonSerializer.Serialize(new
|
||||
@@ -208,31 +210,6 @@ public sealed class VerifyOfflineCommandHandlersTests
|
||||
await File.WriteAllTextAsync(path, envelopeJson, new UTF8Encoding(false), ct);
|
||||
}
|
||||
|
||||
private static byte[] BuildDssePae(string payloadType, string payloadBase64)
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
var payloadText = Encoding.UTF8.GetString(payloadBytes);
|
||||
var parts = new[]
|
||||
{
|
||||
"DSSEv1",
|
||||
payloadType,
|
||||
payloadText
|
||||
};
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("PAE:");
|
||||
builder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
builder.Append(' ');
|
||||
builder.Append(part.Length);
|
||||
builder.Append(' ');
|
||||
builder.Append(part);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(builder.ToString());
|
||||
}
|
||||
|
||||
private static async Task WriteCheckpointAsync(string path, ECDsa signingKey, byte[] rootHash, CancellationToken ct)
|
||||
{
|
||||
var origin = "rekor.sigstore.dev - 2605736670972794746";
|
||||
@@ -285,4 +262,3 @@ public sealed class VerifyOfflineCommandHandlersTests
|
||||
|
||||
private sealed record CapturedConsoleOutput(string Console, string Plain);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,42 @@ public sealed class VexGenCommandTests
|
||||
Assert.NotNull(verboseOpt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexGenCommand_HasLinkEvidenceOption()
|
||||
{
|
||||
var command = BuildVexGenCommand();
|
||||
|
||||
var option = command.Options.FirstOrDefault(o =>
|
||||
o.Name == "link-evidence" || o.Name == "--link-evidence" || o.Aliases.Contains("--link-evidence"));
|
||||
|
||||
Assert.NotNull(option);
|
||||
Assert.Contains("evidence", option.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexGenCommand_HasEvidenceThresholdOption()
|
||||
{
|
||||
var command = BuildVexGenCommand();
|
||||
|
||||
var option = command.Options.FirstOrDefault(o =>
|
||||
o.Name == "evidence-threshold" || o.Name == "--evidence-threshold" || o.Aliases.Contains("--evidence-threshold"));
|
||||
|
||||
Assert.NotNull(option);
|
||||
Assert.Contains("confidence", option.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexGenCommand_HasShowEvidenceUriOption()
|
||||
{
|
||||
var command = BuildVexGenCommand();
|
||||
|
||||
var option = command.Options.FirstOrDefault(o =>
|
||||
o.Name == "show-evidence-uri" || o.Name == "--show-evidence-uri" || o.Aliases.Contains("--show-evidence-uri"));
|
||||
|
||||
Assert.NotNull(option);
|
||||
Assert.Contains("URI", option.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexGenCommand_AllOptionsAreConfigured()
|
||||
{
|
||||
@@ -150,7 +186,8 @@ public sealed class VexGenCommandTests
|
||||
var command = BuildVexGenCommand();
|
||||
var expectedOptions = new[]
|
||||
{
|
||||
"from-drift", "image", "baseline", "output", "format", "status", "verbose"
|
||||
"from-drift", "image", "baseline", "output", "format", "status", "link-evidence",
|
||||
"evidence-threshold", "show-evidence-uri", "verbose"
|
||||
};
|
||||
|
||||
// Act - normalize all option names by stripping leading dashes
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VexGenEvidenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AttachEvidenceLinksAsync_AddsEvidenceWhenAvailable()
|
||||
{
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
Id = "vex:stmt:1",
|
||||
Status = "not_affected",
|
||||
Timestamp = "2026-01-13T12:00:00Z",
|
||||
Products =
|
||||
[
|
||||
new OpenVexProduct
|
||||
{
|
||||
Id = "sha256:demo",
|
||||
Identifiers = new OpenVexIdentifiers { Facet = "facet-a" }
|
||||
}
|
||||
],
|
||||
Justification = "facet drift authorization",
|
||||
ActionStatement = "Review required"
|
||||
};
|
||||
|
||||
var document = new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = "https://stellaops.io/vex/vex:doc:1",
|
||||
Author = "StellaOps CLI",
|
||||
Timestamp = "2026-01-13T12:00:00Z",
|
||||
Version = 1,
|
||||
Statements = ImmutableArray.Create(statement)
|
||||
};
|
||||
|
||||
var linker = new TestEvidenceLinker("vex:stmt:1", 0.95);
|
||||
|
||||
var result = await VexGenCommandGroup.AttachEvidenceLinksAsync(
|
||||
document,
|
||||
linker,
|
||||
0.8,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Document.Statements);
|
||||
Assert.NotNull(result.Document.Statements[0].Evidence);
|
||||
Assert.Single(result.Summaries);
|
||||
Assert.Equal("binarydiff", result.Document.Statements[0].Evidence!.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachEvidenceLinksAsync_SkipsBelowThreshold()
|
||||
{
|
||||
var statement = new OpenVexStatement
|
||||
{
|
||||
Id = "vex:stmt:2",
|
||||
Status = "not_affected",
|
||||
Timestamp = "2026-01-13T12:00:00Z",
|
||||
Products =
|
||||
[
|
||||
new OpenVexProduct
|
||||
{
|
||||
Id = "sha256:demo",
|
||||
Identifiers = new OpenVexIdentifiers { Facet = "facet-b" }
|
||||
}
|
||||
],
|
||||
Justification = "facet drift authorization",
|
||||
ActionStatement = "Review required"
|
||||
};
|
||||
|
||||
var document = new OpenVexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns",
|
||||
Id = "https://stellaops.io/vex/vex:doc:2",
|
||||
Author = "StellaOps CLI",
|
||||
Timestamp = "2026-01-13T12:00:00Z",
|
||||
Version = 1,
|
||||
Statements = ImmutableArray.Create(statement)
|
||||
};
|
||||
|
||||
var linker = new TestEvidenceLinker("vex:stmt:2", 0.4);
|
||||
|
||||
var result = await VexGenCommandGroup.AttachEvidenceLinksAsync(
|
||||
document,
|
||||
linker,
|
||||
0.8,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Document.Statements[0].Evidence);
|
||||
Assert.Empty(result.Summaries);
|
||||
}
|
||||
|
||||
private sealed class TestEvidenceLinker : IVexEvidenceLinker
|
||||
{
|
||||
private readonly string _entryId;
|
||||
private readonly double _confidence;
|
||||
|
||||
public TestEvidenceLinker(string entryId, double confidence)
|
||||
{
|
||||
_entryId = entryId;
|
||||
_confidence = confidence;
|
||||
}
|
||||
|
||||
public Task<VexEvidenceLink> LinkAsync(string vexEntryId, EvidenceSource source, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<VexEvidenceLinkSet> GetLinksAsync(string vexEntryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.Equals(vexEntryId, _entryId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(new VexEvidenceLinkSet
|
||||
{
|
||||
VexEntryId = vexEntryId,
|
||||
Links = ImmutableArray<VexEvidenceLink>.Empty
|
||||
});
|
||||
}
|
||||
|
||||
var link = new VexEvidenceLink
|
||||
{
|
||||
LinkId = "vexlink:test",
|
||||
VexEntryId = vexEntryId,
|
||||
EvidenceType = EvidenceType.BinaryDiff,
|
||||
EvidenceUri = "oci://registry/evidence@sha256:abc",
|
||||
EnvelopeDigest = "sha256:abc",
|
||||
PredicateType = "stellaops.binarydiff.v1",
|
||||
Confidence = _confidence,
|
||||
Justification = VexJustification.CodeNotReachable,
|
||||
EvidenceCreatedAt = DateTimeOffset.UtcNow,
|
||||
LinkedAt = DateTimeOffset.UtcNow,
|
||||
SignatureValidated = false
|
||||
};
|
||||
|
||||
return Task.FromResult(new VexEvidenceLinkSet
|
||||
{
|
||||
VexEntryId = vexEntryId,
|
||||
Links = ImmutableArray.Create(link)
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<VexEvidenceLink>> AutoLinkFromBinaryDiffAsync(
|
||||
StellaOps.Attestor.StandardPredicates.BinaryDiff.BinaryDiffPredicate diff,
|
||||
string dsseEnvelopeUri,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ImmutableArray<VexEvidenceLink>.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Testing;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.Scanner.Storage.Oci;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.GoldenOutput;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "GoldenOutput")]
|
||||
public sealed class ImageInspectGoldenOutputTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ImageInspect_TableOutput_IsDeterministic()
|
||||
{
|
||||
var provider = BuildServices(new StubInspector(CreateResult()));
|
||||
|
||||
var output1 = await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/app:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "table",
|
||||
timeoutSeconds: 60,
|
||||
verbose: true,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
});
|
||||
|
||||
var output2 = await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/app:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "table",
|
||||
timeoutSeconds: 60,
|
||||
verbose: true,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
});
|
||||
|
||||
output1.Should().Be(output2);
|
||||
output1.Should().Contain("Image:");
|
||||
output1.Should().Contain("Resolved Digest:");
|
||||
output1.Should().Contain("Layers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImageInspect_JsonOutput_IsDeterministic()
|
||||
{
|
||||
var provider = BuildServices(new StubInspector(CreateResult()));
|
||||
|
||||
var output1 = await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/app:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "json",
|
||||
timeoutSeconds: 60,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
});
|
||||
|
||||
var output2 = await CaptureConsoleAsync(async _ =>
|
||||
{
|
||||
var exitCode = await CommandHandlers.HandleInspectImageAsync(
|
||||
provider,
|
||||
"registry.example/demo/app:1.0",
|
||||
resolveIndex: true,
|
||||
printLayers: true,
|
||||
platformFilter: null,
|
||||
output: "json",
|
||||
timeoutSeconds: 60,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
exitCode.Should().Be(0);
|
||||
});
|
||||
|
||||
output1.Should().Be(output2);
|
||||
output1.Should().Contain("\"reference\"");
|
||||
output1.Should().Contain("\"platforms\"");
|
||||
}
|
||||
|
||||
private static ImageInspectionResult CreateResult()
|
||||
{
|
||||
var layer = new LayerInfo
|
||||
{
|
||||
Order = 0,
|
||||
Digest = "sha256:layer",
|
||||
MediaType = "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
Size = 100
|
||||
};
|
||||
|
||||
var platform = new PlatformManifest
|
||||
{
|
||||
Os = "linux",
|
||||
Architecture = "amd64",
|
||||
Variant = null,
|
||||
OsVersion = null,
|
||||
ManifestDigest = "sha256:manifest",
|
||||
ManifestMediaType = OciMediaTypes.ImageManifest,
|
||||
ConfigDigest = "sha256:config",
|
||||
Layers = ImmutableArray.Create(layer),
|
||||
TotalSize = 100
|
||||
};
|
||||
|
||||
return new ImageInspectionResult
|
||||
{
|
||||
Reference = "registry.example/demo/app:1.0",
|
||||
ResolvedDigest = "sha256:manifest",
|
||||
MediaType = OciMediaTypes.ImageManifest,
|
||||
IsMultiArch = false,
|
||||
Platforms = ImmutableArray.Create(platform),
|
||||
InspectedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
|
||||
InspectorVersion = "1.0.0",
|
||||
Registry = "registry.example",
|
||||
Repository = "demo/app",
|
||||
Warnings = ImmutableArray.Create("Manifest HEAD returned NotFound.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildServices(IOciImageInspector inspector)
|
||||
{
|
||||
OfflineModeGuard.IsOffline = false;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
services.AddSingleton(new StellaOpsCliOptions());
|
||||
services.AddSingleton(inspector);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static async Task<string> CaptureConsoleAsync(Func<TestConsole, Task> action)
|
||||
{
|
||||
var testConsole = new TestConsole();
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
var originalOut = Console.Out;
|
||||
using var writer = new StringWriter();
|
||||
|
||||
try
|
||||
{
|
||||
AnsiConsole.Console = testConsole;
|
||||
Console.SetOut(writer);
|
||||
await action(testConsole).ConfigureAwait(false);
|
||||
var output = testConsole.Output.ToString();
|
||||
if (string.IsNullOrEmpty(output))
|
||||
{
|
||||
output = writer.ToString();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
AnsiConsole.Console = originalConsole;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubInspector : IOciImageInspector
|
||||
{
|
||||
private readonly ImageInspectionResult _result;
|
||||
|
||||
public StubInspector(ImageInspectionResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<ImageInspectionResult?> InspectAsync(
|
||||
string reference,
|
||||
ImageInspectionOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<ImageInspectionResult?>(_result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
|
||||
using StellaOps.Cli.Commands.Scan;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Scanner.Analyzers.Native;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Integration;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class BinaryDiffIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ComputeDiffAsync_WithElfFixtures_ProducesModifiedFinding()
|
||||
{
|
||||
var baseRef = "registry.example.com/app:1";
|
||||
var targetRef = "registry.example.com/app:2";
|
||||
var datasetRoot = Path.Combine(FindRepositoryRoot(), "src", "Scanner", "__Tests", "__Datasets", "elf-section-hashes");
|
||||
var baseElf = File.ReadAllBytes(Path.Combine(datasetRoot, "minimal-amd64.elf"));
|
||||
var targetElf = File.ReadAllBytes(Path.Combine(datasetRoot, "standard-amd64.elf"));
|
||||
var baseLayer = CreateLayer(("usr/bin/app", baseElf));
|
||||
var targetLayer = CreateLayer(("usr/bin/app", targetElf));
|
||||
|
||||
var registry = new TestOciRegistryClient();
|
||||
registry.AddImage(baseRef, "sha256:base", CreateManifest("sha256:layer-base", baseLayer.Length));
|
||||
registry.AddImage(targetRef, "sha256:target", CreateManifest("sha256:layer-target", targetLayer.Length));
|
||||
registry.AddBlob("sha256:layer-base", baseLayer);
|
||||
registry.AddBlob("sha256:layer-target", targetLayer);
|
||||
|
||||
var extractor = new ElfSectionHashExtractor(
|
||||
TimeProvider.System,
|
||||
Options.Create(new ElfSectionHashOptions()));
|
||||
var service = new BinaryDiffService(
|
||||
registry,
|
||||
extractor,
|
||||
Options.Create(new BinaryDiffOptions { ToolVersion = "test" }),
|
||||
TimeProvider.System,
|
||||
NullLogger<BinaryDiffService>.Instance);
|
||||
|
||||
var result = await service.ComputeDiffAsync(
|
||||
new BinaryDiffRequest
|
||||
{
|
||||
BaseImageRef = baseRef,
|
||||
TargetImageRef = targetRef,
|
||||
Mode = BinaryDiffMode.Elf,
|
||||
Platform = new BinaryDiffPlatform { Os = "linux", Architecture = "amd64" }
|
||||
},
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.Summary.TotalBinaries);
|
||||
var finding = Assert.Single(result.Findings);
|
||||
Assert.Equal(ChangeType.Modified, finding.ChangeType);
|
||||
Assert.Equal("/usr/bin/app", finding.Path);
|
||||
Assert.NotEmpty(finding.SectionDeltas);
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (directory.GetDirectories("src").Length > 0 &&
|
||||
directory.GetDirectories("docs").Length > 0)
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Repository root not found.");
|
||||
}
|
||||
|
||||
private static OciManifest CreateManifest(string layerDigest, long size)
|
||||
{
|
||||
return new OciManifest
|
||||
{
|
||||
Layers = new List<OciDescriptor>
|
||||
{
|
||||
new OciDescriptor
|
||||
{
|
||||
Digest = layerDigest,
|
||||
Size = size,
|
||||
MediaType = "application/vnd.oci.image.layer.v1.tar+gzip"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateLayer(params (string Path, byte[] Content)[] entries)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
|
||||
using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax))
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.Path)
|
||||
{
|
||||
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
|
||||
ModificationTime = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero),
|
||||
DataStream = new MemoryStream(entry.Content, writable: false)
|
||||
};
|
||||
tarWriter.WriteEntry(tarEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private sealed class TestOciRegistryClient : IOciRegistryClient
|
||||
{
|
||||
private readonly Dictionary<string, string> _digestsByReference = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, OciManifest> _manifestsByDigest = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, byte[]> _blobsByDigest = new(StringComparer.Ordinal);
|
||||
|
||||
public void AddImage(string reference, string digest, OciManifest manifest)
|
||||
{
|
||||
_digestsByReference[reference] = digest;
|
||||
_manifestsByDigest[digest] = manifest;
|
||||
}
|
||||
|
||||
public void AddBlob(string digest, byte[] blob)
|
||||
{
|
||||
_blobsByDigest[digest] = blob;
|
||||
}
|
||||
|
||||
public Task<string> ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_digestsByReference.TryGetValue(reference.Original, out var digest))
|
||||
{
|
||||
return Task.FromResult(digest);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Digest not configured for {reference.Original}");
|
||||
}
|
||||
|
||||
public Task<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException("ResolveTagAsync is not used by these tests.");
|
||||
}
|
||||
|
||||
public Task<OciReferrersResponse> ListReferrersAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException("ListReferrersAsync is not used by these tests.");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OciReferrerDescriptor>> GetReferrersAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
string? artifactType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException("GetReferrersAsync is not used by these tests.");
|
||||
}
|
||||
|
||||
public Task<OciManifest> GetManifestAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_manifestsByDigest.TryGetValue(digest, out var manifest))
|
||||
{
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Manifest not configured for {digest}");
|
||||
}
|
||||
|
||||
public Task<byte[]> GetBlobAsync(
|
||||
OciImageReference reference,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_blobsByDigest.TryGetValue(digest, out var blob))
|
||||
{
|
||||
return Task.FromResult(blob);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Blob not configured for {digest}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Aoc/StellaOps.Cli.Plugins.Aoc.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Symbols/StellaOps.Cli.Plugins.Symbols.csproj" />
|
||||
|
||||
@@ -8,3 +8,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0143-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| CLI-IMAGE-TESTS-0001 | DONE | SPRINT_20260113_002_002 - Unit tests for image inspect. |
|
||||
| CLI-IMAGE-GOLDEN-0001 | DONE | SPRINT_20260113_002_002 - Golden output determinism tests. |
|
||||
| CLI-DIFF-TESTS-0001 | DONE | SPRINT_20260113_001_003 - Binary diff unit tests added. |
|
||||
| CLI-DIFF-INTEGRATION-0001 | DONE | SPRINT_20260113_001_003 - Binary diff integration test added. |
|
||||
| CLI-VEX-EVIDENCE-TESTS-0001 | DONE | SPRINT_20260113_003_002 - VEX evidence tests. |
|
||||
|
||||
Reference in New Issue
Block a user