up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
master
2025-11-28 18:21:46 +02:00
parent 05da719048
commit d1cbb905f8
103 changed files with 49604 additions and 105 deletions

View File

@@ -0,0 +1,164 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// HTTP transport implementation for online mode.
/// CLI-SDK-62-001: Provides HTTP transport for online API operations.
/// </summary>
public sealed class HttpTransport : IStellaOpsTransport
{
private readonly HttpClient _httpClient;
private readonly TransportOptions _options;
private readonly ILogger<HttpTransport> _logger;
private bool _disposed;
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
{
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
{
_httpClient.BaseAddress = baseUri;
}
}
_httpClient.Timeout = _options.Timeout;
}
/// <inheritdoc />
public bool IsOffline => false;
/// <inheritdoc />
public string TransportMode => "http";
/// <inheritdoc />
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return await SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var attempt = 0;
var maxAttempts = Math.Max(1, _options.MaxRetries);
while (true)
{
attempt++;
try
{
_logger.LogDebug("Sending {Method} request to {Uri} (attempt {Attempt}/{MaxAttempts})",
request.Method, request.RequestUri, attempt, maxAttempts);
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Received response {StatusCode} from {Uri}",
(int)response.StatusCode, request.RequestUri);
return response;
}
catch (HttpRequestException ex) when (attempt < maxAttempts && IsRetryableException(ex))
{
var delay = GetRetryDelay(attempt);
_logger.LogWarning(ex, "Request failed (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s...",
attempt, maxAttempts, delay.TotalSeconds);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
// Clone the request for retry
request = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
}
}
}
/// <inheritdoc />
public async Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// For HTTP transport, we return a memory stream that will be uploaded
// The caller is responsible for writing to the stream and then calling upload
return await Task.FromResult<Stream>(new MemoryStream()).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
var response = await SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
private static bool IsRetryableException(HttpRequestException ex)
{
// Retry on connection errors, timeouts, and server errors
return ex.InnerException is IOException
|| ex.InnerException is OperationCanceledException
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
}
private static TimeSpan GetRetryDelay(int attempt)
{
// Exponential backoff with jitter
var baseDelay = Math.Pow(2, attempt);
var jitter = Random.Shared.NextDouble() * 0.5;
return TimeSpan.FromSeconds(baseDelay + jitter);
}
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
// Copy headers
foreach (var header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
// Copy content if present
if (request.Content is not null)
{
var content = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
clone.Content = new ByteArrayContent(content);
foreach (var header in request.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
// Copy options
foreach (var option in request.Options)
{
clone.Options.TryAdd(option.Key, option.Value);
}
return clone;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Note: We don't dispose _httpClient as it's typically managed by DI
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Transport abstraction for CLI operations.
/// CLI-SDK-62-001: Supports modular transport for online and air-gapped modes.
/// </summary>
public interface IStellaOpsTransport : IDisposable
{
/// <summary>
/// Gets whether this transport is operating in offline/air-gapped mode.
/// </summary>
bool IsOffline { get; }
/// <summary>
/// Gets the transport mode identifier.
/// </summary>
string TransportMode { get; }
/// <summary>
/// Sends an HTTP request and returns the response.
/// </summary>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
/// <summary>
/// Sends an HTTP request and returns the response with streaming content.
/// </summary>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
/// <summary>
/// Gets a stream for uploading content.
/// </summary>
Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken);
/// <summary>
/// Gets a stream for downloading content.
/// </summary>
Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken);
}
/// <summary>
/// Transport configuration options.
/// </summary>
public sealed class TransportOptions
{
/// <summary>
/// Base URL for the backend API.
/// </summary>
public string? BackendUrl { get; set; }
/// <summary>
/// Whether to operate in offline/air-gapped mode.
/// </summary>
public bool IsOffline { get; set; }
/// <summary>
/// Directory for offline kit data.
/// </summary>
public string? OfflineKitDirectory { get; set; }
/// <summary>
/// Request timeout.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of retry attempts.
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// Whether to validate SSL certificates.
/// </summary>
public bool ValidateSsl { get; set; } = true;
/// <summary>
/// Custom CA certificate path for SSL validation.
/// </summary>
public string? CaCertificatePath { get; set; }
/// <summary>
/// Proxy URL if required.
/// </summary>
public string? ProxyUrl { get; set; }
/// <summary>
/// User agent string.
/// </summary>
public string UserAgent { get; set; } = "StellaOps-CLI/1.0";
}

View File

@@ -0,0 +1,186 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Offline transport implementation for air-gapped mode.
/// CLI-SDK-62-001: Provides offline transport for air-gapped operations.
/// </summary>
public sealed class OfflineTransport : IStellaOpsTransport
{
private readonly TransportOptions _options;
private readonly ILogger<OfflineTransport> _logger;
private readonly string _offlineKitDirectory;
private bool _disposed;
public OfflineTransport(TransportOptions options, ILogger<OfflineTransport> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
{
throw new ArgumentException("OfflineKitDirectory must be specified for offline transport.", nameof(options));
}
_offlineKitDirectory = options.OfflineKitDirectory;
if (!Directory.Exists(_offlineKitDirectory))
{
throw new DirectoryNotFoundException($"Offline kit directory not found: {_offlineKitDirectory}");
}
}
/// <inheritdoc />
public bool IsOffline => true;
/// <inheritdoc />
public string TransportMode => "offline";
/// <inheritdoc />
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
}
/// <inheritdoc />
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_logger.LogDebug("Offline transport handling {Method} {Uri}", request.Method, request.RequestUri);
// Map the request to an offline resource
var (found, content) = await TryGetOfflineContentAsync(request, cancellationToken).ConfigureAwait(false);
if (found)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = content,
RequestMessage = request
};
}
// Return a 503 Service Unavailable for operations that require online access
_logger.LogWarning("Operation not available in offline mode: {Method} {Uri}", request.Method, request.RequestUri);
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent(JsonSerializer.Serialize(new
{
error = new
{
code = "ERR_AIRGAP_EGRESS_BLOCKED",
message = "This operation is not available in offline/air-gapped mode."
}
})),
RequestMessage = request
};
}
/// <inheritdoc />
public Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Create a staging area for uploads in offline mode
var stagingDir = Path.Combine(_offlineKitDirectory, "staging", "uploads");
Directory.CreateDirectory(stagingDir);
var stagingFile = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.dat");
_logger.LogDebug("Creating offline upload staging file: {Path}", stagingFile);
return Task.FromResult<Stream>(File.Create(stagingFile));
}
/// <inheritdoc />
public Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Map endpoint to offline file
var localPath = MapEndpointToLocalPath(endpoint);
if (!File.Exists(localPath))
{
_logger.LogWarning("Offline resource not found: {Path}", localPath);
throw new FileNotFoundException($"Offline resource not found: {endpoint}", localPath);
}
_logger.LogDebug("Opening offline resource: {Path}", localPath);
return Task.FromResult<Stream>(File.OpenRead(localPath));
}
private async Task<(bool Found, HttpContent? Content)> TryGetOfflineContentAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var uri = request.RequestUri;
if (uri is null)
return (false, null);
var path = uri.PathAndQuery.TrimStart('/');
// Check for cached API responses
var cachePath = Path.Combine(_offlineKitDirectory, "cache", "api", path.Replace('/', Path.DirectorySeparatorChar));
// Try with .json extension
var jsonPath = cachePath + ".json";
if (File.Exists(jsonPath))
{
var content = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false);
return (true, new StringContent(content, System.Text.Encoding.UTF8, "application/json"));
}
// Try exact path
if (File.Exists(cachePath))
{
var content = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
return (true, new ByteArrayContent(content));
}
// Check for bundled data
var bundlePath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(bundlePath))
{
var content = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
return (true, new ByteArrayContent(content));
}
return (false, null);
}
private string MapEndpointToLocalPath(string endpoint)
{
var path = endpoint.TrimStart('/');
// Check data directory first
var dataPath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(dataPath))
return dataPath;
// Check cache directory
var cachePath = Path.Combine(_offlineKitDirectory, "cache", path.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(cachePath))
return cachePath;
// Return data path as default (will throw FileNotFoundException if not found)
return dataPath;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
}
}

