audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -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);

View 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";
}
}

View File

@@ -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);
}

View 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;
}
}

View 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);
}
}

View 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('\\', '-');
}
}

View 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
};
}

View 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}.")
};
}
}

View 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; }
}

View 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;
}
}

View 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; }
}
}

View 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; }
}
}

View File

@@ -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 ?? []
};
}

View File

@@ -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; }
}
}

View 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);
}

View 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;
}
}

View 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;
}
}

View 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; }
}

View File

@@ -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;
}
}

View File

@@ -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]}";
}
}

View 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
}

View 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);
}

View 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
}

View 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
};
}

View File

@@ -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}");
}

View 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>()
};
}

View File

@@ -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);

View File

@@ -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>();

View File

@@ -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")]

View File

@@ -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);
}

View File

@@ -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) -->

View File

@@ -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. |

View File

@@ -0,0 +1 @@
/c/dev/New folder/git.stella-ops.org/src/Cli/StellaOps.Cli

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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]

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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" />

View File

@@ -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. |