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

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