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:
StellaOps Bot
2025-12-05 01:00:10 +02:00
parent 8768c27f30
commit 175b750e29
111 changed files with 25407 additions and 19242 deletions

View File

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

View File

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

View File

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

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

View File

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

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