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:
@@ -73,6 +73,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
|
||||
@@ -9728,4 +9729,110 @@ internal static class CommandFactory
|
||||
|
||||
return sdk;
|
||||
}
|
||||
|
||||
private static Command BuildMirrorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var mirror = new Command("mirror", "Manage air-gap mirror bundles for offline distribution.");
|
||||
|
||||
// mirror create
|
||||
var create = new Command("create", "Create an air-gap mirror bundle.");
|
||||
|
||||
var domainOption = new Option<string>("--domain", new[] { "-d" })
|
||||
{
|
||||
Description = "Domain identifier (e.g., vex-advisories, vulnerability-feeds, policy-packs).",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output directory for the bundle files.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var formatOption = new Option<string?>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Export format filter (openvex, csaf, cyclonedx, spdx, ndjson, json)."
|
||||
};
|
||||
|
||||
var tenantOption = new Option<string?>("--tenant")
|
||||
{
|
||||
Description = "Tenant scope for the exports."
|
||||
};
|
||||
|
||||
var displayNameOption = new Option<string?>("--display-name")
|
||||
{
|
||||
Description = "Human-readable display name for the bundle."
|
||||
};
|
||||
|
||||
var targetRepoOption = new Option<string?>("--target-repository")
|
||||
{
|
||||
Description = "Target OCI repository URI for this bundle."
|
||||
};
|
||||
|
||||
var providersOption = new Option<string[]?>("--provider", new[] { "-p" })
|
||||
{
|
||||
Description = "Provider filter for VEX exports (can be specified multiple times).",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var signOption = new Option<bool>("--sign")
|
||||
{
|
||||
Description = "Include DSSE signatures in the bundle."
|
||||
};
|
||||
|
||||
var attestOption = new Option<bool>("--attest")
|
||||
{
|
||||
Description = "Include attestation metadata in the bundle."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output result in JSON format."
|
||||
};
|
||||
|
||||
create.Add(domainOption);
|
||||
create.Add(outputOption);
|
||||
create.Add(formatOption);
|
||||
create.Add(tenantOption);
|
||||
create.Add(displayNameOption);
|
||||
create.Add(targetRepoOption);
|
||||
create.Add(providersOption);
|
||||
create.Add(signOption);
|
||||
create.Add(attestOption);
|
||||
create.Add(jsonOption);
|
||||
|
||||
create.SetAction((parseResult, _) =>
|
||||
{
|
||||
var domain = parseResult.GetValue(domainOption) ?? string.Empty;
|
||||
var output = parseResult.GetValue(outputOption) ?? string.Empty;
|
||||
var format = parseResult.GetValue(formatOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var displayName = parseResult.GetValue(displayNameOption);
|
||||
var targetRepo = parseResult.GetValue(targetRepoOption);
|
||||
var providers = parseResult.GetValue(providersOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var attest = parseResult.GetValue(attestOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleMirrorCreateAsync(
|
||||
services,
|
||||
domain,
|
||||
output,
|
||||
format,
|
||||
tenant,
|
||||
displayName,
|
||||
targetRepo,
|
||||
providers?.ToList(),
|
||||
sign,
|
||||
attest,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
mirror.Add(create);
|
||||
|
||||
return mirror;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ internal static class Program
|
||||
services.AddAirGapEgressPolicy(configuration);
|
||||
services.AddStellaOpsCrypto(options.Crypto);
|
||||
|
||||
// CLI-AIRGAP-56-002: Add sealed mode telemetry for air-gapped operation
|
||||
services.AddSealedModeTelemetryIfOffline(
|
||||
options.IsOffline,
|
||||
options.IsOffline ? Path.Combine(options.Offline.KitsDirectory, "telemetry") : null);
|
||||
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
|
||||
249
src/Cli/StellaOps.Cli/Services/Models/MirrorBundleModels.cs
Normal file
249
src/Cli/StellaOps.Cli/Services/Models/MirrorBundleModels.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap mirror bundle format for offline operation with DSSE signature support.
|
||||
/// Maps to docs/schemas/mirror-bundle.schema.json
|
||||
/// </summary>
|
||||
internal sealed record MirrorBundle
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("targetRepository")]
|
||||
public string? TargetRepository { get; init; }
|
||||
|
||||
[JsonPropertyName("domainId")]
|
||||
public required string DomainId { get; init; }
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("exports")]
|
||||
public required IReadOnlyList<MirrorBundleExport> Exports { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record MirrorBundleExport
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public required string Key { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; init; }
|
||||
|
||||
[JsonPropertyName("exportId")]
|
||||
public required string ExportId { get; init; }
|
||||
|
||||
[JsonPropertyName("querySignature")]
|
||||
public string? QuerySignature { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactSizeBytes")]
|
||||
public long? ArtifactSizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("consensusRevision")]
|
||||
public string? ConsensusRevision { get; init; }
|
||||
|
||||
[JsonPropertyName("policyRevisionId")]
|
||||
public string? PolicyRevisionId { get; init; }
|
||||
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public string? PolicyDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("consensusDigest")]
|
||||
public string? ConsensusDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("scoreDigest")]
|
||||
public string? ScoreDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceProviders")]
|
||||
public IReadOnlyList<string>? SourceProviders { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation")]
|
||||
public MirrorAttestationDescriptor? Attestation { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record MirrorAttestationDescriptor
|
||||
{
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLocation")]
|
||||
public string? RekorLocation { get; init; }
|
||||
|
||||
[JsonPropertyName("envelopeDigest")]
|
||||
public string? EnvelopeDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record MirrorBundleSignature
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public string? Provider { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record MirrorBundleManifest
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("domainId")]
|
||||
public required string DomainId { get; init; }
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("targetRepository")]
|
||||
public string? TargetRepository { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public required MirrorFileDescriptor Bundle { get; init; }
|
||||
|
||||
[JsonPropertyName("exports")]
|
||||
public IReadOnlyList<MirrorBundleExport>? Exports { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record MirrorFileDescriptor
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public required long SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public MirrorBundleSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for creating a mirror bundle.
|
||||
/// </summary>
|
||||
internal sealed record MirrorCreateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Domain identifier (e.g., "vex-advisories", "vulnerability-feeds", "policy-packs").
|
||||
/// </summary>
|
||||
public required string DomainId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name for the bundle.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target OCI repository for this bundle.
|
||||
/// </summary>
|
||||
public string? TargetRepository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Export format filter (e.g., "openvex", "csaf", "cyclonedx").
|
||||
/// </summary>
|
||||
public string? Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider filter for VEX exports.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Providers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for the bundle files.
|
||||
/// </summary>
|
||||
public required string OutputDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include DSSE signatures.
|
||||
/// </summary>
|
||||
public bool IncludeSignatures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include attestation metadata.
|
||||
/// </summary>
|
||||
public bool IncludeAttestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant scope for the exports.
|
||||
/// </summary>
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result model for mirror bundle creation.
|
||||
/// </summary>
|
||||
internal sealed record MirrorCreateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the created bundle manifest.
|
||||
/// </summary>
|
||||
public required string ManifestPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the bundle archive (if created).
|
||||
/// </summary>
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the bundle signature (if created).
|
||||
/// </summary>
|
||||
public string? SignaturePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of exports included in the bundle.
|
||||
/// </summary>
|
||||
public int ExportCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size in bytes of all exported artifacts.
|
||||
/// </summary>
|
||||
public long TotalSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle digest for verification.
|
||||
/// </summary>
|
||||
public string? BundleDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the bundle was generated.
|
||||
/// </summary>
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Domain ID of the bundle.
|
||||
/// </summary>
|
||||
public required string DomainId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Export details for verbose output.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MirrorBundleExport>? Exports { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
@@ -7,6 +8,33 @@ internal static class CliMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Cli", "1.0.0");
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the CLI is running in sealed/air-gapped mode.
|
||||
/// Per CLI-AIRGAP-56-002: when true, adds "AirGapped-Phase-1" label to all metrics.
|
||||
/// </summary>
|
||||
public static bool IsSealedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase label for sealed mode telemetry.
|
||||
/// </summary>
|
||||
public static string SealedModePhaseLabel { get; set; } = "AirGapped-Phase-1";
|
||||
|
||||
/// <summary>
|
||||
/// Appends sealed mode tags to the given tags array if in sealed mode.
|
||||
/// </summary>
|
||||
private static KeyValuePair<string, object?>[] WithSealedModeTag(params KeyValuePair<string, object?>[] baseTags)
|
||||
{
|
||||
if (!IsSealedMode)
|
||||
{
|
||||
return baseTags;
|
||||
}
|
||||
|
||||
var tags = new KeyValuePair<string, object?>[baseTags.Length + 1];
|
||||
Array.Copy(baseTags, tags, baseTags.Length);
|
||||
tags[baseTags.Length] = new KeyValuePair<string, object?>("deployment.phase", SealedModePhaseLabel);
|
||||
return tags;
|
||||
}
|
||||
|
||||
private static readonly Counter<long> ScannerDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.download.count");
|
||||
private static readonly Counter<long> ScannerInstallCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.install.count");
|
||||
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
|
||||
@@ -31,34 +59,26 @@ internal static class CliMetrics
|
||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||
|
||||
public static void RecordScannerDownload(string channel, bool fromCache)
|
||||
=> ScannerDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
=> ScannerDownloadCounter.Add(1, WithSealedModeTag(
|
||||
new("channel", channel),
|
||||
new("cache", fromCache ? "hit" : "miss")
|
||||
});
|
||||
new("cache", fromCache ? "hit" : "miss")));
|
||||
|
||||
public static void RecordScannerInstall(string channel)
|
||||
=> ScannerInstallCounter.Add(1, new KeyValuePair<string, object?>[] { new("channel", channel) });
|
||||
=> ScannerInstallCounter.Add(1, WithSealedModeTag(new("channel", channel)));
|
||||
|
||||
public static void RecordScanRun(string runner, int exitCode)
|
||||
=> ScanRunCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
=> ScanRunCounter.Add(1, WithSealedModeTag(
|
||||
new("runner", runner),
|
||||
new("exit_code", exitCode)
|
||||
});
|
||||
new("exit_code", exitCode)));
|
||||
|
||||
public static void RecordOfflineKitDownload(string kind, bool fromCache)
|
||||
=> OfflineKitDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
=> OfflineKitDownloadCounter.Add(1, WithSealedModeTag(
|
||||
new("kind", string.IsNullOrWhiteSpace(kind) ? "unknown" : kind),
|
||||
new("cache", fromCache ? "hit" : "miss")
|
||||
});
|
||||
new("cache", fromCache ? "hit" : "miss")));
|
||||
|
||||
public static void RecordOfflineKitImport(string? status)
|
||||
=> OfflineKitImportCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)
|
||||
});
|
||||
=> OfflineKitImportCounter.Add(1, WithSealedModeTag(
|
||||
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)));
|
||||
|
||||
public static void RecordPolicySimulation(string outcome)
|
||||
=> PolicySimulationCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
|
||||
284
src/Cli/StellaOps.Cli/Telemetry/SealedModeTelemetry.cs
Normal file
284
src/Cli/StellaOps.Cli/Telemetry/SealedModeTelemetry.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed mode telemetry configuration for air-gapped environments.
|
||||
/// Per CLI-AIRGAP-56-002: ensures telemetry propagation under sealed mode
|
||||
/// (no remote exporters) while preserving correlation IDs.
|
||||
/// </summary>
|
||||
public sealed class SealedModeTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether sealed mode is active (no remote telemetry export).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local directory for telemetry file export (optional).
|
||||
/// If null/empty, telemetry is only logged, not persisted.
|
||||
/// </summary>
|
||||
public string? LocalExportDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum telemetry records to buffer before flushing to file.
|
||||
/// </summary>
|
||||
public int BufferSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Phase label for air-gapped telemetry (e.g., "AirGapped-Phase-1").
|
||||
/// </summary>
|
||||
public string PhaseLabel { get; set; } = "AirGapped-Phase-1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local telemetry sink for sealed/air-gapped mode.
|
||||
/// Preserves correlation IDs and records telemetry locally without remote exports.
|
||||
/// </summary>
|
||||
public sealed class SealedModeTelemetrySink : IDisposable
|
||||
{
|
||||
private readonly ILogger<SealedModeTelemetrySink> _logger;
|
||||
private readonly SealedModeTelemetryOptions _options;
|
||||
private readonly ConcurrentQueue<TelemetryRecord> _buffer;
|
||||
private readonly SemaphoreSlim _flushLock;
|
||||
private bool _disposed;
|
||||
|
||||
public SealedModeTelemetrySink(
|
||||
ILogger<SealedModeTelemetrySink> logger,
|
||||
SealedModeTelemetryOptions options)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_buffer = new ConcurrentQueue<TelemetryRecord>();
|
||||
_flushLock = new SemaphoreSlim(1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a telemetry event locally with correlation ID preservation.
|
||||
/// </summary>
|
||||
public void Record(
|
||||
string eventName,
|
||||
string? traceId = null,
|
||||
string? spanId = null,
|
||||
IDictionary<string, object?>? attributes = null)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
var record = new TelemetryRecord
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
EventName = eventName,
|
||||
TraceId = traceId ?? activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"),
|
||||
SpanId = spanId ?? activity?.SpanId.ToString() ?? Guid.NewGuid().ToString("N")[..16],
|
||||
PhaseLabel = _options.PhaseLabel,
|
||||
Attributes = attributes ?? new Dictionary<string, object?>()
|
||||
};
|
||||
|
||||
_buffer.Enqueue(record);
|
||||
|
||||
_logger.LogDebug(
|
||||
"[{PhaseLabel}] Telemetry recorded: {EventName} trace_id={TraceId} span_id={SpanId}",
|
||||
_options.PhaseLabel,
|
||||
eventName,
|
||||
record.TraceId,
|
||||
record.SpanId);
|
||||
|
||||
// Flush if buffer is full
|
||||
if (_buffer.Count >= _options.BufferSize)
|
||||
{
|
||||
_ = FlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a metric event locally.
|
||||
/// </summary>
|
||||
public void RecordMetric(
|
||||
string metricName,
|
||||
double value,
|
||||
IDictionary<string, object?>? tags = null)
|
||||
{
|
||||
var attributes = new Dictionary<string, object?>(tags ?? new Dictionary<string, object?>())
|
||||
{
|
||||
["metric.value"] = value
|
||||
};
|
||||
|
||||
Record($"metric.{metricName}", attributes: attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes buffered telemetry to local file (if configured).
|
||||
/// </summary>
|
||||
public async Task FlushAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.LocalExportDirectory))
|
||||
{
|
||||
// No file export configured; just drain the queue
|
||||
while (_buffer.TryDequeue(out _)) { }
|
||||
return;
|
||||
}
|
||||
|
||||
await _flushLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var records = new List<TelemetryRecord>();
|
||||
while (_buffer.TryDequeue(out var record))
|
||||
{
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var directory = Path.GetFullPath(_options.LocalExportDirectory);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var fileName = $"telemetry-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.ndjson";
|
||||
var filePath = Path.Combine(directory, fileName);
|
||||
|
||||
await using var writer = new StreamWriter(filePath, append: false);
|
||||
foreach (var record in records)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(record, TelemetryJsonContext.Default.TelemetryRecord);
|
||||
await writer.WriteLineAsync(json).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"[{PhaseLabel}] Flushed {Count} telemetry records to {Path}",
|
||||
_options.PhaseLabel,
|
||||
records.Count,
|
||||
filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"[{PhaseLabel}] Failed to flush telemetry to file",
|
||||
_options.PhaseLabel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_flushLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current correlation context for propagation.
|
||||
/// </summary>
|
||||
public static CorrelationContext GetCorrelationContext()
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
return new CorrelationContext
|
||||
{
|
||||
TraceId = activity?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"),
|
||||
SpanId = activity?.SpanId.ToString() ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TraceFlags = activity?.Recorded == true ? "01" : "00"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a traceparent header value for correlation ID propagation.
|
||||
/// </summary>
|
||||
public static string CreateTraceparent(CorrelationContext? context = null)
|
||||
{
|
||||
context ??= GetCorrelationContext();
|
||||
return $"00-{context.TraceId}-{context.SpanId}-{context.TraceFlags}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Final flush
|
||||
FlushAsync().GetAwaiter().GetResult();
|
||||
_flushLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Correlation context for trace propagation in sealed mode.
|
||||
/// </summary>
|
||||
public sealed class CorrelationContext
|
||||
{
|
||||
public required string TraceId { get; init; }
|
||||
public required string SpanId { get; init; }
|
||||
public string TraceFlags { get; init; } = "00";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry record for local storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryRecord
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public required string EventName { get; init; }
|
||||
public required string TraceId { get; init; }
|
||||
public required string SpanId { get; init; }
|
||||
public string? PhaseLabel { get; init; }
|
||||
public IDictionary<string, object?> Attributes { get; init; } = new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for telemetry records.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(TelemetryRecord))]
|
||||
internal partial class TelemetryJsonContext : System.Text.Json.Serialization.JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for sealed mode telemetry registration.
|
||||
/// </summary>
|
||||
public static class SealedModeTelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds sealed mode telemetry services for air-gapped operation.
|
||||
/// Per CLI-AIRGAP-56-002.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSealedModeTelemetry(
|
||||
this IServiceCollection services,
|
||||
Action<SealedModeTelemetryOptions>? configure = null)
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<SealedModeTelemetrySink>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds sealed mode telemetry if running in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSealedModeTelemetryIfOffline(
|
||||
this IServiceCollection services,
|
||||
bool isOffline,
|
||||
string? localExportDirectory = null)
|
||||
{
|
||||
if (!isOffline)
|
||||
{
|
||||
return services;
|
||||
}
|
||||
|
||||
return services.AddSealedModeTelemetry(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.LocalExportDirectory = localExportDirectory;
|
||||
opts.PhaseLabel = "AirGapped-Phase-1";
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user