save progress
This commit is contained in:
@@ -23,6 +23,7 @@ internal static class AirGapCommandGroup
|
||||
airgap.Add(BuildImportCommand(services, verboseOption, cancellationToken));
|
||||
airgap.Add(BuildDiffCommand(services, verboseOption, cancellationToken));
|
||||
airgap.Add(BuildStatusCommand(services, verboseOption, cancellationToken));
|
||||
airgap.Add(BuildJobsCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return airgap;
|
||||
}
|
||||
@@ -104,7 +105,7 @@ internal static class AirGapCommandGroup
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var output = parseResult.GetValue(outputOption) ?? $"knowledge-{DateTime.UtcNow:yyyyMMdd}.tar.gz";
|
||||
var includeAdvisories = parseResult.GetValue(includeAdvisoriesOption);
|
||||
var includeVex = parseResult.GetValue(includeVexOption);
|
||||
var includePolicies = parseResult.GetValue(includePoliciesOption);
|
||||
@@ -300,4 +301,179 @@ internal static class AirGapCommandGroup
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the 'airgap jobs' subcommand group for HLC job sync bundles.
|
||||
/// Sprint: SPRINT_20260105_002_003_ROUTER
|
||||
/// </summary>
|
||||
private static Command BuildJobsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var jobs = new Command("jobs", "Manage HLC job sync bundles for offline/air-gap scenarios.");
|
||||
|
||||
jobs.Add(BuildJobsExportCommand(services, verboseOption, cancellationToken));
|
||||
jobs.Add(BuildJobsImportCommand(services, verboseOption, cancellationToken));
|
||||
jobs.Add(BuildJobsListCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private static Command BuildJobsExportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outputOption = new Option<string>("--output", "-o")
|
||||
{
|
||||
Description = "Output file path for the job sync bundle."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string>("--tenant", "-t")
|
||||
{
|
||||
Description = "Tenant ID for the export (required)."
|
||||
}.SetDefaultValue("default");
|
||||
|
||||
var nodeOption = new Option<string?>("--node")
|
||||
{
|
||||
Description = "Specific node ID to export (default: current node)."
|
||||
};
|
||||
|
||||
var signOption = new Option<bool>("--sign")
|
||||
{
|
||||
Description = "Sign the bundle with DSSE."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output result as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("export", "Export offline job logs to a sync bundle.")
|
||||
{
|
||||
outputOption,
|
||||
tenantOption,
|
||||
nodeOption,
|
||||
signOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var output = parseResult.GetValue(outputOption) ?? string.Empty;
|
||||
var tenant = parseResult.GetValue(tenantOption) ?? "default";
|
||||
var node = parseResult.GetValue(nodeOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAirGapJobsExportAsync(
|
||||
services,
|
||||
output,
|
||||
tenant,
|
||||
node,
|
||||
sign,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildJobsImportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundleArg = new Argument<string>("bundle")
|
||||
{
|
||||
Description = "Path to the job sync bundle file."
|
||||
};
|
||||
|
||||
var verifyOnlyOption = new Option<bool>("--verify-only")
|
||||
{
|
||||
Description = "Only verify the bundle without importing."
|
||||
};
|
||||
|
||||
var forceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Force import even if validation fails."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output result as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("import", "Import a job sync bundle.")
|
||||
{
|
||||
bundleArg,
|
||||
verifyOnlyOption,
|
||||
forceOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var bundle = parseResult.GetValue(bundleArg) ?? string.Empty;
|
||||
var verifyOnly = parseResult.GetValue(verifyOnlyOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAirGapJobsImportAsync(
|
||||
services,
|
||||
bundle,
|
||||
verifyOnly,
|
||||
force,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildJobsListCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceOption = new Option<string?>("--source", "-s")
|
||||
{
|
||||
Description = "Source directory to scan for bundles (default: current directory)."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output result as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("list", "List available job sync bundles.")
|
||||
{
|
||||
sourceOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var source = parseResult.GetValue(sourceOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAirGapJobsListAsync(
|
||||
services,
|
||||
source,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CommandHandlers.AirGap.cs
|
||||
// Sprint: SPRINT_4300_0001_0002_one_command_audit_replay
|
||||
// Sprint: SPRINT_20260105_002_003_ROUTER (HLC Offline Merge Protocol)
|
||||
// Description: Command handlers for airgap operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
using StellaOps.AirGap.Sync;
|
||||
using StellaOps.AirGap.Sync.Models;
|
||||
using StellaOps.AirGap.Sync.Services;
|
||||
using StellaOps.AirGap.Sync.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -104,4 +110,371 @@ internal static partial class CommandHandlers
|
||||
AnsiConsole.MarkupLine("[green]Airgap mode: Enabled[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
#region Job Sync Commands (SPRINT_20260105_002_003_ROUTER)
|
||||
|
||||
/// <summary>
|
||||
/// Handler for 'stella airgap jobs export' command.
|
||||
/// Exports offline job logs for air-gap transfer.
|
||||
/// </summary>
|
||||
internal static async Task<int> HandleAirGapJobsExportAsync(
|
||||
IServiceProvider services,
|
||||
string output,
|
||||
string tenantId,
|
||||
string? nodeId,
|
||||
bool sign,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitGeneralError = 1;
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetService<IAirGapBundleExporter>();
|
||||
if (exporter is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Air-gap sync services not configured. Register with AddAirGapSyncServices().");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Exporting job logs for tenant: {Markup.Escape(tenantId)}[/]");
|
||||
}
|
||||
|
||||
// Export bundle
|
||||
var nodeIds = !string.IsNullOrWhiteSpace(nodeId) ? new[] { nodeId } : null;
|
||||
var bundle = await exporter.ExportAsync(tenantId, nodeIds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle.JobLogs.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Warning:[/] No offline job logs found to export.");
|
||||
return ExitSuccess;
|
||||
}
|
||||
|
||||
// Export to file
|
||||
var outputPath = output;
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
outputPath = $"job-sync-{bundle.BundleId:N}.json";
|
||||
}
|
||||
|
||||
await exporter.ExportToFileAsync(bundle, outputPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Output result
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = bundle.BundleId,
|
||||
tenantId = bundle.TenantId,
|
||||
outputPath,
|
||||
createdAt = bundle.CreatedAt,
|
||||
nodeCount = bundle.JobLogs.Count,
|
||||
totalEntries = bundle.JobLogs.Sum(l => l.Entries.Count),
|
||||
manifestDigest = bundle.ManifestDigest
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Exported job sync bundle:[/] {Markup.Escape(outputPath)}");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{bundle.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Tenant: {Markup.Escape(bundle.TenantId)}");
|
||||
AnsiConsole.MarkupLine($" Node logs: {bundle.JobLogs.Count}");
|
||||
AnsiConsole.MarkupLine($" Total entries: {bundle.JobLogs.Sum(l => l.Entries.Count)}");
|
||||
AnsiConsole.MarkupLine($" Manifest digest: {Markup.Escape(bundle.ManifestDigest)}");
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return ExitGeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for 'stella airgap jobs import' command.
|
||||
/// Imports job sync bundle from air-gap transfer.
|
||||
/// </summary>
|
||||
internal static async Task<int> HandleAirGapJobsImportAsync(
|
||||
IServiceProvider services,
|
||||
string bundlePath,
|
||||
bool verifyOnly,
|
||||
bool force,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitGeneralError = 1;
|
||||
const int ExitValidationFailed = 2;
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetService<IAirGapBundleImporter>();
|
||||
if (importer is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Air-gap sync services not configured. Register with AddAirGapSyncServices().");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Bundle file not found: {Markup.Escape(bundlePath)}");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Importing job sync bundle: {Markup.Escape(bundlePath)}[/]");
|
||||
}
|
||||
|
||||
// Import bundle
|
||||
var bundle = await importer.ImportFromFileAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Validate bundle
|
||||
var validation = importer.Validate(bundle);
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
if (emitJson)
|
||||
{
|
||||
var errorResult = new
|
||||
{
|
||||
success = false,
|
||||
bundleId = bundle.BundleId,
|
||||
validationPassed = false,
|
||||
issues = validation.Issues
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(errorResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Bundle validation failed![/]");
|
||||
foreach (var issue in validation.Issues)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" - {Markup.Escape(issue)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!force)
|
||||
{
|
||||
return ExitValidationFailed;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[yellow]Warning:[/] Proceeding with import despite validation failures (--force).");
|
||||
}
|
||||
|
||||
if (verifyOnly)
|
||||
{
|
||||
if (emitJson)
|
||||
{
|
||||
var verifyResult = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = bundle.BundleId,
|
||||
tenantId = bundle.TenantId,
|
||||
validationPassed = validation.IsValid,
|
||||
nodeCount = bundle.JobLogs.Count,
|
||||
totalEntries = bundle.JobLogs.Sum(l => l.Entries.Count),
|
||||
manifestDigest = bundle.ManifestDigest
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(verifyResult, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Bundle verification passed.[/]");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{bundle.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Tenant: {Markup.Escape(bundle.TenantId)}");
|
||||
AnsiConsole.MarkupLine($" Node logs: {bundle.JobLogs.Count}");
|
||||
AnsiConsole.MarkupLine($" Total entries: {bundle.JobLogs.Sum(l => l.Entries.Count)}");
|
||||
}
|
||||
return ExitSuccess;
|
||||
}
|
||||
|
||||
// Sync to scheduler (if service available)
|
||||
var syncService = scope.ServiceProvider.GetService<IAirGapSyncService>();
|
||||
if (syncService is not null)
|
||||
{
|
||||
var syncResult = await syncService.SyncFromBundleAsync(bundle, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = syncResult.BundleId,
|
||||
totalInBundle = syncResult.TotalInBundle,
|
||||
appended = syncResult.Appended,
|
||||
duplicates = syncResult.Duplicates,
|
||||
newChainHead = syncResult.NewChainHead is not null ? Convert.ToBase64String(syncResult.NewChainHead) : null
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Job sync bundle imported successfully.[/]");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{syncResult.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Jobs in bundle: {syncResult.TotalInBundle}");
|
||||
AnsiConsole.MarkupLine($" Jobs appended: {syncResult.Appended}");
|
||||
AnsiConsole.MarkupLine($" Duplicates skipped: {syncResult.Duplicates}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No sync service - just report the imported bundle
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
success = true,
|
||||
bundleId = bundle.BundleId,
|
||||
tenantId = bundle.TenantId,
|
||||
nodeCount = bundle.JobLogs.Count,
|
||||
totalEntries = bundle.JobLogs.Sum(l => l.Entries.Count),
|
||||
note = "Bundle imported but sync service not available"
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Job sync bundle loaded.[/]");
|
||||
AnsiConsole.MarkupLine($" Bundle ID: [bold]{bundle.BundleId}[/]");
|
||||
AnsiConsole.MarkupLine($" Tenant: {Markup.Escape(bundle.TenantId)}");
|
||||
AnsiConsole.MarkupLine($" Node logs: {bundle.JobLogs.Count}");
|
||||
AnsiConsole.MarkupLine($" Total entries: {bundle.JobLogs.Sum(l => l.Entries.Count)}");
|
||||
AnsiConsole.MarkupLine("[yellow]Note:[/] Sync service not available. Bundle validated but not synced to scheduler.");
|
||||
}
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return ExitGeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for 'stella airgap jobs list' command.
|
||||
/// Lists available job sync bundles.
|
||||
/// </summary>
|
||||
internal static async Task<int> HandleAirGapJobsListAsync(
|
||||
IServiceProvider services,
|
||||
string? source,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitGeneralError = 1;
|
||||
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
var transport = scope.ServiceProvider.GetService<IJobSyncTransport>();
|
||||
if (transport is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Job sync transport not configured. Register with AddFileBasedJobSyncTransport().");
|
||||
return ExitGeneralError;
|
||||
}
|
||||
|
||||
var sourcePath = source ?? ".";
|
||||
var bundles = await transport.ListAvailableBundlesAsync(sourcePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
source = sourcePath,
|
||||
bundles = bundles.Select(b => new
|
||||
{
|
||||
bundleId = b.BundleId,
|
||||
tenantId = b.TenantId,
|
||||
sourceNodeId = b.SourceNodeId,
|
||||
createdAt = b.CreatedAt,
|
||||
entryCount = b.EntryCount,
|
||||
sizeBytes = b.SizeBytes
|
||||
})
|
||||
};
|
||||
AnsiConsole.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (bundles.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]No job sync bundles found in: {Markup.Escape(sourcePath)}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
var table = new Table { Border = TableBorder.Rounded };
|
||||
table.AddColumn("Bundle ID");
|
||||
table.AddColumn("Tenant");
|
||||
table.AddColumn("Source Node");
|
||||
table.AddColumn("Created");
|
||||
table.AddColumn("Entries");
|
||||
table.AddColumn("Size");
|
||||
|
||||
foreach (var b in bundles)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(b.BundleId.ToString("N")[..8] + "..."),
|
||||
Markup.Escape(b.TenantId),
|
||||
Markup.Escape(b.SourceNodeId),
|
||||
b.CreatedAt.ToString("yyyy-MM-dd HH:mm"),
|
||||
b.EntryCount.ToString(),
|
||||
FormatBytesCompact(b.SizeBytes));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.WriteException(ex);
|
||||
}
|
||||
return ExitGeneralError;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatBytesCompact(long bytes)
|
||||
{
|
||||
string[] sizes = ["B", "KB", "MB", "GB"];
|
||||
double size = bytes;
|
||||
var order = 0;
|
||||
while (size >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
size /= 1024;
|
||||
}
|
||||
return $"{size:0.#} {sizes[order]}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Replay.Core.Models;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
@@ -33,7 +37,8 @@ internal static partial class CommandHandlers
|
||||
var logger = loggerFactory.CreateLogger("verify-bundle");
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.verify.bundle", ActivityKind.Client);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("verify bundle");
|
||||
using var durationMetric = CliMetrics.MeasureCommandDuration("verify bundle");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var emitJson = string.Equals(outputFormat, "json", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -128,14 +133,40 @@ internal static partial class CommandHandlers
|
||||
|
||||
// 5. Verify DSSE signature (if present)
|
||||
var signatureVerified = false;
|
||||
string? signatureKeyId = null;
|
||||
var dssePath = Path.Combine(workingDir, "outputs", "verdict.dsse.json");
|
||||
if (File.Exists(dssePath))
|
||||
{
|
||||
logger.LogInformation("Verifying DSSE signature...");
|
||||
signatureVerified = await VerifyDsseSignatureAsync(dssePath, workingDir, violations, logger, cancellationToken).ConfigureAwait(false);
|
||||
var (verified, keyId) = await VerifyDsseSignatureAsync(dssePath, workingDir, violations, logger, cancellationToken).ConfigureAwait(false);
|
||||
signatureVerified = verified;
|
||||
signatureKeyId = keyId;
|
||||
}
|
||||
|
||||
// 6. Output result
|
||||
// 6. Compute bundle hash for replay proof
|
||||
var bundleHash = await ComputeDirectoryHashAsync(workingDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 7. Generate ReplayProof
|
||||
var verdictMatches = replayedVerdictHash is not null
|
||||
&& manifest.ExpectedOutputs.VerdictHash is not null
|
||||
&& string.Equals(replayedVerdictHash, manifest.ExpectedOutputs.VerdictHash, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var replayProof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: bundleHash,
|
||||
policyVersion: manifest.Scan.PolicyDigest,
|
||||
verdictRoot: replayedVerdictHash ?? manifest.ExpectedOutputs.VerdictHash ?? "unknown",
|
||||
verdictMatches: verdictMatches,
|
||||
durationMs: stopwatch.ElapsedMilliseconds,
|
||||
replayedAt: DateTimeOffset.UtcNow,
|
||||
engineVersion: "1.0.0",
|
||||
artifactDigest: manifest.Scan.ImageDigest,
|
||||
signatureVerified: signatureVerified,
|
||||
signatureKeyId: signatureKeyId,
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("bundleId", manifest.BundleId)
|
||||
.Add("schemaVersion", manifest.SchemaVersion));
|
||||
|
||||
// 8. Output result
|
||||
var passed = violations.Count == 0;
|
||||
var exitCode = passed ? CliExitCodes.Success : CliExitCodes.GeneralError;
|
||||
|
||||
@@ -147,10 +178,12 @@ internal static partial class CommandHandlers
|
||||
BundleId: manifest.BundleId,
|
||||
BundlePath: workingDir,
|
||||
SchemaVersion: manifest.SchemaVersion,
|
||||
InputsValidated: violations.Count(v => v.Rule.StartsWith("input.hash")) == 0,
|
||||
InputsValidated: violations.Count(v => v.Rule.StartsWith("input.hash", StringComparison.Ordinal)) == 0,
|
||||
ReplayedVerdictHash: replayedVerdictHash,
|
||||
ExpectedVerdictHash: manifest.ExpectedOutputs.VerdictHash,
|
||||
SignatureVerified: signatureVerified,
|
||||
ReplayProofCompact: replayProof.ToCompactString(),
|
||||
ReplayProofJson: replayProof.ToCanonicalJson(),
|
||||
Violations: violations),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -290,27 +323,80 @@ internal static partial class CommandHandlers
|
||||
return await Task.FromResult<string?>(null).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<bool> VerifyDsseSignatureAsync(
|
||||
private static async Task<(bool IsValid, string? KeyId)> VerifyDsseSignatureAsync(
|
||||
string dssePath,
|
||||
string bundleDir,
|
||||
List<BundleViolation> violations,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// STUB: DSSE signature verification not yet available
|
||||
// This would normally call:
|
||||
// var signer = services.GetRequiredService<ISigner>();
|
||||
// var dsseEnvelope = await File.ReadAllTextAsync(dssePath);
|
||||
// var publicKey = await File.ReadAllTextAsync(Path.Combine(bundleDir, "attestation", "public-key.pem"));
|
||||
// var result = await signer.VerifyAsync(dsseEnvelope, publicKey);
|
||||
// return result.IsValid;
|
||||
// Load the DSSE envelope
|
||||
string envelopeJson;
|
||||
try
|
||||
{
|
||||
envelopeJson = await File.ReadAllTextAsync(dssePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
violations.Add(new BundleViolation(
|
||||
"signature.file.read_error",
|
||||
$"Failed to read DSSE envelope: {ex.Message}"));
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
logger.LogWarning("DSSE signature verification not implemented - Signer service integration pending");
|
||||
violations.Add(new BundleViolation(
|
||||
"signature.verify.not_implemented",
|
||||
"DSSE signature verification requires Signer service (not yet integrated)"));
|
||||
// Look for public key in standard locations
|
||||
var publicKeyPaths = new[]
|
||||
{
|
||||
Path.Combine(bundleDir, "attestation", "public-key.pem"),
|
||||
Path.Combine(bundleDir, "keys", "public-key.pem"),
|
||||
Path.Combine(bundleDir, "public-key.pem"),
|
||||
};
|
||||
|
||||
return await Task.FromResult(false).ConfigureAwait(false);
|
||||
string? publicKeyPem = null;
|
||||
foreach (var keyPath in publicKeyPaths)
|
||||
{
|
||||
if (File.Exists(keyPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
publicKeyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("Loaded public key from {KeyPath}", keyPath);
|
||||
break;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to read public key from {KeyPath}", keyPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(publicKeyPem))
|
||||
{
|
||||
violations.Add(new BundleViolation(
|
||||
"signature.key.not_found",
|
||||
"No public key found for DSSE signature verification"));
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
// Use the DsseVerifier for verification
|
||||
var verifier = new DsseVerifier(
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance.CreateLogger<DsseVerifier>());
|
||||
|
||||
var result = await verifier.VerifyAsync(envelopeJson, publicKeyPem, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
foreach (var issue in result.Issues)
|
||||
{
|
||||
violations.Add(new BundleViolation($"signature.{issue}", issue));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("DSSE signature verified successfully. KeyId: {KeyId}", result.PrimaryKeyId ?? "unknown");
|
||||
}
|
||||
|
||||
return (result.IsValid, result.PrimaryKeyId);
|
||||
}
|
||||
|
||||
private static Task WriteVerifyBundleErrorAsync(
|
||||
@@ -366,7 +452,7 @@ internal static partial class CommandHandlers
|
||||
table.AddRow("Bundle ID", Markup.Escape(payload.BundleId));
|
||||
table.AddRow("Bundle Path", Markup.Escape(payload.BundlePath));
|
||||
table.AddRow("Schema Version", Markup.Escape(payload.SchemaVersion));
|
||||
table.AddRow("Inputs Validated", payload.InputsValidated ? "[green]✓[/]" : "[red]✗[/]");
|
||||
table.AddRow("Inputs Validated", payload.InputsValidated ? "[green]Yes[/]" : "[red]No[/]");
|
||||
|
||||
if (payload.ReplayedVerdictHash is not null)
|
||||
{
|
||||
@@ -378,7 +464,13 @@ internal static partial class CommandHandlers
|
||||
table.AddRow("Expected Verdict Hash", Markup.Escape(payload.ExpectedVerdictHash));
|
||||
}
|
||||
|
||||
table.AddRow("Signature Verified", payload.SignatureVerified ? "[green]✓[/]" : "[yellow]N/A[/]");
|
||||
table.AddRow("Signature Verified", payload.SignatureVerified ? "[green]Yes[/]" : "[yellow]N/A[/]");
|
||||
|
||||
if (!string.IsNullOrEmpty(payload.ReplayProofCompact))
|
||||
{
|
||||
table.AddRow("Replay Proof", Markup.Escape(payload.ReplayProofCompact));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
|
||||
if (payload.Violations.Count > 0)
|
||||
@@ -406,6 +498,8 @@ internal static partial class CommandHandlers
|
||||
string? ReplayedVerdictHash,
|
||||
string? ExpectedVerdictHash,
|
||||
bool SignatureVerified,
|
||||
string? ReplayProofCompact,
|
||||
string? ReplayProofJson,
|
||||
IReadOnlyList<BundleViolation> Violations);
|
||||
}
|
||||
|
||||
|
||||
@@ -10375,6 +10375,7 @@ internal static partial class CommandHandlers
|
||||
var required = requiredSigners.EnumerateArray()
|
||||
.Select(s => s.GetString())
|
||||
.Where(s => s != null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var actualSigners = signatures.Select(s => s.KeyId).ToHashSet();
|
||||
@@ -11730,7 +11731,6 @@ internal static partial class CommandHandlers
|
||||
}
|
||||
|
||||
// Check 3: Integrity verification (root hash)
|
||||
var integrityOk = false;
|
||||
if (index.TryGetProperty("integrity", out var integrity) &&
|
||||
integrity.TryGetProperty("rootHash", out var rootHashElem))
|
||||
{
|
||||
@@ -11750,7 +11750,6 @@ internal static partial class CommandHandlers
|
||||
if (computedRootHash == expectedRootHash.ToLowerInvariant())
|
||||
{
|
||||
checks.Add(("Root Hash Integrity", "PASS", $"Root hash matches: {expectedRootHash[..16]}..."));
|
||||
integrityOk = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -13656,7 +13655,6 @@ internal static partial class CommandHandlers
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitInputError = 4;
|
||||
|
||||
var workspacePath = Path.GetFullPath(path ?? ".");
|
||||
var policyName = name ?? Path.GetFileName(workspacePath);
|
||||
|
||||
@@ -181,7 +181,7 @@ internal static class FeedsCommandGroup
|
||||
|
||||
return CommandHandlers.HandleFeedsSnapshotExportAsync(
|
||||
services,
|
||||
snapshotId,
|
||||
snapshotId!,
|
||||
output!,
|
||||
compression,
|
||||
json,
|
||||
@@ -230,7 +230,7 @@ internal static class FeedsCommandGroup
|
||||
|
||||
return CommandHandlers.HandleFeedsSnapshotImportAsync(
|
||||
services,
|
||||
input,
|
||||
input!,
|
||||
validate,
|
||||
json,
|
||||
verbose,
|
||||
@@ -270,7 +270,7 @@ internal static class FeedsCommandGroup
|
||||
|
||||
return CommandHandlers.HandleFeedsSnapshotValidateAsync(
|
||||
services,
|
||||
snapshotId,
|
||||
snapshotId!,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
|
||||
@@ -122,7 +122,7 @@ public class KeyRotationCommandGroup
|
||||
var algorithm = parseResult.GetValue(algorithmOption) ?? "Ed25519";
|
||||
var publicKeyPath = parseResult.GetValue(publicKeyOption);
|
||||
var notes = parseResult.GetValue(notesOption);
|
||||
Environment.ExitCode = await AddKeyAsync(anchorId, keyId, algorithm, publicKeyPath, notes, ct).ConfigureAwait(false);
|
||||
Environment.ExitCode = await AddKeyAsync(anchorId, keyId!, algorithm, publicKeyPath, notes, ct).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return addCommand;
|
||||
@@ -171,7 +171,7 @@ public class KeyRotationCommandGroup
|
||||
var reason = parseResult.GetValue(reasonOption) ?? "rotation-complete";
|
||||
var effectiveAt = parseResult.GetValue(effectiveOption) ?? DateTimeOffset.UtcNow;
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
Environment.ExitCode = await RevokeKeyAsync(anchorId, keyId, reason, effectiveAt, force, ct).ConfigureAwait(false);
|
||||
Environment.ExitCode = await RevokeKeyAsync(anchorId, keyId!, reason, effectiveAt, force, ct).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return revokeCommand;
|
||||
@@ -227,7 +227,7 @@ public class KeyRotationCommandGroup
|
||||
var algorithm = parseResult.GetValue(algorithmOption) ?? "Ed25519";
|
||||
var publicKeyPath = parseResult.GetValue(publicKeyOption);
|
||||
var overlapDays = parseResult.GetValue(overlapOption);
|
||||
Environment.ExitCode = await RotateKeyAsync(anchorId, oldKeyId, newKeyId, algorithm, publicKeyPath, overlapDays, ct).ConfigureAwait(false);
|
||||
Environment.ExitCode = await RotateKeyAsync(anchorId, oldKeyId!, newKeyId!, algorithm, publicKeyPath, overlapDays, ct).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return rotateCommand;
|
||||
@@ -332,7 +332,7 @@ public class KeyRotationCommandGroup
|
||||
var anchorId = parseResult.GetValue(anchorArg);
|
||||
var keyId = parseResult.GetValue(keyIdArg);
|
||||
var signedAt = parseResult.GetValue(signedAtOption) ?? DateTimeOffset.UtcNow;
|
||||
Environment.ExitCode = await VerifyKeyAsync(anchorId, keyId, signedAt, ct).ConfigureAwait(false);
|
||||
Environment.ExitCode = await VerifyKeyAsync(anchorId, keyId!, signedAt, ct).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return verifyCommand;
|
||||
|
||||
@@ -153,7 +153,7 @@ internal static class WitnessCommandGroup
|
||||
var tierOption = new Option<string?>("--tier")
|
||||
{
|
||||
Description = "Filter by confidence tier: confirmed, likely, present, unreachable."
|
||||
}?.FromAmong("confirmed", "likely", "present", "unreachable");
|
||||
}.FromAmong("confirmed", "likely", "present", "unreachable");
|
||||
|
||||
var reachableOnlyOption = new Option<bool>("--reachable-only")
|
||||
{
|
||||
|
||||
@@ -223,13 +223,16 @@ internal static class CliErrorRenderer
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((!error.Metadata.TryGetValue("reason_code", out reasonCode) || string.IsNullOrWhiteSpace(reasonCode)) &&
|
||||
(!error.Metadata.TryGetValue("reasonCode", out reasonCode) || string.IsNullOrWhiteSpace(reasonCode)))
|
||||
string? code1 = null;
|
||||
string? code2 = null;
|
||||
|
||||
if ((!error.Metadata.TryGetValue("reason_code", out code1) || string.IsNullOrWhiteSpace(code1)) &&
|
||||
(!error.Metadata.TryGetValue("reasonCode", out code2) || string.IsNullOrWhiteSpace(code2)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
reasonCode = OfflineKitReasonCodes.Normalize(reasonCode) ?? "";
|
||||
reasonCode = OfflineKitReasonCodes.Normalize(code1 ?? code2 ?? "") ?? "";
|
||||
return reasonCode.Length > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -328,8 +328,8 @@ public sealed class OutputRenderer : IOutputRenderer
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
widths[i] = columns[i].Header.Length;
|
||||
if (columns[i].MinWidth.HasValue)
|
||||
widths[i] = Math.Max(widths[i], columns[i].MinWidth.Value);
|
||||
if (columns[i].MinWidth is { } minWidth)
|
||||
widths[i] = Math.Max(widths[i], minWidth);
|
||||
}
|
||||
|
||||
// Get all values and update widths
|
||||
@@ -340,9 +340,9 @@ public sealed class OutputRenderer : IOutputRenderer
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
var value = columns[i].ValueSelector(item) ?? "";
|
||||
if (columns[i].MaxWidth.HasValue && value.Length > columns[i].MaxWidth.Value)
|
||||
if (columns[i].MaxWidth is { } maxWidth && value.Length > maxWidth)
|
||||
{
|
||||
value = value[..(columns[i].MaxWidth.Value - 3)] + "...";
|
||||
value = value[..(maxWidth - 3)] + "...";
|
||||
}
|
||||
row[i] = value;
|
||||
widths[i] = Math.Max(widths[i], value.Length);
|
||||
|
||||
@@ -359,7 +359,7 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnView);
|
||||
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
|
||||
@@ -65,7 +65,7 @@ public sealed class MirrorBundleImportService : IMirrorBundleImportService
|
||||
|
||||
// Register in catalog
|
||||
var bundleId = GenerateBundleId(manifest);
|
||||
var manifestDigest = ComputeDigest(File.ReadAllBytes(manifestResult.ManifestPath));
|
||||
var manifestDigest = ComputeDigest(File.ReadAllBytes(manifestResult.ManifestPath!));
|
||||
|
||||
var catalogEntry = new ImportModels.BundleCatalogEntry(
|
||||
request.TenantId ?? "default",
|
||||
|
||||
@@ -861,7 +861,7 @@ internal sealed partial class PromotionAssembler : IPromotionAssembler
|
||||
try
|
||||
{
|
||||
var certBytes = Convert.FromBase64String(sig.Cert);
|
||||
using var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(certBytes);
|
||||
using var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadCertificate(certBytes);
|
||||
|
||||
// Build PAE for verification
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload);
|
||||
|
||||
@@ -94,6 +94,10 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
|
||||
<!-- Secrets Bundle CLI (SPRINT_20260104_003_SCANNER) -->
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
|
||||
<!-- Replay Infrastructure (SPRINT_20260105_002_001_REPLAY) -->
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<!-- Air-Gap Job Sync (SPRINT_20260105_002_003_ROUTER) -->
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Sync/StellaOps.AirGap.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- GOST Crypto Plugins (Russia distribution) -->
|
||||
|
||||
Reference in New Issue
Block a user