This commit is contained in:
StellaOps Bot
2025-12-07 22:49:53 +02:00
parent 11597679ed
commit 7c24ed96ee
204 changed files with 23313 additions and 1430 deletions

View File

@@ -0,0 +1,310 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.ExportCenter.Client.Models;
namespace StellaOps.ExportCenter.Client;
/// <summary>
/// HTTP client implementation for the ExportCenter WebService API.
/// </summary>
public sealed class ExportCenterClient : IExportCenterClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
/// <summary>
/// Creates a new ExportCenterClient with the specified HttpClient.
/// </summary>
/// <param name="httpClient">HTTP client instance.</param>
public ExportCenterClient(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
/// <summary>
/// Creates a new ExportCenterClient with the specified options.
/// </summary>
/// <param name="httpClient">HTTP client instance.</param>
/// <param name="options">Client options.</param>
public ExportCenterClient(HttpClient httpClient, IOptions<ExportCenterClientOptions> options)
: this(httpClient)
{
ArgumentNullException.ThrowIfNull(options);
var opts = options.Value;
_httpClient.BaseAddress = new Uri(opts.BaseUrl);
_httpClient.Timeout = opts.Timeout;
}
#region Discovery
/// <inheritdoc />
public async Task<OpenApiDiscoveryMetadata> GetDiscoveryMetadataAsync(
CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync("/.well-known/openapi", cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var metadata = await response.Content.ReadFromJsonAsync<OpenApiDiscoveryMetadata>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return metadata ?? throw new InvalidOperationException("Invalid discovery metadata response.");
}
#endregion
#region Profiles
/// <inheritdoc />
public async Task<ExportProfileListResponse> ListProfilesAsync(
string? continuationToken = null,
int? limit = null,
CancellationToken cancellationToken = default)
{
var url = "/v1/exports/profiles";
var queryParams = new List<string>();
if (!string.IsNullOrEmpty(continuationToken))
{
queryParams.Add($"continuationToken={Uri.EscapeDataString(continuationToken)}");
}
if (limit.HasValue)
{
queryParams.Add($"limit={limit.Value}");
}
if (queryParams.Count > 0)
{
url += "?" + string.Join("&", queryParams);
}
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ExportProfileListResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ExportProfileListResponse([], null, false);
}
/// <inheritdoc />
public async Task<ExportProfile?> GetProfileAsync(
string profileId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(profileId);
var response = await _httpClient.GetAsync($"/v1/exports/profiles/{Uri.EscapeDataString(profileId)}", cancellationToken)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ExportProfile>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
#endregion
#region Runs
/// <inheritdoc />
public async Task<ExportRunListResponse> ListRunsAsync(
string? profileId = null,
string? continuationToken = null,
int? limit = null,
CancellationToken cancellationToken = default)
{
var url = "/v1/exports/runs";
var queryParams = new List<string>();
if (!string.IsNullOrEmpty(profileId))
{
queryParams.Add($"profileId={Uri.EscapeDataString(profileId)}");
}
if (!string.IsNullOrEmpty(continuationToken))
{
queryParams.Add($"continuationToken={Uri.EscapeDataString(continuationToken)}");
}
if (limit.HasValue)
{
queryParams.Add($"limit={limit.Value}");
}
if (queryParams.Count > 0)
{
url += "?" + string.Join("&", queryParams);
}
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ExportRunListResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new ExportRunListResponse([], null, false);
}
/// <inheritdoc />
public async Task<ExportRun?> GetRunAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var response = await _httpClient.GetAsync($"/v1/exports/runs/{Uri.EscapeDataString(runId)}", cancellationToken)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ExportRun>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
#endregion
#region Evidence Exports
/// <inheritdoc />
public async Task<CreateEvidenceExportResponse> CreateEvidenceExportAsync(
CreateEvidenceExportRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var response = await _httpClient.PostAsJsonAsync("/v1/exports/evidence", request, JsonOptions, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CreateEvidenceExportResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Invalid evidence export response.");
}
/// <inheritdoc />
public async Task<EvidenceExportStatus?> GetEvidenceExportStatusAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var response = await _httpClient.GetAsync($"/v1/exports/evidence/{Uri.EscapeDataString(runId)}/status", cancellationToken)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<EvidenceExportStatus>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Stream?> DownloadEvidenceExportAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var response = await _httpClient.GetAsync(
$"/v1/exports/evidence/{Uri.EscapeDataString(runId)}/download",
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound ||
response.StatusCode == HttpStatusCode.Conflict)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
#endregion
#region Attestation Exports
/// <inheritdoc />
public async Task<CreateAttestationExportResponse> CreateAttestationExportAsync(
CreateAttestationExportRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var response = await _httpClient.PostAsJsonAsync("/v1/exports/attestations", request, JsonOptions, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CreateAttestationExportResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Invalid attestation export response.");
}
/// <inheritdoc />
public async Task<AttestationExportStatus?> GetAttestationExportStatusAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var response = await _httpClient.GetAsync($"/v1/exports/attestations/{Uri.EscapeDataString(runId)}/status", cancellationToken)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<AttestationExportStatus>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Stream?> DownloadAttestationExportAsync(
string runId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var response = await _httpClient.GetAsync(
$"/v1/exports/attestations/{Uri.EscapeDataString(runId)}/download",
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound ||
response.StatusCode == HttpStatusCode.Conflict)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
#endregion
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.ExportCenter.Client;
/// <summary>
/// Configuration options for the ExportCenter client.
/// </summary>
public sealed class ExportCenterClientOptions
{
/// <summary>
/// Base URL for the ExportCenter API.
/// </summary>
public string BaseUrl { get; set; } = "https://localhost:5001";
/// <summary>
/// Timeout for HTTP requests.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Timeout for streaming downloads.
/// </summary>
public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromMinutes(10);
}

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace StellaOps.ExportCenter.Client.Extensions;
/// <summary>
/// Extension methods for configuring ExportCenter client services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the ExportCenter client to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Action to configure client options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddExportCenterClient(
this IServiceCollection services,
Action<ExportCenterClientOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(configureOptions);
services.AddHttpClient<IExportCenterClient, ExportCenterClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<ExportCenterClientOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = options.Timeout;
});
return services;
}
/// <summary>
/// Adds the ExportCenter client to the service collection with a named HttpClient.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="name">HttpClient name.</param>
/// <param name="configureOptions">Action to configure client options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddExportCenterClient(
this IServiceCollection services,
string name,
Action<ExportCenterClientOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(configureOptions);
services.Configure(name, configureOptions);
services.AddHttpClient<IExportCenterClient, ExportCenterClient>(name, (sp, client) =>
{
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<ExportCenterClientOptions>>();
var options = optionsMonitor.Get(name);
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = options.Timeout;
});
return services;
}
/// <summary>
/// Adds the ExportCenter client with custom HttpClient configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Action to configure client options.</param>
/// <param name="configureClient">Additional HttpClient configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddExportCenterClient(
this IServiceCollection services,
Action<ExportCenterClientOptions> configureOptions,
Action<HttpClient> configureClient)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
ArgumentNullException.ThrowIfNull(configureClient);
services.Configure(configureOptions);
services.AddHttpClient<IExportCenterClient, ExportCenterClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<ExportCenterClientOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = options.Timeout;
configureClient(client);
});
return services;
}
}

