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