View File

@@ -0,0 +1,264 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Output;
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Base class for SDK-generated clients.
/// CLI-SDK-62-001: Provides common functionality for SDK clients with modular transport.
/// </summary>
public abstract class StellaOpsClientBase : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly IStellaOpsTransport _transport;
private readonly ILogger _logger;
private bool _disposed;
/// <summary>
/// Authorization token for API requests.
/// </summary>
protected string? AccessToken { get; set; }
/// <summary>
/// Current tenant context.
/// </summary>
protected string? TenantId { get; set; }
protected StellaOpsClientBase(IStellaOpsTransport transport, ILogger logger)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Gets whether the client is operating in offline mode.
/// </summary>
public bool IsOffline => _transport.IsOffline;
/// <summary>
/// Sets the access token for authenticated requests.
/// </summary>
public void SetAccessToken(string? token)
{
AccessToken = token;
}
/// <summary>
/// Sets the tenant context.
/// </summary>
public void SetTenant(string? tenantId)
{
TenantId = tenantId;
}
/// <summary>
/// Throws if the operation requires online connectivity and transport is offline.
/// </summary>
protected void ThrowIfOffline(string operation)
{
if (_transport.IsOffline)
{
throw new InvalidOperationException(
$"Operation '{operation}' is not available in offline/air-gapped mode. " +
"Please use online mode or import the required data to the offline kit.");
}
}
/// <summary>
/// Sends a GET request and deserializes the response.
/// </summary>
protected async Task<TResponse?> GetAsync<TResponse>(
string relativeUrl,
CancellationToken cancellationToken) where TResponse : class
{
using var request = CreateRequest(HttpMethod.Get, relativeUrl);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a POST request with JSON body and deserializes the response.
/// </summary>
protected async Task<TResponse?> PostAsync<TRequest, TResponse>(
string relativeUrl,
TRequest body,
CancellationToken cancellationToken)
where TRequest : class
where TResponse : class
{
using var request = CreateRequest(HttpMethod.Post, relativeUrl);
request.Content = JsonContent.Create(body, options: JsonOptions);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a PUT request with JSON body and deserializes the response.
/// </summary>
protected async Task<TResponse?> PutAsync<TRequest, TResponse>(
string relativeUrl,
TRequest body,
CancellationToken cancellationToken)
where TRequest : class
where TResponse : class
{
using var request = CreateRequest(HttpMethod.Put, relativeUrl);
request.Content = JsonContent.Create(body, options: JsonOptions);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a DELETE request.
/// </summary>
protected async Task DeleteAsync(string relativeUrl, CancellationToken cancellationToken)
{
using var request = CreateRequest(HttpMethod.Delete, relativeUrl);
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Sends a request and parses any error response.
/// </summary>
protected async Task<(TResponse? Result, CliError? Error)> TrySendAsync<TResponse>(
HttpRequestMessage request,
CancellationToken cancellationToken) where TResponse : class
{
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return (result, null);
}
var error = await ParseErrorAsync(response, cancellationToken).ConfigureAwait(false);
return (null, error);
}
/// <summary>
/// Creates an HTTP request with standard headers.
/// </summary>
protected HttpRequestMessage CreateRequest(HttpMethod method, string relativeUrl)
{
var request = new HttpRequestMessage(method, relativeUrl);
// Add authorization header
if (!string.IsNullOrWhiteSpace(AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
}
// Add tenant header
if (!string.IsNullOrWhiteSpace(TenantId))
{
request.Headers.TryAddWithoutValidation("X-Tenant-Id", TenantId);
}
// Add standard headers
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return request;
}
/// <summary>
/// Sends a request through the transport.
/// </summary>
protected async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_logger.LogDebug("Sending {Method} request to {Uri}", request.Method, request.RequestUri);
return await _transport.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Parses an error response into a CliError.
/// </summary>
protected async Task<CliError> ParseErrorAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
string? content = null;
try
{
content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
// Ignore content read errors
}
// Try to parse as error envelope
if (!string.IsNullOrWhiteSpace(content))
{
try
{
var envelope = JsonSerializer.Deserialize<ApiErrorEnvelope>(content, JsonOptions);
if (envelope?.Error is not null)
{
return CliError.FromApiErrorEnvelope(envelope, statusCode);
}
}
catch (JsonException)
{
// Not an error envelope
}
// Try to parse as problem details
try
{
var problem = JsonSerializer.Deserialize<ProblemDocument>(content, JsonOptions);
if (problem is not null)
{
return new CliError(
Code: problem.Type ?? $"ERR_HTTP_{statusCode}",
Message: problem.Title ?? $"HTTP error {statusCode}",
Detail: problem.Detail);
}
}
catch (JsonException)
{
// Not a problem document
}
}
// Fall back to HTTP status-based error
return CliError.FromHttpStatus(statusCode, content);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_transport.Dispose();
}
}

View File

@@ -0,0 +1,126 @@
using System;
using System.Net.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
namespace StellaOps.Cli.Services.Transport;
/// <summary>
/// Factory for creating transport instances based on configuration.
/// CLI-SDK-62-001: Provides modular transport selection for online/offline modes.
/// </summary>
public sealed class TransportFactory : ITransportFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly StellaOpsCliOptions _options;
private readonly CliProfileManager _profileManager;
public TransportFactory(
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory,
StellaOpsCliOptions options,
CliProfileManager profileManager)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_options = options ?? throw new ArgumentNullException(nameof(options));
_profileManager = profileManager ?? throw new ArgumentNullException(nameof(profileManager));
}
/// <summary>
/// Creates a transport instance based on current configuration.
/// </summary>
public IStellaOpsTransport CreateTransport()
{
var transportOptions = CreateTransportOptions();
if (transportOptions.IsOffline)
{
return CreateOfflineTransport(transportOptions);
}
return CreateHttpTransport(transportOptions);
}
/// <summary>
/// Creates an HTTP transport for online operations.
/// </summary>
public IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null)
{
options ??= CreateTransportOptions();
var httpClient = _httpClientFactory.CreateClient("StellaOps");
var logger = _loggerFactory.CreateLogger<HttpTransport>();
return new HttpTransport(httpClient, options, logger);
}
/// <summary>
/// Creates an offline transport for air-gapped operations.
/// </summary>
public IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null)
{
options ??= CreateTransportOptions();
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
{
throw new InvalidOperationException("Offline kit directory must be specified for offline transport.");
}
var logger = _loggerFactory.CreateLogger<OfflineTransport>();
return new OfflineTransport(options, logger);
}
/// <summary>
/// Creates transport options from current configuration.
/// </summary>
public TransportOptions CreateTransportOptions()
{
var profile = _profileManager.GetCurrentProfileAsync().GetAwaiter().GetResult();
return new TransportOptions
{
BackendUrl = profile?.BackendUrl ?? _options.BackendUrl,
IsOffline = profile?.IsOffline ?? _options.IsOffline,
OfflineKitDirectory = profile?.OfflineKitDirectory ?? _options.OfflineKitDirectory,
Timeout = TimeSpan.FromMinutes(5),
MaxRetries = 3,
ValidateSsl = true,
UserAgent = $"StellaOps-CLI/{GetVersion()}"
};
}
private static string GetVersion()
{
var assembly = typeof(TransportFactory).Assembly;
var version = assembly.GetName().Version;
return version?.ToString() ?? "1.0.0";
}
}
/// <summary>
/// Factory interface for creating transport instances.
/// </summary>
public interface ITransportFactory
{
/// <summary>
/// Creates a transport instance based on current configuration.
/// </summary>
IStellaOpsTransport CreateTransport();
/// <summary>
/// Creates an HTTP transport for online operations.
/// </summary>
IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null);
/// <summary>
/// Creates an offline transport for air-gapped operations.
/// </summary>
IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null);
/// <summary>
/// Creates transport options from current configuration.
/// </summary>
TransportOptions CreateTransportOptions();
}