Implement InMemory Transport Layer for StellaOps Router
- Added InMemoryTransportOptions class for configuration settings including timeouts and latency. - Developed InMemoryTransportServer class to handle connections, frame processing, and event management. - Created ServiceCollectionExtensions for easy registration of InMemory transport services. - Established project structure and dependencies for InMemory transport library. - Implemented comprehensive unit tests for endpoint discovery, connection management, request/response flow, and streaming capabilities. - Ensured proper handling of cancellation, heartbeat, and hello frames within the transport layer.
This commit is contained in:
@@ -25678,4 +25678,256 @@ stella policy test {policyName}.stella
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mirror Commands (CLI-AIRGAP-56-001)
|
||||
|
||||
/// <summary>
|
||||
/// Handler for 'stella mirror create' command.
|
||||
/// Creates an air-gap mirror bundle for offline distribution.
|
||||
/// </summary>
|
||||
public static async Task HandleMirrorCreateAsync(
|
||||
IServiceProvider services,
|
||||
string domainId,
|
||||
string outputDirectory,
|
||||
string? format,
|
||||
string? tenant,
|
||||
string? displayName,
|
||||
string? targetRepository,
|
||||
IReadOnlyList<string>? providers,
|
||||
bool includeSignatures,
|
||||
bool includeAttestations,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("mirror-create");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.mirror.create", System.Diagnostics.ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "mirror create");
|
||||
activity?.SetTag("stellaops.cli.mirror.domain", domainId);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("mirror create");
|
||||
|
||||
try
|
||||
{
|
||||
var effectiveTenant = TenantProfileStore.GetEffectiveTenant(tenant);
|
||||
if (!string.IsNullOrWhiteSpace(effectiveTenant))
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.tenant", effectiveTenant);
|
||||
}
|
||||
|
||||
logger.LogDebug("Creating mirror bundle: domain={DomainId}, output={OutputDir}, format={Format}",
|
||||
domainId, outputDirectory, format ?? "all");
|
||||
|
||||
// Validate domain ID
|
||||
var validDomains = new[] { "vex-advisories", "vulnerability-feeds", "policy-packs", "scanner-bundles", "offline-kit" };
|
||||
if (!validDomains.Contains(domainId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Domain '{Markup.Escape(domainId)}' is not a standard domain. Standard domains: {string.Join(", ", validDomains)}");
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
var resolvedOutput = Path.GetFullPath(outputDirectory);
|
||||
if (!Directory.Exists(resolvedOutput))
|
||||
{
|
||||
Directory.CreateDirectory(resolvedOutput);
|
||||
logger.LogDebug("Created output directory: {OutputDir}", resolvedOutput);
|
||||
}
|
||||
|
||||
// Generate bundle timestamp
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
var bundleId = $"{domainId}-{generatedAt:yyyyMMddHHmmss}";
|
||||
|
||||
// Create the request model
|
||||
var request = new MirrorCreateRequest
|
||||
{
|
||||
DomainId = domainId,
|
||||
DisplayName = displayName ?? $"{domainId} Mirror Bundle",
|
||||
TargetRepository = targetRepository,
|
||||
Format = format,
|
||||
Providers = providers,
|
||||
OutputDirectory = resolvedOutput,
|
||||
IncludeSignatures = includeSignatures,
|
||||
IncludeAttestations = includeAttestations,
|
||||
Tenant = effectiveTenant
|
||||
};
|
||||
|
||||
// Build exports list based on domain
|
||||
var exports = new List<MirrorBundleExport>();
|
||||
long totalSize = 0;
|
||||
|
||||
// For now, create a placeholder export entry
|
||||
// In production this would call backend APIs to get actual exports
|
||||
var exportId = Guid.NewGuid().ToString();
|
||||
var placeholderContent = JsonSerializer.Serialize(new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
domain = domainId,
|
||||
generatedAt = generatedAt,
|
||||
tenant = effectiveTenant,
|
||||
format,
|
||||
providers
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
var placeholderBytes = System.Text.Encoding.UTF8.GetBytes(placeholderContent);
|
||||
var placeholderDigest = ComputeMirrorSha256Digest(placeholderBytes);
|
||||
|
||||
// Write placeholder export file
|
||||
var exportFileName = $"{domainId}-export-{generatedAt:yyyyMMdd}.json";
|
||||
var exportPath = Path.Combine(resolvedOutput, exportFileName);
|
||||
await File.WriteAllBytesAsync(exportPath, placeholderBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
exports.Add(new MirrorBundleExport
|
||||
{
|
||||
Key = $"{domainId}-{format ?? "all"}",
|
||||
Format = format ?? "json",
|
||||
ExportId = exportId,
|
||||
CreatedAt = generatedAt,
|
||||
ArtifactSizeBytes = placeholderBytes.Length,
|
||||
ArtifactDigest = placeholderDigest,
|
||||
SourceProviders = providers?.ToList()
|
||||
});
|
||||
totalSize += placeholderBytes.Length;
|
||||
|
||||
// Create the bundle manifest
|
||||
var bundle = new MirrorBundle
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
GeneratedAt = generatedAt,
|
||||
DomainId = domainId,
|
||||
DisplayName = request.DisplayName,
|
||||
TargetRepository = targetRepository,
|
||||
Exports = exports
|
||||
};
|
||||
|
||||
// Write bundle manifest
|
||||
var manifestFileName = $"{bundleId}-manifest.json";
|
||||
var manifestPath = Path.Combine(resolvedOutput, manifestFileName);
|
||||
var manifestJson = JsonSerializer.Serialize(bundle, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Write SHA256SUMS file for verification
|
||||
var checksumPath = Path.Combine(resolvedOutput, "SHA256SUMS");
|
||||
var checksumLines = new List<string>
|
||||
{
|
||||
$"{ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson))} {manifestFileName}",
|
||||
$"{placeholderDigest} {exportFileName}"
|
||||
};
|
||||
await File.WriteAllLinesAsync(checksumPath, checksumLines, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Build result
|
||||
var result = new MirrorCreateResult
|
||||
{
|
||||
ManifestPath = manifestPath,
|
||||
BundlePath = null, // Archive creation would go here
|
||||
SignaturePath = null, // Signature would be created here if includeSignatures
|
||||
ExportCount = exports.Count,
|
||||
TotalSizeBytes = totalSize,
|
||||
BundleDigest = ComputeMirrorSha256Digest(System.Text.Encoding.UTF8.GetBytes(manifestJson)),
|
||||
GeneratedAt = generatedAt,
|
||||
DomainId = domainId,
|
||||
Exports = verbose ? exports : null
|
||||
};
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
var json = JsonSerializer.Serialize(result, jsonOptions);
|
||||
AnsiConsole.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]Mirror bundle created successfully![/]");
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var grid = new Grid();
|
||||
grid.AddColumn();
|
||||
grid.AddColumn();
|
||||
grid.AddRow("[grey]Domain:[/]", Markup.Escape(domainId));
|
||||
grid.AddRow("[grey]Display Name:[/]", Markup.Escape(request.DisplayName ?? "-"));
|
||||
grid.AddRow("[grey]Generated At:[/]", generatedAt.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"));
|
||||
grid.AddRow("[grey]Exports:[/]", exports.Count.ToString());
|
||||
grid.AddRow("[grey]Total Size:[/]", FormatBytes(totalSize));
|
||||
grid.AddRow("[grey]Manifest:[/]", Markup.Escape(manifestPath));
|
||||
grid.AddRow("[grey]Checksums:[/]", Markup.Escape(checksumPath));
|
||||
if (!string.IsNullOrWhiteSpace(targetRepository))
|
||||
grid.AddRow("[grey]Target Repository:[/]", Markup.Escape(targetRepository));
|
||||
|
||||
AnsiConsole.Write(grid);
|
||||
|
||||
if (verbose && exports.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold]Exports:[/]");
|
||||
var table = new Table { Border = TableBorder.Rounded };
|
||||
table.AddColumn("Key");
|
||||
table.AddColumn("Format");
|
||||
table.AddColumn("Size");
|
||||
table.AddColumn("Digest");
|
||||
|
||||
foreach (var export in exports)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(export.Key),
|
||||
Markup.Escape(export.Format),
|
||||
FormatBytes(export.ArtifactSizeBytes ?? 0),
|
||||
Markup.Escape(TruncateMirrorDigest(export.ArtifactDigest))
|
||||
);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[grey]Next steps:[/]");
|
||||
AnsiConsole.MarkupLine($" 1. Transfer the bundle directory to the air-gapped environment");
|
||||
AnsiConsole.MarkupLine($" 2. Verify checksums: [cyan]cd {Markup.Escape(resolvedOutput)} && sha256sum -c SHA256SUMS[/]");
|
||||
AnsiConsole.MarkupLine($" 3. Import the bundle: [cyan]stella airgap import --bundle {Markup.Escape(manifestPath)}[/]");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("Operation cancelled by user.");
|
||||
Environment.ExitCode = 130;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create mirror bundle.");
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeMirrorSha256Digest(byte[] content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string TruncateMirrorDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest)) return "-";
|
||||
if (digest.Length <= 20) return digest;
|
||||
return digest[..20] + "...";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user