View File

@@ -0,0 +1,143 @@
using StellaOps.ExportCenter.Client.Models;
namespace StellaOps.ExportCenter.Client;
/// <summary>
/// Client interface for the ExportCenter WebService API.
/// </summary>
public interface IExportCenterClient
{
#region Discovery
/// <summary>
/// Gets OpenAPI discovery metadata.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>OpenAPI discovery metadata.</returns>
Task<OpenApiDiscoveryMetadata> GetDiscoveryMetadataAsync(
CancellationToken cancellationToken = default);
#endregion
#region Profiles
/// <summary>
/// Lists export profiles.
/// </summary>
/// <param name="continuationToken">Continuation token for pagination.</param>
/// <param name="limit">Maximum number of profiles to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Paginated list of export profiles.</returns>
Task<ExportProfileListResponse> ListProfilesAsync(
string? continuationToken = null,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific export profile by ID.
/// </summary>
/// <param name="profileId">Profile identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Export profile or null if not found.</returns>
Task<ExportProfile?> GetProfileAsync(
string profileId,
CancellationToken cancellationToken = default);
#endregion
#region Runs
/// <summary>
/// Lists export runs, optionally filtered by profile.
/// </summary>
/// <param name="profileId">Optional profile ID filter.</param>
/// <param name="continuationToken">Continuation token for pagination.</param>
/// <param name="limit">Maximum number of runs to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Paginated list of export runs.</returns>
Task<ExportRunListResponse> ListRunsAsync(
string? profileId = null,
string? continuationToken = null,
int? limit = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific export run by ID.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Export run or null if not found.</returns>
Task<ExportRun?> GetRunAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Evidence Exports
/// <summary>
/// Creates a new evidence export job.
/// </summary>
/// <param name="request">Export creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Export creation response.</returns>
Task<CreateEvidenceExportResponse> CreateEvidenceExportAsync(
CreateEvidenceExportRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the status of an evidence export job.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evidence export status or null if not found.</returns>
Task<EvidenceExportStatus?> GetEvidenceExportStatusAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Downloads an evidence export bundle as a stream.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Stream containing the bundle, or null if not ready/found.</returns>
Task<Stream?> DownloadEvidenceExportAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
#region Attestation Exports
/// <summary>
/// Creates a new attestation export job.
/// </summary>
/// <param name="request">Export creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Export creation response.</returns>
Task<CreateAttestationExportResponse> CreateAttestationExportAsync(
CreateAttestationExportRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the status of an attestation export job.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Attestation export status or null if not found.</returns>
Task<AttestationExportStatus?> GetAttestationExportStatusAsync(
string runId,
CancellationToken cancellationToken = default);
/// <summary>
/// Downloads an attestation export bundle as a stream.
/// </summary>
/// <param name="runId">Run identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Stream containing the bundle, or null if not ready/found.</returns>
Task<Stream?> DownloadAttestationExportAsync(
string runId,
CancellationToken cancellationToken = default);
#endregion
}

View File

@@ -0,0 +1,257 @@
using StellaOps.ExportCenter.Client.Models;
namespace StellaOps.ExportCenter.Client.Lifecycle;
/// <summary>
/// Helper methods for export job lifecycle operations.
/// </summary>
public static class ExportJobLifecycleHelper
{
/// <summary>
/// Terminal statuses for export jobs.
/// </summary>
public static readonly IReadOnlySet<string> TerminalStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"completed",
"failed",
"cancelled"
};
/// <summary>
/// Determines if a status is terminal (export job has finished).
/// </summary>
/// <param name="status">Status to check.</param>
/// <returns>True if terminal status.</returns>
public static bool IsTerminalStatus(string status)
=> TerminalStatuses.Contains(status);
/// <summary>
/// Creates an evidence export and waits for completion.
/// </summary>
/// <param name="client">ExportCenter client.</param>
/// <param name="request">Export creation request.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final evidence export status.</returns>
public static async Task<EvidenceExportStatus> CreateEvidenceExportAndWaitAsync(
IExportCenterClient client,
CreateEvidenceExportRequest request,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
var createResponse = await client.CreateEvidenceExportAsync(request, cancellationToken)
.ConfigureAwait(false);
return await WaitForEvidenceExportCompletionAsync(
client, createResponse.RunId, pollInterval, timeout, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Waits for an evidence export to complete.
/// </summary>
/// <param name="client">ExportCenter client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final evidence export status.</returns>
public static async Task<EvidenceExportStatus> WaitForEvidenceExportCompletionAsync(
IExportCenterClient client,
string runId,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var status = await client.GetEvidenceExportStatusAsync(runId, cts.Token)
.ConfigureAwait(false);
if (status is null)
{
throw new InvalidOperationException($"Evidence export '{runId}' not found.");
}
if (IsTerminalStatus(status.Status))
{
return status;
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Creates an attestation export and waits for completion.
/// </summary>
/// <param name="client">ExportCenter client.</param>
/// <param name="request">Export creation request.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final attestation export status.</returns>
public static async Task<AttestationExportStatus> CreateAttestationExportAndWaitAsync(
IExportCenterClient client,
CreateAttestationExportRequest request,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
var createResponse = await client.CreateAttestationExportAsync(request, cancellationToken)
.ConfigureAwait(false);
return await WaitForAttestationExportCompletionAsync(
client, createResponse.RunId, pollInterval, timeout, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Waits for an attestation export to complete.
/// </summary>
/// <param name="client">ExportCenter client.</param>
/// <param name="runId">Run identifier.</param>
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final attestation export status.</returns>
public static async Task<AttestationExportStatus> WaitForAttestationExportCompletionAsync(
IExportCenterClient client,
string runId,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maxWait);
while (true)
{
var status = await client.GetAttestationExportStatusAsync(runId, cts.Token)
.ConfigureAwait(false);
if (status is null)
{
throw new InvalidOperationException($"Attestation export '{runId}' not found.");
}
if (IsTerminalStatus(status.Status))
{
return status;
}
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
}
}
/// <summary>
/// Creates an evidence export, waits for completion, and downloads the bundle.
/// </summary>
/// <param name="client">ExportCenter client.</param>
/// <param name="request">Export creation request.</param>
/// <param name="outputPath">Path to save the downloaded bundle.</param>
/// <param name="pollInterval">Interval between status checks.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final evidence export status.</returns>
public static async Task<EvidenceExportStatus> CreateEvidenceExportAndDownloadAsync(
IExportCenterClient client,
CreateEvidenceExportRequest request,
string outputPath,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
var status = await CreateEvidenceExportAndWaitAsync(client, request, pollInterval, timeout, cancellationToken)
.ConfigureAwait(false);
if (status.Status != "completed")
{
throw new InvalidOperationException($"Evidence export failed: {status.ErrorCode} - {status.ErrorMessage}");
}
await using var stream = await client.DownloadEvidenceExportAsync(status.RunId, cancellationToken)
.ConfigureAwait(false);
if (stream is null)
{
throw new InvalidOperationException($"Evidence export bundle not available for download.");
}
await using var fileStream = File.Create(outputPath);
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
return status;
}
/// <summary>
/// Creates an attestation export, waits for completion, and downloads the bundle.
/// </summary>
/// <param name="client">ExportCenter client.</param>
/// <param name="request">Export creation request.</param>
/// <param name="outputPath">Path to save the downloaded bundle.</param>
/// <param name="pollInterval">Interval between status checks.</param>
/// <param name="timeout">Maximum time to wait.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Final attestation export status.</returns>
public static async Task<AttestationExportStatus> CreateAttestationExportAndDownloadAsync(
IExportCenterClient client,
CreateAttestationExportRequest request,
string outputPath,
TimeSpan? pollInterval = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
var status = await CreateAttestationExportAndWaitAsync(client, request, pollInterval, timeout, cancellationToken)
.ConfigureAwait(false);
if (status.Status != "completed")
{
throw new InvalidOperationException($"Attestation export failed: {status.ErrorCode} - {status.ErrorMessage}");
}
await using var stream = await client.DownloadAttestationExportAsync(status.RunId, cancellationToken)
.ConfigureAwait(false);
if (stream is null)
{
throw new InvalidOperationException($"Attestation export bundle not available for download.");
}
await using var fileStream = File.Create(outputPath);
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
return status;
}
}

View File

@@ -0,0 +1,152 @@
using System.Text.Json.Serialization;
namespace StellaOps.ExportCenter.Client.Models;
/// <summary>
/// Export profile metadata.
/// </summary>
public sealed record ExportProfile(
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("adapter")] string Adapter,
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors,
[property: JsonPropertyName("outputFormat")] string OutputFormat,
[property: JsonPropertyName("signingEnabled")] bool SigningEnabled,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("updatedAt")] DateTimeOffset? UpdatedAt);
/// <summary>
/// Paginated list of export profiles.
/// </summary>
public sealed record ExportProfileListResponse(
[property: JsonPropertyName("profiles")] IReadOnlyList<ExportProfile> Profiles,
[property: JsonPropertyName("continuationToken")] string? ContinuationToken,
[property: JsonPropertyName("hasMore")] bool HasMore);
/// <summary>
/// Export run representing a single export job execution.
/// </summary>
public sealed record ExportRun(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("progress")] int? Progress,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
[property: JsonPropertyName("bundleHash")] string? BundleHash,
[property: JsonPropertyName("bundleUrl")] string? BundleUrl,
[property: JsonPropertyName("errorCode")] string? ErrorCode,
[property: JsonPropertyName("errorMessage")] string? ErrorMessage,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
/// <summary>
/// Paginated list of export runs.
/// </summary>
public sealed record ExportRunListResponse(
[property: JsonPropertyName("runs")] IReadOnlyList<ExportRun> Runs,
[property: JsonPropertyName("continuationToken")] string? ContinuationToken,
[property: JsonPropertyName("hasMore")] bool HasMore);
/// <summary>
/// Request to create a new evidence export.
/// </summary>
public sealed record CreateEvidenceExportRequest(
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors = null,
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
/// <summary>
/// Response from creating an evidence export.
/// </summary>
public sealed record CreateEvidenceExportResponse(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("statusUrl")] string StatusUrl,
[property: JsonPropertyName("estimatedCompletionSeconds")] int? EstimatedCompletionSeconds);
/// <summary>
/// Status of an evidence export.
/// </summary>
public sealed record EvidenceExportStatus(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("progress")] int Progress,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
[property: JsonPropertyName("bundleHash")] string? BundleHash,
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl,
[property: JsonPropertyName("errorCode")] string? ErrorCode,
[property: JsonPropertyName("errorMessage")] string? ErrorMessage);
/// <summary>
/// Request to create a new attestation export.
/// </summary>
public sealed record CreateAttestationExportRequest(
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors = null,
[property: JsonPropertyName("includeTransparencyLog")] bool IncludeTransparencyLog = true,
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
/// <summary>
/// Response from creating an attestation export.
/// </summary>
public sealed record CreateAttestationExportResponse(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("statusUrl")] string StatusUrl,
[property: JsonPropertyName("estimatedCompletionSeconds")] int? EstimatedCompletionSeconds);
/// <summary>
/// Status of an attestation export.
/// </summary>
public sealed record AttestationExportStatus(
[property: JsonPropertyName("runId")] string RunId,
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("progress")] int Progress,
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
[property: JsonPropertyName("bundleHash")] string? BundleHash,
[property: JsonPropertyName("downloadUrl")] string? DownloadUrl,
[property: JsonPropertyName("transparencyLogIncluded")] bool TransparencyLogIncluded,
[property: JsonPropertyName("errorCode")] string? ErrorCode,
[property: JsonPropertyName("errorMessage")] string? ErrorMessage);
/// <summary>
/// OpenAPI discovery metadata.
/// </summary>
public sealed record OpenApiDiscoveryMetadata(
[property: JsonPropertyName("service")] string Service,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("specVersion")] string SpecVersion,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("jsonUrl")] string? JsonUrl,
[property: JsonPropertyName("errorEnvelopeSchema")] string ErrorEnvelopeSchema,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("profilesSupported")] IReadOnlyList<string>? ProfilesSupported,
[property: JsonPropertyName("checksumSha256")] string? ChecksumSha256);
/// <summary>
/// Standard error envelope.
/// </summary>
public sealed record ErrorEnvelope(
[property: JsonPropertyName("error")] ErrorDetail Error);
/// <summary>
/// Error detail within an error envelope.
/// </summary>
public sealed record ErrorDetail(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
[property: JsonPropertyName("details")] IReadOnlyList<ErrorDetailItem>? Details = null);
/// <summary>
/// Individual error detail item.
/// </summary>
public sealed record ErrorDetailItem(
[property: JsonPropertyName("field")] string? Field,
[property: JsonPropertyName("reason")] string Reason);

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<Description>SDK client for StellaOps ExportCenter WebService API</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,175 @@
using System.Security.Cryptography;
namespace StellaOps.ExportCenter.Client.Streaming;
/// <summary>
/// Helper methods for streaming export bundle downloads.
/// </summary>
public static class ExportDownloadHelper
{
private const int DefaultBufferSize = 81920; // 80 KB
/// <summary>
/// Downloads a stream to a file with progress reporting.
/// </summary>
/// <param name="stream">Source stream.</param>
/// <param name="outputPath">Destination file path.</param>
/// <param name="expectedLength">Expected content length (if known).</param>
/// <param name="progressCallback">Progress callback (bytes downloaded, total bytes or null).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Total bytes downloaded.</returns>
public static async Task<long> DownloadToFileAsync(
Stream stream,
string outputPath,
long? expectedLength = null,
Action<long, long?>? progressCallback = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
await using var fileStream = File.Create(outputPath);
return await CopyWithProgressAsync(stream, fileStream, expectedLength, progressCallback, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Downloads a stream to a file and verifies SHA-256 checksum.
/// </summary>
/// <param name="stream">Source stream.</param>
/// <param name="outputPath">Destination file path.</param>
/// <param name="expectedSha256">Expected SHA-256 hash (hex string).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Actual SHA-256 hash of the downloaded file.</returns>
/// <exception cref="InvalidOperationException">Thrown if checksum doesn't match.</exception>
public static async Task<string> DownloadAndVerifyAsync(
Stream stream,
string outputPath,
string expectedSha256,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedSha256);
using var sha256 = SHA256.Create();
await using var fileStream = File.Create(outputPath);
await using var cryptoStream = new CryptoStream(fileStream, sha256, CryptoStreamMode.Write);
var buffer = new byte[DefaultBufferSize];
int bytesRead;
while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await cryptoStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
}
await cryptoStream.FlushFinalBlockAsync(cancellationToken).ConfigureAwait(false);
var actualHash = Convert.ToHexString(sha256.Hash!).ToLowerInvariant();
var expectedNormalized = expectedSha256.ToLowerInvariant().Replace("sha256:", "");
if (!string.Equals(actualHash, expectedNormalized, StringComparison.Ordinal))
{
// Delete the corrupted file
File.Delete(outputPath);
throw new InvalidOperationException(
$"Checksum verification failed. Expected: {expectedNormalized}, Actual: {actualHash}");
}
return actualHash;
}
/// <summary>
/// Computes SHA-256 hash of a stream.
/// </summary>
/// <param name="stream">Source stream.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>SHA-256 hash as hex string.</returns>
public static async Task<string> ComputeSha256Async(
Stream stream,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Copies a stream with progress reporting.
/// </summary>
/// <param name="source">Source stream.</param>
/// <param name="destination">Destination stream.</param>
/// <param name="expectedLength">Expected content length (if known).</param>
/// <param name="progressCallback">Progress callback (bytes copied, total bytes or null).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Total bytes copied.</returns>
public static async Task<long> CopyWithProgressAsync(
Stream source,
Stream destination,
long? expectedLength = null,
Action<long, long?>? progressCallback = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(destination);
var buffer = new byte[DefaultBufferSize];
long totalBytes = 0;
int bytesRead;
while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
totalBytes += bytesRead;
progressCallback?.Invoke(totalBytes, expectedLength);
}
return totalBytes;
}
/// <summary>
/// Creates a progress callback that logs progress at specified intervals.
/// </summary>
/// <param name="logAction">Action to invoke with progress message.</param>
/// <param name="reportIntervalBytes">Minimum bytes between progress reports (default: 1 MB).</param>
/// <returns>Progress callback action.</returns>
public static Action<long, long?> CreateProgressLogger(
Action<string> logAction,
long reportIntervalBytes = 1_048_576)
{
ArgumentNullException.ThrowIfNull(logAction);
long lastReportedBytes = 0;
return (bytesDownloaded, totalBytes) =>
{
if (bytesDownloaded - lastReportedBytes >= reportIntervalBytes)
{
lastReportedBytes = bytesDownloaded;
var message = totalBytes.HasValue
? $"Downloaded {FormatBytes(bytesDownloaded)} of {FormatBytes(totalBytes.Value)} ({bytesDownloaded * 100 / totalBytes.Value}%)"
: $"Downloaded {FormatBytes(bytesDownloaded)}";
logAction(message);
}
};
}
private static string FormatBytes(long bytes)
{
string[] sizes = ["B", "KB", "MB", "GB", "TB"];
var order = 0;
double len = bytes;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}