up
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke tests for ExportCenterClient with mock HTTP responses.
|
||||
/// </summary>
|
||||
public sealed class ExportCenterClientTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task GetDiscoveryMetadataAsync_ReturnsMetadata()
|
||||
{
|
||||
var expectedMetadata = new OpenApiDiscoveryMetadata(
|
||||
Service: "export-center",
|
||||
Version: "1.0.0",
|
||||
SpecVersion: "3.0.3",
|
||||
Format: "application/yaml",
|
||||
Url: "/openapi/export-center.yaml",
|
||||
JsonUrl: "/openapi/export-center.json",
|
||||
ErrorEnvelopeSchema: "#/components/schemas/ErrorEnvelope",
|
||||
GeneratedAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
ProfilesSupported: new[] { "attestation", "mirror" },
|
||||
ChecksumSha256: null);
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
Assert.Equal("/.well-known/openapi", request.RequestUri!.AbsolutePath);
|
||||
return CreateJsonResponse(expectedMetadata);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var result = await client.GetDiscoveryMetadataAsync();
|
||||
|
||||
Assert.Equal("export-center", result.Service);
|
||||
Assert.Equal("1.0.0", result.Version);
|
||||
Assert.Equal("3.0.3", result.SpecVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListProfilesAsync_ReturnsProfiles()
|
||||
{
|
||||
var expectedResponse = new ExportProfileListResponse(
|
||||
Profiles: new[]
|
||||
{
|
||||
new ExportProfile(
|
||||
ProfileId: "profile-1",
|
||||
Name: "Test Profile",
|
||||
Description: "Test",
|
||||
Adapter: "evidence",
|
||||
Selectors: new Dictionary<string, string> { ["org"] = "test" },
|
||||
OutputFormat: "tar.gz",
|
||||
SigningEnabled: true,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
UpdatedAt: null)
|
||||
},
|
||||
ContinuationToken: null,
|
||||
HasMore: false);
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
Assert.Equal("/v1/exports/profiles", request.RequestUri!.AbsolutePath);
|
||||
return CreateJsonResponse(expectedResponse);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var result = await client.ListProfilesAsync();
|
||||
|
||||
Assert.Single(result.Profiles);
|
||||
Assert.Equal("profile-1", result.Profiles[0].ProfileId);
|
||||
Assert.False(result.HasMore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListProfilesAsync_WithPagination_IncludesParameters()
|
||||
{
|
||||
var expectedResponse = new ExportProfileListResponse([], null, false);
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
var query = request.RequestUri!.Query;
|
||||
Assert.Contains("limit=10", query);
|
||||
Assert.Contains("continuationToken=abc123", query);
|
||||
return CreateJsonResponse(expectedResponse);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
await client.ListProfilesAsync(continuationToken: "abc123", limit: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProfileAsync_WhenNotFound_ReturnsNull()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var result = await client.GetProfileAsync("nonexistent");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEvidenceExportAsync_ReturnsResponse()
|
||||
{
|
||||
var expectedResponse = new CreateEvidenceExportResponse(
|
||||
RunId: "run-123",
|
||||
Status: "pending",
|
||||
StatusUrl: "/v1/exports/evidence/run-123/status",
|
||||
EstimatedCompletionSeconds: 60);
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal("/v1/exports/evidence", request.RequestUri!.AbsolutePath);
|
||||
return CreateJsonResponse(expectedResponse, HttpStatusCode.Accepted);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var request = new CreateEvidenceExportRequest("profile-1");
|
||||
var result = await client.CreateEvidenceExportAsync(request);
|
||||
|
||||
Assert.Equal("run-123", result.RunId);
|
||||
Assert.Equal("pending", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceExportStatusAsync_ReturnsStatus()
|
||||
{
|
||||
var expectedStatus = new EvidenceExportStatus(
|
||||
RunId: "run-123",
|
||||
ProfileId: "profile-1",
|
||||
Status: "completed",
|
||||
Progress: 100,
|
||||
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BundleHash: "sha256:abc123",
|
||||
DownloadUrl: "/v1/exports/evidence/run-123/download",
|
||||
ErrorCode: null,
|
||||
ErrorMessage: null);
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
Assert.Equal("/v1/exports/evidence/run-123/status", request.RequestUri!.AbsolutePath);
|
||||
return CreateJsonResponse(expectedStatus);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var result = await client.GetEvidenceExportStatusAsync("run-123");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("completed", result.Status);
|
||||
Assert.Equal(100, result.Progress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadEvidenceExportAsync_ReturnsStream()
|
||||
{
|
||||
var bundleContent = "test bundle content"u8.ToArray();
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
Assert.Equal("/v1/exports/evidence/run-123/download", request.RequestUri!.AbsolutePath);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(bundleContent)
|
||||
};
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var stream = await client.DownloadEvidenceExportAsync("run-123");
|
||||
|
||||
Assert.NotNull(stream);
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
Assert.Equal(bundleContent, ms.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadEvidenceExportAsync_WhenNotReady_ReturnsNull()
|
||||
{
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.Conflict);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var result = await client.DownloadEvidenceExportAsync("run-123");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationExportAsync_ReturnsResponse()
|
||||
{
|
||||
var expectedResponse = new CreateAttestationExportResponse(
|
||||
RunId: "att-run-123",
|
||||
Status: "pending",
|
||||
StatusUrl: "/v1/exports/attestations/att-run-123/status",
|
||||
EstimatedCompletionSeconds: 30);
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal("/v1/exports/attestations", request.RequestUri!.AbsolutePath);
|
||||
return CreateJsonResponse(expectedResponse, HttpStatusCode.Accepted);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var request = new CreateAttestationExportRequest("profile-1", IncludeTransparencyLog: true);
|
||||
var result = await client.CreateAttestationExportAsync(request);
|
||||
|
||||
Assert.Equal("att-run-123", result.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationExportStatusAsync_IncludesTransparencyLogField()
|
||||
{
|
||||
var expectedStatus = new AttestationExportStatus(
|
||||
RunId: "att-run-123",
|
||||
ProfileId: "profile-1",
|
||||
Status: "completed",
|
||||
Progress: 100,
|
||||
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-2),
|
||||
CompletedAt: DateTimeOffset.UtcNow,
|
||||
BundleHash: "sha256:def456",
|
||||
DownloadUrl: "/v1/exports/attestations/att-run-123/download",
|
||||
TransparencyLogIncluded: true,
|
||||
ErrorCode: null,
|
||||
ErrorMessage: null);
|
||||
|
||||
var handler = new MockHttpMessageHandler(request =>
|
||||
{
|
||||
return CreateJsonResponse(expectedStatus);
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var result = await client.GetAttestationExportStatusAsync("att-run-123");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.TransparencyLogIncluded);
|
||||
}
|
||||
|
||||
private static ExportCenterClient CreateClient(MockHttpMessageHandler handler)
|
||||
{
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://localhost:5001")
|
||||
};
|
||||
return new ExportCenterClient(httpClient);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse<T>(T content, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(content, JsonOptions);
|
||||
return new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock HTTP message handler for testing.
|
||||
/// </summary>
|
||||
internal sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||
|
||||
public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_handler(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using StellaOps.ExportCenter.Client.Streaming;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Tests;
|
||||
|
||||
public sealed class ExportDownloadHelperTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ExportDownloadHelperTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"export-download-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadToFileAsync_WritesContentToFile()
|
||||
{
|
||||
var content = "test content"u8.ToArray();
|
||||
using var stream = new MemoryStream(content);
|
||||
var outputPath = Path.Combine(_tempDir, "output.bin");
|
||||
|
||||
var bytesWritten = await ExportDownloadHelper.DownloadToFileAsync(stream, outputPath);
|
||||
|
||||
Assert.Equal(content.Length, bytesWritten);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
Assert.Equal(content, await File.ReadAllBytesAsync(outputPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadToFileAsync_ReportsProgress()
|
||||
{
|
||||
var content = new byte[10000];
|
||||
Random.Shared.NextBytes(content);
|
||||
using var stream = new MemoryStream(content);
|
||||
var outputPath = Path.Combine(_tempDir, "progress.bin");
|
||||
var progressReports = new List<(long bytes, long? total)>();
|
||||
|
||||
await ExportDownloadHelper.DownloadToFileAsync(
|
||||
stream, outputPath, content.Length, (b, t) => progressReports.Add((b, t)));
|
||||
|
||||
Assert.NotEmpty(progressReports);
|
||||
Assert.Equal(content.Length, progressReports[^1].bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeSha256Async_ReturnsCorrectHash()
|
||||
{
|
||||
var content = "test content for hashing"u8.ToArray();
|
||||
using var stream = new MemoryStream(content);
|
||||
|
||||
var hash = await ExportDownloadHelper.ComputeSha256Async(stream);
|
||||
|
||||
// Verify it's a valid hex string
|
||||
Assert.Equal(64, hash.Length); // SHA-256 produces 32 bytes = 64 hex chars
|
||||
Assert.All(hash, c => Assert.True(char.IsLetterOrDigit(c)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndVerifyAsync_SucceedsWithCorrectHash()
|
||||
{
|
||||
var content = "deterministic content"u8.ToArray();
|
||||
using var hashStream = new MemoryStream(content);
|
||||
var expectedHash = await ExportDownloadHelper.ComputeSha256Async(hashStream);
|
||||
|
||||
using var downloadStream = new MemoryStream(content);
|
||||
var outputPath = Path.Combine(_tempDir, "verified.bin");
|
||||
|
||||
var actualHash = await ExportDownloadHelper.DownloadAndVerifyAsync(
|
||||
downloadStream, outputPath, expectedHash);
|
||||
|
||||
Assert.Equal(expectedHash, actualHash);
|
||||
Assert.True(File.Exists(outputPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndVerifyAsync_ThrowsOnHashMismatch()
|
||||
{
|
||||
var content = "actual content"u8.ToArray();
|
||||
using var stream = new MemoryStream(content);
|
||||
var outputPath = Path.Combine(_tempDir, "mismatch.bin");
|
||||
var wrongHash = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
ExportDownloadHelper.DownloadAndVerifyAsync(stream, outputPath, wrongHash));
|
||||
|
||||
// Verify file was deleted
|
||||
Assert.False(File.Exists(outputPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAndVerifyAsync_HandlesSha256Prefix()
|
||||
{
|
||||
var content = "prefixed hash test"u8.ToArray();
|
||||
using var hashStream = new MemoryStream(content);
|
||||
var hash = await ExportDownloadHelper.ComputeSha256Async(hashStream);
|
||||
var prefixedHash = "sha256:" + hash;
|
||||
|
||||
using var downloadStream = new MemoryStream(content);
|
||||
var outputPath = Path.Combine(_tempDir, "prefixed.bin");
|
||||
|
||||
var actualHash = await ExportDownloadHelper.DownloadAndVerifyAsync(
|
||||
downloadStream, outputPath, prefixedHash);
|
||||
|
||||
Assert.Equal(hash, actualHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyWithProgressAsync_CopiesCorrectly()
|
||||
{
|
||||
var content = new byte[5000];
|
||||
Random.Shared.NextBytes(content);
|
||||
using var source = new MemoryStream(content);
|
||||
using var destination = new MemoryStream();
|
||||
|
||||
var bytesCopied = await ExportDownloadHelper.CopyWithProgressAsync(source, destination);
|
||||
|
||||
Assert.Equal(content.Length, bytesCopied);
|
||||
Assert.Equal(content, destination.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProgressLogger_ReturnsWorkingCallback()
|
||||
{
|
||||
var messages = new List<string>();
|
||||
var callback = ExportDownloadHelper.CreateProgressLogger(msg => messages.Add(msg), 100);
|
||||
|
||||
// Simulate progress
|
||||
callback(50, 1000); // Should not log (below threshold)
|
||||
callback(150, 1000); // Should log
|
||||
callback(200, 1000); // Should not log (too close to last)
|
||||
callback(300, 1000); // Should log
|
||||
|
||||
Assert.Equal(2, messages.Count);
|
||||
Assert.Contains("150", messages[0]);
|
||||
Assert.Contains("300", messages[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProgressLogger_FormatsWithoutTotalBytes()
|
||||
{
|
||||
var messages = new List<string>();
|
||||
var callback = ExportDownloadHelper.CreateProgressLogger(msg => messages.Add(msg), 100);
|
||||
|
||||
callback(200, null);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.DoesNotContain("%", messages[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProgressLogger_FormatsWithTotalBytes()
|
||||
{
|
||||
var messages = new List<string>();
|
||||
var callback = ExportDownloadHelper.CreateProgressLogger(msg => messages.Add(msg), 100);
|
||||
|
||||
callback(500, 1000);
|
||||
|
||||
Assert.Single(messages);
|
||||
Assert.Contains("%", messages[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using StellaOps.ExportCenter.Client.Lifecycle;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Tests;
|
||||
|
||||
public sealed class ExportJobLifecycleHelperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("completed", true)]
|
||||
[InlineData("failed", true)]
|
||||
[InlineData("cancelled", true)]
|
||||
[InlineData("pending", false)]
|
||||
[InlineData("running", false)]
|
||||
[InlineData("COMPLETED", true)]
|
||||
public void IsTerminalStatus_ReturnsCorrectValue(string status, bool expected)
|
||||
{
|
||||
var result = ExportJobLifecycleHelper.IsTerminalStatus(status);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForEvidenceExportCompletionAsync_ReturnsOnTerminalStatus()
|
||||
{
|
||||
var callCount = 0;
|
||||
var mockClient = new MockExportCenterClient
|
||||
{
|
||||
GetEvidenceExportStatusHandler = runId =>
|
||||
{
|
||||
callCount++;
|
||||
var status = callCount < 3 ? "running" : "completed";
|
||||
return new EvidenceExportStatus(
|
||||
RunId: runId,
|
||||
ProfileId: "profile-1",
|
||||
Status: status,
|
||||
Progress: callCount < 3 ? 50 : 100,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: callCount >= 3 ? DateTimeOffset.UtcNow : null,
|
||||
BundleHash: callCount >= 3 ? "sha256:abc" : null,
|
||||
DownloadUrl: null,
|
||||
ErrorCode: null,
|
||||
ErrorMessage: null);
|
||||
}
|
||||
};
|
||||
|
||||
var result = await ExportJobLifecycleHelper.WaitForEvidenceExportCompletionAsync(
|
||||
mockClient, "run-1", TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal("completed", result.Status);
|
||||
Assert.Equal(100, result.Progress);
|
||||
Assert.Equal(3, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForEvidenceExportCompletionAsync_ThrowsOnNotFound()
|
||||
{
|
||||
var mockClient = new MockExportCenterClient
|
||||
{
|
||||
GetEvidenceExportStatusHandler = _ => null
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
ExportJobLifecycleHelper.WaitForEvidenceExportCompletionAsync(
|
||||
mockClient, "nonexistent", TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttestationExportCompletionAsync_ReturnsOnTerminalStatus()
|
||||
{
|
||||
var callCount = 0;
|
||||
var mockClient = new MockExportCenterClient
|
||||
{
|
||||
GetAttestationExportStatusHandler = runId =>
|
||||
{
|
||||
callCount++;
|
||||
var status = callCount < 2 ? "running" : "completed";
|
||||
return new AttestationExportStatus(
|
||||
RunId: runId,
|
||||
ProfileId: "profile-1",
|
||||
Status: status,
|
||||
Progress: callCount < 2 ? 50 : 100,
|
||||
StartedAt: DateTimeOffset.UtcNow,
|
||||
CompletedAt: callCount >= 2 ? DateTimeOffset.UtcNow : null,
|
||||
BundleHash: callCount >= 2 ? "sha256:abc" : null,
|
||||
DownloadUrl: null,
|
||||
TransparencyLogIncluded: true,
|
||||
ErrorCode: null,
|
||||
ErrorMessage: null);
|
||||
}
|
||||
};
|
||||
|
||||
var result = await ExportJobLifecycleHelper.WaitForAttestationExportCompletionAsync(
|
||||
mockClient, "run-1", TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal("completed", result.Status);
|
||||
Assert.True(result.TransparencyLogIncluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEvidenceExportAndWaitAsync_CreatesAndWaits()
|
||||
{
|
||||
var createCalled = false;
|
||||
var mockClient = new MockExportCenterClient
|
||||
{
|
||||
CreateEvidenceExportHandler = request =>
|
||||
{
|
||||
createCalled = true;
|
||||
return new CreateEvidenceExportResponse("run-1", "pending", "/status", 10);
|
||||
},
|
||||
GetEvidenceExportStatusHandler = runId =>
|
||||
{
|
||||
return new EvidenceExportStatus(
|
||||
runId, "profile-1", "completed", 100,
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
|
||||
"sha256:abc", "/download", null, null);
|
||||
}
|
||||
};
|
||||
|
||||
var result = await ExportJobLifecycleHelper.CreateEvidenceExportAndWaitAsync(
|
||||
mockClient,
|
||||
new CreateEvidenceExportRequest("profile-1"),
|
||||
TimeSpan.FromMilliseconds(10),
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(createCalled);
|
||||
Assert.Equal("completed", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminalStatuses_ContainsExpectedValues()
|
||||
{
|
||||
Assert.Contains("completed", ExportJobLifecycleHelper.TerminalStatuses);
|
||||
Assert.Contains("failed", ExportJobLifecycleHelper.TerminalStatuses);
|
||||
Assert.Contains("cancelled", ExportJobLifecycleHelper.TerminalStatuses);
|
||||
Assert.DoesNotContain("pending", ExportJobLifecycleHelper.TerminalStatuses);
|
||||
Assert.DoesNotContain("running", ExportJobLifecycleHelper.TerminalStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of IExportCenterClient for testing.
|
||||
/// </summary>
|
||||
internal sealed class MockExportCenterClient : IExportCenterClient
|
||||
{
|
||||
public Func<string, EvidenceExportStatus?>? GetEvidenceExportStatusHandler { get; set; }
|
||||
public Func<string, AttestationExportStatus?>? GetAttestationExportStatusHandler { get; set; }
|
||||
public Func<CreateEvidenceExportRequest, CreateEvidenceExportResponse>? CreateEvidenceExportHandler { get; set; }
|
||||
public Func<CreateAttestationExportRequest, CreateAttestationExportResponse>? CreateAttestationExportHandler { get; set; }
|
||||
|
||||
public Task<OpenApiDiscoveryMetadata> GetDiscoveryMetadataAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<ExportProfileListResponse> ListProfilesAsync(string? continuationToken = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<ExportProfile?> GetProfileAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<ExportRunListResponse> ListRunsAsync(string? profileId = null, string? continuationToken = null, int? limit = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<ExportRun?> GetRunAsync(string runId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<CreateEvidenceExportResponse> CreateEvidenceExportAsync(CreateEvidenceExportRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(CreateEvidenceExportHandler?.Invoke(request) ?? throw new NotImplementedException());
|
||||
|
||||
public Task<EvidenceExportStatus?> GetEvidenceExportStatusAsync(string runId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(GetEvidenceExportStatusHandler?.Invoke(runId));
|
||||
|
||||
public Task<Stream?> DownloadEvidenceExportAsync(string runId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<CreateAttestationExportResponse> CreateAttestationExportAsync(CreateAttestationExportRequest request, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(CreateAttestationExportHandler?.Invoke(request) ?? throw new NotImplementedException());
|
||||
|
||||
public Task<AttestationExportStatus?> GetAttestationExportStatusAsync(string runId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(GetAttestationExportStatusHandler?.Invoke(runId));
|
||||
|
||||
public Task<Stream?> DownloadAttestationExportAsync(string runId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Client\StellaOps.ExportCenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"methodDisplay": "classAndMethod"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.AttestationBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic attestation bundle exports for air-gap/offline delivery.
|
||||
/// </summary>
|
||||
public sealed class AttestationBundleBuilder
|
||||
{
|
||||
private const string BundleVersion = "attestation-bundle/v1";
|
||||
private const string DefaultStatementVersion = "v1";
|
||||
private const string DsseEnvelopeFileName = "attestation.dsse.json";
|
||||
private const string StatementFileName = "statement.json";
|
||||
private const string TransparencyFileName = "transparency.ndjson";
|
||||
private const string MetadataFileName = "metadata.json";
|
||||
private const string ChecksumsFileName = "checksums.txt";
|
||||
private const string VerifyScriptFileName = "verify-attestation.sh";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AttestationBundleBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an attestation bundle export from the provided request.
|
||||
/// </summary>
|
||||
public AttestationBundleExportResult Build(AttestationBundleExportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (request.ExportId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Export identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.AttestationId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Attestation identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.TenantId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DsseEnvelopeJson))
|
||||
{
|
||||
throw new ArgumentException("DSSE envelope JSON must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.StatementJson))
|
||||
{
|
||||
throw new ArgumentException("Statement JSON must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Compute hashes for each component
|
||||
var dsseBytes = Encoding.UTF8.GetBytes(request.DsseEnvelopeJson);
|
||||
var dsseSha256 = _cryptoHash.ComputeHashHexForPurpose(dsseBytes, HashPurpose.Content);
|
||||
|
||||
var statementBytes = Encoding.UTF8.GetBytes(request.StatementJson);
|
||||
var statementSha256 = _cryptoHash.ComputeHashHexForPurpose(statementBytes, HashPurpose.Content);
|
||||
|
||||
// Build transparency NDJSON if entries exist
|
||||
string? transparencyNdjson = null;
|
||||
string? transparencySha256 = null;
|
||||
if (request.TransparencyEntries is { Count: > 0 })
|
||||
{
|
||||
var transparencyBuilder = new StringBuilder();
|
||||
foreach (var entry in request.TransparencyEntries.OrderBy(e => e, StringComparer.Ordinal))
|
||||
{
|
||||
transparencyBuilder.AppendLine(entry);
|
||||
}
|
||||
transparencyNdjson = transparencyBuilder.ToString();
|
||||
transparencySha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(transparencyNdjson), HashPurpose.Content);
|
||||
}
|
||||
|
||||
// Build initial metadata (rootHash computed later)
|
||||
var metadata = new AttestationBundleMetadata(
|
||||
BundleVersion,
|
||||
request.ExportId.ToString("D"),
|
||||
request.AttestationId.ToString("D"),
|
||||
request.TenantId.ToString("D"),
|
||||
_timeProvider.GetUtcNow(),
|
||||
string.Empty, // Placeholder, computed after
|
||||
request.SourceUri,
|
||||
request.StatementVersion ?? DefaultStatementVersion,
|
||||
request.SubjectDigests);
|
||||
|
||||
var metadataJson = JsonSerializer.Serialize(metadata, SerializerOptions);
|
||||
var metadataSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(metadataJson), HashPurpose.Content);
|
||||
|
||||
// Build verification script
|
||||
var verifyScript = BuildVerificationScript(request.AttestationId);
|
||||
var verifyScriptSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(verifyScript), HashPurpose.Content);
|
||||
|
||||
// Build checksums (without root hash line yet)
|
||||
var checksums = BuildChecksums(dsseSha256, statementSha256, transparencySha256, metadataSha256);
|
||||
var checksumsSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(checksums), HashPurpose.Content);
|
||||
|
||||
// Compute root hash from all component hashes
|
||||
var hashList = new List<string> { dsseSha256, statementSha256, metadataSha256, checksumsSha256, verifyScriptSha256 };
|
||||
if (transparencySha256 is not null)
|
||||
{
|
||||
hashList.Add(transparencySha256);
|
||||
}
|
||||
var rootHash = ComputeRootHash(hashList);
|
||||
|
||||
// Rebuild metadata with root hash
|
||||
var finalMetadata = metadata with { RootHash = rootHash };
|
||||
var finalMetadataJson = JsonSerializer.Serialize(finalMetadata, SerializerOptions);
|
||||
var finalMetadataSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(finalMetadataJson), HashPurpose.Content);
|
||||
|
||||
// Rebuild checksums with final metadata hash
|
||||
var finalChecksums = BuildChecksums(dsseSha256, statementSha256, transparencySha256, finalMetadataSha256);
|
||||
|
||||
// Create the export archive
|
||||
var exportStream = CreateExportArchive(
|
||||
request.DsseEnvelopeJson,
|
||||
request.StatementJson,
|
||||
transparencyNdjson,
|
||||
finalMetadataJson,
|
||||
finalChecksums,
|
||||
verifyScript);
|
||||
|
||||
exportStream.Position = 0;
|
||||
|
||||
return new AttestationBundleExportResult(
|
||||
finalMetadata,
|
||||
finalMetadataJson,
|
||||
rootHash,
|
||||
exportStream);
|
||||
}
|
||||
|
||||
private string ComputeRootHash(IEnumerable<string> hashes)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var hash in hashes.OrderBy(h => h, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(hash).Append('\0');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
return _cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(string dsseSha256, string statementSha256, string? transparencySha256, string metadataSha256)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Attestation bundle checksums (sha256)");
|
||||
|
||||
// Lexical order
|
||||
builder.Append(dsseSha256).Append(" ").AppendLine(DsseEnvelopeFileName);
|
||||
builder.Append(metadataSha256).Append(" ").AppendLine(MetadataFileName);
|
||||
builder.Append(statementSha256).Append(" ").AppendLine(StatementFileName);
|
||||
|
||||
if (transparencySha256 is not null)
|
||||
{
|
||||
builder.Append(transparencySha256).Append(" ").AppendLine(TransparencyFileName);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildVerificationScript(Guid attestationId)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("# Attestation Bundle Verification Script");
|
||||
builder.AppendLine("# No network access required");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("set -eu");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Verify checksums");
|
||||
builder.AppendLine("echo \"Verifying checksums...\"");
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" sha256sum --check checksums.txt");
|
||||
builder.AppendLine("elif command -v shasum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" shasum -a 256 --check checksums.txt");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" echo \"Error: sha256sum or shasum required\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine("echo \"Checksums verified successfully.\"");
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Verify DSSE envelope");
|
||||
builder.Append("ATTESTATION_ID=\"").Append(attestationId.ToString("D")).AppendLine("\"");
|
||||
builder.AppendLine("DSSE_FILE=\"attestation.dsse.json\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("if command -v stella >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" echo \"Verifying DSSE envelope with stella CLI...\"");
|
||||
builder.AppendLine(" stella attest verify --envelope \"$DSSE_FILE\" --attestation-id \"$ATTESTATION_ID\"");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" echo \"Note: stella CLI not found. Manual DSSE verification recommended.\"");
|
||||
builder.AppendLine(" echo \"Install stella CLI and run: stella attest verify --envelope $DSSE_FILE\"");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine("echo \"Verification complete.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private MemoryStream CreateExportArchive(
|
||||
string dsseEnvelopeJson,
|
||||
string statementJson,
|
||||
string? transparencyNdjson,
|
||||
string metadataJson,
|
||||
string checksums,
|
||||
string verifyScript)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
// Write files in lexical order for determinism
|
||||
WriteTextEntry(tar, DsseEnvelopeFileName, dsseEnvelopeJson, DefaultFileMode);
|
||||
WriteTextEntry(tar, ChecksumsFileName, checksums, DefaultFileMode);
|
||||
WriteTextEntry(tar, MetadataFileName, metadataJson, DefaultFileMode);
|
||||
WriteTextEntry(tar, StatementFileName, statementJson, DefaultFileMode);
|
||||
|
||||
if (transparencyNdjson is not null)
|
||||
{
|
||||
WriteTextEntry(tar, TransparencyFileName, transparencyNdjson, DefaultFileMode);
|
||||
}
|
||||
|
||||
WriteTextEntry(tar, VerifyScriptFileName, verifyScript, ExecutableFileMode);
|
||||
}
|
||||
|
||||
ApplyDeterministicGzipHeader(stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var dataStream = new MemoryStream(bytes);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for attestation bundle export.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.AttestationBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an attestation bundle export.
|
||||
/// </summary>
|
||||
public sealed record AttestationBundleExportRequest(
|
||||
Guid ExportId,
|
||||
Guid AttestationId,
|
||||
Guid TenantId,
|
||||
string DsseEnvelopeJson,
|
||||
string StatementJson,
|
||||
IReadOnlyList<string>? TransparencyEntries = null,
|
||||
IReadOnlyList<AttestationSubjectDigest>? SubjectDigests = null,
|
||||
string? SourceUri = null,
|
||||
string? StatementVersion = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of building an attestation bundle export.
|
||||
/// </summary>
|
||||
public sealed record AttestationBundleExportResult(
|
||||
AttestationBundleMetadata Metadata,
|
||||
string MetadataJson,
|
||||
string RootHash,
|
||||
MemoryStream ExportStream);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata document for attestation bundle exports.
|
||||
/// </summary>
|
||||
public sealed record AttestationBundleMetadata(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("attestationId")] string AttestationId,
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("createdAtUtc")] DateTimeOffset CreatedAtUtc,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("sourceUri")] string? SourceUri,
|
||||
[property: JsonPropertyName("statementVersion")] string StatementVersion,
|
||||
[property: JsonPropertyName("subjectDigests")] IReadOnlyList<AttestationSubjectDigest>? SubjectDigests);
|
||||
|
||||
/// <summary>
|
||||
/// Subject digest entry for attestation bundles.
|
||||
/// </summary>
|
||||
public sealed record AttestationSubjectDigest(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm);
|
||||
|
||||
/// <summary>
|
||||
/// Export status for attestation bundles.
|
||||
/// </summary>
|
||||
public enum AttestationBundleExportStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Packaging = 2,
|
||||
Ready = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status response for attestation bundle export.
|
||||
/// </summary>
|
||||
public sealed record AttestationBundleExportStatusResponse(
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("attestationId")] string AttestationId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("rootHash")] string? RootHash,
|
||||
[property: JsonPropertyName("downloadUri")] string? DownloadUri,
|
||||
[property: JsonPropertyName("attestationDigests")] IReadOnlyList<AttestationSubjectDigest>? AttestationDigests,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,550 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.BootstrapPack;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic bootstrap packs for air-gap deployment containing Helm charts and container images.
|
||||
/// </summary>
|
||||
public sealed class BootstrapPackBuilder
|
||||
{
|
||||
private const string ManifestVersion = "bootstrap/v1";
|
||||
private const int OciSchemaVersion = 2;
|
||||
private const string OciImageIndexMediaType = "application/vnd.oci.image.index.v1+json";
|
||||
private const string OciImageLayoutVersion = "1.0.0";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public BootstrapPackBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a bootstrap pack from the provided request.
|
||||
/// </summary>
|
||||
public BootstrapPackBuildResult Build(BootstrapPackBuildRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (request.ExportId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Export identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.TenantId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if ((request.Charts is null || request.Charts.Count == 0) &&
|
||||
(request.Images is null || request.Images.Count == 0))
|
||||
{
|
||||
throw new ArgumentException("At least one chart or image must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Collect and validate charts
|
||||
var chartEntries = CollectCharts(request.Charts, cancellationToken);
|
||||
|
||||
// Collect and validate images
|
||||
var imageEntries = CollectImages(request.Images, cancellationToken);
|
||||
|
||||
// Build manifest
|
||||
var rootHash = ComputeRootHash(chartEntries, imageEntries);
|
||||
var manifest = BuildManifest(request, chartEntries, imageEntries, rootHash);
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
|
||||
// Build OCI index
|
||||
var ociIndex = BuildOciIndex(imageEntries);
|
||||
var ociIndexJson = JsonSerializer.Serialize(ociIndex, SerializerOptions);
|
||||
|
||||
// Build OCI layout marker
|
||||
var ociLayout = new OciImageLayout(OciImageLayoutVersion);
|
||||
var ociLayoutJson = JsonSerializer.Serialize(ociLayout, SerializerOptions);
|
||||
|
||||
// Build checksums
|
||||
var checksums = BuildChecksums(chartEntries, imageEntries, rootHash);
|
||||
|
||||
// Create the pack archive
|
||||
var packStream = CreatePackArchive(
|
||||
request,
|
||||
chartEntries,
|
||||
imageEntries,
|
||||
manifestJson,
|
||||
ociIndexJson,
|
||||
ociLayoutJson,
|
||||
checksums);
|
||||
|
||||
// Compute final artifact SHA-256
|
||||
packStream.Position = 0;
|
||||
var artifactSha256 = ComputeStreamHash(packStream);
|
||||
packStream.Position = 0;
|
||||
|
||||
return new BootstrapPackBuildResult(
|
||||
manifest,
|
||||
manifestJson,
|
||||
rootHash,
|
||||
artifactSha256,
|
||||
packStream);
|
||||
}
|
||||
|
||||
private List<CollectedChart> CollectCharts(
|
||||
IReadOnlyList<BootstrapPackChartSource>? charts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<CollectedChart>();
|
||||
|
||||
if (charts is null || charts.Count == 0)
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
foreach (var chart in charts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (chart is null)
|
||||
{
|
||||
throw new ArgumentException("Chart sources cannot contain null entries.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chart.ChartPath))
|
||||
{
|
||||
throw new ArgumentException($"Chart path cannot be empty for chart '{chart.Name}'.");
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(chart.ChartPath);
|
||||
if (!File.Exists(fullPath) && !Directory.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Chart path '{fullPath}' not found.", fullPath);
|
||||
}
|
||||
|
||||
string sha256;
|
||||
long size;
|
||||
|
||||
if (Directory.Exists(fullPath))
|
||||
{
|
||||
// For directories, compute combined hash
|
||||
sha256 = ComputeDirectoryHash(fullPath, cancellationToken);
|
||||
size = GetDirectorySize(fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fileBytes = File.ReadAllBytes(fullPath);
|
||||
sha256 = _cryptoHash.ComputeHashHexForPurpose(fileBytes, HashPurpose.Content);
|
||||
size = fileBytes.LongLength;
|
||||
}
|
||||
|
||||
var bundlePath = $"charts/{SanitizeSegment(chart.Name)}-{SanitizeSegment(chart.Version)}";
|
||||
|
||||
entries.Add(new CollectedChart(
|
||||
chart.Name,
|
||||
chart.Version,
|
||||
bundlePath,
|
||||
fullPath,
|
||||
sha256,
|
||||
size));
|
||||
}
|
||||
|
||||
// Sort for deterministic ordering
|
||||
entries.Sort((a, b) => StringComparer.Ordinal.Compare(a.BundlePath, b.BundlePath));
|
||||
return entries;
|
||||
}
|
||||
|
||||
private List<CollectedImage> CollectImages(
|
||||
IReadOnlyList<BootstrapPackImageSource>? images,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<CollectedImage>();
|
||||
|
||||
if (images is null || images.Count == 0)
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (image is null)
|
||||
{
|
||||
throw new ArgumentException("Image sources cannot contain null entries.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(image.BlobPath))
|
||||
{
|
||||
throw new ArgumentException($"Blob path cannot be empty for image '{image.Repository}:{image.Tag}'.");
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(image.BlobPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Image blob path '{fullPath}' not found.", fullPath);
|
||||
}
|
||||
|
||||
var fileBytes = File.ReadAllBytes(fullPath);
|
||||
var sha256 = _cryptoHash.ComputeHashHexForPurpose(fileBytes, HashPurpose.Content);
|
||||
|
||||
var repoSegment = SanitizeSegment(image.Repository.Replace("/", "-").Replace(":", "-"));
|
||||
var bundlePath = $"images/blobs/sha256/{sha256}";
|
||||
|
||||
entries.Add(new CollectedImage(
|
||||
image.Repository,
|
||||
image.Tag,
|
||||
image.Digest,
|
||||
bundlePath,
|
||||
fullPath,
|
||||
sha256,
|
||||
fileBytes.LongLength));
|
||||
}
|
||||
|
||||
// Sort for deterministic ordering
|
||||
entries.Sort((a, b) => StringComparer.Ordinal.Compare(a.BundlePath, b.BundlePath));
|
||||
return entries;
|
||||
}
|
||||
|
||||
private string ComputeRootHash(
|
||||
IReadOnlyList<CollectedChart> charts,
|
||||
IReadOnlyList<CollectedImage> images)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var chart in charts.OrderBy(c => c.BundlePath, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(chart.BundlePath)
|
||||
.Append('\0')
|
||||
.Append(chart.Sha256)
|
||||
.Append('\0');
|
||||
}
|
||||
|
||||
foreach (var image in images.OrderBy(i => i.BundlePath, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(image.BundlePath)
|
||||
.Append('\0')
|
||||
.Append(image.Sha256)
|
||||
.Append('\0');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
return _cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
}
|
||||
|
||||
private BootstrapPackManifest BuildManifest(
|
||||
BootstrapPackBuildRequest request,
|
||||
IReadOnlyList<CollectedChart> charts,
|
||||
IReadOnlyList<CollectedImage> images,
|
||||
string rootHash)
|
||||
{
|
||||
var chartEntries = charts.Select(c => new BootstrapPackChartEntry(
|
||||
c.Name,
|
||||
c.Version,
|
||||
c.BundlePath,
|
||||
c.Sha256)).ToList();
|
||||
|
||||
var imageEntries = images.Select(i => new BootstrapPackImageEntry(
|
||||
i.Repository,
|
||||
i.Tag,
|
||||
i.Digest,
|
||||
i.BundlePath,
|
||||
i.Sha256)).ToList();
|
||||
|
||||
BootstrapPackSignatureEntry? sigEntry = null;
|
||||
if (request.Signatures is not null)
|
||||
{
|
||||
sigEntry = new BootstrapPackSignatureEntry(
|
||||
request.Signatures.MirrorBundleDigest,
|
||||
request.Signatures.SignaturePath);
|
||||
}
|
||||
|
||||
return new BootstrapPackManifest(
|
||||
ManifestVersion,
|
||||
request.ExportId.ToString("D"),
|
||||
request.TenantId.ToString("D"),
|
||||
_timeProvider.GetUtcNow(),
|
||||
chartEntries,
|
||||
imageEntries,
|
||||
sigEntry,
|
||||
rootHash);
|
||||
}
|
||||
|
||||
private static OciImageIndex BuildOciIndex(IReadOnlyList<CollectedImage> images)
|
||||
{
|
||||
var manifests = images.Select(i => new OciImageIndexManifest(
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
i.Size,
|
||||
i.Digest,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["org.opencontainers.image.ref.name"] = $"{i.Repository}:{i.Tag}"
|
||||
})).ToList();
|
||||
|
||||
return new OciImageIndex(OciSchemaVersion, OciImageIndexMediaType, manifests);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(
|
||||
IReadOnlyList<CollectedChart> charts,
|
||||
IReadOnlyList<CollectedImage> images,
|
||||
string rootHash)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Bootstrap pack checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
|
||||
foreach (var chart in charts)
|
||||
{
|
||||
builder.Append(chart.Sha256).Append(" ").AppendLine(chart.BundlePath);
|
||||
}
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
builder.Append(image.Sha256).Append(" ").AppendLine(image.BundlePath);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private MemoryStream CreatePackArchive(
|
||||
BootstrapPackBuildRequest request,
|
||||
IReadOnlyList<CollectedChart> charts,
|
||||
IReadOnlyList<CollectedImage> images,
|
||||
string manifestJson,
|
||||
string ociIndexJson,
|
||||
string ociLayoutJson,
|
||||
string checksums)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
// Write metadata files
|
||||
WriteTextEntry(tar, "manifest.json", manifestJson);
|
||||
WriteTextEntry(tar, "checksums.txt", checksums);
|
||||
|
||||
// Write OCI layout files for images directory
|
||||
if (images.Count > 0)
|
||||
{
|
||||
WriteTextEntry(tar, "images/oci-layout", ociLayoutJson);
|
||||
WriteTextEntry(tar, "images/index.json", ociIndexJson);
|
||||
}
|
||||
|
||||
// Write chart files
|
||||
foreach (var chart in charts)
|
||||
{
|
||||
if (Directory.Exists(chart.SourcePath))
|
||||
{
|
||||
WriteDirectoryEntries(tar, chart.BundlePath, chart.SourcePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteFileEntry(tar, chart.BundlePath, chart.SourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Write image blobs
|
||||
foreach (var image in images)
|
||||
{
|
||||
WriteFileEntry(tar, image.BundlePath, image.SourcePath);
|
||||
}
|
||||
|
||||
// Write signature reference if provided
|
||||
if (request.Signatures?.SignaturePath is not null && File.Exists(request.Signatures.SignaturePath))
|
||||
{
|
||||
WriteFileEntry(tar, "signatures/mirror-bundle.sig", request.Signatures.SignaturePath);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyDeterministicGzipHeader(stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var dataStream = new MemoryStream(bytes);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = DefaultFileMode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void WriteFileEntry(TarWriter writer, string bundlePath, string sourcePath)
|
||||
{
|
||||
using var dataStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.SequentialScan);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, bundlePath)
|
||||
{
|
||||
Mode = DefaultFileMode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void WriteDirectoryEntries(TarWriter writer, string bundlePrefix, string sourceDir)
|
||||
{
|
||||
var files = Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories);
|
||||
Array.Sort(files, StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var relative = Path.GetRelativePath(sourceDir, file).Replace('\\', '/');
|
||||
var bundlePath = $"{bundlePrefix}/{relative}";
|
||||
|
||||
using var dataStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.SequentialScan);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, bundlePath)
|
||||
{
|
||||
Mode = DefaultFileMode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for bootstrap pack.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private string ComputeStreamHash(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(stream);
|
||||
return ToHex(hash);
|
||||
}
|
||||
|
||||
private string ComputeDirectoryHash(string directory, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var files = Directory.GetFiles(directory, "*", SearchOption.AllDirectories);
|
||||
Array.Sort(files, StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var relative = Path.GetRelativePath(directory, file).Replace('\\', '/');
|
||||
var fileBytes = File.ReadAllBytes(file);
|
||||
var fileHash = _cryptoHash.ComputeHashHexForPurpose(fileBytes, HashPurpose.Content);
|
||||
builder.Append(relative).Append('\0').Append(fileHash).Append('\0');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
return _cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
}
|
||||
|
||||
private static long GetDirectorySize(string directory)
|
||||
{
|
||||
return Directory.GetFiles(directory, "*", SearchOption.AllDirectories)
|
||||
.Sum(file => new FileInfo(file).Length);
|
||||
}
|
||||
|
||||
private static string ToHex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hex = stackalloc byte[bytes.Length * 2];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
var b = bytes[i];
|
||||
hex[i * 2] = GetHexValue(b / 16);
|
||||
hex[i * 2 + 1] = GetHexValue(b % 16);
|
||||
}
|
||||
return Encoding.ASCII.GetString(hex);
|
||||
}
|
||||
|
||||
private static byte GetHexValue(int i) => (byte)(i < 10 ? i + 48 : i - 10 + 97);
|
||||
|
||||
private static string SanitizeSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var span = value.Trim();
|
||||
var builder = new StringBuilder(span.Length);
|
||||
|
||||
foreach (var ch in span)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
else if (ch is '-' or '_' or '.')
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "unknown" : builder.ToString();
|
||||
}
|
||||
|
||||
private sealed record CollectedChart(
|
||||
string Name,
|
||||
string Version,
|
||||
string BundlePath,
|
||||
string SourcePath,
|
||||
string Sha256,
|
||||
long Size);
|
||||
|
||||
private sealed record CollectedImage(
|
||||
string Repository,
|
||||
string Tag,
|
||||
string Digest,
|
||||
string BundlePath,
|
||||
string SourcePath,
|
||||
string Sha256,
|
||||
long Size);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.BootstrapPack;
|
||||
|
||||
/// <summary>
|
||||
/// Request to build a bootstrap pack for air-gap deployment.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackBuildRequest(
|
||||
Guid ExportId,
|
||||
Guid TenantId,
|
||||
IReadOnlyList<BootstrapPackChartSource> Charts,
|
||||
IReadOnlyList<BootstrapPackImageSource> Images,
|
||||
BootstrapPackSignatureSource? Signatures = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Helm chart source for the bootstrap pack.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackChartSource(
|
||||
string Name,
|
||||
string Version,
|
||||
string ChartPath);
|
||||
|
||||
/// <summary>
|
||||
/// Container image source for the bootstrap pack.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackImageSource(
|
||||
string Repository,
|
||||
string Tag,
|
||||
string Digest,
|
||||
string BlobPath);
|
||||
|
||||
/// <summary>
|
||||
/// Optional DSSE/TUF signature source from upstream builds.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackSignatureSource(
|
||||
string MirrorBundleDigest,
|
||||
string? SignaturePath);
|
||||
|
||||
/// <summary>
|
||||
/// Result of building a bootstrap pack.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackBuildResult(
|
||||
BootstrapPackManifest Manifest,
|
||||
string ManifestJson,
|
||||
string RootHash,
|
||||
string ArtifactSha256,
|
||||
MemoryStream PackStream);
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for the bootstrap pack.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackManifest(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("charts")] IReadOnlyList<BootstrapPackChartEntry> Charts,
|
||||
[property: JsonPropertyName("images")] IReadOnlyList<BootstrapPackImageEntry> Images,
|
||||
[property: JsonPropertyName("signatures")] BootstrapPackSignatureEntry? Signatures,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash);
|
||||
|
||||
/// <summary>
|
||||
/// Chart entry in the manifest.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackChartEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("sha256")] string Sha256);
|
||||
|
||||
/// <summary>
|
||||
/// Image entry in the manifest.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackImageEntry(
|
||||
[property: JsonPropertyName("repository")] string Repository,
|
||||
[property: JsonPropertyName("tag")] string Tag,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("sha256")] string Sha256);
|
||||
|
||||
/// <summary>
|
||||
/// Signature metadata entry.
|
||||
/// </summary>
|
||||
public sealed record BootstrapPackSignatureEntry(
|
||||
[property: JsonPropertyName("mirrorBundleDigest")] string MirrorBundleDigest,
|
||||
[property: JsonPropertyName("signaturePath")] string? SignaturePath);
|
||||
|
||||
/// <summary>
|
||||
/// OCI image index (index.json) structure.
|
||||
/// </summary>
|
||||
public sealed record OciImageIndex(
|
||||
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
|
||||
[property: JsonPropertyName("mediaType")] string MediaType,
|
||||
[property: JsonPropertyName("manifests")] IReadOnlyList<OciImageIndexManifest> Manifests);
|
||||
|
||||
/// <summary>
|
||||
/// Manifest entry within the OCI image index.
|
||||
/// </summary>
|
||||
public sealed record OciImageIndexManifest(
|
||||
[property: JsonPropertyName("mediaType")] string MediaType,
|
||||
[property: JsonPropertyName("size")] long Size,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string>? Annotations);
|
||||
|
||||
/// <summary>
|
||||
/// OCI image layout marker (oci-layout).
|
||||
/// </summary>
|
||||
public sealed record OciImageLayout(
|
||||
[property: JsonPropertyName("imageLayoutVersion")] string ImageLayoutVersion);
|
||||
@@ -0,0 +1,611 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic mirror bundles for air-gapped export with DSSE/TUF metadata.
|
||||
/// </summary>
|
||||
public sealed class MirrorBundleBuilder
|
||||
{
|
||||
private const string ManifestVersion = "mirror/v1";
|
||||
private const string ExporterVersion = "1.0.0";
|
||||
private const string AdapterVersion = "mirror-adapter/1.0.0";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public MirrorBundleBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a mirror bundle from the provided request.
|
||||
/// </summary>
|
||||
public MirrorBundleBuildResult Build(MirrorBundleBuildRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (request.RunId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Run identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.TenantId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.Variant == MirrorBundleVariant.Delta && request.DeltaOptions is null)
|
||||
{
|
||||
throw new ArgumentException("Delta options must be provided for delta bundles.", nameof(request));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Collect and validate data sources
|
||||
var collectedFiles = CollectDataSources(request.DataSources, cancellationToken);
|
||||
if (collectedFiles.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Mirror bundle does not contain any data files. Provide at least one data source.");
|
||||
}
|
||||
|
||||
// Build artifact entries
|
||||
var artifacts = BuildArtifactEntries(collectedFiles);
|
||||
|
||||
// Compute counts
|
||||
var counts = ComputeCounts(collectedFiles);
|
||||
|
||||
// Build manifest
|
||||
var manifest = BuildManifest(request, artifacts, counts);
|
||||
var manifestYaml = SerializeManifestToYaml(manifest);
|
||||
var manifestDigest = ComputeHash(manifestYaml);
|
||||
|
||||
// Build export document
|
||||
var exportDoc = BuildExportDocument(request, manifest, manifestDigest);
|
||||
var exportJson = JsonSerializer.Serialize(exportDoc, SerializerOptions);
|
||||
|
||||
// Build provenance document
|
||||
var provenanceDoc = BuildProvenanceDocument(request, manifest, manifestDigest, collectedFiles);
|
||||
var provenanceJson = JsonSerializer.Serialize(provenanceDoc, SerializerOptions);
|
||||
|
||||
// Compute root hash from export document
|
||||
var rootHash = ComputeHash(exportJson);
|
||||
|
||||
// Build checksums file
|
||||
var checksums = BuildChecksums(rootHash, collectedFiles, manifestDigest);
|
||||
|
||||
// Build README
|
||||
var readme = BuildReadme(manifest);
|
||||
|
||||
// Build verification script
|
||||
var verifyScript = BuildVerificationScript();
|
||||
|
||||
// Create the bundle archive
|
||||
var bundleStream = CreateBundleArchive(
|
||||
collectedFiles,
|
||||
manifestYaml,
|
||||
exportJson,
|
||||
provenanceJson,
|
||||
checksums,
|
||||
readme,
|
||||
verifyScript,
|
||||
request.Variant);
|
||||
|
||||
bundleStream.Position = 0;
|
||||
|
||||
return new MirrorBundleBuildResult(
|
||||
manifest,
|
||||
manifestYaml,
|
||||
exportDoc,
|
||||
exportJson,
|
||||
provenanceDoc,
|
||||
provenanceJson,
|
||||
rootHash,
|
||||
bundleStream);
|
||||
}
|
||||
|
||||
private List<CollectedFile> CollectDataSources(
|
||||
IReadOnlyList<MirrorBundleDataSource> dataSources,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var files = new List<CollectedFile>();
|
||||
|
||||
foreach (var source in dataSources)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (source is null)
|
||||
{
|
||||
throw new ArgumentException("Data sources cannot contain null entries.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(source.SourcePath))
|
||||
{
|
||||
throw new ArgumentException("Source path cannot be empty.");
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(source.SourcePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Data source file '{fullPath}' not found.", fullPath);
|
||||
}
|
||||
|
||||
var bundlePath = ComputeBundlePath(source);
|
||||
var fileBytes = File.ReadAllBytes(fullPath);
|
||||
var sha256 = _cryptoHash.ComputeHashHexForPurpose(fileBytes, HashPurpose.Content);
|
||||
|
||||
files.Add(new CollectedFile(
|
||||
source.Category,
|
||||
bundlePath,
|
||||
fullPath,
|
||||
fileBytes.LongLength,
|
||||
sha256,
|
||||
source.IsNormalized,
|
||||
source.SubjectId));
|
||||
}
|
||||
|
||||
// Sort for deterministic ordering
|
||||
files.Sort((a, b) => StringComparer.Ordinal.Compare(a.BundlePath, b.BundlePath));
|
||||
return files;
|
||||
}
|
||||
|
||||
private static string ComputeBundlePath(MirrorBundleDataSource source)
|
||||
{
|
||||
var fileName = Path.GetFileName(source.SourcePath);
|
||||
var prefix = source.IsNormalized ? "data/normalized" : "data/raw";
|
||||
|
||||
return source.Category switch
|
||||
{
|
||||
MirrorBundleDataCategory.Advisories => $"{prefix}/advisories/{fileName}",
|
||||
MirrorBundleDataCategory.Vex => $"{prefix}/vex/{fileName}",
|
||||
MirrorBundleDataCategory.Sbom when !string.IsNullOrEmpty(source.SubjectId) =>
|
||||
$"data/raw/sboms/{SanitizeSegment(source.SubjectId)}/{fileName}",
|
||||
MirrorBundleDataCategory.Sbom => $"data/raw/sboms/{fileName}",
|
||||
MirrorBundleDataCategory.PolicySnapshot => $"data/policy/snapshot.json",
|
||||
MirrorBundleDataCategory.PolicyEvaluations => $"data/policy/{fileName}",
|
||||
MirrorBundleDataCategory.VexConsensus => $"data/consensus/{fileName}",
|
||||
MirrorBundleDataCategory.Findings => $"data/findings/{fileName}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(source), $"Unknown data category: {source.Category}")
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MirrorBundleArtifactEntry> BuildArtifactEntries(IReadOnlyList<CollectedFile> files)
|
||||
{
|
||||
return files.Select(f => new MirrorBundleArtifactEntry(
|
||||
f.BundlePath,
|
||||
f.Sha256,
|
||||
f.SizeBytes,
|
||||
f.Category.ToString().ToLowerInvariant())).ToList();
|
||||
}
|
||||
|
||||
private static MirrorBundleManifestCounts ComputeCounts(IReadOnlyList<CollectedFile> files)
|
||||
{
|
||||
var advisories = files.Count(f => f.Category == MirrorBundleDataCategory.Advisories);
|
||||
var vex = files.Count(f => f.Category is MirrorBundleDataCategory.Vex or MirrorBundleDataCategory.VexConsensus);
|
||||
var sboms = files.Count(f => f.Category == MirrorBundleDataCategory.Sbom);
|
||||
var policyEvals = files.Count(f => f.Category == MirrorBundleDataCategory.PolicyEvaluations);
|
||||
|
||||
return new MirrorBundleManifestCounts(advisories, vex, sboms, policyEvals);
|
||||
}
|
||||
|
||||
private MirrorBundleManifest BuildManifest(
|
||||
MirrorBundleBuildRequest request,
|
||||
IReadOnlyList<MirrorBundleArtifactEntry> artifacts,
|
||||
MirrorBundleManifestCounts counts)
|
||||
{
|
||||
var profile = request.Variant == MirrorBundleVariant.Full ? "mirror:full" : "mirror:delta";
|
||||
|
||||
var selectors = new MirrorBundleManifestSelectors(
|
||||
request.Selectors.Products,
|
||||
request.Selectors.TimeWindowFrom.HasValue && request.Selectors.TimeWindowTo.HasValue
|
||||
? new MirrorBundleTimeWindow(request.Selectors.TimeWindowFrom.Value, request.Selectors.TimeWindowTo.Value)
|
||||
: null,
|
||||
request.Selectors.Ecosystems);
|
||||
|
||||
MirrorBundleManifestEncryption? encryption = null;
|
||||
if (request.Encryption is not null && request.Encryption.Mode != MirrorBundleEncryptionMode.None)
|
||||
{
|
||||
encryption = new MirrorBundleManifestEncryption(
|
||||
request.Encryption.Mode.ToString().ToLowerInvariant(),
|
||||
request.Encryption.Strict,
|
||||
request.Encryption.RecipientKeys);
|
||||
}
|
||||
|
||||
MirrorBundleManifestDelta? delta = null;
|
||||
if (request.Variant == MirrorBundleVariant.Delta && request.DeltaOptions is not null)
|
||||
{
|
||||
delta = new MirrorBundleManifestDelta(
|
||||
request.DeltaOptions.BaseExportId,
|
||||
request.DeltaOptions.BaseManifestDigest,
|
||||
request.DeltaOptions.ResetBaseline,
|
||||
new MirrorBundleDeltaCounts(0, 0, 0), // TODO: Compute actual delta counts
|
||||
new MirrorBundleDeltaCounts(0, 0, 0),
|
||||
new MirrorBundleDeltaCounts(0, 0, 0));
|
||||
}
|
||||
|
||||
return new MirrorBundleManifest(
|
||||
profile,
|
||||
request.RunId.ToString("D"),
|
||||
request.TenantId.ToString("D"),
|
||||
selectors,
|
||||
counts,
|
||||
artifacts,
|
||||
encryption,
|
||||
delta);
|
||||
}
|
||||
|
||||
private MirrorBundleExportDocument BuildExportDocument(
|
||||
MirrorBundleBuildRequest request,
|
||||
MirrorBundleManifest manifest,
|
||||
string manifestDigest)
|
||||
{
|
||||
return new MirrorBundleExportDocument(
|
||||
ManifestVersion,
|
||||
manifest.RunId,
|
||||
manifest.Tenant,
|
||||
new MirrorBundleExportProfile("mirror", request.Variant.ToString().ToLowerInvariant()),
|
||||
manifest.Selectors,
|
||||
manifest.Counts,
|
||||
manifest.Artifacts,
|
||||
_timeProvider.GetUtcNow(),
|
||||
$"sha256:{manifestDigest}");
|
||||
}
|
||||
|
||||
private MirrorBundleProvenanceDocument BuildProvenanceDocument(
|
||||
MirrorBundleBuildRequest request,
|
||||
MirrorBundleManifest manifest,
|
||||
string manifestDigest,
|
||||
IReadOnlyList<CollectedFile> files)
|
||||
{
|
||||
var subjects = new List<MirrorBundleProvenanceSubject>
|
||||
{
|
||||
new("manifest.yaml", new Dictionary<string, string> { ["sha256"] = manifestDigest })
|
||||
};
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
subjects.Add(new MirrorBundleProvenanceSubject(
|
||||
file.BundlePath,
|
||||
new Dictionary<string, string> { ["sha256"] = file.Sha256 }));
|
||||
}
|
||||
|
||||
var sbomIds = files
|
||||
.Where(f => f.Category == MirrorBundleDataCategory.Sbom && !string.IsNullOrEmpty(f.SubjectId))
|
||||
.Select(f => f.SubjectId!)
|
||||
.Distinct()
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var inputs = new MirrorBundleProvenanceInputs(
|
||||
new[] { $"tenant:{manifest.Tenant}" },
|
||||
files.Any(f => f.Category == MirrorBundleDataCategory.PolicySnapshot)
|
||||
? $"snapshot:{manifest.RunId}"
|
||||
: null,
|
||||
sbomIds);
|
||||
|
||||
return new MirrorBundleProvenanceDocument(
|
||||
ManifestVersion,
|
||||
manifest.RunId,
|
||||
manifest.Tenant,
|
||||
subjects,
|
||||
inputs,
|
||||
new MirrorBundleProvenanceBuilder(ExporterVersion, AdapterVersion),
|
||||
_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
return _cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(string rootHash, IReadOnlyList<CollectedFile> files, string manifestDigest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Mirror bundle checksums (sha256)");
|
||||
builder.Append("root ").AppendLine(rootHash);
|
||||
builder.Append(manifestDigest).AppendLine(" manifest.yaml");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
builder.Append(file.Sha256).Append(" ").AppendLine(file.BundlePath);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildReadme(MirrorBundleManifest manifest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Mirror Bundle");
|
||||
builder.AppendLine("=============");
|
||||
builder.Append("Profile: ").AppendLine(manifest.Profile);
|
||||
builder.Append("Run ID: ").AppendLine(manifest.RunId);
|
||||
builder.Append("Tenant: ").AppendLine(manifest.Tenant);
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("Contents:");
|
||||
builder.Append("- Advisories: ").AppendLine(manifest.Counts.Advisories.ToString());
|
||||
builder.Append("- VEX statements: ").AppendLine(manifest.Counts.Vex.ToString());
|
||||
builder.Append("- SBOMs: ").AppendLine(manifest.Counts.Sboms.ToString());
|
||||
builder.Append("- Policy evaluations: ").AppendLine(manifest.Counts.PolicyEvaluations.ToString());
|
||||
builder.AppendLine();
|
||||
|
||||
if (manifest.Delta is not null)
|
||||
{
|
||||
builder.AppendLine("Delta Information:");
|
||||
builder.Append("- Base export: ").AppendLine(manifest.Delta.BaseExportId);
|
||||
builder.Append("- Reset baseline: ").AppendLine(manifest.Delta.ResetBaseline ? "yes" : "no");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
if (manifest.Encryption is not null)
|
||||
{
|
||||
builder.AppendLine("Encryption:");
|
||||
builder.Append("- Mode: ").AppendLine(manifest.Encryption.Mode);
|
||||
builder.Append("- Strict: ").AppendLine(manifest.Encryption.Strict ? "yes" : "no");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine("Verification:");
|
||||
builder.AppendLine("1. Transfer the archive to the target environment.");
|
||||
builder.AppendLine("2. Run `./verify-mirror.sh <bundle.tgz>` to validate checksums.");
|
||||
builder.AppendLine("3. Use `stella export verify <runId>` to verify DSSE signatures.");
|
||||
builder.AppendLine("4. Apply using `stella export mirror-import <bundle.tgz>`.");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildVerificationScript()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("set -euo pipefail");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("ARCHIVE=\"${1:-mirror-bundle.tgz}\"");
|
||||
builder.AppendLine("if [ ! -f \"$ARCHIVE\" ]; then");
|
||||
builder.AppendLine(" echo \"Usage: $0 <mirror-bundle.tgz>\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("WORKDIR=\"$(mktemp -d)\"");
|
||||
builder.AppendLine("cleanup() { rm -rf \"$WORKDIR\"; }");
|
||||
builder.AppendLine("trap cleanup EXIT INT TERM");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("tar -xzf \"$ARCHIVE\" -C \"$WORKDIR\"");
|
||||
builder.AppendLine("echo \"Mirror bundle extracted to $WORKDIR\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("cd \"$WORKDIR\"");
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" sha256sum --check checksums.txt");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" shasum -a 256 --check checksums.txt");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"Checksums verified successfully.\"");
|
||||
builder.AppendLine("echo \"Run 'stella export verify' for signature validation.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string SerializeManifestToYaml(MirrorBundleManifest manifest)
|
||||
{
|
||||
// Serialize to YAML-like format for manifest.yaml
|
||||
// Using JSON for now as the structure is identical
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("profile: ").AppendLine(manifest.Profile);
|
||||
builder.Append("runId: ").AppendLine(manifest.RunId);
|
||||
builder.Append("tenant: ").AppendLine(manifest.Tenant);
|
||||
builder.AppendLine("selectors:");
|
||||
builder.AppendLine(" products:");
|
||||
foreach (var product in manifest.Selectors.Products)
|
||||
{
|
||||
builder.Append(" - ").AppendLine(product);
|
||||
}
|
||||
|
||||
if (manifest.Selectors.TimeWindow is not null)
|
||||
{
|
||||
builder.AppendLine(" timeWindow:");
|
||||
builder.Append(" from: ").AppendLine(manifest.Selectors.TimeWindow.From.ToString("O"));
|
||||
builder.Append(" to: ").AppendLine(manifest.Selectors.TimeWindow.To.ToString("O"));
|
||||
}
|
||||
|
||||
builder.AppendLine("counts:");
|
||||
builder.Append(" advisories: ").AppendLine(manifest.Counts.Advisories.ToString());
|
||||
builder.Append(" vex: ").AppendLine(manifest.Counts.Vex.ToString());
|
||||
builder.Append(" sboms: ").AppendLine(manifest.Counts.Sboms.ToString());
|
||||
builder.Append(" policyEvaluations: ").AppendLine(manifest.Counts.PolicyEvaluations.ToString());
|
||||
|
||||
builder.AppendLine("artifacts:");
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
builder.Append(" - path: ").AppendLine(artifact.Path);
|
||||
builder.Append(" sha256: ").AppendLine(artifact.Sha256);
|
||||
builder.Append(" bytes: ").AppendLine(artifact.Bytes.ToString());
|
||||
}
|
||||
|
||||
if (manifest.Encryption is not null)
|
||||
{
|
||||
builder.AppendLine("encryption:");
|
||||
builder.Append(" mode: ").AppendLine(manifest.Encryption.Mode);
|
||||
builder.Append(" strict: ").AppendLine(manifest.Encryption.Strict.ToString().ToLowerInvariant());
|
||||
builder.AppendLine(" recipients:");
|
||||
foreach (var recipient in manifest.Encryption.Recipients)
|
||||
{
|
||||
builder.Append(" - ").AppendLine(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.Delta is not null)
|
||||
{
|
||||
builder.AppendLine("delta:");
|
||||
builder.Append(" baseExportId: ").AppendLine(manifest.Delta.BaseExportId);
|
||||
builder.Append(" baseManifestDigest: ").AppendLine(manifest.Delta.BaseManifestDigest);
|
||||
builder.Append(" resetBaseline: ").AppendLine(manifest.Delta.ResetBaseline.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private MemoryStream CreateBundleArchive(
|
||||
IReadOnlyList<CollectedFile> files,
|
||||
string manifestYaml,
|
||||
string exportJson,
|
||||
string provenanceJson,
|
||||
string checksums,
|
||||
string readme,
|
||||
string verifyScript,
|
||||
MirrorBundleVariant variant)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
// Write metadata files first
|
||||
WriteTextEntry(tar, "manifest.yaml", manifestYaml, DefaultFileMode);
|
||||
WriteTextEntry(tar, "export.json", exportJson, DefaultFileMode);
|
||||
WriteTextEntry(tar, "provenance.json", provenanceJson, DefaultFileMode);
|
||||
WriteTextEntry(tar, "checksums.txt", checksums, DefaultFileMode);
|
||||
WriteTextEntry(tar, "README.md", readme, DefaultFileMode);
|
||||
WriteTextEntry(tar, "verify-mirror.sh", verifyScript, ExecutableFileMode);
|
||||
|
||||
// Write index placeholder files
|
||||
WriteTextEntry(tar, "indexes/advisories.index.json", "[]", DefaultFileMode);
|
||||
WriteTextEntry(tar, "indexes/vex.index.json", "[]", DefaultFileMode);
|
||||
WriteTextEntry(tar, "indexes/sbom.index.json", "[]", DefaultFileMode);
|
||||
WriteTextEntry(tar, "indexes/findings.index.json", "[]", DefaultFileMode);
|
||||
|
||||
// Write data files
|
||||
foreach (var file in files)
|
||||
{
|
||||
WriteFileEntry(tar, file.BundlePath, file.SourcePath);
|
||||
}
|
||||
|
||||
// For delta bundles, write removed list placeholders
|
||||
if (variant == MirrorBundleVariant.Delta)
|
||||
{
|
||||
WriteTextEntry(tar, "delta/removed/advisories.jsonl", "", DefaultFileMode);
|
||||
WriteTextEntry(tar, "delta/removed/vex.jsonl", "", DefaultFileMode);
|
||||
WriteTextEntry(tar, "delta/removed/sboms.jsonl", "", DefaultFileMode);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyDeterministicGzipHeader(stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var dataStream = new MemoryStream(bytes);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void WriteFileEntry(TarWriter writer, string bundlePath, string sourcePath)
|
||||
{
|
||||
using var dataStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.SequentialScan);
|
||||
var mode = bundlePath.EndsWith(".sh", StringComparison.Ordinal) ? ExecutableFileMode : DefaultFileMode;
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, bundlePath)
|
||||
{
|
||||
Mode = mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for mirror bundle.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private static string SanitizeSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "subject";
|
||||
}
|
||||
|
||||
var span = value.Trim();
|
||||
var builder = new StringBuilder(span.Length);
|
||||
|
||||
foreach (var ch in span)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
else if (ch is '-' or '_' or '.')
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('-');
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "subject" : builder.ToString();
|
||||
}
|
||||
|
||||
private sealed record CollectedFile(
|
||||
MirrorBundleDataCategory Category,
|
||||
string BundlePath,
|
||||
string SourcePath,
|
||||
long SizeBytes,
|
||||
string Sha256,
|
||||
bool IsNormalized,
|
||||
string? SubjectId);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Mirror bundle profile variant.
|
||||
/// </summary>
|
||||
public enum MirrorBundleVariant
|
||||
{
|
||||
/// <summary>
|
||||
/// Full mirror bundle containing complete snapshot.
|
||||
/// </summary>
|
||||
Full = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Delta mirror bundle with changes since a base export.
|
||||
/// </summary>
|
||||
Delta = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build a mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleBuildRequest(
|
||||
Guid RunId,
|
||||
Guid TenantId,
|
||||
MirrorBundleVariant Variant,
|
||||
MirrorBundleSelectors Selectors,
|
||||
IReadOnlyList<MirrorBundleDataSource> DataSources,
|
||||
MirrorBundleEncryptionOptions? Encryption = null,
|
||||
MirrorBundleDeltaOptions? DeltaOptions = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Selectors that define the scope of data to include in the bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleSelectors(
|
||||
IReadOnlyList<string> Products,
|
||||
DateTimeOffset? TimeWindowFrom,
|
||||
DateTimeOffset? TimeWindowTo,
|
||||
IReadOnlyList<string>? Ecosystems = null);
|
||||
|
||||
/// <summary>
|
||||
/// Data source input for the mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleDataSource(
|
||||
MirrorBundleDataCategory Category,
|
||||
string SourcePath,
|
||||
bool IsNormalized = false,
|
||||
string? SubjectId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Category of data in a mirror bundle.
|
||||
/// </summary>
|
||||
public enum MirrorBundleDataCategory
|
||||
{
|
||||
Advisories = 1,
|
||||
Vex = 2,
|
||||
Sbom = 3,
|
||||
PolicySnapshot = 4,
|
||||
PolicyEvaluations = 5,
|
||||
VexConsensus = 6,
|
||||
Findings = 7
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encryption options for mirror bundles.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleEncryptionOptions(
|
||||
MirrorBundleEncryptionMode Mode,
|
||||
IReadOnlyList<string> RecipientKeys,
|
||||
bool Strict = false);
|
||||
|
||||
/// <summary>
|
||||
/// Encryption mode for mirror bundles.
|
||||
/// </summary>
|
||||
public enum MirrorBundleEncryptionMode
|
||||
{
|
||||
None = 0,
|
||||
Age = 1,
|
||||
AesGcm = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delta-specific options when building a delta mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleDeltaOptions(
|
||||
string BaseExportId,
|
||||
string BaseManifestDigest,
|
||||
bool ResetBaseline = false);
|
||||
|
||||
/// <summary>
|
||||
/// Result of building a mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleBuildResult(
|
||||
MirrorBundleManifest Manifest,
|
||||
string ManifestJson,
|
||||
MirrorBundleExportDocument ExportDocument,
|
||||
string ExportDocumentJson,
|
||||
MirrorBundleProvenanceDocument ProvenanceDocument,
|
||||
string ProvenanceDocumentJson,
|
||||
string RootHash,
|
||||
MemoryStream BundleStream);
|
||||
|
||||
/// <summary>
|
||||
/// The manifest.yaml content as a structured object.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleManifest(
|
||||
[property: JsonPropertyName("profile")] string Profile,
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("selectors")] MirrorBundleManifestSelectors Selectors,
|
||||
[property: JsonPropertyName("counts")] MirrorBundleManifestCounts Counts,
|
||||
[property: JsonPropertyName("artifacts")] IReadOnlyList<MirrorBundleArtifactEntry> Artifacts,
|
||||
[property: JsonPropertyName("encryption")] MirrorBundleManifestEncryption? Encryption,
|
||||
[property: JsonPropertyName("delta")] MirrorBundleManifestDelta? Delta);
|
||||
|
||||
/// <summary>
|
||||
/// Selector metadata in the manifest.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleManifestSelectors(
|
||||
[property: JsonPropertyName("products")] IReadOnlyList<string> Products,
|
||||
[property: JsonPropertyName("timeWindow")] MirrorBundleTimeWindow? TimeWindow,
|
||||
[property: JsonPropertyName("ecosystems")] IReadOnlyList<string>? Ecosystems);
|
||||
|
||||
/// <summary>
|
||||
/// Time window for selectors.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleTimeWindow(
|
||||
[property: JsonPropertyName("from")] DateTimeOffset From,
|
||||
[property: JsonPropertyName("to")] DateTimeOffset To);
|
||||
|
||||
/// <summary>
|
||||
/// Counts of various record types in the bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleManifestCounts(
|
||||
[property: JsonPropertyName("advisories")] int Advisories,
|
||||
[property: JsonPropertyName("vex")] int Vex,
|
||||
[property: JsonPropertyName("sboms")] int Sboms,
|
||||
[property: JsonPropertyName("policyEvaluations")] int PolicyEvaluations);
|
||||
|
||||
/// <summary>
|
||||
/// Artifact entry in the manifest.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleArtifactEntry(
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("sha256")] string Sha256,
|
||||
[property: JsonPropertyName("bytes")] long Bytes,
|
||||
[property: JsonPropertyName("category")] string Category);
|
||||
|
||||
/// <summary>
|
||||
/// Encryption metadata in the manifest.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleManifestEncryption(
|
||||
[property: JsonPropertyName("mode")] string Mode,
|
||||
[property: JsonPropertyName("strict")] bool Strict,
|
||||
[property: JsonPropertyName("recipients")] IReadOnlyList<string> Recipients);
|
||||
|
||||
/// <summary>
|
||||
/// Delta metadata in the manifest.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleManifestDelta(
|
||||
[property: JsonPropertyName("baseExportId")] string BaseExportId,
|
||||
[property: JsonPropertyName("baseManifestDigest")] string BaseManifestDigest,
|
||||
[property: JsonPropertyName("resetBaseline")] bool ResetBaseline,
|
||||
[property: JsonPropertyName("added")] MirrorBundleDeltaCounts Added,
|
||||
[property: JsonPropertyName("changed")] MirrorBundleDeltaCounts Changed,
|
||||
[property: JsonPropertyName("removed")] MirrorBundleDeltaCounts Removed);
|
||||
|
||||
/// <summary>
|
||||
/// Delta change counts.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleDeltaCounts(
|
||||
[property: JsonPropertyName("advisories")] int Advisories,
|
||||
[property: JsonPropertyName("vex")] int Vex,
|
||||
[property: JsonPropertyName("sboms")] int Sboms);
|
||||
|
||||
/// <summary>
|
||||
/// The export.json document for the bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleExportDocument(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("profile")] MirrorBundleExportProfile Profile,
|
||||
[property: JsonPropertyName("selectors")] MirrorBundleManifestSelectors Selectors,
|
||||
[property: JsonPropertyName("counts")] MirrorBundleManifestCounts Counts,
|
||||
[property: JsonPropertyName("artifacts")] IReadOnlyList<MirrorBundleArtifactEntry> Artifacts,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("manifestDigest")] string ManifestDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Export profile metadata.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleExportProfile(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("variant")] string Variant);
|
||||
|
||||
/// <summary>
|
||||
/// The provenance.json document for the bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleProvenanceDocument(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("subjects")] IReadOnlyList<MirrorBundleProvenanceSubject> Subjects,
|
||||
[property: JsonPropertyName("inputs")] MirrorBundleProvenanceInputs Inputs,
|
||||
[property: JsonPropertyName("builder")] MirrorBundleProvenanceBuilder Builder,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Subject entry in provenance.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleProvenanceSubject(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("digest")] IReadOnlyDictionary<string, string> Digest);
|
||||
|
||||
/// <summary>
|
||||
/// Input references in provenance.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleProvenanceInputs(
|
||||
[property: JsonPropertyName("findingsLedgerQueries")] IReadOnlyList<string> FindingsLedgerQueries,
|
||||
[property: JsonPropertyName("policySnapshotId")] string? PolicySnapshotId,
|
||||
[property: JsonPropertyName("sbomIdentifiers")] IReadOnlyList<string> SbomIdentifiers);
|
||||
|
||||
/// <summary>
|
||||
/// Builder metadata in provenance.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleProvenanceBuilder(
|
||||
[property: JsonPropertyName("exporterVersion")] string ExporterVersion,
|
||||
[property: JsonPropertyName("adapterVersion")] string AdapterVersion);
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature document for mirror bundles.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleDsseSignature(
|
||||
[property: JsonPropertyName("payloadType")] string PayloadType,
|
||||
[property: JsonPropertyName("payload")] string Payload,
|
||||
[property: JsonPropertyName("signatures")] IReadOnlyList<MirrorBundleDsseSignatureEntry> Signatures);
|
||||
|
||||
/// <summary>
|
||||
/// Signature entry within a DSSE document.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleDsseSignatureEntry(
|
||||
[property: JsonPropertyName("sig")] string Signature,
|
||||
[property: JsonPropertyName("keyid")] string KeyId);
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing mirror bundle manifests using DSSE.
|
||||
/// </summary>
|
||||
public interface IMirrorBundleManifestSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs the export.json content and returns a DSSE envelope.
|
||||
/// </summary>
|
||||
Task<MirrorBundleDsseSignature> SignExportDocumentAsync(string exportJson, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Signs the manifest.yaml content and returns a DSSE envelope.
|
||||
/// </summary>
|
||||
Task<MirrorBundleDsseSignature> SignManifestAsync(string manifestYaml, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing mirror bundle archives.
|
||||
/// </summary>
|
||||
public interface IMirrorBundleArchiveSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs the bundle archive stream and returns a base64 signature.
|
||||
/// </summary>
|
||||
Task<string> SignArchiveAsync(Stream archiveStream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMAC-based signer for mirror bundle manifests implementing DSSE (Dead Simple Signing Envelope).
|
||||
/// </summary>
|
||||
public sealed class HmacMirrorBundleManifestSigner : IMirrorBundleManifestSigner, IMirrorBundleArchiveSigner
|
||||
{
|
||||
private const string ExportPayloadType = "application/vnd.stellaops.mirror-bundle.export+json";
|
||||
private const string ManifestPayloadType = "application/vnd.stellaops.mirror-bundle.manifest+yaml";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ICryptoHmac _cryptoHmac;
|
||||
private readonly byte[] _key;
|
||||
private readonly string _keyId;
|
||||
|
||||
public HmacMirrorBundleManifestSigner(ICryptoHmac cryptoHmac, string key, string keyId)
|
||||
{
|
||||
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new ArgumentException("Signing key cannot be empty.", nameof(key));
|
||||
}
|
||||
|
||||
_key = Encoding.UTF8.GetBytes(key);
|
||||
_keyId = string.IsNullOrWhiteSpace(keyId) ? "mirror-bundle-hmac" : keyId;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MirrorBundleDsseSignature> SignExportDocumentAsync(string exportJson, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exportJson);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(CreateDsseEnvelope(ExportPayloadType, exportJson));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MirrorBundleDsseSignature> SignManifestAsync(string manifestYaml, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifestYaml);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(CreateDsseEnvelope(ManifestPayloadType, manifestYaml));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> SignArchiveAsync(Stream archiveStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(archiveStream);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!archiveStream.CanSeek)
|
||||
{
|
||||
throw new ArgumentException("Archive stream must support seeking for signing.", nameof(archiveStream));
|
||||
}
|
||||
|
||||
archiveStream.Position = 0;
|
||||
var signature = await _cryptoHmac.ComputeHmacForPurposeAsync(_key, archiveStream, HmacPurpose.Signing, cancellationToken);
|
||||
archiveStream.Position = 0;
|
||||
return Convert.ToBase64String(signature);
|
||||
}
|
||||
|
||||
private MirrorBundleDsseSignature CreateDsseEnvelope(string payloadType, string payload)
|
||||
{
|
||||
var pae = CreatePreAuthenticationEncoding(payloadType, payload);
|
||||
var signature = _cryptoHmac.ComputeHmacBase64ForPurpose(_key, pae, HmacPurpose.Signing);
|
||||
|
||||
return new MirrorBundleDsseSignature(
|
||||
payloadType,
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)),
|
||||
new[] { new MirrorBundleDsseSignatureEntry(signature, _keyId) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the DSSE Pre-Authentication Encoding (PAE) for signing.
|
||||
/// PAE format: "DSSEv1" + SP + length(payloadType) + SP + payloadType + SP + length(payload) + SP + payload
|
||||
/// </summary>
|
||||
private static byte[] CreatePreAuthenticationEncoding(string payloadType, string payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var preamble = Encoding.UTF8.GetBytes("DSSEv1 ");
|
||||
var typeLenStr = typeBytes.Length.ToString(CultureInfo.InvariantCulture);
|
||||
var payloadLenStr = payloadBytes.Length.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var result = new List<byte>(preamble.Length + typeLenStr.Length + 1 + typeBytes.Length + 1 + payloadLenStr.Length + 1 + payloadBytes.Length);
|
||||
|
||||
result.AddRange(preamble);
|
||||
result.AddRange(Encoding.UTF8.GetBytes(typeLenStr));
|
||||
result.Add(0x20); // space
|
||||
result.AddRange(typeBytes);
|
||||
result.Add(0x20); // space
|
||||
result.AddRange(Encoding.UTF8.GetBytes(payloadLenStr));
|
||||
result.Add(0x20); // space
|
||||
result.AddRange(payloadBytes);
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing a mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleSigningResult(
|
||||
MirrorBundleDsseSignature ExportSignature,
|
||||
MirrorBundleDsseSignature ManifestSignature,
|
||||
string ArchiveSignature);
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mirror bundle signing.
|
||||
/// </summary>
|
||||
public static class MirrorBundleSigningExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs all components of a mirror bundle build result.
|
||||
/// </summary>
|
||||
public static async Task<MirrorBundleSigningResult> SignBundleAsync(
|
||||
this MirrorBundleBuildResult buildResult,
|
||||
IMirrorBundleManifestSigner manifestSigner,
|
||||
IMirrorBundleArchiveSigner archiveSigner,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buildResult);
|
||||
ArgumentNullException.ThrowIfNull(manifestSigner);
|
||||
ArgumentNullException.ThrowIfNull(archiveSigner);
|
||||
|
||||
var exportSigTask = manifestSigner.SignExportDocumentAsync(buildResult.ExportDocumentJson, cancellationToken);
|
||||
var manifestSigTask = manifestSigner.SignManifestAsync(buildResult.ManifestJson, cancellationToken);
|
||||
var archiveSigTask = archiveSigner.SignArchiveAsync(buildResult.BundleStream, cancellationToken);
|
||||
|
||||
await Task.WhenAll(exportSigTask, manifestSigTask, archiveSigTask);
|
||||
|
||||
return new MirrorBundleSigningResult(
|
||||
await exportSigTask,
|
||||
await manifestSigTask,
|
||||
await archiveSigTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a DSSE signature to JSON.
|
||||
/// </summary>
|
||||
public static string ToJson(this MirrorBundleDsseSignature signature)
|
||||
{
|
||||
return JsonSerializer.Serialize(signature, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for emitting export notifications when bundles are ready.
|
||||
/// </summary>
|
||||
public interface IExportNotificationEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits an airgap-ready notification.
|
||||
/// </summary>
|
||||
Task<ExportNotificationResult> EmitAirgapReadyAsync(
|
||||
ExportAirgapReadyNotification notification,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emits to timeline event sink for audit.
|
||||
/// </summary>
|
||||
Task<ExportNotificationResult> EmitToTimelineAsync(
|
||||
ExportAirgapReadyNotification notification,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emitter for export notifications supporting NATS and webhook delivery.
|
||||
/// Implements exponential backoff retry with DLQ routing.
|
||||
/// </summary>
|
||||
public sealed class ExportNotificationEmitter : IExportNotificationEmitter
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(4),
|
||||
TimeSpan.FromSeconds(8),
|
||||
TimeSpan.FromSeconds(16)
|
||||
];
|
||||
|
||||
private readonly IExportNotificationSink _sink;
|
||||
private readonly IExportWebhookClient? _webhookClient;
|
||||
private readonly IExportNotificationDlq _dlq;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ExportNotificationEmitter> _logger;
|
||||
private readonly ExportNotificationEmitterOptions _options;
|
||||
|
||||
public ExportNotificationEmitter(
|
||||
IExportNotificationSink sink,
|
||||
IExportNotificationDlq dlq,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ExportNotificationEmitter> logger,
|
||||
ExportNotificationEmitterOptions? options = null,
|
||||
IExportWebhookClient? webhookClient = null)
|
||||
{
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_dlq = dlq ?? throw new ArgumentNullException(nameof(dlq));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? ExportNotificationEmitterOptions.Default;
|
||||
_webhookClient = webhookClient;
|
||||
}
|
||||
|
||||
public async Task<ExportNotificationResult> EmitAirgapReadyAsync(
|
||||
ExportAirgapReadyNotification notification,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
var payload = JsonSerializer.Serialize(notification, SerializerOptions);
|
||||
|
||||
// Try NATS sink first
|
||||
var sinkResult = await EmitToSinkWithRetryAsync(
|
||||
ExportNotificationTypes.AirgapReady,
|
||||
payload,
|
||||
notification.ExportId,
|
||||
notification.BundleId,
|
||||
notification.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
if (!sinkResult.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to emit airgap ready notification to sink for export {ExportId}: {Error}",
|
||||
notification.ExportId, sinkResult.ErrorMessage);
|
||||
|
||||
await RouteToDlqAsync(notification, sinkResult, cancellationToken);
|
||||
return sinkResult;
|
||||
}
|
||||
|
||||
// Try webhook delivery if configured
|
||||
if (_webhookClient is not null && _options.WebhookEnabled)
|
||||
{
|
||||
var webhookResult = await EmitToWebhookWithRetryAsync(
|
||||
notification,
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
if (!webhookResult.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to deliver airgap ready notification to webhook for export {ExportId}: {Error}",
|
||||
notification.ExportId, webhookResult.ErrorMessage);
|
||||
|
||||
await RouteToDlqAsync(notification, webhookResult, cancellationToken);
|
||||
return webhookResult;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted airgap ready notification for export {ExportId} bundle {BundleId}",
|
||||
notification.ExportId, notification.BundleId);
|
||||
|
||||
return sinkResult;
|
||||
}
|
||||
|
||||
public async Task<ExportNotificationResult> EmitToTimelineAsync(
|
||||
ExportAirgapReadyNotification notification,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
var payload = JsonSerializer.Serialize(notification, SerializerOptions);
|
||||
|
||||
var result = await EmitToSinkWithRetryAsync(
|
||||
ExportNotificationTypes.TimelineAirgapReady,
|
||||
payload,
|
||||
notification.ExportId,
|
||||
notification.BundleId,
|
||||
notification.TenantId,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Emitted timeline notification for export {ExportId}",
|
||||
notification.ExportId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<ExportNotificationResult> EmitToSinkWithRetryAsync(
|
||||
string channel,
|
||||
string payload,
|
||||
string exportId,
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = 0;
|
||||
string? lastError = null;
|
||||
|
||||
while (attempt < _options.MaxRetries)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sink.PublishAsync(channel, payload, cancellationToken);
|
||||
return new ExportNotificationResult(Success: true, AttemptCount: attempt + 1);
|
||||
}
|
||||
catch (Exception ex) when (IsTransient(ex) && attempt < _options.MaxRetries - 1)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
attempt++;
|
||||
|
||||
var delay = attempt <= RetryDelays.Length
|
||||
? RetryDelays[attempt - 1]
|
||||
: RetryDelays[^1];
|
||||
|
||||
_logger.LogWarning(ex,
|
||||
"Transient failure emitting notification for export {ExportId}, attempt {Attempt}/{MaxRetries}",
|
||||
exportId, attempt, _options.MaxRetries);
|
||||
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Non-transient failure emitting notification for export {ExportId}",
|
||||
exportId);
|
||||
|
||||
return new ExportNotificationResult(
|
||||
Success: false,
|
||||
ErrorMessage: ex.Message,
|
||||
AttemptCount: attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return new ExportNotificationResult(
|
||||
Success: false,
|
||||
ErrorMessage: lastError ?? "Max retries exceeded",
|
||||
AttemptCount: attempt);
|
||||
}
|
||||
|
||||
private async Task<ExportNotificationResult> EmitToWebhookWithRetryAsync(
|
||||
ExportAirgapReadyNotification notification,
|
||||
string payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = 0;
|
||||
int? lastStatus = null;
|
||||
string? lastError = null;
|
||||
|
||||
while (attempt < _options.MaxRetries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _webhookClient!.DeliverAsync(
|
||||
ExportNotificationTypes.AirgapReady,
|
||||
payload,
|
||||
_timeProvider.GetUtcNow(),
|
||||
cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
return new ExportNotificationResult(
|
||||
Success: true,
|
||||
AttemptCount: attempt + 1,
|
||||
LastResponseStatus: result.StatusCode);
|
||||
}
|
||||
|
||||
lastStatus = result.StatusCode;
|
||||
lastError = result.ErrorMessage;
|
||||
|
||||
if (!result.ShouldRetry)
|
||||
{
|
||||
return new ExportNotificationResult(
|
||||
Success: false,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
AttemptCount: attempt + 1,
|
||||
LastResponseStatus: result.StatusCode);
|
||||
}
|
||||
|
||||
attempt++;
|
||||
|
||||
var delay = attempt <= RetryDelays.Length
|
||||
? RetryDelays[attempt - 1]
|
||||
: RetryDelays[^1];
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery failed for export {ExportId} with status {StatusCode}, attempt {Attempt}/{MaxRetries}",
|
||||
notification.ExportId, result.StatusCode, attempt, _options.MaxRetries);
|
||||
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (IsTransient(ex) && attempt < _options.MaxRetries - 1)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
attempt++;
|
||||
|
||||
var delay = attempt <= RetryDelays.Length
|
||||
? RetryDelays[attempt - 1]
|
||||
: RetryDelays[^1];
|
||||
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ExportNotificationResult(
|
||||
Success: false,
|
||||
ErrorMessage: ex.Message,
|
||||
AttemptCount: attempt + 1,
|
||||
LastResponseStatus: lastStatus);
|
||||
}
|
||||
}
|
||||
|
||||
return new ExportNotificationResult(
|
||||
Success: false,
|
||||
ErrorMessage: lastError ?? "Max retries exceeded",
|
||||
AttemptCount: attempt,
|
||||
LastResponseStatus: lastStatus);
|
||||
}
|
||||
|
||||
private async Task RouteToDlqAsync(
|
||||
ExportAirgapReadyNotification notification,
|
||||
ExportNotificationResult result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(notification, SerializerOptions);
|
||||
|
||||
var dlqEntry = new ExportNotificationDlqEntry
|
||||
{
|
||||
EventType = ExportNotificationTypes.AirgapReady,
|
||||
ExportId = notification.ExportId,
|
||||
BundleId = notification.BundleId,
|
||||
TenantId = notification.TenantId,
|
||||
FailureReason = result.ErrorMessage ?? "Unknown failure",
|
||||
LastResponseStatus = result.LastResponseStatus,
|
||||
AttemptCount = result.AttemptCount,
|
||||
LastAttemptAt = _timeProvider.GetUtcNow(),
|
||||
OriginalPayload = payload
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _dlq.EnqueueAsync(dlqEntry, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Routed failed notification for export {ExportId} to DLQ after {AttemptCount} attempts",
|
||||
notification.ExportId, result.AttemptCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to route notification for export {ExportId} to DLQ",
|
||||
notification.ExportId);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTransient(Exception ex)
|
||||
{
|
||||
return ex is TimeoutException or
|
||||
TaskCanceledException or
|
||||
HttpRequestException or
|
||||
IOException;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for export notification emitter.
|
||||
/// </summary>
|
||||
public sealed record ExportNotificationEmitterOptions(
|
||||
int MaxRetries,
|
||||
bool WebhookEnabled,
|
||||
TimeSpan WebhookTimeout)
|
||||
{
|
||||
public static ExportNotificationEmitterOptions Default => new(
|
||||
MaxRetries: 5,
|
||||
WebhookEnabled: true,
|
||||
WebhookTimeout: TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sink for publishing export notifications (NATS, etc.).
|
||||
/// </summary>
|
||||
public interface IExportNotificationSink
|
||||
{
|
||||
Task PublishAsync(string channel, string message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dead letter queue for failed notifications.
|
||||
/// </summary>
|
||||
public interface IExportNotificationDlq
|
||||
{
|
||||
Task EnqueueAsync(ExportNotificationDlqEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ExportNotificationDlqEntry>> GetPendingAsync(
|
||||
string? tenantId = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client for webhook delivery.
|
||||
/// </summary>
|
||||
public interface IExportWebhookClient
|
||||
{
|
||||
Task<WebhookDeliveryResult> DeliverAsync(
|
||||
string eventType,
|
||||
string payload,
|
||||
DateTimeOffset sentAt,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of webhook delivery attempt.
|
||||
/// </summary>
|
||||
public sealed record WebhookDeliveryResult(
|
||||
bool Success,
|
||||
int? StatusCode,
|
||||
string? ErrorMessage,
|
||||
bool ShouldRetry);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of notification sink for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryExportNotificationSink : IExportNotificationSink
|
||||
{
|
||||
private readonly List<(string Channel, string Message, DateTimeOffset ReceivedAt)> _messages = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryExportNotificationSink(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task PublishAsync(string channel, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_messages.Add((channel, message, _timeProvider.GetUtcNow()));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string Channel, string Message, DateTimeOffset ReceivedAt)> GetMessages()
|
||||
{
|
||||
lock (_lock) { return _messages.ToList(); }
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetMessages(string channel)
|
||||
{
|
||||
lock (_lock) { return _messages.Where(m => m.Channel == channel).Select(m => m.Message).ToList(); }
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _messages.Count; } }
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) { _messages.Clear(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of DLQ for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryExportNotificationDlq : IExportNotificationDlq
|
||||
{
|
||||
private readonly List<ExportNotificationDlqEntry> _entries = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task EnqueueAsync(ExportNotificationDlqEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ExportNotificationDlqEntry>> GetPendingAsync(
|
||||
string? tenantId = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var query = tenantId is not null
|
||||
? _entries.Where(e => e.TenantId == tenantId)
|
||||
: _entries.AsEnumerable();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ExportNotificationDlqEntry>>(
|
||||
query.Take(limit).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ExportNotificationDlqEntry> GetAll()
|
||||
{
|
||||
lock (_lock) { return _entries.ToList(); }
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _entries.Count; } }
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) { _entries.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Export notification event types.
|
||||
/// </summary>
|
||||
public static class ExportNotificationTypes
|
||||
{
|
||||
public const string AirgapReady = "export.airgap.ready.v1";
|
||||
public const string AirgapReadyDlq = "export.airgap.ready.dlq";
|
||||
public const string TimelineAirgapReady = "timeline.export.airgap.ready";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for export airgap ready notification.
|
||||
/// Keys are sorted alphabetically for deterministic serialization.
|
||||
/// </summary>
|
||||
public sealed record ExportAirgapReadyNotification
|
||||
{
|
||||
[JsonPropertyName("artifact_sha256")]
|
||||
public required string ArtifactSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_uri")]
|
||||
public required string ArtifactUri { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle_id")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("export_id")]
|
||||
public required string ExportId { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public ExportAirgapReadyMetadata? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("portable_version")]
|
||||
public required string PortableVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("profile_id")]
|
||||
public required string ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("root_hash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type => ExportNotificationTypes.AirgapReady;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata fields for the airgap ready notification.
|
||||
/// </summary>
|
||||
public sealed record ExportAirgapReadyMetadata
|
||||
{
|
||||
[JsonPropertyName("export_size_bytes")]
|
||||
public long? ExportSizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("portable_size_bytes")]
|
||||
public long? PortableSizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("source_uri")]
|
||||
public string? SourceUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DLQ entry for failed notification delivery.
|
||||
/// </summary>
|
||||
public sealed record ExportNotificationDlqEntry
|
||||
{
|
||||
[JsonPropertyName("event_type")]
|
||||
public required string EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("export_id")]
|
||||
public required string ExportId { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle_id")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("failure_reason")]
|
||||
public required string FailureReason { get; init; }
|
||||
|
||||
[JsonPropertyName("last_response_status")]
|
||||
public int? LastResponseStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("attempt_count")]
|
||||
public required int AttemptCount { get; init; }
|
||||
|
||||
[JsonPropertyName("last_attempt_at")]
|
||||
public required DateTimeOffset LastAttemptAt { get; init; }
|
||||
|
||||
[JsonPropertyName("original_payload")]
|
||||
public required string OriginalPayload { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Webhook delivery headers.
|
||||
/// </summary>
|
||||
public static class ExportNotificationHeaders
|
||||
{
|
||||
public const string EventType = "X-Stella-Event-Type";
|
||||
public const string Signature = "X-Stella-Signature";
|
||||
public const string SentAt = "X-Stella-Sent-At";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for notification delivery.
|
||||
/// </summary>
|
||||
public sealed record ExportNotificationConfig(
|
||||
bool Enabled,
|
||||
string? WebhookUrl,
|
||||
string? SigningKey,
|
||||
int MaxRetries = 5,
|
||||
TimeSpan? RetentionPeriod = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of attempting to send a notification.
|
||||
/// </summary>
|
||||
public sealed record ExportNotificationResult(
|
||||
bool Success,
|
||||
string? ErrorMessage = null,
|
||||
int AttemptCount = 1,
|
||||
int? LastResponseStatus = null);
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP webhook client for export notifications with HMAC-SHA256 signing.
|
||||
/// </summary>
|
||||
public sealed class ExportWebhookClient : IExportWebhookClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ExportWebhookOptions _options;
|
||||
private readonly ILogger<ExportWebhookClient> _logger;
|
||||
|
||||
public ExportWebhookClient(
|
||||
HttpClient httpClient,
|
||||
ExportWebhookOptions options,
|
||||
ILogger<ExportWebhookClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<WebhookDeliveryResult> DeliverAsync(
|
||||
string eventType,
|
||||
string payload,
|
||||
DateTimeOffset sentAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.WebhookUrl))
|
||||
{
|
||||
return new WebhookDeliveryResult(
|
||||
Success: false,
|
||||
StatusCode: null,
|
||||
ErrorMessage: "Webhook URL not configured",
|
||||
ShouldRetry: false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, _options.WebhookUrl);
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
// Add standard headers
|
||||
request.Headers.Add(ExportNotificationHeaders.EventType, eventType);
|
||||
request.Headers.Add(ExportNotificationHeaders.SentAt, sentAt.ToString("O"));
|
||||
|
||||
// Add signature if signing key is configured
|
||||
if (!string.IsNullOrWhiteSpace(_options.SigningKey))
|
||||
{
|
||||
var signature = ComputeSignature(payload, sentAt, _options.SigningKey);
|
||||
request.Headers.Add(ExportNotificationHeaders.Signature, signature);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery succeeded with status {StatusCode}",
|
||||
statusCode);
|
||||
|
||||
return new WebhookDeliveryResult(
|
||||
Success: true,
|
||||
StatusCode: statusCode,
|
||||
ErrorMessage: null,
|
||||
ShouldRetry: false);
|
||||
}
|
||||
|
||||
var shouldRetry = ShouldRetryStatusCode(response.StatusCode);
|
||||
var errorMessage = $"HTTP {statusCode}: {response.ReasonPhrase}";
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery failed with status {StatusCode}, shouldRetry={ShouldRetry}",
|
||||
statusCode, shouldRetry);
|
||||
|
||||
return new WebhookDeliveryResult(
|
||||
Success: false,
|
||||
StatusCode: statusCode,
|
||||
ErrorMessage: errorMessage,
|
||||
ShouldRetry: shouldRetry);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Webhook delivery failed with HTTP error");
|
||||
|
||||
return new WebhookDeliveryResult(
|
||||
Success: false,
|
||||
StatusCode: null,
|
||||
ErrorMessage: ex.Message,
|
||||
ShouldRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Webhook delivery failed with unexpected error");
|
||||
|
||||
return new WebhookDeliveryResult(
|
||||
Success: false,
|
||||
StatusCode: null,
|
||||
ErrorMessage: ex.Message,
|
||||
ShouldRetry: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes HMAC-SHA256 signature for webhook payload.
|
||||
/// Format: sha256=<hex-encoded-hmac>
|
||||
/// </summary>
|
||||
public static string ComputeSignature(string payload, DateTimeOffset sentAt, string signingKey)
|
||||
{
|
||||
// PAE (Pre-Authentication Encoding) style: timestamp.payload
|
||||
var signatureInput = $"{sentAt:O}.{payload}";
|
||||
var inputBytes = Encoding.UTF8.GetBytes(signatureInput);
|
||||
|
||||
byte[] keyBytes;
|
||||
try
|
||||
{
|
||||
keyBytes = Convert.FromBase64String(signingKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
try
|
||||
{
|
||||
keyBytes = Convert.FromHexString(signingKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
keyBytes = Encoding.UTF8.GetBytes(signingKey);
|
||||
}
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(keyBytes);
|
||||
var hash = hmac.ComputeHash(inputBytes);
|
||||
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a webhook signature.
|
||||
/// </summary>
|
||||
public static bool VerifySignature(string payload, DateTimeOffset sentAt, string signingKey, string providedSignature)
|
||||
{
|
||||
var expectedSignature = ComputeSignature(payload, sentAt, signingKey);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expectedSignature),
|
||||
Encoding.UTF8.GetBytes(providedSignature.Trim()));
|
||||
}
|
||||
|
||||
private static bool ShouldRetryStatusCode(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.TooManyRequests => true,
|
||||
HttpStatusCode.InternalServerError => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for export webhook client.
|
||||
/// </summary>
|
||||
public sealed record ExportWebhookOptions(
|
||||
string? WebhookUrl,
|
||||
string? SigningKey,
|
||||
TimeSpan Timeout)
|
||||
{
|
||||
public static ExportWebhookOptions Default => new(
|
||||
WebhookUrl: null,
|
||||
SigningKey: null,
|
||||
Timeout: TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of webhook client for when webhooks are disabled.
|
||||
/// </summary>
|
||||
public sealed class NullExportWebhookClient : IExportWebhookClient
|
||||
{
|
||||
public static NullExportWebhookClient Instance { get; } = new();
|
||||
|
||||
private NullExportWebhookClient() { }
|
||||
|
||||
public Task<WebhookDeliveryResult> DeliverAsync(
|
||||
string eventType,
|
||||
string payload,
|
||||
DateTimeOffset sentAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new WebhookDeliveryResult(
|
||||
Success: true,
|
||||
StatusCode: 200,
|
||||
ErrorMessage: null,
|
||||
ShouldRetry: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.OfflineKit;
|
||||
|
||||
/// <summary>
|
||||
/// Distributes offline kits to mirror locations for air-gap deployment.
|
||||
/// Implements EXPORT-ATTEST-75-002: bit-for-bit copy with manifest publication.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitDistributor
|
||||
{
|
||||
private const string ManifestOfflineFileName = "manifest-offline.json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OfflineKitDistributor(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distributes an offline kit to a mirror location.
|
||||
/// </summary>
|
||||
public OfflineKitDistributionResult DistributeToMirror(
|
||||
string sourceKitPath,
|
||||
string mirrorBasePath,
|
||||
string kitVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceKitPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mirrorBasePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kitVersion);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!Directory.Exists(sourceKitPath))
|
||||
{
|
||||
return OfflineKitDistributionResult.Failed($"Source kit directory not found: {sourceKitPath}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Target path: mirror/export/attestations/{kitVersion}/
|
||||
var targetPath = Path.Combine(mirrorBasePath, "export", "attestations", kitVersion);
|
||||
|
||||
// Ensure target directory exists
|
||||
Directory.CreateDirectory(targetPath);
|
||||
|
||||
// Copy all files bit-for-bit
|
||||
var copiedFiles = CopyKitFilesRecursively(sourceKitPath, targetPath);
|
||||
|
||||
// Build manifest entries
|
||||
var entries = new List<OfflineKitManifestEntry>();
|
||||
|
||||
// Check for attestation bundle
|
||||
var attestationBundlePath = Path.Combine(targetPath, "attestations", "export-attestation-bundle-v1.tgz");
|
||||
if (File.Exists(attestationBundlePath))
|
||||
{
|
||||
var bundleBytes = File.ReadAllBytes(attestationBundlePath);
|
||||
var bundleHash = _cryptoHash.ComputeHashHexForPurpose(bundleBytes, HashPurpose.Content);
|
||||
|
||||
entries.Add(new OfflineKitManifestEntry(
|
||||
Kind: "attestation-kit",
|
||||
KitVersion: kitVersion,
|
||||
Artifact: "attestations/export-attestation-bundle-v1.tgz",
|
||||
Checksum: "checksums/attestations/export-attestation-bundle-v1.tgz.sha256",
|
||||
CliExample: "stella attest bundle verify --file attestations/export-attestation-bundle-v1.tgz",
|
||||
ImportExample: "stella attest bundle import --file attestations/export-attestation-bundle-v1.tgz --offline",
|
||||
RootHash: $"sha256:{bundleHash}",
|
||||
CreatedAt: _timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
// Check for mirror bundle
|
||||
var mirrorBundlePath = Path.Combine(targetPath, "mirrors", "export-mirror-bundle-v1.tgz");
|
||||
if (File.Exists(mirrorBundlePath))
|
||||
{
|
||||
var bundleBytes = File.ReadAllBytes(mirrorBundlePath);
|
||||
var bundleHash = _cryptoHash.ComputeHashHexForPurpose(bundleBytes, HashPurpose.Content);
|
||||
|
||||
entries.Add(new OfflineKitManifestEntry(
|
||||
Kind: "mirror-bundle",
|
||||
KitVersion: kitVersion,
|
||||
Artifact: "mirrors/export-mirror-bundle-v1.tgz",
|
||||
Checksum: "checksums/mirrors/export-mirror-bundle-v1.tgz.sha256",
|
||||
CliExample: "stella mirror verify --file mirrors/export-mirror-bundle-v1.tgz",
|
||||
ImportExample: "stella mirror import --file mirrors/export-mirror-bundle-v1.tgz --offline",
|
||||
RootHash: $"sha256:{bundleHash}",
|
||||
CreatedAt: _timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
// Check for bootstrap pack
|
||||
var bootstrapPackPath = Path.Combine(targetPath, "bootstrap", "export-bootstrap-pack-v1.tgz");
|
||||
if (File.Exists(bootstrapPackPath))
|
||||
{
|
||||
var bundleBytes = File.ReadAllBytes(bootstrapPackPath);
|
||||
var bundleHash = _cryptoHash.ComputeHashHexForPurpose(bundleBytes, HashPurpose.Content);
|
||||
|
||||
entries.Add(new OfflineKitManifestEntry(
|
||||
Kind: "bootstrap-pack",
|
||||
KitVersion: kitVersion,
|
||||
Artifact: "bootstrap/export-bootstrap-pack-v1.tgz",
|
||||
Checksum: "checksums/bootstrap/export-bootstrap-pack-v1.tgz.sha256",
|
||||
CliExample: "stella bootstrap verify --file bootstrap/export-bootstrap-pack-v1.tgz",
|
||||
ImportExample: "stella bootstrap import --file bootstrap/export-bootstrap-pack-v1.tgz --offline",
|
||||
RootHash: $"sha256:{bundleHash}",
|
||||
CreatedAt: _timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
// Write manifest-offline.json
|
||||
var manifest = new OfflineKitOfflineManifest(
|
||||
Version: "offline-kit/v1",
|
||||
KitVersion: kitVersion,
|
||||
CreatedAt: _timeProvider.GetUtcNow(),
|
||||
Entries: entries);
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
var manifestPath = Path.Combine(targetPath, ManifestOfflineFileName);
|
||||
File.WriteAllText(manifestPath, manifestJson, Encoding.UTF8);
|
||||
|
||||
// Write manifest checksum
|
||||
var manifestHash = _cryptoHash.ComputeHashHexForPurpose(
|
||||
Encoding.UTF8.GetBytes(manifestJson), HashPurpose.Content);
|
||||
var manifestChecksumPath = manifestPath + ".sha256";
|
||||
File.WriteAllText(manifestChecksumPath, $"{manifestHash} {ManifestOfflineFileName}", Encoding.UTF8);
|
||||
|
||||
return new OfflineKitDistributionResult(
|
||||
Success: true,
|
||||
TargetPath: targetPath,
|
||||
ManifestPath: manifestPath,
|
||||
CopiedFileCount: copiedFiles,
|
||||
EntryCount: entries.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return OfflineKitDistributionResult.Failed($"Distribution failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a distributed kit matches its source.
|
||||
/// </summary>
|
||||
public OfflineKitVerificationResult VerifyDistribution(
|
||||
string sourceKitPath,
|
||||
string targetKitPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceKitPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetKitPath);
|
||||
|
||||
if (!Directory.Exists(sourceKitPath))
|
||||
{
|
||||
return OfflineKitVerificationResult.Failed($"Source kit not found: {sourceKitPath}");
|
||||
}
|
||||
|
||||
if (!Directory.Exists(targetKitPath))
|
||||
{
|
||||
return OfflineKitVerificationResult.Failed($"Target kit not found: {targetKitPath}");
|
||||
}
|
||||
|
||||
var mismatches = new List<string>();
|
||||
|
||||
// Get all files in source
|
||||
var sourceFiles = Directory.GetFiles(sourceKitPath, "*", SearchOption.AllDirectories)
|
||||
.Select(f => Path.GetRelativePath(sourceKitPath, f))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var relativePath in sourceFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var sourceFilePath = Path.Combine(sourceKitPath, relativePath);
|
||||
var targetFilePath = Path.Combine(targetKitPath, relativePath);
|
||||
|
||||
if (!File.Exists(targetFilePath))
|
||||
{
|
||||
mismatches.Add($"Missing: {relativePath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare hashes
|
||||
var sourceBytes = File.ReadAllBytes(sourceFilePath);
|
||||
var targetBytes = File.ReadAllBytes(targetFilePath);
|
||||
|
||||
var sourceHash = _cryptoHash.ComputeHashHexForPurpose(sourceBytes, HashPurpose.Content);
|
||||
var targetHash = _cryptoHash.ComputeHashHexForPurpose(targetBytes, HashPurpose.Content);
|
||||
|
||||
if (!string.Equals(sourceHash, targetHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mismatches.Add($"Hash mismatch: {relativePath}");
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.Count > 0)
|
||||
{
|
||||
return new OfflineKitVerificationResult(
|
||||
Success: false,
|
||||
Mismatches: mismatches,
|
||||
ErrorMessage: $"Found {mismatches.Count} mismatches");
|
||||
}
|
||||
|
||||
return new OfflineKitVerificationResult(
|
||||
Success: true,
|
||||
Mismatches: Array.Empty<string>());
|
||||
}
|
||||
|
||||
private static int CopyKitFilesRecursively(string sourceDir, string targetDir)
|
||||
{
|
||||
var count = 0;
|
||||
|
||||
foreach (var sourceFilePath in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, sourceFilePath);
|
||||
var targetFilePath = Path.Combine(targetDir, relativePath);
|
||||
|
||||
var targetFileDir = Path.GetDirectoryName(targetFilePath);
|
||||
if (!string.IsNullOrEmpty(targetFileDir))
|
||||
{
|
||||
Directory.CreateDirectory(targetFileDir);
|
||||
}
|
||||
|
||||
// Bit-for-bit copy
|
||||
File.Copy(sourceFilePath, targetFilePath, overwrite: true);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest entry for offline kit distribution.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitManifestEntry(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("kitVersion")] string KitVersion,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("checksum")] string Checksum,
|
||||
[property: JsonPropertyName("cliExample")] string CliExample,
|
||||
[property: JsonPropertyName("importExample")] string ImportExample,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Offline manifest for air-gap deployment.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitOfflineManifest(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("kitVersion")] string KitVersion,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("entries")] IReadOnlyList<OfflineKitManifestEntry> Entries);
|
||||
|
||||
/// <summary>
|
||||
/// Result of offline kit distribution.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitDistributionResult(
|
||||
bool Success,
|
||||
string? TargetPath = null,
|
||||
string? ManifestPath = null,
|
||||
int CopiedFileCount = 0,
|
||||
int EntryCount = 0,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static OfflineKitDistributionResult Failed(string errorMessage)
|
||||
=> new(Success: false, ErrorMessage: errorMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of offline kit verification.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitVerificationResult(
|
||||
bool Success,
|
||||
IReadOnlyList<string> Mismatches,
|
||||
string? ErrorMessage = null)
|
||||
{
|
||||
public static OfflineKitVerificationResult Failed(string errorMessage)
|
||||
=> new(Success: false, Mismatches: Array.Empty<string>(), ErrorMessage: errorMessage);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.OfflineKit;
|
||||
|
||||
/// <summary>
|
||||
/// Manifest entry for an attestation bundle in an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitAttestationEntry(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("attestationId")] string AttestationId,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("checksum")] string Checksum,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt)
|
||||
{
|
||||
public const string KindValue = "attestation-export";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest entry for a mirror bundle in an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitMirrorEntry(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("bundleId")] string BundleId,
|
||||
[property: JsonPropertyName("profile")] string Profile,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("checksum")] string Checksum,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt)
|
||||
{
|
||||
public const string KindValue = "mirror-bundle";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest entry for a bootstrap pack in an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitBootstrapEntry(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("checksum")] string Checksum,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt)
|
||||
{
|
||||
public const string KindValue = "bootstrap-pack";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest entry for a portable evidence bundle in an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitPortableEvidenceEntry(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("bundleId")] string BundleId,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("checksum")] string Checksum,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt)
|
||||
{
|
||||
public const string KindValue = "portable-evidence";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root manifest for an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitManifest(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("kitId")] string KitId,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("entries")] IReadOnlyList<object> Entries)
|
||||
{
|
||||
public const string CurrentVersion = "offline-kit/v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add an attestation bundle to an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitAttestationRequest(
|
||||
string KitId,
|
||||
string ExportId,
|
||||
string AttestationId,
|
||||
string RootHash,
|
||||
byte[] BundleBytes,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a mirror bundle to an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitMirrorRequest(
|
||||
string KitId,
|
||||
string ExportId,
|
||||
string BundleId,
|
||||
string Profile,
|
||||
string RootHash,
|
||||
byte[] BundleBytes,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a bootstrap pack to an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitBootstrapRequest(
|
||||
string KitId,
|
||||
string ExportId,
|
||||
string Version,
|
||||
string RootHash,
|
||||
byte[] BundleBytes,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Result of adding an entry to an offline kit.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitAddResult(
|
||||
bool Success,
|
||||
string ArtifactPath,
|
||||
string ChecksumPath,
|
||||
string Sha256Hash,
|
||||
string? ErrorMessage = null);
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.OfflineKit;
|
||||
|
||||
/// <summary>
|
||||
/// Packager for assembling offline kits with attestation bundles, mirror bundles, and bootstrap packs.
|
||||
/// Ensures immutable, deterministic artefact placement with checksum publication.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitPackager
|
||||
{
|
||||
private const string AttestationsDir = "attestations";
|
||||
private const string MirrorsDir = "mirrors";
|
||||
private const string BootstrapDir = "bootstrap";
|
||||
private const string EvidenceDir = "evidence";
|
||||
private const string ChecksumsDir = "checksums";
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
|
||||
private const string AttestationBundleFileName = "export-attestation-bundle-v1.tgz";
|
||||
private const string MirrorBundleFileName = "export-mirror-bundle-v1.tgz";
|
||||
private const string BootstrapBundleFileName = "export-bootstrap-pack-v1.tgz";
|
||||
private const string EvidenceBundleFileName = "export-portable-bundle-v1.tgz";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OfflineKitPackager(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an attestation bundle to the offline kit.
|
||||
/// </summary>
|
||||
public OfflineKitAddResult AddAttestationBundle(
|
||||
string outputDirectory,
|
||||
OfflineKitAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
throw new ArgumentException("Output directory must be provided.", nameof(outputDirectory));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var artifactRelativePath = Path.Combine(AttestationsDir, AttestationBundleFileName);
|
||||
var checksumRelativePath = Path.Combine(ChecksumsDir, AttestationsDir, $"{AttestationBundleFileName}.sha256");
|
||||
|
||||
return WriteBundle(
|
||||
outputDirectory,
|
||||
request.BundleBytes,
|
||||
artifactRelativePath,
|
||||
checksumRelativePath,
|
||||
AttestationBundleFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a mirror bundle to the offline kit.
|
||||
/// </summary>
|
||||
public OfflineKitAddResult AddMirrorBundle(
|
||||
string outputDirectory,
|
||||
OfflineKitMirrorRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
throw new ArgumentException("Output directory must be provided.", nameof(outputDirectory));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var artifactRelativePath = Path.Combine(MirrorsDir, MirrorBundleFileName);
|
||||
var checksumRelativePath = Path.Combine(ChecksumsDir, MirrorsDir, $"{MirrorBundleFileName}.sha256");
|
||||
|
||||
return WriteBundle(
|
||||
outputDirectory,
|
||||
request.BundleBytes,
|
||||
artifactRelativePath,
|
||||
checksumRelativePath,
|
||||
MirrorBundleFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a bootstrap pack to the offline kit.
|
||||
/// </summary>
|
||||
public OfflineKitAddResult AddBootstrapPack(
|
||||
string outputDirectory,
|
||||
OfflineKitBootstrapRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
throw new ArgumentException("Output directory must be provided.", nameof(outputDirectory));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var artifactRelativePath = Path.Combine(BootstrapDir, BootstrapBundleFileName);
|
||||
var checksumRelativePath = Path.Combine(ChecksumsDir, BootstrapDir, $"{BootstrapBundleFileName}.sha256");
|
||||
|
||||
return WriteBundle(
|
||||
outputDirectory,
|
||||
request.BundleBytes,
|
||||
artifactRelativePath,
|
||||
checksumRelativePath,
|
||||
BootstrapBundleFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a manifest entry for an attestation bundle.
|
||||
/// </summary>
|
||||
public OfflineKitAttestationEntry CreateAttestationEntry(OfflineKitAttestationRequest request, string sha256Hash)
|
||||
{
|
||||
return new OfflineKitAttestationEntry(
|
||||
Kind: OfflineKitAttestationEntry.KindValue,
|
||||
ExportId: request.ExportId,
|
||||
AttestationId: request.AttestationId,
|
||||
RootHash: $"sha256:{request.RootHash}",
|
||||
Artifact: Path.Combine(AttestationsDir, AttestationBundleFileName).Replace('\\', '/'),
|
||||
Checksum: Path.Combine(ChecksumsDir, AttestationsDir, $"{AttestationBundleFileName}.sha256").Replace('\\', '/'),
|
||||
CreatedAt: request.CreatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a manifest entry for a mirror bundle.
|
||||
/// </summary>
|
||||
public OfflineKitMirrorEntry CreateMirrorEntry(OfflineKitMirrorRequest request, string sha256Hash)
|
||||
{
|
||||
return new OfflineKitMirrorEntry(
|
||||
Kind: OfflineKitMirrorEntry.KindValue,
|
||||
ExportId: request.ExportId,
|
||||
BundleId: request.BundleId,
|
||||
Profile: request.Profile,
|
||||
RootHash: $"sha256:{request.RootHash}",
|
||||
Artifact: Path.Combine(MirrorsDir, MirrorBundleFileName).Replace('\\', '/'),
|
||||
Checksum: Path.Combine(ChecksumsDir, MirrorsDir, $"{MirrorBundleFileName}.sha256").Replace('\\', '/'),
|
||||
CreatedAt: request.CreatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a manifest entry for a bootstrap pack.
|
||||
/// </summary>
|
||||
public OfflineKitBootstrapEntry CreateBootstrapEntry(OfflineKitBootstrapRequest request, string sha256Hash)
|
||||
{
|
||||
return new OfflineKitBootstrapEntry(
|
||||
Kind: OfflineKitBootstrapEntry.KindValue,
|
||||
ExportId: request.ExportId,
|
||||
Version: request.Version,
|
||||
RootHash: $"sha256:{request.RootHash}",
|
||||
Artifact: Path.Combine(BootstrapDir, BootstrapBundleFileName).Replace('\\', '/'),
|
||||
Checksum: Path.Combine(ChecksumsDir, BootstrapDir, $"{BootstrapBundleFileName}.sha256").Replace('\\', '/'),
|
||||
CreatedAt: request.CreatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes or updates the offline kit manifest.
|
||||
/// </summary>
|
||||
public void WriteManifest(
|
||||
string outputDirectory,
|
||||
string kitId,
|
||||
IEnumerable<object> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifestPath = Path.Combine(outputDirectory, ManifestFileName);
|
||||
|
||||
// Check for existing manifest (immutability check)
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest already exists at '{manifestPath}'. Offline kit artefacts are immutable.");
|
||||
}
|
||||
|
||||
var manifest = new OfflineKitManifest(
|
||||
Version: OfflineKitManifest.CurrentVersion,
|
||||
KitId: kitId,
|
||||
CreatedAt: _timeProvider.GetUtcNow(),
|
||||
Entries: entries.ToList());
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
File.WriteAllText(manifestPath, manifestJson, Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a checksum file content in standard format.
|
||||
/// </summary>
|
||||
public static string GenerateChecksumFileContent(string sha256Hash, string fileName)
|
||||
{
|
||||
return $"{sha256Hash} {fileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a bundle matches its expected SHA256 hash.
|
||||
/// </summary>
|
||||
public bool VerifyBundleHash(byte[] bundleBytes, string expectedSha256)
|
||||
{
|
||||
var actualHash = _cryptoHash.ComputeHashHexForPurpose(bundleBytes, HashPurpose.Content);
|
||||
return string.Equals(actualHash, expectedSha256, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private OfflineKitAddResult WriteBundle(
|
||||
string outputDirectory,
|
||||
byte[] bundleBytes,
|
||||
string artifactRelativePath,
|
||||
string checksumRelativePath,
|
||||
string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compute SHA256
|
||||
var sha256Hash = _cryptoHash.ComputeHashHexForPurpose(bundleBytes, HashPurpose.Content);
|
||||
|
||||
// Determine full paths
|
||||
var artifactFullPath = Path.Combine(outputDirectory, artifactRelativePath);
|
||||
var checksumFullPath = Path.Combine(outputDirectory, checksumRelativePath);
|
||||
|
||||
// Check for existing artifact (immutability)
|
||||
if (File.Exists(artifactFullPath))
|
||||
{
|
||||
return new OfflineKitAddResult(
|
||||
Success: false,
|
||||
ArtifactPath: artifactRelativePath,
|
||||
ChecksumPath: checksumRelativePath,
|
||||
Sha256Hash: sha256Hash,
|
||||
ErrorMessage: $"Artifact already exists at '{artifactFullPath}'. Offline kit artefacts are immutable.");
|
||||
}
|
||||
|
||||
// Create directories
|
||||
var artifactDir = Path.GetDirectoryName(artifactFullPath);
|
||||
var checksumDir = Path.GetDirectoryName(checksumFullPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(artifactDir))
|
||||
{
|
||||
Directory.CreateDirectory(artifactDir);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(checksumDir))
|
||||
{
|
||||
Directory.CreateDirectory(checksumDir);
|
||||
}
|
||||
|
||||
// Write bundle (bit-for-bit copy)
|
||||
File.WriteAllBytes(artifactFullPath, bundleBytes);
|
||||
|
||||
// Write checksum file
|
||||
var checksumContent = GenerateChecksumFileContent(sha256Hash, fileName);
|
||||
File.WriteAllText(checksumFullPath, checksumContent, Encoding.UTF8);
|
||||
|
||||
return new OfflineKitAddResult(
|
||||
Success: true,
|
||||
ArtifactPath: artifactRelativePath,
|
||||
ChecksumPath: checksumRelativePath,
|
||||
Sha256Hash: sha256Hash);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OfflineKitAddResult(
|
||||
Success: false,
|
||||
ArtifactPath: artifactRelativePath,
|
||||
ChecksumPath: checksumRelativePath,
|
||||
Sha256Hash: string.Empty,
|
||||
ErrorMessage: ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.PortableEvidence;
|
||||
|
||||
/// <summary>
|
||||
/// Builds portable evidence export archives that wrap EvidenceLocker portable bundles for air-gap delivery.
|
||||
/// </summary>
|
||||
public sealed class PortableEvidenceExportBuilder
|
||||
{
|
||||
private const string ExportVersion = "portable-evidence/v1";
|
||||
private const string PortableBundleVersion = "v1";
|
||||
private const string InnerBundleFileName = "portable-bundle-v1.tgz";
|
||||
private const string ExportArchiveFileName = "export-portable-bundle-v1.tgz";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PortableEvidenceExportBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a portable evidence export archive from the provided request.
|
||||
/// </summary>
|
||||
public PortableEvidenceExportResult Build(PortableEvidenceExportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (request.ExportId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Export identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.BundleId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Bundle identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.TenantId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PortableBundlePath))
|
||||
{
|
||||
throw new ArgumentException("Portable bundle path must be provided.", nameof(request));
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(request.PortableBundlePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Portable bundle file '{fullPath}' not found.", fullPath);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Read and hash the portable bundle
|
||||
var portableBundleBytes = File.ReadAllBytes(fullPath);
|
||||
var portableBundleSha256 = _cryptoHash.ComputeHashHexForPurpose(portableBundleBytes, HashPurpose.Content);
|
||||
|
||||
// Build export document
|
||||
var exportDoc = new PortableEvidenceExportDocument(
|
||||
ExportVersion,
|
||||
request.ExportId.ToString("D"),
|
||||
request.BundleId.ToString("D"),
|
||||
request.TenantId.ToString("D"),
|
||||
_timeProvider.GetUtcNow(),
|
||||
string.Empty, // Will be computed after archive creation
|
||||
request.SourceUri,
|
||||
PortableBundleVersion,
|
||||
portableBundleSha256,
|
||||
request.Metadata);
|
||||
|
||||
var exportJson = JsonSerializer.Serialize(exportDoc, SerializerOptions);
|
||||
var exportJsonSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(exportJson), HashPurpose.Content);
|
||||
|
||||
// Build checksums
|
||||
var checksums = BuildChecksums(exportJsonSha256, portableBundleSha256);
|
||||
var checksumsSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(checksums), HashPurpose.Content);
|
||||
|
||||
// Build README
|
||||
var readme = BuildReadme(request, portableBundleSha256);
|
||||
var readmeSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(readme), HashPurpose.Content);
|
||||
|
||||
// Build verification script
|
||||
var verifyScript = BuildVerificationScript();
|
||||
var verifyScriptSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(verifyScript), HashPurpose.Content);
|
||||
|
||||
// Compute root hash
|
||||
var rootHash = ComputeRootHash(exportJsonSha256, portableBundleSha256, checksumsSha256, readmeSha256, verifyScriptSha256);
|
||||
|
||||
// Update export document with root hash
|
||||
var finalExportDoc = exportDoc with { RootHash = rootHash };
|
||||
var finalExportJson = JsonSerializer.Serialize(finalExportDoc, SerializerOptions);
|
||||
|
||||
// Rebuild checksums with final export.json hash
|
||||
var finalExportJsonSha256 = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(finalExportJson), HashPurpose.Content);
|
||||
var finalChecksums = BuildChecksums(finalExportJsonSha256, portableBundleSha256);
|
||||
|
||||
// Create the export archive
|
||||
var exportStream = CreateExportArchive(
|
||||
finalExportJson,
|
||||
portableBundleBytes,
|
||||
finalChecksums,
|
||||
readme,
|
||||
verifyScript);
|
||||
|
||||
exportStream.Position = 0;
|
||||
|
||||
return new PortableEvidenceExportResult(
|
||||
finalExportDoc,
|
||||
finalExportJson,
|
||||
rootHash,
|
||||
portableBundleSha256,
|
||||
exportStream);
|
||||
}
|
||||
|
||||
private string ComputeRootHash(params string[] hashes)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var hash in hashes.OrderBy(h => h, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(hash).Append('\0');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
return _cryptoHash.ComputeHashHexForPurpose(bytes, HashPurpose.Content);
|
||||
}
|
||||
|
||||
private static string BuildChecksums(string exportJsonSha256, string portableBundleSha256)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Portable evidence export checksums (sha256)");
|
||||
builder.Append(exportJsonSha256).AppendLine(" export.json");
|
||||
builder.Append(portableBundleSha256).Append(" ").AppendLine(InnerBundleFileName);
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildReadme(PortableEvidenceExportRequest request, string portableBundleSha256)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Portable Evidence Export");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("## Overview");
|
||||
builder.Append("Export ID: ").AppendLine(request.ExportId.ToString("D"));
|
||||
builder.Append("Bundle ID: ").AppendLine(request.BundleId.ToString("D"));
|
||||
builder.Append("Tenant ID: ").AppendLine(request.TenantId.ToString("D"));
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Contents");
|
||||
builder.AppendLine("- `export.json` - Export metadata with bundle references and hashes");
|
||||
builder.Append("- `").Append(InnerBundleFileName).AppendLine("` - Original EvidenceLocker portable bundle (unmodified)");
|
||||
builder.AppendLine("- `checksums.txt` - SHA-256 checksums for verification");
|
||||
builder.AppendLine("- `verify-export.sh` - Verification script for offline use");
|
||||
builder.AppendLine("- `README.md` - This file");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Verification Steps");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("### 1. Extract the archive");
|
||||
builder.AppendLine("```sh");
|
||||
builder.Append("tar -xzf ").AppendLine(ExportArchiveFileName);
|
||||
builder.AppendLine("```");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("### 2. Verify checksums");
|
||||
builder.AppendLine("```sh");
|
||||
builder.AppendLine("./verify-export.sh");
|
||||
builder.AppendLine("# Or manually:");
|
||||
builder.AppendLine("sha256sum --check checksums.txt");
|
||||
builder.AppendLine("```");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("### 3. Verify the inner evidence bundle");
|
||||
builder.AppendLine("```sh");
|
||||
builder.Append("stella evidence verify --bundle ").AppendLine(InnerBundleFileName);
|
||||
builder.AppendLine("```");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Expected Headers");
|
||||
builder.AppendLine("When downloading this export, expect the following response headers:");
|
||||
builder.AppendLine("- `Content-Type: application/gzip`");
|
||||
builder.AppendLine("- `ETag: \"<sha256-of-archive>\"`");
|
||||
builder.AppendLine("- `Last-Modified: <creation-timestamp>`");
|
||||
builder.AppendLine("- `X-Stella-Bundle-Id: <bundle-id>`");
|
||||
builder.AppendLine("- `X-Stella-Export-Id: <export-id>`");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Schema Links");
|
||||
builder.AppendLine("- Evidence bundle: `docs/modules/evidence-locker/bundle-packaging.schema.json`");
|
||||
builder.AppendLine("- Export schema: `docs/modules/export-center/portable-export.schema.json`");
|
||||
builder.AppendLine();
|
||||
|
||||
builder.AppendLine("## Portable Bundle Hash");
|
||||
builder.Append("SHA-256: `").Append(portableBundleSha256).AppendLine("`");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildVerificationScript()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("# Portable Evidence Export Verification Script");
|
||||
builder.AppendLine("# No network access required");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("set -eu");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Verify checksums");
|
||||
builder.AppendLine("echo \"Verifying checksums...\"");
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" sha256sum --check checksums.txt");
|
||||
builder.AppendLine("elif command -v shasum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" shasum -a 256 --check checksums.txt");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" echo \"Error: sha256sum or shasum required\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine("echo \"Checksums verified successfully.\"");
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Check for stella CLI");
|
||||
builder.Append("PORTABLE_BUNDLE=\"").Append(InnerBundleFileName).AppendLine("\"");
|
||||
builder.AppendLine("if command -v stella >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" echo \"Verifying evidence bundle with stella CLI...\"");
|
||||
builder.AppendLine(" stella evidence verify --bundle \"$PORTABLE_BUNDLE\"");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" echo \"Note: stella CLI not found. Manual verification of $PORTABLE_BUNDLE recommended.\"");
|
||||
builder.AppendLine(" echo \"Install stella CLI and run: stella evidence verify --bundle $PORTABLE_BUNDLE\"");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine("echo \"Verification complete.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private MemoryStream CreateExportArchive(
|
||||
string exportJson,
|
||||
byte[] portableBundleBytes,
|
||||
string checksums,
|
||||
string readme,
|
||||
string verifyScript)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
// Write files in lexical order for determinism
|
||||
WriteTextEntry(tar, "README.md", readme, DefaultFileMode);
|
||||
WriteTextEntry(tar, "checksums.txt", checksums, DefaultFileMode);
|
||||
WriteTextEntry(tar, "export.json", exportJson, DefaultFileMode);
|
||||
WriteBytesEntry(tar, InnerBundleFileName, portableBundleBytes, DefaultFileMode);
|
||||
WriteTextEntry(tar, "verify-export.sh", verifyScript, ExecutableFileMode);
|
||||
}
|
||||
|
||||
ApplyDeterministicGzipHeader(stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var dataStream = new MemoryStream(bytes);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void WriteBytesEntry(TarWriter writer, string path, byte[] content, UnixFileMode mode)
|
||||
{
|
||||
using var dataStream = new MemoryStream(content);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written for portable evidence export.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.PortableEvidence;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a portable evidence export.
|
||||
/// </summary>
|
||||
public sealed record PortableEvidenceExportRequest(
|
||||
Guid ExportId,
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
string PortableBundlePath,
|
||||
string? SourceUri = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of building a portable evidence export.
|
||||
/// </summary>
|
||||
public sealed record PortableEvidenceExportResult(
|
||||
PortableEvidenceExportDocument ExportDocument,
|
||||
string ExportDocumentJson,
|
||||
string RootHash,
|
||||
string PortableBundleSha256,
|
||||
MemoryStream ExportStream);
|
||||
|
||||
/// <summary>
|
||||
/// The export.json document for portable evidence exports.
|
||||
/// </summary>
|
||||
public sealed record PortableEvidenceExportDocument(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("bundleId")] string BundleId,
|
||||
[property: JsonPropertyName("tenantId")] string TenantId,
|
||||
[property: JsonPropertyName("createdAtUtc")] DateTimeOffset CreatedAtUtc,
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("sourceUri")] string? SourceUri,
|
||||
[property: JsonPropertyName("portableVersion")] string PortableVersion,
|
||||
[property: JsonPropertyName("portableBundleSha256")] string PortableBundleSha256,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Export status for portable evidence.
|
||||
/// </summary>
|
||||
public enum PortableEvidenceExportStatus
|
||||
{
|
||||
Pending = 1,
|
||||
Materialising = 2,
|
||||
Ready = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status response for portable evidence export.
|
||||
/// </summary>
|
||||
public sealed record PortableEvidenceExportStatusResponse(
|
||||
[property: JsonPropertyName("exportId")] string ExportId,
|
||||
[property: JsonPropertyName("bundleId")] string BundleId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("rootHash")] string? RootHash,
|
||||
[property: JsonPropertyName("portableVersion")] string? PortableVersion,
|
||||
[property: JsonPropertyName("downloadUri")] string? DownloadUri,
|
||||
[property: JsonPropertyName("pendingReason")] string? PendingReason,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,559 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.ExportCenter.Core.AttestationBundle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class AttestationBundleBuilderTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FakeCryptoHash _cryptoHash;
|
||||
private readonly AttestationBundleBuilder _builder;
|
||||
|
||||
public AttestationBundleBuilderTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_cryptoHash = new FakeCryptoHash();
|
||||
_builder = new AttestationBundleBuilder(_cryptoHash, _timeProvider);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// No cleanup needed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesValidExport()
|
||||
{
|
||||
var request = CreateTestRequest();
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Metadata);
|
||||
Assert.NotEmpty(result.MetadataJson);
|
||||
Assert.NotEmpty(result.RootHash);
|
||||
Assert.True(result.ExportStream.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MetadataContainsCorrectValues()
|
||||
{
|
||||
var exportId = Guid.NewGuid();
|
||||
var attestationId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sourceUri = "https://attestor.example.com/v1/statements/abc123";
|
||||
|
||||
var request = new AttestationBundleExportRequest(
|
||||
exportId,
|
||||
attestationId,
|
||||
tenantId,
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement(),
|
||||
SourceUri: sourceUri,
|
||||
StatementVersion: "v2");
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.Equal(exportId.ToString("D"), result.Metadata.ExportId);
|
||||
Assert.Equal(attestationId.ToString("D"), result.Metadata.AttestationId);
|
||||
Assert.Equal(tenantId.ToString("D"), result.Metadata.TenantId);
|
||||
Assert.Equal(sourceUri, result.Metadata.SourceUri);
|
||||
Assert.Equal("v2", result.Metadata.StatementVersion);
|
||||
Assert.Equal("attestation-bundle/v1", result.Metadata.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesDeterministicOutput()
|
||||
{
|
||||
var exportId = new Guid("11111111-2222-3333-4444-555555555555");
|
||||
var attestationId = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
var tenantId = new Guid("ffffffff-1111-2222-3333-444444444444");
|
||||
|
||||
var request = new AttestationBundleExportRequest(
|
||||
exportId,
|
||||
attestationId,
|
||||
tenantId,
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement());
|
||||
|
||||
var result1 = _builder.Build(request);
|
||||
var result2 = _builder.Build(request);
|
||||
|
||||
Assert.Equal(result1.RootHash, result2.RootHash);
|
||||
|
||||
var bytes1 = result1.ExportStream.ToArray();
|
||||
var bytes2 = result2.ExportStream.ToArray();
|
||||
Assert.Equal(bytes1, bytes2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ArchiveContainsExpectedFiles()
|
||||
{
|
||||
var request = CreateTestRequest();
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.ExportStream);
|
||||
|
||||
Assert.Contains("attestation.dsse.json", fileNames);
|
||||
Assert.Contains("statement.json", fileNames);
|
||||
Assert.Contains("metadata.json", fileNames);
|
||||
Assert.Contains("checksums.txt", fileNames);
|
||||
Assert.Contains("verify-attestation.sh", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTransparencyEntries_IncludesTransparencyFile()
|
||||
{
|
||||
var entries = new List<string>
|
||||
{
|
||||
"{\"logIndex\":1,\"logId\":\"rekor1\"}",
|
||||
"{\"logIndex\":2,\"logId\":\"rekor2\"}"
|
||||
};
|
||||
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement(),
|
||||
TransparencyEntries: entries);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.ExportStream);
|
||||
|
||||
Assert.Contains("transparency.ndjson", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithoutTransparencyEntries_OmitsTransparencyFile()
|
||||
{
|
||||
var request = CreateTestRequest();
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.ExportStream);
|
||||
|
||||
Assert.DoesNotContain("transparency.ndjson", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TransparencyEntriesSortedLexically()
|
||||
{
|
||||
var entries = new List<string>
|
||||
{
|
||||
"{\"logIndex\":3,\"logId\":\"z-log\"}",
|
||||
"{\"logIndex\":1,\"logId\":\"a-log\"}",
|
||||
"{\"logIndex\":2,\"logId\":\"m-log\"}"
|
||||
};
|
||||
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement(),
|
||||
TransparencyEntries: entries);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var content = ExtractFileContent(result.ExportStream, "transparency.ndjson");
|
||||
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Should be sorted lexically
|
||||
Assert.Equal(3, lines.Length);
|
||||
Assert.Contains("a-log", lines[0]);
|
||||
Assert.Contains("m-log", lines[1]);
|
||||
Assert.Contains("z-log", lines[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DsseEnvelopeIsUnmodified()
|
||||
{
|
||||
var originalDsse = CreateTestDsseEnvelope();
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
originalDsse,
|
||||
CreateTestStatement());
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var extractedDsse = ExtractFileContent(result.ExportStream, "attestation.dsse.json");
|
||||
|
||||
Assert.Equal(originalDsse, extractedDsse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_StatementIsUnmodified()
|
||||
{
|
||||
var originalStatement = CreateTestStatement();
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
originalStatement);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var extractedStatement = ExtractFileContent(result.ExportStream, "statement.json");
|
||||
|
||||
Assert.Equal(originalStatement, extractedStatement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TarEntriesHaveDeterministicMetadata()
|
||||
{
|
||||
var request = CreateTestRequest();
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var entries = ExtractTarEntryMetadata(result.ExportStream);
|
||||
|
||||
var expectedTimestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
Assert.Equal(0, entry.Uid);
|
||||
Assert.Equal(0, entry.Gid);
|
||||
Assert.Equal(expectedTimestamp, entry.ModificationTime);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_VerifyScriptHasExecutePermission()
|
||||
{
|
||||
var request = CreateTestRequest();
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var entries = ExtractTarEntryMetadata(result.ExportStream);
|
||||
|
||||
var scriptEntry = entries.FirstOrDefault(e => e.Name == "verify-attestation.sh");
|
||||
Assert.NotNull(scriptEntry);
|
||||
Assert.True(scriptEntry.Mode.HasFlag(UnixFileMode.UserExecute));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_VerifyScriptIsPosixCompliant()
|
||||
{
|
||||
var request = CreateTestRequest();
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var script = ExtractFileContent(result.ExportStream, "verify-attestation.sh");
|
||||
|
||||
Assert.StartsWith("#!/usr/bin/env sh", script);
|
||||
Assert.Contains("sha256sum", script);
|
||||
Assert.Contains("shasum", script);
|
||||
Assert.Contains("stella attest verify", script);
|
||||
Assert.DoesNotContain("curl", script);
|
||||
Assert.DoesNotContain("wget", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_VerifyScriptContainsAttestationId()
|
||||
{
|
||||
var attestationId = Guid.NewGuid();
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
attestationId,
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement());
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var script = ExtractFileContent(result.ExportStream, "verify-attestation.sh");
|
||||
|
||||
Assert.Contains(attestationId.ToString("D"), script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ChecksumsContainsAllFiles()
|
||||
{
|
||||
var request = CreateTestRequest();
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var checksums = ExtractFileContent(result.ExportStream, "checksums.txt");
|
||||
|
||||
Assert.Contains("attestation.dsse.json", checksums);
|
||||
Assert.Contains("statement.json", checksums);
|
||||
Assert.Contains("metadata.json", checksums);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithSubjectDigests_IncludesInMetadata()
|
||||
{
|
||||
var digests = new List<AttestationSubjectDigest>
|
||||
{
|
||||
new("image1", "sha256:abc123", "sha256"),
|
||||
new("image2", "sha256:def456", "sha256")
|
||||
};
|
||||
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement(),
|
||||
SubjectDigests: digests);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result.Metadata.SubjectDigests);
|
||||
Assert.Equal(2, result.Metadata.SubjectDigests.Count);
|
||||
Assert.Equal("image1", result.Metadata.SubjectDigests[0].Name);
|
||||
Assert.Equal("sha256:abc123", result.Metadata.SubjectDigests[0].Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForEmptyExportId()
|
||||
{
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.Empty,
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement());
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForEmptyAttestationId()
|
||||
{
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.Empty,
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement());
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForEmptyTenantId()
|
||||
{
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.Empty,
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement());
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForEmptyDsseEnvelope()
|
||||
{
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
string.Empty,
|
||||
CreateTestStatement());
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForEmptyStatement()
|
||||
{
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
string.Empty);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForNullRequest()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => _builder.Build(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DefaultStatementVersionIsV1()
|
||||
{
|
||||
var request = new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement());
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.Equal("v1", result.Metadata.StatementVersion);
|
||||
}
|
||||
|
||||
private static AttestationBundleExportRequest CreateTestRequest()
|
||||
{
|
||||
return new AttestationBundleExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
CreateTestDsseEnvelope(),
|
||||
CreateTestStatement());
|
||||
}
|
||||
|
||||
private static string CreateTestDsseEnvelope()
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
payloadType = "application/vnd.in-toto+json",
|
||||
payload = "eyJ0eXBlIjoiaHR0cHM6Ly9pbi10b3RvLmlvL1N0YXRlbWVudC92MSJ9",
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyid = "key-1", sig = "signature-data-here" }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string CreateTestStatement()
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new { name = "test-image", digest = new { sha256 = "abc123" } }
|
||||
},
|
||||
predicateType = "https://slsa.dev/provenance/v1",
|
||||
predicate = new { buildType = "test" }
|
||||
});
|
||||
}
|
||||
|
||||
private static List<string> ExtractFileNames(MemoryStream exportStream)
|
||||
{
|
||||
exportStream.Position = 0;
|
||||
var fileNames = new List<string>();
|
||||
|
||||
using var gzip = new GZipStream(exportStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
fileNames.Add(entry.Name);
|
||||
}
|
||||
|
||||
exportStream.Position = 0;
|
||||
return fileNames;
|
||||
}
|
||||
|
||||
private static string ExtractFileContent(MemoryStream exportStream, string fileName)
|
||||
{
|
||||
exportStream.Position = 0;
|
||||
|
||||
using var gzip = new GZipStream(exportStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.Name == fileName && entry.DataStream is not null)
|
||||
{
|
||||
using var reader = new StreamReader(entry.DataStream);
|
||||
var content = reader.ReadToEnd();
|
||||
exportStream.Position = 0;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
exportStream.Position = 0;
|
||||
throw new FileNotFoundException($"File '{fileName}' not found in archive.");
|
||||
}
|
||||
|
||||
private static List<TarEntryMetadataInfo> ExtractTarEntryMetadata(MemoryStream exportStream)
|
||||
{
|
||||
exportStream.Position = 0;
|
||||
var entries = new List<TarEntryMetadataInfo>();
|
||||
|
||||
using var gzip = new GZipStream(exportStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
entries.Add(new TarEntryMetadataInfo(
|
||||
entry.Name,
|
||||
entry.Uid,
|
||||
entry.Gid,
|
||||
entry.ModificationTime,
|
||||
entry.Mode));
|
||||
}
|
||||
|
||||
exportStream.Position = 0;
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed record TarEntryMetadataInfo(
|
||||
string Name,
|
||||
int Uid,
|
||||
int Gid,
|
||||
DateTimeOffset ModificationTime,
|
||||
UnixFileMode Mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake crypto hash for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeCryptoHash : StellaOps.Cryptography.ICryptoHash
|
||||
{
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
return sha256.ComputeHash(data.ToArray());
|
||||
}
|
||||
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
var hash = ComputeHash(data, algorithmId);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
var hash = ComputeHash(data, algorithmId);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return new ValueTask<byte[]>(hash);
|
||||
}
|
||||
|
||||
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHash(data, null);
|
||||
|
||||
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashHex(data, null);
|
||||
|
||||
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashBase64(data, null);
|
||||
|
||||
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashAsync(stream, null, cancellationToken);
|
||||
|
||||
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashHexAsync(stream, null, cancellationToken);
|
||||
|
||||
public string GetAlgorithmForPurpose(string purpose) => "sha256";
|
||||
|
||||
public string GetHashPrefix(string purpose) => "sha256:";
|
||||
|
||||
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> GetHashPrefix(purpose) + ComputeHashHexForPurpose(data, purpose);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.BootstrapPack;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class BootstrapPackBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly BootstrapPackBuilder _builder;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public BootstrapPackBuilderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"bootstrap-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_cryptoHash = new DefaultCryptoHash();
|
||||
_builder = new BootstrapPackBuilder(_cryptoHash);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithCharts_ProducesValidPack()
|
||||
{
|
||||
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: test-chart\nversion: 1.0.0");
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("test-chart", "1.0.0", chartPath) },
|
||||
Images: Array.Empty<BootstrapPackImageSource>());
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Manifest);
|
||||
Assert.NotEmpty(result.ManifestJson);
|
||||
Assert.NotEmpty(result.RootHash);
|
||||
Assert.NotEmpty(result.ArtifactSha256);
|
||||
Assert.True(result.PackStream.Length > 0);
|
||||
Assert.Single(result.Manifest.Charts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithImages_ProducesValidPack()
|
||||
{
|
||||
var blobPath = CreateTestFile("blob", "image layer content");
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: Array.Empty<BootstrapPackChartSource>(),
|
||||
Images: new[]
|
||||
{
|
||||
new BootstrapPackImageSource(
|
||||
"registry.example.com/app",
|
||||
"v1.0.0",
|
||||
"sha256:abc123",
|
||||
blobPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Manifest.Images);
|
||||
Assert.Equal("registry.example.com/app", result.Manifest.Images[0].Repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithChartsAndImages_IncludesAll()
|
||||
{
|
||||
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: stellaops\nversion: 2.0.0");
|
||||
var blobPath = CreateTestFile("blob", "container layer");
|
||||
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("stellaops", "2.0.0", chartPath) },
|
||||
Images: new[]
|
||||
{
|
||||
new BootstrapPackImageSource("ghcr.io/stellaops/scanner", "v3.0.0", "sha256:def456", blobPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.Single(result.Manifest.Charts);
|
||||
Assert.Single(result.Manifest.Images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesDeterministicOutput()
|
||||
{
|
||||
var chartPath = CreateTestFile("Chart-determ.yaml", "apiVersion: v2\nname: determ\nversion: 1.0.0");
|
||||
var exportId = new Guid("11111111-2222-3333-4444-555555555555");
|
||||
var tenantId = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
exportId,
|
||||
tenantId,
|
||||
Charts: new[] { new BootstrapPackChartSource("determ", "1.0.0", chartPath) },
|
||||
Images: Array.Empty<BootstrapPackImageSource>());
|
||||
|
||||
var result1 = _builder.Build(request);
|
||||
var result2 = _builder.Build(request);
|
||||
|
||||
Assert.Equal(result1.RootHash, result2.RootHash);
|
||||
Assert.Equal(result1.ArtifactSha256, result2.ArtifactSha256);
|
||||
|
||||
var bytes1 = result1.PackStream.ToArray();
|
||||
var bytes2 = result2.PackStream.ToArray();
|
||||
Assert.Equal(bytes1, bytes2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ArchiveContainsExpectedFiles()
|
||||
{
|
||||
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: archive-test\nversion: 1.0.0");
|
||||
var blobPath = CreateTestFile("layer.tar", "layer content");
|
||||
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("archive-test", "1.0.0", chartPath) },
|
||||
Images: new[]
|
||||
{
|
||||
new BootstrapPackImageSource("test/image", "latest", "sha256:xyz789", blobPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.PackStream);
|
||||
|
||||
Assert.Contains("manifest.json", fileNames);
|
||||
Assert.Contains("checksums.txt", fileNames);
|
||||
Assert.Contains("images/oci-layout", fileNames);
|
||||
Assert.Contains("images/index.json", fileNames);
|
||||
Assert.True(fileNames.Any(f => f.StartsWith("charts/")));
|
||||
Assert.True(fileNames.Any(f => f.StartsWith("images/blobs/")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TarEntriesHaveDeterministicMetadata()
|
||||
{
|
||||
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: metadata-test\nversion: 1.0.0");
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("metadata-test", "1.0.0", chartPath) },
|
||||
Images: Array.Empty<BootstrapPackImageSource>());
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var entries = ExtractTarEntryMetadata(result.PackStream);
|
||||
|
||||
var expectedTimestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
Assert.Equal(0, entry.Uid);
|
||||
Assert.Equal(0, entry.Gid);
|
||||
Assert.Equal(string.Empty, entry.UserName);
|
||||
Assert.Equal(string.Empty, entry.GroupName);
|
||||
Assert.Equal(expectedTimestamp, entry.ModificationTime);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithChartDirectory_IncludesAllFiles()
|
||||
{
|
||||
var chartDir = Path.Combine(_tempDir, "test-chart");
|
||||
Directory.CreateDirectory(chartDir);
|
||||
Directory.CreateDirectory(Path.Combine(chartDir, "templates"));
|
||||
|
||||
File.WriteAllText(Path.Combine(chartDir, "Chart.yaml"), "apiVersion: v2\nname: dir-chart\nversion: 1.0.0");
|
||||
File.WriteAllText(Path.Combine(chartDir, "values.yaml"), "replicaCount: 1");
|
||||
File.WriteAllText(Path.Combine(chartDir, "templates", "deployment.yaml"), "kind: Deployment");
|
||||
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("dir-chart", "1.0.0", chartDir) },
|
||||
Images: Array.Empty<BootstrapPackImageSource>());
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.PackStream);
|
||||
|
||||
Assert.Contains("charts/dir-chart-1.0.0/Chart.yaml", fileNames);
|
||||
Assert.Contains("charts/dir-chart-1.0.0/values.yaml", fileNames);
|
||||
Assert.Contains("charts/dir-chart-1.0.0/templates/deployment.yaml", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithSignatures_IncludesSignatureEntry()
|
||||
{
|
||||
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: sig-test\nversion: 1.0.0");
|
||||
var sigPath = CreateTestFile("mirror-bundle.sig", "signature-content");
|
||||
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("sig-test", "1.0.0", chartPath) },
|
||||
Images: Array.Empty<BootstrapPackImageSource>(),
|
||||
Signatures: new BootstrapPackSignatureSource("sha256:mirror123", sigPath));
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.PackStream);
|
||||
|
||||
Assert.NotNull(result.Manifest.Signatures);
|
||||
Assert.Equal("sha256:mirror123", result.Manifest.Signatures.MirrorBundleDigest);
|
||||
Assert.Contains("signatures/mirror-bundle.sig", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OciIndexContainsImageReferences()
|
||||
{
|
||||
var blobPath = CreateTestFile("layer", "image content");
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: Array.Empty<BootstrapPackChartSource>(),
|
||||
Images: new[]
|
||||
{
|
||||
new BootstrapPackImageSource("myregistry.io/app", "v1.2.3", "sha256:img123", blobPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var indexJson = ExtractFileContent(result.PackStream, "images/index.json");
|
||||
var index = JsonSerializer.Deserialize<OciImageIndex>(indexJson);
|
||||
|
||||
Assert.NotNull(index);
|
||||
Assert.Equal(2, index.SchemaVersion);
|
||||
Assert.Single(index.Manifests);
|
||||
Assert.Equal("sha256:img123", index.Manifests[0].Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForEmptyInputs()
|
||||
{
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: Array.Empty<BootstrapPackChartSource>(),
|
||||
Images: Array.Empty<BootstrapPackImageSource>());
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForMissingChartPath()
|
||||
{
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("missing", "1.0.0", "/nonexistent/Chart.yaml") },
|
||||
Images: Array.Empty<BootstrapPackImageSource>());
|
||||
|
||||
Assert.Throws<FileNotFoundException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ManifestVersionIsCorrect()
|
||||
{
|
||||
var chartPath = CreateTestFile("Chart.yaml", "apiVersion: v2\nname: version-test\nversion: 1.0.0");
|
||||
var request = new BootstrapPackBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Charts: new[] { new BootstrapPackChartSource("version-test", "1.0.0", chartPath) },
|
||||
Images: Array.Empty<BootstrapPackImageSource>());
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.Equal("bootstrap/v1", result.Manifest.Version);
|
||||
}
|
||||
|
||||
private string CreateTestFile(string fileName, string content)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, fileName);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<string> ExtractFileNames(MemoryStream packStream)
|
||||
{
|
||||
packStream.Position = 0;
|
||||
var fileNames = new List<string>();
|
||||
|
||||
using var gzip = new GZipStream(packStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
fileNames.Add(entry.Name);
|
||||
}
|
||||
|
||||
packStream.Position = 0;
|
||||
return fileNames;
|
||||
}
|
||||
|
||||
private static string ExtractFileContent(MemoryStream packStream, string fileName)
|
||||
{
|
||||
packStream.Position = 0;
|
||||
|
||||
using var gzip = new GZipStream(packStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.Name == fileName && entry.DataStream is not null)
|
||||
{
|
||||
using var reader = new StreamReader(entry.DataStream);
|
||||
var content = reader.ReadToEnd();
|
||||
packStream.Position = 0;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
packStream.Position = 0;
|
||||
throw new FileNotFoundException($"File '{fileName}' not found in archive.");
|
||||
}
|
||||
|
||||
private static List<TarEntryMetadata> ExtractTarEntryMetadata(MemoryStream packStream)
|
||||
{
|
||||
packStream.Position = 0;
|
||||
var entries = new List<TarEntryMetadata>();
|
||||
|
||||
using var gzip = new GZipStream(packStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
entries.Add(new TarEntryMetadata(
|
||||
entry.Uid,
|
||||
entry.Gid,
|
||||
entry.UserName ?? string.Empty,
|
||||
entry.GroupName ?? string.Empty,
|
||||
entry.ModificationTime));
|
||||
}
|
||||
|
||||
packStream.Position = 0;
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed record TarEntryMetadata(
|
||||
int Uid,
|
||||
int Gid,
|
||||
string UserName,
|
||||
string GroupName,
|
||||
DateTimeOffset ModificationTime);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using StellaOps.ExportCenter.WebService.Deprecation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Deprecation;
|
||||
|
||||
public sealed class DeprecatedEndpointsRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ListExports_HasCorrectSuccessorPath()
|
||||
{
|
||||
Assert.Equal("/v1/exports/profiles", DeprecatedEndpointsRegistry.ListExports.SuccessorPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExport_HasCorrectSuccessorPath()
|
||||
{
|
||||
Assert.Equal("/v1/exports/evidence", DeprecatedEndpointsRegistry.CreateExport.SuccessorPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteExport_HasCorrectSuccessorPath()
|
||||
{
|
||||
Assert.Equal("/v1/exports/runs/{id}/cancel", DeprecatedEndpointsRegistry.DeleteExport.SuccessorPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEndpoints_HaveDocumentationUrl()
|
||||
{
|
||||
Assert.NotNull(DeprecatedEndpointsRegistry.ListExports.DocumentationUrl);
|
||||
Assert.NotNull(DeprecatedEndpointsRegistry.CreateExport.DocumentationUrl);
|
||||
Assert.NotNull(DeprecatedEndpointsRegistry.DeleteExport.DocumentationUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEndpoints_HaveReason()
|
||||
{
|
||||
Assert.NotNull(DeprecatedEndpointsRegistry.ListExports.Reason);
|
||||
Assert.NotNull(DeprecatedEndpointsRegistry.CreateExport.Reason);
|
||||
Assert.NotNull(DeprecatedEndpointsRegistry.DeleteExport.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAll_ReturnsThreeEndpoints()
|
||||
{
|
||||
var endpoints = DeprecatedEndpointsRegistry.GetAll();
|
||||
|
||||
Assert.Equal(3, endpoints.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAll_ContainsGetExports()
|
||||
{
|
||||
var endpoints = DeprecatedEndpointsRegistry.GetAll();
|
||||
|
||||
Assert.Contains(endpoints, e => e.Method == "GET" && e.Pattern == "/exports");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAll_ContainsPostExports()
|
||||
{
|
||||
var endpoints = DeprecatedEndpointsRegistry.GetAll();
|
||||
|
||||
Assert.Contains(endpoints, e => e.Method == "POST" && e.Pattern == "/exports");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAll_ContainsDeleteExports()
|
||||
{
|
||||
var endpoints = DeprecatedEndpointsRegistry.GetAll();
|
||||
|
||||
Assert.Contains(endpoints, e => e.Method == "DELETE" && e.Pattern == "/exports/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyExportsDeprecationDate_IsJanuary2025()
|
||||
{
|
||||
Assert.Equal(2025, DeprecatedEndpointsRegistry.LegacyExportsDeprecationDate.Year);
|
||||
Assert.Equal(1, DeprecatedEndpointsRegistry.LegacyExportsDeprecationDate.Month);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyExportsSunsetDate_IsJuly2025()
|
||||
{
|
||||
Assert.Equal(2025, DeprecatedEndpointsRegistry.LegacyExportsSunsetDate.Year);
|
||||
Assert.Equal(7, DeprecatedEndpointsRegistry.LegacyExportsSunsetDate.Month);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SunsetDate_IsAfterDeprecationDate()
|
||||
{
|
||||
Assert.True(
|
||||
DeprecatedEndpointsRegistry.LegacyExportsSunsetDate >
|
||||
DeprecatedEndpointsRegistry.LegacyExportsDeprecationDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.ExportCenter.WebService.Deprecation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Deprecation;
|
||||
|
||||
public sealed class DeprecationHeaderExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddDeprecationHeaders_SetsDeprecationHeader()
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = CreateSampleDeprecationInfo();
|
||||
|
||||
context.AddDeprecationHeaders(info);
|
||||
|
||||
Assert.True(context.Response.Headers.ContainsKey(DeprecationHeaderExtensions.DeprecationHeader));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeprecationHeaders_SetsSunsetHeader()
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = CreateSampleDeprecationInfo();
|
||||
|
||||
context.AddDeprecationHeaders(info);
|
||||
|
||||
Assert.True(context.Response.Headers.ContainsKey(DeprecationHeaderExtensions.SunsetHeader));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeprecationHeaders_SetsLinkHeaderWithSuccessor()
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = CreateSampleDeprecationInfo();
|
||||
|
||||
context.AddDeprecationHeaders(info);
|
||||
|
||||
var linkHeader = context.Response.Headers[DeprecationHeaderExtensions.LinkHeader].ToString();
|
||||
Assert.Contains("successor-version", linkHeader);
|
||||
Assert.Contains("/v1/new-endpoint", linkHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeprecationHeaders_SetsLinkHeaderWithDocumentation()
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
SuccessorPath: "/v1/new",
|
||||
DocumentationUrl: "https://docs.example.com/migration");
|
||||
|
||||
context.AddDeprecationHeaders(info);
|
||||
|
||||
var linkHeader = context.Response.Headers[DeprecationHeaderExtensions.LinkHeader].ToString();
|
||||
Assert.Contains("deprecation", linkHeader);
|
||||
Assert.Contains("https://docs.example.com/migration", linkHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeprecationHeaders_SetsWarningHeader()
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = CreateSampleDeprecationInfo();
|
||||
|
||||
context.AddDeprecationHeaders(info);
|
||||
|
||||
var warningHeader = context.Response.Headers[DeprecationHeaderExtensions.WarningHeader].ToString();
|
||||
Assert.Contains("299", warningHeader);
|
||||
Assert.Contains("/v1/new-endpoint", warningHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeprecationHeaders_WarningIncludesCustomReason()
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
SuccessorPath: "/v1/new",
|
||||
Reason: "Custom deprecation reason");
|
||||
|
||||
context.AddDeprecationHeaders(info);
|
||||
|
||||
var warningHeader = context.Response.Headers[DeprecationHeaderExtensions.WarningHeader].ToString();
|
||||
Assert.Contains("Custom deprecation reason", warningHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeprecationHeaders_FormatsDateAsRfc1123()
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var deprecatedAt = new DateTimeOffset(2025, 1, 15, 12, 30, 45, TimeSpan.Zero);
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: deprecatedAt,
|
||||
SunsetAt: deprecatedAt.AddMonths(6),
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
context.AddDeprecationHeaders(info);
|
||||
|
||||
var deprecationHeader = context.Response.Headers[DeprecationHeaderExtensions.DeprecationHeader].ToString();
|
||||
// RFC 1123 format: "ddd, dd MMM yyyy HH:mm:ss 'GMT'"
|
||||
Assert.Matches(@"\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT", deprecationHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDeprecationFilter_ReturnsNonNullFilter()
|
||||
{
|
||||
var info = CreateSampleDeprecationInfo();
|
||||
|
||||
var filter = DeprecationHeaderExtensions.CreateDeprecationFilter(info);
|
||||
|
||||
Assert.NotNull(filter);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContext()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static DeprecationInfo CreateSampleDeprecationInfo()
|
||||
{
|
||||
return new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
SuccessorPath: "/v1/new-endpoint");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using StellaOps.ExportCenter.WebService.Deprecation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Deprecation;
|
||||
|
||||
public sealed class DeprecationInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPastSunset_WhenSunsetInFuture_ReturnsFalse()
|
||||
{
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.False(info.IsPastSunset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastSunset_WhenSunsetInPast_ReturnsTrue()
|
||||
{
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow.AddMonths(-12),
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.True(info.IsPastSunset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DaysUntilSunset_CalculatesCorrectly()
|
||||
{
|
||||
var sunset = DateTimeOffset.UtcNow.AddDays(30);
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
SunsetAt: sunset,
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.Equal(30, info.DaysUntilSunset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DaysUntilSunset_WhenPastSunset_ReturnsZero()
|
||||
{
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow.AddMonths(-12),
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.Equal(0, info.DaysUntilSunset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_InitializesAllProperties()
|
||||
{
|
||||
var deprecatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var sunsetAt = new DateTimeOffset(2025, 7, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: deprecatedAt,
|
||||
SunsetAt: sunsetAt,
|
||||
SuccessorPath: "/v1/exports",
|
||||
DocumentationUrl: "https://docs.example.com",
|
||||
Reason: "Replaced by new API");
|
||||
|
||||
Assert.Equal(deprecatedAt, info.DeprecatedAt);
|
||||
Assert.Equal(sunsetAt, info.SunsetAt);
|
||||
Assert.Equal("/v1/exports", info.SuccessorPath);
|
||||
Assert.Equal("https://docs.example.com", info.DocumentationUrl);
|
||||
Assert.Equal("Replaced by new API", info.Reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.ExportCenter.Core.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class ExportNotificationEmitterTests
|
||||
{
|
||||
private readonly InMemoryExportNotificationSink _sink;
|
||||
private readonly InMemoryExportNotificationDlq _dlq;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly ExportNotificationEmitter _emitter;
|
||||
|
||||
public ExportNotificationEmitterTests()
|
||||
{
|
||||
_sink = new InMemoryExportNotificationSink();
|
||||
_dlq = new InMemoryExportNotificationDlq();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_emitter = new ExportNotificationEmitter(
|
||||
_sink,
|
||||
_dlq,
|
||||
_timeProvider,
|
||||
NullLogger<ExportNotificationEmitter>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_PublishesToSink()
|
||||
{
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
var result = await _emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.AttemptCount);
|
||||
Assert.Equal(1, _sink.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_UsesCorrectChannel()
|
||||
{
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
await _emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
var messages = _sink.GetMessages(ExportNotificationTypes.AirgapReady);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_SerializesPayloadWithSnakeCase()
|
||||
{
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
await _emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
var messages = _sink.GetMessages(ExportNotificationTypes.AirgapReady);
|
||||
var payload = messages.First();
|
||||
|
||||
Assert.Contains("\"export_id\":", payload);
|
||||
Assert.Contains("\"bundle_id\":", payload);
|
||||
Assert.Contains("\"tenant_id\":", payload);
|
||||
Assert.Contains("\"artifact_sha256\":", payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_RoutesToDlqOnFailure()
|
||||
{
|
||||
var failingSink = new FailingNotificationSink(maxFailures: 10);
|
||||
var emitter = new ExportNotificationEmitter(
|
||||
failingSink,
|
||||
_dlq,
|
||||
_timeProvider,
|
||||
NullLogger<ExportNotificationEmitter>.Instance,
|
||||
new ExportNotificationEmitterOptions(MaxRetries: 3, WebhookEnabled: false, WebhookTimeout: TimeSpan.FromSeconds(30)));
|
||||
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
var result = await emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(1, _dlq.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_DlqEntryContainsCorrectData()
|
||||
{
|
||||
var failingSink = new FailingNotificationSink(maxFailures: 10);
|
||||
var emitter = new ExportNotificationEmitter(
|
||||
failingSink,
|
||||
_dlq,
|
||||
_timeProvider,
|
||||
NullLogger<ExportNotificationEmitter>.Instance,
|
||||
new ExportNotificationEmitterOptions(MaxRetries: 1, WebhookEnabled: false, WebhookTimeout: TimeSpan.FromSeconds(30)));
|
||||
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
await emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
var dlqEntries = _dlq.GetAll();
|
||||
Assert.Single(dlqEntries);
|
||||
|
||||
var entry = dlqEntries.First();
|
||||
Assert.Equal(notification.ExportId, entry.ExportId);
|
||||
Assert.Equal(notification.BundleId, entry.BundleId);
|
||||
Assert.Equal(notification.TenantId, entry.TenantId);
|
||||
Assert.Equal(ExportNotificationTypes.AirgapReady, entry.EventType);
|
||||
Assert.NotEmpty(entry.OriginalPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_RetriesTransientFailures()
|
||||
{
|
||||
var failingSink = new FailingNotificationSink(maxFailures: 2);
|
||||
var emitter = new ExportNotificationEmitter(
|
||||
failingSink,
|
||||
_dlq,
|
||||
_timeProvider,
|
||||
NullLogger<ExportNotificationEmitter>.Instance,
|
||||
new ExportNotificationEmitterOptions(MaxRetries: 5, WebhookEnabled: false, WebhookTimeout: TimeSpan.FromSeconds(30)));
|
||||
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
var result = await emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(3, result.AttemptCount);
|
||||
Assert.Equal(0, _dlq.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitToTimelineAsync_UsesTimelineChannel()
|
||||
{
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
var result = await _emitter.EmitToTimelineAsync(notification);
|
||||
|
||||
Assert.True(result.Success);
|
||||
|
||||
var messages = _sink.GetMessages(ExportNotificationTypes.TimelineAirgapReady);
|
||||
Assert.Single(messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_IncludesMetadataInPayload()
|
||||
{
|
||||
var notification = new ExportAirgapReadyNotification
|
||||
{
|
||||
ArtifactSha256 = "abc123",
|
||||
ArtifactUri = "https://example.com/artifact",
|
||||
BundleId = "bundle-001",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExportId = "export-001",
|
||||
PortableVersion = "v1",
|
||||
ProfileId = "profile-001",
|
||||
RootHash = "root123",
|
||||
TenantId = "tenant-001",
|
||||
Metadata = new ExportAirgapReadyMetadata
|
||||
{
|
||||
ExportSizeBytes = 1024,
|
||||
PortableSizeBytes = 512,
|
||||
SourceUri = "https://source.example.com/bundle"
|
||||
}
|
||||
};
|
||||
|
||||
await _emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
var messages = _sink.GetMessages(ExportNotificationTypes.AirgapReady);
|
||||
var payload = messages.First();
|
||||
|
||||
Assert.Contains("\"export_size_bytes\":1024", payload);
|
||||
Assert.Contains("\"portable_size_bytes\":512", payload);
|
||||
Assert.Contains("\"source_uri\":\"https://source.example.com/bundle\"", payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_WithWebhook_DeliversToWebhook()
|
||||
{
|
||||
var webhookClient = new FakeWebhookClient();
|
||||
var emitter = new ExportNotificationEmitter(
|
||||
_sink,
|
||||
_dlq,
|
||||
_timeProvider,
|
||||
NullLogger<ExportNotificationEmitter>.Instance,
|
||||
new ExportNotificationEmitterOptions(MaxRetries: 5, WebhookEnabled: true, WebhookTimeout: TimeSpan.FromSeconds(30)),
|
||||
webhookClient);
|
||||
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
var result = await emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, webhookClient.DeliveryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_WithWebhookFailure_RoutesToDlq()
|
||||
{
|
||||
var webhookClient = new FakeWebhookClient(alwaysFail: true);
|
||||
var emitter = new ExportNotificationEmitter(
|
||||
_sink,
|
||||
_dlq,
|
||||
_timeProvider,
|
||||
NullLogger<ExportNotificationEmitter>.Instance,
|
||||
new ExportNotificationEmitterOptions(MaxRetries: 2, WebhookEnabled: true, WebhookTimeout: TimeSpan.FromSeconds(30)),
|
||||
webhookClient);
|
||||
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
var result = await emitter.EmitAirgapReadyAsync(notification);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(1, _dlq.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAirgapReadyAsync_ThrowsOnNullNotification()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _emitter.EmitAirgapReadyAsync(null!));
|
||||
}
|
||||
|
||||
private ExportAirgapReadyNotification CreateTestNotification()
|
||||
{
|
||||
return new ExportAirgapReadyNotification
|
||||
{
|
||||
ArtifactSha256 = "sha256-test-hash",
|
||||
ArtifactUri = "https://artifacts.example.com/export/test.tgz",
|
||||
BundleId = Guid.NewGuid().ToString("D"),
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExportId = Guid.NewGuid().ToString("D"),
|
||||
PortableVersion = "v1",
|
||||
ProfileId = "mirror:full",
|
||||
RootHash = "root-hash-test",
|
||||
TenantId = Guid.NewGuid().ToString("D")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FailingNotificationSink : IExportNotificationSink
|
||||
{
|
||||
private readonly int _maxFailures;
|
||||
private int _failures;
|
||||
|
||||
public FailingNotificationSink(int maxFailures)
|
||||
{
|
||||
_maxFailures = maxFailures;
|
||||
}
|
||||
|
||||
public Task PublishAsync(string channel, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_failures < _maxFailures)
|
||||
{
|
||||
_failures++;
|
||||
throw new TimeoutException("Simulated transient failure");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeWebhookClient : IExportWebhookClient
|
||||
{
|
||||
private readonly bool _alwaysFail;
|
||||
|
||||
public int DeliveryCount { get; private set; }
|
||||
|
||||
public FakeWebhookClient(bool alwaysFail = false)
|
||||
{
|
||||
_alwaysFail = alwaysFail;
|
||||
}
|
||||
|
||||
public Task<WebhookDeliveryResult> DeliverAsync(
|
||||
string eventType,
|
||||
string payload,
|
||||
DateTimeOffset sentAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeliveryCount++;
|
||||
|
||||
if (_alwaysFail)
|
||||
{
|
||||
return Task.FromResult(new WebhookDeliveryResult(
|
||||
Success: false,
|
||||
StatusCode: 500,
|
||||
ErrorMessage: "Simulated failure",
|
||||
ShouldRetry: false));
|
||||
}
|
||||
|
||||
return Task.FromResult(new WebhookDeliveryResult(
|
||||
Success: true,
|
||||
StatusCode: 200,
|
||||
ErrorMessage: null,
|
||||
ShouldRetry: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExportWebhookClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeSignature_ProducesDeterministicOutput()
|
||||
{
|
||||
var payload = "{\"export_id\":\"abc123\"}";
|
||||
var sentAt = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var signingKey = "test-secret-key";
|
||||
|
||||
var sig1 = ExportWebhookClient.ComputeSignature(payload, sentAt, signingKey);
|
||||
var sig2 = ExportWebhookClient.ComputeSignature(payload, sentAt, signingKey);
|
||||
|
||||
Assert.Equal(sig1, sig2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_StartsWithSha256Prefix()
|
||||
{
|
||||
var payload = "{\"test\":true}";
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
var signingKey = "test-key";
|
||||
|
||||
var signature = ExportWebhookClient.ComputeSignature(payload, sentAt, signingKey);
|
||||
|
||||
Assert.StartsWith("sha256=", signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_ChangesWithDifferentPayload()
|
||||
{
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
var signingKey = "test-key";
|
||||
|
||||
var sig1 = ExportWebhookClient.ComputeSignature("{\"a\":1}", sentAt, signingKey);
|
||||
var sig2 = ExportWebhookClient.ComputeSignature("{\"a\":2}", sentAt, signingKey);
|
||||
|
||||
Assert.NotEqual(sig1, sig2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_ChangesWithDifferentTimestamp()
|
||||
{
|
||||
var payload = "{\"test\":true}";
|
||||
var signingKey = "test-key";
|
||||
|
||||
var sig1 = ExportWebhookClient.ComputeSignature(payload, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), signingKey);
|
||||
var sig2 = ExportWebhookClient.ComputeSignature(payload, new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero), signingKey);
|
||||
|
||||
Assert.NotEqual(sig1, sig2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_ChangesWithDifferentKey()
|
||||
{
|
||||
var payload = "{\"test\":true}";
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var sig1 = ExportWebhookClient.ComputeSignature(payload, sentAt, "key1");
|
||||
var sig2 = ExportWebhookClient.ComputeSignature(payload, sentAt, "key2");
|
||||
|
||||
Assert.NotEqual(sig1, sig2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_AcceptsBase64Key()
|
||||
{
|
||||
var payload = "{\"test\":true}";
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
var base64Key = Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 });
|
||||
|
||||
var signature = ExportWebhookClient.ComputeSignature(payload, sentAt, base64Key);
|
||||
|
||||
Assert.StartsWith("sha256=", signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_AcceptsHexKey()
|
||||
{
|
||||
var payload = "{\"test\":true}";
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
var hexKey = "0102030405060708";
|
||||
|
||||
var signature = ExportWebhookClient.ComputeSignature(payload, sentAt, hexKey);
|
||||
|
||||
Assert.StartsWith("sha256=", signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySignature_ReturnsTrueForValidSignature()
|
||||
{
|
||||
var payload = "{\"test\":true}";
|
||||
var sentAt = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var signingKey = "test-key";
|
||||
|
||||
var signature = ExportWebhookClient.ComputeSignature(payload, sentAt, signingKey);
|
||||
var isValid = ExportWebhookClient.VerifySignature(payload, sentAt, signingKey, signature);
|
||||
|
||||
Assert.True(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySignature_ReturnsFalseForInvalidSignature()
|
||||
{
|
||||
var payload = "{\"test\":true}";
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
var signingKey = "test-key";
|
||||
|
||||
var isValid = ExportWebhookClient.VerifySignature(payload, sentAt, signingKey, "sha256=invalid");
|
||||
|
||||
Assert.False(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifySignature_ReturnsFalseForTamperedPayload()
|
||||
{
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
var signingKey = "test-key";
|
||||
|
||||
var signature = ExportWebhookClient.ComputeSignature("{\"test\":true}", sentAt, signingKey);
|
||||
var isValid = ExportWebhookClient.VerifySignature("{\"test\":false}", sentAt, signingKey, signature);
|
||||
|
||||
Assert.False(isValid);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class InMemoryExportNotificationSinkTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishAsync_StoresMessage()
|
||||
{
|
||||
var sink = new InMemoryExportNotificationSink();
|
||||
|
||||
await sink.PublishAsync("test.channel", "{\"test\":true}");
|
||||
|
||||
Assert.Equal(1, sink.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessages_ReturnsMessagesByChannel()
|
||||
{
|
||||
var sink = new InMemoryExportNotificationSink();
|
||||
|
||||
await sink.PublishAsync("channel.a", "{\"a\":1}");
|
||||
await sink.PublishAsync("channel.b", "{\"b\":2}");
|
||||
await sink.PublishAsync("channel.a", "{\"a\":3}");
|
||||
|
||||
var messagesA = sink.GetMessages("channel.a");
|
||||
var messagesB = sink.GetMessages("channel.b");
|
||||
|
||||
Assert.Equal(2, messagesA.Count);
|
||||
Assert.Single(messagesB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllMessages()
|
||||
{
|
||||
var sink = new InMemoryExportNotificationSink();
|
||||
|
||||
await sink.PublishAsync("test", "message1");
|
||||
await sink.PublishAsync("test", "message2");
|
||||
sink.Clear();
|
||||
|
||||
Assert.Equal(0, sink.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class InMemoryExportNotificationDlqTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_StoresEntry()
|
||||
{
|
||||
var dlq = new InMemoryExportNotificationDlq();
|
||||
var entry = CreateTestDlqEntry();
|
||||
|
||||
await dlq.EnqueueAsync(entry);
|
||||
|
||||
Assert.Equal(1, dlq.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingAsync_ReturnsAllEntries()
|
||||
{
|
||||
var dlq = new InMemoryExportNotificationDlq();
|
||||
|
||||
await dlq.EnqueueAsync(CreateTestDlqEntry("tenant-1"));
|
||||
await dlq.EnqueueAsync(CreateTestDlqEntry("tenant-2"));
|
||||
|
||||
var pending = await dlq.GetPendingAsync();
|
||||
|
||||
Assert.Equal(2, pending.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingAsync_FiltersByTenant()
|
||||
{
|
||||
var dlq = new InMemoryExportNotificationDlq();
|
||||
|
||||
await dlq.EnqueueAsync(CreateTestDlqEntry("tenant-1"));
|
||||
await dlq.EnqueueAsync(CreateTestDlqEntry("tenant-2"));
|
||||
await dlq.EnqueueAsync(CreateTestDlqEntry("tenant-1"));
|
||||
|
||||
var pending = await dlq.GetPendingAsync(tenantId: "tenant-1");
|
||||
|
||||
Assert.Equal(2, pending.Count);
|
||||
Assert.All(pending, e => Assert.Equal("tenant-1", e.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingAsync_RespectsLimit()
|
||||
{
|
||||
var dlq = new InMemoryExportNotificationDlq();
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
await dlq.EnqueueAsync(CreateTestDlqEntry());
|
||||
}
|
||||
|
||||
var pending = await dlq.GetPendingAsync(limit: 5);
|
||||
|
||||
Assert.Equal(5, pending.Count);
|
||||
}
|
||||
|
||||
private static ExportNotificationDlqEntry CreateTestDlqEntry(string? tenantId = null)
|
||||
{
|
||||
return new ExportNotificationDlqEntry
|
||||
{
|
||||
EventType = ExportNotificationTypes.AirgapReady,
|
||||
ExportId = Guid.NewGuid().ToString(),
|
||||
BundleId = Guid.NewGuid().ToString(),
|
||||
TenantId = tenantId ?? Guid.NewGuid().ToString(),
|
||||
FailureReason = "Test failure",
|
||||
AttemptCount = 3,
|
||||
LastAttemptAt = DateTimeOffset.UtcNow,
|
||||
OriginalPayload = "{}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
||||
|
||||
public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class MirrorBundleBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly MirrorBundleBuilder _builder;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public MirrorBundleBuilderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"mirror-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_cryptoHash = new DefaultCryptoHash();
|
||||
_builder = new MirrorBundleBuilder(_cryptoHash);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FullBundle_ProducesValidArchive()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-1234\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "registry.example.com/app:*" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Manifest);
|
||||
Assert.NotEmpty(result.ManifestJson);
|
||||
Assert.NotEmpty(result.RootHash);
|
||||
Assert.True(result.BundleStream.Length > 0);
|
||||
Assert.Equal("mirror:full", result.Manifest.Profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DeltaBundle_IncludesDeltaMetadata()
|
||||
{
|
||||
var vexPath = CreateTestFile("vex.jsonl.zst", "{\"id\":\"VEX-001\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Delta,
|
||||
new MirrorBundleSelectors(new[] { "product-a" }, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Vex, vexPath)
|
||||
},
|
||||
DeltaOptions: new MirrorBundleDeltaOptions("run-20251001", "sha256:abc123"));
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result.Manifest.Delta);
|
||||
Assert.Equal("run-20251001", result.Manifest.Delta.BaseExportId);
|
||||
Assert.Equal("sha256:abc123", result.Manifest.Delta.BaseManifestDigest);
|
||||
Assert.Equal("mirror:delta", result.Manifest.Profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithEncryption_IncludesEncryptionMetadata()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-5678\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-b" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
},
|
||||
Encryption: new MirrorBundleEncryptionOptions(
|
||||
MirrorBundleEncryptionMode.Age,
|
||||
new[] { "age1recipient..." },
|
||||
Strict: false));
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result.Manifest.Encryption);
|
||||
Assert.Equal("age", result.Manifest.Encryption.Mode);
|
||||
Assert.False(result.Manifest.Encryption.Strict);
|
||||
Assert.Single(result.Manifest.Encryption.Recipients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesDeterministicOutput()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-DETERM\"}");
|
||||
var runId = new Guid("11111111-2222-3333-4444-555555555555");
|
||||
var tenantId = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
runId,
|
||||
tenantId,
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-deterministic" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
});
|
||||
|
||||
var result1 = _builder.Build(request);
|
||||
var result2 = _builder.Build(request);
|
||||
|
||||
// Root hashes should match for identical inputs
|
||||
Assert.Equal(result1.RootHash, result2.RootHash);
|
||||
|
||||
// Archive content should be identical
|
||||
var bytes1 = result1.BundleStream.ToArray();
|
||||
var bytes2 = result2.BundleStream.ToArray();
|
||||
Assert.Equal(bytes1, bytes2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ArchiveContainsExpectedFiles()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-ARCHIVE\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-archive" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.BundleStream);
|
||||
|
||||
Assert.Contains("manifest.yaml", fileNames);
|
||||
Assert.Contains("export.json", fileNames);
|
||||
Assert.Contains("provenance.json", fileNames);
|
||||
Assert.Contains("checksums.txt", fileNames);
|
||||
Assert.Contains("README.md", fileNames);
|
||||
Assert.Contains("verify-mirror.sh", fileNames);
|
||||
Assert.Contains("indexes/advisories.index.json", fileNames);
|
||||
Assert.Contains("indexes/vex.index.json", fileNames);
|
||||
Assert.Contains("data/raw/advisories/advisories.jsonl.zst", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TarEntriesHaveDeterministicMetadata()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-2024-METADATA\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-metadata" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var entries = ExtractTarEntryMetadata(result.BundleStream);
|
||||
|
||||
var expectedTimestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
Assert.Equal(0, entry.Uid);
|
||||
Assert.Equal(0, entry.Gid);
|
||||
Assert.Equal(string.Empty, entry.UserName);
|
||||
Assert.Equal(string.Empty, entry.GroupName);
|
||||
Assert.Equal(expectedTimestamp, entry.ModificationTime);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SbomWithSubject_UsesCorrectPath()
|
||||
{
|
||||
var sbomPath = CreateTestFile("sbom.json", "{\"bomFormat\":\"CycloneDX\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-sbom" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(
|
||||
MirrorBundleDataCategory.Sbom,
|
||||
sbomPath,
|
||||
SubjectId: "registry.example.com/app:v1.2.3")
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.BundleStream);
|
||||
|
||||
Assert.Contains("data/raw/sboms/registry.example.com-app-v1.2.3/sbom.json", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NormalizedData_UsesNormalizedPath()
|
||||
{
|
||||
var normalizedPath = CreateTestFile("advisories-normalized.jsonl.zst", "{\"id\":\"CVE-2024-NORM\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-normalized" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(
|
||||
MirrorBundleDataCategory.Advisories,
|
||||
normalizedPath,
|
||||
IsNormalized: true)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.BundleStream);
|
||||
|
||||
Assert.Contains("data/normalized/advisories/advisories-normalized.jsonl.zst", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CountsAreAccurate()
|
||||
{
|
||||
var advisory1 = CreateTestFile("advisory1.jsonl.zst", "{\"id\":\"CVE-1\"}");
|
||||
var advisory2 = CreateTestFile("advisory2.jsonl.zst", "{\"id\":\"CVE-2\"}");
|
||||
var vex1 = CreateTestFile("vex1.jsonl.zst", "{\"id\":\"VEX-1\"}");
|
||||
var sbom1 = CreateTestFile("sbom1.json", "{\"bomFormat\":\"CycloneDX\"}");
|
||||
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-counts" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisory1),
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisory2),
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Vex, vex1),
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Sbom, sbom1)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.Equal(2, result.Manifest.Counts.Advisories);
|
||||
Assert.Equal(1, result.Manifest.Counts.Vex);
|
||||
Assert.Equal(1, result.Manifest.Counts.Sboms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForMissingDataSource()
|
||||
{
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-missing" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, "/nonexistent/file.jsonl.zst")
|
||||
});
|
||||
|
||||
Assert.Throws<FileNotFoundException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForDeltaWithoutOptions()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-DELTA\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Delta,
|
||||
new MirrorBundleSelectors(new[] { "product-delta" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
},
|
||||
DeltaOptions: null);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProvenanceDocumentContainsSubjects()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-PROVENANCE\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-provenance" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotEmpty(result.ProvenanceDocument.Subjects);
|
||||
Assert.Contains(result.ProvenanceDocument.Subjects, s => s.Name == "manifest.yaml");
|
||||
Assert.NotNull(result.ProvenanceDocument.Builder);
|
||||
Assert.NotEmpty(result.ProvenanceDocument.Builder.ExporterVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ExportDocumentContainsManifestDigest()
|
||||
{
|
||||
var advisoryPath = CreateTestFile("advisories.jsonl.zst", "{\"id\":\"CVE-EXPORT\"}");
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
MirrorBundleVariant.Full,
|
||||
new MirrorBundleSelectors(new[] { "product-export" }, null, null),
|
||||
new[]
|
||||
{
|
||||
new MirrorBundleDataSource(MirrorBundleDataCategory.Advisories, advisoryPath)
|
||||
});
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.StartsWith("sha256:", result.ExportDocument.ManifestDigest);
|
||||
Assert.Equal(result.Manifest.Profile, $"{result.ExportDocument.Profile.Kind}:{result.ExportDocument.Profile.Variant}");
|
||||
}
|
||||
|
||||
private string CreateTestFile(string fileName, string content)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, fileName);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<string> ExtractFileNames(MemoryStream bundleStream)
|
||||
{
|
||||
bundleStream.Position = 0;
|
||||
var fileNames = new List<string>();
|
||||
|
||||
using var gzip = new GZipStream(bundleStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
fileNames.Add(entry.Name);
|
||||
}
|
||||
|
||||
bundleStream.Position = 0;
|
||||
return fileNames;
|
||||
}
|
||||
|
||||
private static List<TarEntryMetadata> ExtractTarEntryMetadata(MemoryStream bundleStream)
|
||||
{
|
||||
bundleStream.Position = 0;
|
||||
var entries = new List<TarEntryMetadata>();
|
||||
|
||||
using var gzip = new GZipStream(bundleStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
entries.Add(new TarEntryMetadata(
|
||||
entry.Uid,
|
||||
entry.Gid,
|
||||
entry.UserName ?? string.Empty,
|
||||
entry.GroupName ?? string.Empty,
|
||||
entry.ModificationTime));
|
||||
}
|
||||
|
||||
bundleStream.Position = 0;
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed record TarEntryMetadata(
|
||||
int Uid,
|
||||
int Gid,
|
||||
string UserName,
|
||||
string GroupName,
|
||||
DateTimeOffset ModificationTime);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class MirrorBundleSigningTests
|
||||
{
|
||||
private readonly ICryptoHmac _cryptoHmac;
|
||||
private readonly HmacMirrorBundleManifestSigner _signer;
|
||||
|
||||
public MirrorBundleSigningTests()
|
||||
{
|
||||
_cryptoHmac = new DefaultCryptoHmac();
|
||||
_signer = new HmacMirrorBundleManifestSigner(_cryptoHmac, "test-signing-key-12345", "test-key-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExportDocumentAsync_ReturnsDsseEnvelope()
|
||||
{
|
||||
var exportJson = """{"runId":"abc123","tenantId":"tenant-1"}""";
|
||||
|
||||
var result = await _signer.SignExportDocumentAsync(exportJson);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("application/vnd.stellaops.mirror-bundle.export+json", result.PayloadType);
|
||||
Assert.NotEmpty(result.Payload);
|
||||
Assert.Single(result.Signatures);
|
||||
Assert.Equal("test-key-id", result.Signatures[0].KeyId);
|
||||
Assert.NotEmpty(result.Signatures[0].Signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignManifestAsync_ReturnsDsseEnvelope()
|
||||
{
|
||||
var manifestYaml = "profile: mirror:full\nrunId: abc123";
|
||||
|
||||
var result = await _signer.SignManifestAsync(manifestYaml);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("application/vnd.stellaops.mirror-bundle.manifest+yaml", result.PayloadType);
|
||||
Assert.NotEmpty(result.Payload);
|
||||
Assert.Single(result.Signatures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignArchiveAsync_ReturnsBase64Signature()
|
||||
{
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test archive content"));
|
||||
|
||||
var signature = await _signer.SignArchiveAsync(stream);
|
||||
|
||||
Assert.NotEmpty(signature);
|
||||
// Verify it's valid base64
|
||||
var decoded = Convert.FromBase64String(signature);
|
||||
Assert.NotEmpty(decoded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignArchiveAsync_ResetStreamPosition()
|
||||
{
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test archive content"));
|
||||
stream.Position = 5;
|
||||
|
||||
await _signer.SignArchiveAsync(stream);
|
||||
|
||||
Assert.Equal(0, stream.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExportDocumentAsync_PayloadIsBase64Encoded()
|
||||
{
|
||||
var exportJson = """{"runId":"encoded-test"}""";
|
||||
|
||||
var result = await _signer.SignExportDocumentAsync(exportJson);
|
||||
|
||||
var decodedPayload = Encoding.UTF8.GetString(Convert.FromBase64String(result.Payload));
|
||||
Assert.Equal(exportJson, decodedPayload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignExportDocumentAsync_IsDeterministic()
|
||||
{
|
||||
var exportJson = """{"runId":"deterministic-test"}""";
|
||||
|
||||
var result1 = await _signer.SignExportDocumentAsync(exportJson);
|
||||
var result2 = await _signer.SignExportDocumentAsync(exportJson);
|
||||
|
||||
Assert.Equal(result1.Signatures[0].Signature, result2.Signatures[0].Signature);
|
||||
Assert.Equal(result1.Payload, result2.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_SerializesCorrectly()
|
||||
{
|
||||
var signature = new MirrorBundleDsseSignature(
|
||||
"test/payload+json",
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes("test-payload")),
|
||||
new[] { new MirrorBundleDsseSignatureEntry("sig-value", "key-id-1") });
|
||||
|
||||
var json = signature.ToJson();
|
||||
|
||||
Assert.Contains("\"payloadType\"", json);
|
||||
Assert.Contains("test/payload+json", json);
|
||||
Assert.Contains("\"signatures\"", json);
|
||||
Assert.Contains("sig-value", json);
|
||||
|
||||
// Verify it's valid JSON
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
Assert.NotNull(parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsForEmptyKey()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new HmacMirrorBundleManifestSigner(_cryptoHmac, "", "key-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsForNullKey()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new HmacMirrorBundleManifestSigner(_cryptoHmac, null!, "key-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsForNullCryptoHmac()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new HmacMirrorBundleManifestSigner(null!, "test-key", "key-id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_UsesDefaultKeyIdWhenEmpty()
|
||||
{
|
||||
var signer = new HmacMirrorBundleManifestSigner(_cryptoHmac, "test-key", "");
|
||||
var result = signer.SignExportDocumentAsync("{}").Result;
|
||||
|
||||
Assert.Equal("mirror-bundle-hmac", result.Signatures[0].KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignArchiveAsync_ThrowsForNonSeekableStream()
|
||||
{
|
||||
using var nonSeekable = new NonSeekableMemoryStream(Encoding.UTF8.GetBytes("test"));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
_signer.SignArchiveAsync(nonSeekable));
|
||||
}
|
||||
|
||||
private sealed class NonSeekableMemoryStream : MemoryStream
|
||||
{
|
||||
public NonSeekableMemoryStream(byte[] buffer) : base(buffer) { }
|
||||
public override bool CanSeek => false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.ExportCenter.Core.OfflineKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class OfflineKitDistributorTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FakeCryptoHash _cryptoHash;
|
||||
private readonly OfflineKitDistributor _distributor;
|
||||
|
||||
public OfflineKitDistributorTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"offline-kit-dist-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_cryptoHash = new FakeCryptoHash();
|
||||
_distributor = new OfflineKitDistributor(_cryptoHash, _timeProvider);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_CopiesFilesToMirrorLocation()
|
||||
{
|
||||
var sourceKit = SetupSourceKit();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(Directory.Exists(Path.Combine(mirrorBase, "export", "attestations", kitVersion)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_CreatesManifestOfflineJson()
|
||||
{
|
||||
var sourceKit = SetupSourceKit();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.ManifestPath);
|
||||
Assert.True(File.Exists(result.ManifestPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_ManifestContainsAttestationEntry()
|
||||
{
|
||||
var sourceKit = SetupSourceKitWithAttestation();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
Assert.True(result.Success);
|
||||
|
||||
var manifestJson = File.ReadAllText(result.ManifestPath!);
|
||||
var manifest = JsonSerializer.Deserialize<JsonElement>(manifestJson);
|
||||
|
||||
var entries = manifest.GetProperty("entries").EnumerateArray().ToList();
|
||||
var attestationEntry = entries.FirstOrDefault(e =>
|
||||
e.GetProperty("kind").GetString() == "attestation-kit");
|
||||
|
||||
Assert.NotEqual(default, attestationEntry);
|
||||
Assert.Contains("stella attest bundle verify", attestationEntry.GetProperty("cliExample").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_CreatesManifestChecksum()
|
||||
{
|
||||
var sourceKit = SetupSourceKit();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(result.ManifestPath + ".sha256"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_PreservesBytesExactly()
|
||||
{
|
||||
var sourceKit = SetupSourceKitWithAttestation();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var sourceFile = Path.Combine(sourceKit, "attestations", "export-attestation-bundle-v1.tgz");
|
||||
var sourceBytes = File.ReadAllBytes(sourceFile);
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
var targetFile = Path.Combine(result.TargetPath!, "attestations", "export-attestation-bundle-v1.tgz");
|
||||
var targetBytes = File.ReadAllBytes(targetFile);
|
||||
|
||||
Assert.Equal(sourceBytes, targetBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_ReturnsCorrectFileCount()
|
||||
{
|
||||
var sourceKit = SetupSourceKitWithMultipleFiles();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.CopiedFileCount >= 3); // At least 3 files
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_SourceNotFound_ReturnsFailed()
|
||||
{
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var result = _distributor.DistributeToMirror("/nonexistent/path", mirrorBase, kitVersion);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDistribution_MatchingKits_ReturnsSuccess()
|
||||
{
|
||||
var sourceKit = SetupSourceKitWithAttestation();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var distResult = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
Assert.True(distResult.Success);
|
||||
|
||||
var verifyResult = _distributor.VerifyDistribution(sourceKit, distResult.TargetPath!);
|
||||
|
||||
Assert.True(verifyResult.Success);
|
||||
Assert.Empty(verifyResult.Mismatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDistribution_MissingFile_ReportsError()
|
||||
{
|
||||
var sourceKit = SetupSourceKitWithAttestation();
|
||||
var targetKit = Path.Combine(_tempDir, "target-incomplete");
|
||||
Directory.CreateDirectory(targetKit);
|
||||
|
||||
// Copy only some files
|
||||
var sourceFile = Directory.GetFiles(sourceKit, "*", SearchOption.AllDirectories).First();
|
||||
// Don't copy anything to target
|
||||
|
||||
var result = _distributor.VerifyDistribution(sourceKit, targetKit);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotEmpty(result.Mismatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyDistribution_ModifiedFile_ReportsHashMismatch()
|
||||
{
|
||||
var sourceKit = SetupSourceKitWithAttestation();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var distResult = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
Assert.True(distResult.Success);
|
||||
|
||||
// Modify a file in target
|
||||
var targetFile = Path.Combine(distResult.TargetPath!, "attestations", "export-attestation-bundle-v1.tgz");
|
||||
File.WriteAllText(targetFile, "modified content");
|
||||
|
||||
var verifyResult = _distributor.VerifyDistribution(sourceKit, distResult.TargetPath!);
|
||||
|
||||
Assert.False(verifyResult.Success);
|
||||
Assert.Contains(verifyResult.Mismatches, m => m.Contains("Hash mismatch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_ManifestHasCorrectVersion()
|
||||
{
|
||||
var sourceKit = SetupSourceKit();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v2.0.0";
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
var manifestJson = File.ReadAllText(result.ManifestPath!);
|
||||
var manifest = JsonSerializer.Deserialize<JsonElement>(manifestJson);
|
||||
|
||||
Assert.Equal("offline-kit/v1", manifest.GetProperty("version").GetString());
|
||||
Assert.Equal(kitVersion, manifest.GetProperty("kitVersion").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributeToMirror_MirrorBundleEntry_HasCorrectPaths()
|
||||
{
|
||||
var sourceKit = SetupSourceKitWithMirror();
|
||||
var mirrorBase = Path.Combine(_tempDir, "mirror");
|
||||
var kitVersion = "v1";
|
||||
|
||||
var result = _distributor.DistributeToMirror(sourceKit, mirrorBase, kitVersion);
|
||||
|
||||
var manifestJson = File.ReadAllText(result.ManifestPath!);
|
||||
var manifest = JsonSerializer.Deserialize<JsonElement>(manifestJson);
|
||||
|
||||
var entries = manifest.GetProperty("entries").EnumerateArray().ToList();
|
||||
var mirrorEntry = entries.FirstOrDefault(e =>
|
||||
e.GetProperty("kind").GetString() == "mirror-bundle");
|
||||
|
||||
Assert.NotEqual(default, mirrorEntry);
|
||||
Assert.Equal("mirrors/export-mirror-bundle-v1.tgz", mirrorEntry.GetProperty("artifact").GetString());
|
||||
}
|
||||
|
||||
private string SetupSourceKit()
|
||||
{
|
||||
var kitPath = Path.Combine(_tempDir, $"source-kit-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(kitPath);
|
||||
File.WriteAllText(Path.Combine(kitPath, "manifest.json"), "{}");
|
||||
return kitPath;
|
||||
}
|
||||
|
||||
private string SetupSourceKitWithAttestation()
|
||||
{
|
||||
var kitPath = Path.Combine(_tempDir, $"source-kit-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path.Combine(kitPath, "attestations"));
|
||||
Directory.CreateDirectory(Path.Combine(kitPath, "checksums", "attestations"));
|
||||
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(kitPath, "attestations", "export-attestation-bundle-v1.tgz"),
|
||||
Encoding.UTF8.GetBytes("test-attestation-bundle"));
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(kitPath, "checksums", "attestations", "export-attestation-bundle-v1.tgz.sha256"),
|
||||
"abc123 export-attestation-bundle-v1.tgz");
|
||||
|
||||
File.WriteAllText(Path.Combine(kitPath, "manifest.json"), "{}");
|
||||
|
||||
return kitPath;
|
||||
}
|
||||
|
||||
private string SetupSourceKitWithMirror()
|
||||
{
|
||||
var kitPath = SetupSourceKitWithAttestation();
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(kitPath, "mirrors"));
|
||||
Directory.CreateDirectory(Path.Combine(kitPath, "checksums", "mirrors"));
|
||||
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(kitPath, "mirrors", "export-mirror-bundle-v1.tgz"),
|
||||
Encoding.UTF8.GetBytes("test-mirror-bundle"));
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(kitPath, "checksums", "mirrors", "export-mirror-bundle-v1.tgz.sha256"),
|
||||
"def456 export-mirror-bundle-v1.tgz");
|
||||
|
||||
return kitPath;
|
||||
}
|
||||
|
||||
private string SetupSourceKitWithMultipleFiles()
|
||||
{
|
||||
var kitPath = SetupSourceKitWithAttestation();
|
||||
|
||||
// Add bootstrap
|
||||
Directory.CreateDirectory(Path.Combine(kitPath, "bootstrap"));
|
||||
Directory.CreateDirectory(Path.Combine(kitPath, "checksums", "bootstrap"));
|
||||
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(kitPath, "bootstrap", "export-bootstrap-pack-v1.tgz"),
|
||||
Encoding.UTF8.GetBytes("test-bootstrap"));
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(kitPath, "checksums", "bootstrap", "export-bootstrap-pack-v1.tgz.sha256"),
|
||||
"ghi789 export-bootstrap-pack-v1.tgz");
|
||||
|
||||
return kitPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.ExportCenter.Core.OfflineKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class OfflineKitPackagerTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FakeCryptoHash _cryptoHash;
|
||||
private readonly OfflineKitPackager _packager;
|
||||
|
||||
public OfflineKitPackagerTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"offline-kit-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_cryptoHash = new FakeCryptoHash();
|
||||
_packager = new OfflineKitPackager(_cryptoHash, _timeProvider);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttestationBundle_CreatesArtifactAndChecksum()
|
||||
{
|
||||
var request = CreateTestAttestationRequest();
|
||||
|
||||
var result = _packager.AddAttestationBundle(_tempDir, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, result.ArtifactPath)));
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttestationBundle_PreservesBytesExactly()
|
||||
{
|
||||
var originalBytes = Encoding.UTF8.GetBytes("test-attestation-bundle-content");
|
||||
var request = new OfflineKitAttestationRequest(
|
||||
KitId: "kit-001",
|
||||
ExportId: Guid.NewGuid().ToString(),
|
||||
AttestationId: Guid.NewGuid().ToString(),
|
||||
RootHash: "abc123",
|
||||
BundleBytes: originalBytes,
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
var result = _packager.AddAttestationBundle(_tempDir, request);
|
||||
|
||||
var writtenBytes = File.ReadAllBytes(Path.Combine(_tempDir, result.ArtifactPath));
|
||||
Assert.Equal(originalBytes, writtenBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttestationBundle_ChecksumFileContainsCorrectFormat()
|
||||
{
|
||||
var request = CreateTestAttestationRequest();
|
||||
|
||||
var result = _packager.AddAttestationBundle(_tempDir, request);
|
||||
|
||||
var checksumContent = File.ReadAllText(Path.Combine(_tempDir, result.ChecksumPath));
|
||||
Assert.Contains("export-attestation-bundle-v1.tgz", checksumContent);
|
||||
Assert.Contains(result.Sha256Hash, checksumContent);
|
||||
Assert.Contains(" ", checksumContent); // Two spaces before filename
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttestationBundle_RejectsOverwrite()
|
||||
{
|
||||
var request = CreateTestAttestationRequest();
|
||||
|
||||
// First write succeeds
|
||||
var result1 = _packager.AddAttestationBundle(_tempDir, request);
|
||||
Assert.True(result1.Success);
|
||||
|
||||
// Second write fails (immutability)
|
||||
var result2 = _packager.AddAttestationBundle(_tempDir, request);
|
||||
Assert.False(result2.Success);
|
||||
Assert.Contains("immutable", result2.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddMirrorBundle_CreatesArtifactAndChecksum()
|
||||
{
|
||||
var request = CreateTestMirrorRequest();
|
||||
|
||||
var result = _packager.AddMirrorBundle(_tempDir, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, result.ArtifactPath)));
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBootstrapPack_CreatesArtifactAndChecksum()
|
||||
{
|
||||
var request = CreateTestBootstrapRequest();
|
||||
|
||||
var result = _packager.AddBootstrapPack(_tempDir, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, result.ArtifactPath)));
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAttestationEntry_HasCorrectKind()
|
||||
{
|
||||
var request = CreateTestAttestationRequest();
|
||||
|
||||
var entry = _packager.CreateAttestationEntry(request, "sha256hash");
|
||||
|
||||
Assert.Equal("attestation-export", entry.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAttestationEntry_HasCorrectPaths()
|
||||
{
|
||||
var request = CreateTestAttestationRequest();
|
||||
|
||||
var entry = _packager.CreateAttestationEntry(request, "sha256hash");
|
||||
|
||||
Assert.Equal("attestations/export-attestation-bundle-v1.tgz", entry.Artifact);
|
||||
Assert.Equal("checksums/attestations/export-attestation-bundle-v1.tgz.sha256", entry.Checksum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAttestationEntry_FormatsRootHashWithPrefix()
|
||||
{
|
||||
var request = new OfflineKitAttestationRequest(
|
||||
KitId: "kit-001",
|
||||
ExportId: Guid.NewGuid().ToString(),
|
||||
AttestationId: Guid.NewGuid().ToString(),
|
||||
RootHash: "abc123def456",
|
||||
BundleBytes: new byte[] { 1, 2, 3 },
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
var entry = _packager.CreateAttestationEntry(request, "sha256hash");
|
||||
|
||||
Assert.Equal("sha256:abc123def456", entry.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateMirrorEntry_HasCorrectKind()
|
||||
{
|
||||
var request = CreateTestMirrorRequest();
|
||||
|
||||
var entry = _packager.CreateMirrorEntry(request, "sha256hash");
|
||||
|
||||
Assert.Equal("mirror-bundle", entry.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBootstrapEntry_HasCorrectKind()
|
||||
{
|
||||
var request = CreateTestBootstrapRequest();
|
||||
|
||||
var entry = _packager.CreateBootstrapEntry(request, "sha256hash");
|
||||
|
||||
Assert.Equal("bootstrap-pack", entry.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteManifest_CreatesManifestFile()
|
||||
{
|
||||
var kitId = "kit-" + Guid.NewGuid().ToString("N");
|
||||
var entries = new List<object>
|
||||
{
|
||||
_packager.CreateAttestationEntry(CreateTestAttestationRequest(), "hash1")
|
||||
};
|
||||
|
||||
_packager.WriteManifest(_tempDir, kitId, entries);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, "manifest.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteManifest_ContainsCorrectVersion()
|
||||
{
|
||||
var kitId = "kit-" + Guid.NewGuid().ToString("N");
|
||||
var entries = new List<object>();
|
||||
|
||||
_packager.WriteManifest(_tempDir, kitId, entries);
|
||||
|
||||
var manifestJson = File.ReadAllText(Path.Combine(_tempDir, "manifest.json"));
|
||||
var manifest = JsonSerializer.Deserialize<JsonElement>(manifestJson);
|
||||
|
||||
Assert.Equal("offline-kit/v1", manifest.GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteManifest_ContainsKitId()
|
||||
{
|
||||
var kitId = "test-kit-123";
|
||||
var entries = new List<object>();
|
||||
|
||||
_packager.WriteManifest(_tempDir, kitId, entries);
|
||||
|
||||
var manifestJson = File.ReadAllText(Path.Combine(_tempDir, "manifest.json"));
|
||||
var manifest = JsonSerializer.Deserialize<JsonElement>(manifestJson);
|
||||
|
||||
Assert.Equal(kitId, manifest.GetProperty("kitId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteManifest_RejectsOverwrite()
|
||||
{
|
||||
var kitId = "kit-001";
|
||||
var entries = new List<object>();
|
||||
|
||||
// First write succeeds
|
||||
_packager.WriteManifest(_tempDir, kitId, entries);
|
||||
|
||||
// Second write fails (immutability)
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
_packager.WriteManifest(_tempDir, kitId, entries));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateChecksumFileContent_HasCorrectFormat()
|
||||
{
|
||||
var content = OfflineKitPackager.GenerateChecksumFileContent("abc123def456", "test.tgz");
|
||||
|
||||
Assert.Equal("abc123def456 test.tgz", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyBundleHash_ReturnsTrueForMatchingHash()
|
||||
{
|
||||
var bundleBytes = Encoding.UTF8.GetBytes("test-content");
|
||||
var expectedHash = _cryptoHash.ComputeHashHexForPurpose(bundleBytes, StellaOps.Cryptography.HashPurpose.Content);
|
||||
|
||||
var result = _packager.VerifyBundleHash(bundleBytes, expectedHash);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyBundleHash_ReturnsFalseForMismatchedHash()
|
||||
{
|
||||
var bundleBytes = Encoding.UTF8.GetBytes("test-content");
|
||||
|
||||
var result = _packager.VerifyBundleHash(bundleBytes, "wrong-hash");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttestationBundle_ThrowsForNullRequest()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
_packager.AddAttestationBundle(_tempDir, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAttestationBundle_ThrowsForEmptyOutputDirectory()
|
||||
{
|
||||
var request = CreateTestAttestationRequest();
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
_packager.AddAttestationBundle(string.Empty, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectoryStructure_FollowsOfflineKitLayout()
|
||||
{
|
||||
var attestationRequest = CreateTestAttestationRequest();
|
||||
var mirrorRequest = CreateTestMirrorRequest();
|
||||
var bootstrapRequest = CreateTestBootstrapRequest();
|
||||
|
||||
var attestResult = _packager.AddAttestationBundle(_tempDir, attestationRequest);
|
||||
var mirrorResult = _packager.AddMirrorBundle(_tempDir, mirrorRequest);
|
||||
var bootstrapResult = _packager.AddBootstrapPack(_tempDir, bootstrapRequest);
|
||||
|
||||
// Verify directory structure
|
||||
Assert.True(Directory.Exists(Path.Combine(_tempDir, "attestations")));
|
||||
Assert.True(Directory.Exists(Path.Combine(_tempDir, "mirrors")));
|
||||
Assert.True(Directory.Exists(Path.Combine(_tempDir, "bootstrap")));
|
||||
Assert.True(Directory.Exists(Path.Combine(_tempDir, "checksums", "attestations")));
|
||||
Assert.True(Directory.Exists(Path.Combine(_tempDir, "checksums", "mirrors")));
|
||||
Assert.True(Directory.Exists(Path.Combine(_tempDir, "checksums", "bootstrap")));
|
||||
}
|
||||
|
||||
private OfflineKitAttestationRequest CreateTestAttestationRequest()
|
||||
{
|
||||
return new OfflineKitAttestationRequest(
|
||||
KitId: "kit-001",
|
||||
ExportId: Guid.NewGuid().ToString(),
|
||||
AttestationId: Guid.NewGuid().ToString(),
|
||||
RootHash: "test-root-hash",
|
||||
BundleBytes: Encoding.UTF8.GetBytes("test-attestation-bundle"),
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private OfflineKitMirrorRequest CreateTestMirrorRequest()
|
||||
{
|
||||
return new OfflineKitMirrorRequest(
|
||||
KitId: "kit-001",
|
||||
ExportId: Guid.NewGuid().ToString(),
|
||||
BundleId: Guid.NewGuid().ToString(),
|
||||
Profile: "mirror:full",
|
||||
RootHash: "test-root-hash",
|
||||
BundleBytes: Encoding.UTF8.GetBytes("test-mirror-bundle"),
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private OfflineKitBootstrapRequest CreateTestBootstrapRequest()
|
||||
{
|
||||
return new OfflineKitBootstrapRequest(
|
||||
KitId: "kit-001",
|
||||
ExportId: Guid.NewGuid().ToString(),
|
||||
Version: "v1.0.0",
|
||||
RootHash: "test-root-hash",
|
||||
BundleBytes: Encoding.UTF8.GetBytes("test-bootstrap-pack"),
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class OpenApiDiscoveryEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DiscoveryResponse_ContainsRequiredFields()
|
||||
{
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope"
|
||||
};
|
||||
|
||||
Assert.Equal("export-center", response.Service);
|
||||
Assert.Equal("1.0.0", response.Version);
|
||||
Assert.Equal("3.0.3", response.SpecVersion);
|
||||
Assert.Equal("application/yaml", response.Format);
|
||||
Assert.Equal("/openapi/export-center.yaml", response.Url);
|
||||
Assert.Equal("#/components/schemas/ErrorEnvelope", response.ErrorEnvelopeSchema);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryResponse_SupportedProfilesCanBeNull()
|
||||
{
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
ProfilesSupported = null
|
||||
};
|
||||
|
||||
Assert.Null(response.ProfilesSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryResponse_SupportedProfiles_ContainsExpectedValues()
|
||||
{
|
||||
var profiles = new[] { "attestation", "mirror", "bootstrap", "airgap-evidence" };
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
ProfilesSupported = profiles
|
||||
};
|
||||
|
||||
Assert.NotNull(response.ProfilesSupported);
|
||||
Assert.Contains("attestation", response.ProfilesSupported);
|
||||
Assert.Contains("mirror", response.ProfilesSupported);
|
||||
Assert.Contains("bootstrap", response.ProfilesSupported);
|
||||
Assert.Contains("airgap-evidence", response.ProfilesSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryResponse_SerializesToCamelCase()
|
||||
{
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
GeneratedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
var json = JsonSerializer.Serialize(response, options);
|
||||
|
||||
Assert.Contains("\"service\":", json);
|
||||
Assert.Contains("\"version\":", json);
|
||||
Assert.Contains("\"specVersion\":", json);
|
||||
Assert.Contains("\"format\":", json);
|
||||
Assert.Contains("\"url\":", json);
|
||||
Assert.Contains("\"errorEnvelopeSchema\":", json);
|
||||
Assert.Contains("\"generatedAt\":", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryResponse_JsonUrlIsOptional()
|
||||
{
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
JsonUrl = "/openapi/export-center.json"
|
||||
};
|
||||
|
||||
Assert.Equal("/openapi/export-center.json", response.JsonUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryResponse_ChecksumSha256IsOptional()
|
||||
{
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
ChecksumSha256 = "abc123"
|
||||
};
|
||||
|
||||
Assert.Equal("abc123", response.ChecksumSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimalSpec_ContainsOpenApi303Header()
|
||||
{
|
||||
// The minimal spec should be a valid OpenAPI 3.0.3 document
|
||||
var minimalSpecCheck = "openapi: 3.0.3";
|
||||
Assert.NotEmpty(minimalSpecCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryResponse_GeneratedAtIsDateTimeOffset()
|
||||
{
|
||||
var generatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
GeneratedAt = generatedAt
|
||||
};
|
||||
|
||||
Assert.Equal(generatedAt, response.GeneratedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryResponse_CanSerializeToJsonWithNulls()
|
||||
{
|
||||
var response = new WebService.OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = "export-center",
|
||||
Version = "1.0.0",
|
||||
SpecVersion = "3.0.3",
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
JsonUrl = null,
|
||||
ProfilesSupported = null,
|
||||
ChecksumSha256 = null
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
var json = JsonSerializer.Serialize(response, options);
|
||||
|
||||
// Should NOT contain null fields
|
||||
Assert.DoesNotContain("\"jsonUrl\":", json);
|
||||
Assert.DoesNotContain("\"profilesSupported\":", json);
|
||||
Assert.DoesNotContain("\"checksumSha256\":", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.PortableEvidence;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests;
|
||||
|
||||
public sealed class PortableEvidenceExportBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly PortableEvidenceExportBuilder _builder;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public PortableEvidenceExportBuilderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"portable-evidence-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_cryptoHash = new DefaultCryptoHash();
|
||||
_builder = new PortableEvidenceExportBuilder(_cryptoHash);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesValidExport()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.ExportDocument);
|
||||
Assert.NotEmpty(result.ExportDocumentJson);
|
||||
Assert.NotEmpty(result.RootHash);
|
||||
Assert.NotEmpty(result.PortableBundleSha256);
|
||||
Assert.True(result.ExportStream.Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ExportDocumentContainsCorrectMetadata()
|
||||
{
|
||||
var exportId = Guid.NewGuid();
|
||||
var bundleId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var sourceUri = "https://evidencelocker.example.com/v1/bundles/portable/abc123";
|
||||
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
exportId,
|
||||
bundleId,
|
||||
tenantId,
|
||||
portableBundlePath,
|
||||
sourceUri);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.Equal(exportId.ToString("D"), result.ExportDocument.ExportId);
|
||||
Assert.Equal(bundleId.ToString("D"), result.ExportDocument.BundleId);
|
||||
Assert.Equal(tenantId.ToString("D"), result.ExportDocument.TenantId);
|
||||
Assert.Equal(sourceUri, result.ExportDocument.SourceUri);
|
||||
Assert.Equal("v1", result.ExportDocument.PortableVersion);
|
||||
Assert.NotEmpty(result.ExportDocument.PortableBundleSha256);
|
||||
Assert.NotEmpty(result.ExportDocument.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesDeterministicOutput()
|
||||
{
|
||||
var exportId = new Guid("11111111-2222-3333-4444-555555555555");
|
||||
var bundleId = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
var tenantId = new Guid("ffffffff-1111-2222-3333-444444444444");
|
||||
var portableBundlePath = CreateTestPortableBundle("deterministic-content");
|
||||
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
exportId,
|
||||
bundleId,
|
||||
tenantId,
|
||||
portableBundlePath);
|
||||
|
||||
var result1 = _builder.Build(request);
|
||||
var result2 = _builder.Build(request);
|
||||
|
||||
Assert.Equal(result1.RootHash, result2.RootHash);
|
||||
Assert.Equal(result1.PortableBundleSha256, result2.PortableBundleSha256);
|
||||
|
||||
var bytes1 = result1.ExportStream.ToArray();
|
||||
var bytes2 = result2.ExportStream.ToArray();
|
||||
Assert.Equal(bytes1, bytes2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ArchiveContainsExpectedFiles()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var fileNames = ExtractFileNames(result.ExportStream);
|
||||
|
||||
Assert.Contains("export.json", fileNames);
|
||||
Assert.Contains("portable-bundle-v1.tgz", fileNames);
|
||||
Assert.Contains("checksums.txt", fileNames);
|
||||
Assert.Contains("verify-export.sh", fileNames);
|
||||
Assert.Contains("README.md", fileNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TarEntriesHaveDeterministicMetadata()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var entries = ExtractTarEntryMetadata(result.ExportStream);
|
||||
|
||||
var expectedTimestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
Assert.Equal(0, entry.Uid);
|
||||
Assert.Equal(0, entry.Gid);
|
||||
Assert.Equal(string.Empty, entry.UserName);
|
||||
Assert.Equal(string.Empty, entry.GroupName);
|
||||
Assert.Equal(expectedTimestamp, entry.ModificationTime);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PortableBundleIsIncludedUnmodified()
|
||||
{
|
||||
var originalContent = "original-portable-bundle-content-bytes";
|
||||
var portableBundlePath = CreateTestPortableBundle(originalContent);
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var extractedContent = ExtractFileContent(result.ExportStream, "portable-bundle-v1.tgz");
|
||||
|
||||
Assert.Equal(originalContent, extractedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ChecksumsContainsAllFiles()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var checksums = ExtractFileContent(result.ExportStream, "checksums.txt");
|
||||
|
||||
Assert.Contains("export.json", checksums);
|
||||
Assert.Contains("portable-bundle-v1.tgz", checksums);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReadmeContainsBundleInfo()
|
||||
{
|
||||
var bundleId = Guid.NewGuid();
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
bundleId,
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var readme = ExtractFileContent(result.ExportStream, "README.md");
|
||||
|
||||
Assert.Contains(bundleId.ToString("D"), readme);
|
||||
Assert.Contains("Portable Evidence Export", readme);
|
||||
Assert.Contains("stella evidence verify", readme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_VerifyScriptIsPosixCompliant()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var script = ExtractFileContent(result.ExportStream, "verify-export.sh");
|
||||
|
||||
Assert.StartsWith("#!/usr/bin/env sh", script);
|
||||
Assert.Contains("sha256sum", script);
|
||||
Assert.Contains("shasum", script);
|
||||
Assert.DoesNotContain("curl", script);
|
||||
Assert.DoesNotContain("wget", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_VerifyScriptHasExecutePermission()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
var entries = ExtractTarEntryMetadata(result.ExportStream);
|
||||
|
||||
var scriptEntry = entries.FirstOrDefault(e => e.Name == "verify-export.sh");
|
||||
Assert.NotNull(scriptEntry);
|
||||
Assert.True(scriptEntry.Mode.HasFlag(UnixFileMode.UserExecute));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithMetadata_IncludesInExportDocument()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["environment"] = "production",
|
||||
["scannerVersion"] = "v3.0.0"
|
||||
};
|
||||
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath,
|
||||
Metadata: metadata);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.NotNull(result.ExportDocument.Metadata);
|
||||
Assert.Equal("production", result.ExportDocument.Metadata["environment"]);
|
||||
Assert.Equal("v3.0.0", result.ExportDocument.Metadata["scannerVersion"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForMissingPortableBundle()
|
||||
{
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
"/nonexistent/portable-bundle.tgz");
|
||||
|
||||
Assert.Throws<FileNotFoundException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsForEmptyBundleId()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.Empty,
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => _builder.Build(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_VersionIsCorrect()
|
||||
{
|
||||
var portableBundlePath = CreateTestPortableBundle();
|
||||
var request = new PortableEvidenceExportRequest(
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
Guid.NewGuid(),
|
||||
portableBundlePath);
|
||||
|
||||
var result = _builder.Build(request);
|
||||
|
||||
Assert.Equal("portable-evidence/v1", result.ExportDocument.Version);
|
||||
}
|
||||
|
||||
private string CreateTestPortableBundle(string? content = null)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, $"portable-bundle-{Guid.NewGuid():N}.tgz");
|
||||
File.WriteAllText(path, content ?? "test-portable-bundle-content");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<string> ExtractFileNames(MemoryStream exportStream)
|
||||
{
|
||||
exportStream.Position = 0;
|
||||
var fileNames = new List<string>();
|
||||
|
||||
using var gzip = new GZipStream(exportStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
fileNames.Add(entry.Name);
|
||||
}
|
||||
|
||||
exportStream.Position = 0;
|
||||
return fileNames;
|
||||
}
|
||||
|
||||
private static string ExtractFileContent(MemoryStream exportStream, string fileName)
|
||||
{
|
||||
exportStream.Position = 0;
|
||||
|
||||
using var gzip = new GZipStream(exportStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
if (entry.Name == fileName && entry.DataStream is not null)
|
||||
{
|
||||
using var reader = new StreamReader(entry.DataStream);
|
||||
var content = reader.ReadToEnd();
|
||||
exportStream.Position = 0;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
exportStream.Position = 0;
|
||||
throw new FileNotFoundException($"File '{fileName}' not found in archive.");
|
||||
}
|
||||
|
||||
private static List<TarEntryMetadataWithName> ExtractTarEntryMetadata(MemoryStream exportStream)
|
||||
{
|
||||
exportStream.Position = 0;
|
||||
var entries = new List<TarEntryMetadataWithName>();
|
||||
|
||||
using var gzip = new GZipStream(exportStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
using var tar = new TarReader(gzip, leaveOpen: true);
|
||||
|
||||
TarEntry? entry;
|
||||
while ((entry = tar.GetNextEntry()) is not null)
|
||||
{
|
||||
entries.Add(new TarEntryMetadataWithName(
|
||||
entry.Name,
|
||||
entry.Uid,
|
||||
entry.Gid,
|
||||
entry.UserName ?? string.Empty,
|
||||
entry.GroupName ?? string.Empty,
|
||||
entry.ModificationTime,
|
||||
entry.Mode));
|
||||
}
|
||||
|
||||
exportStream.Position = 0;
|
||||
return entries;
|
||||
}
|
||||
|
||||
private sealed record TarEntryMetadataWithName(
|
||||
string Name,
|
||||
int Uid,
|
||||
int Gid,
|
||||
string UserName,
|
||||
string GroupName,
|
||||
DateTimeOffset ModificationTime,
|
||||
UnixFileMode Mode);
|
||||
}
|
||||
@@ -112,21 +112,23 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj"/>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Infrastructure\StellaOps.ExportCenter.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.WebService\StellaOps.ExportCenter.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.ExportCenter.RiskBundles\StellaOps.ExportCenter.RiskBundles.csproj" />
|
||||
|
||||
|
||||
|
||||
|
||||
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping attestation endpoints.
|
||||
/// </summary>
|
||||
public static class AttestationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps attestation endpoints to the application.
|
||||
/// </summary>
|
||||
public static WebApplication MapAttestationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/v1/exports")
|
||||
.WithTags("Attestations")
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
|
||||
|
||||
// GET /v1/exports/{id}/attestation - Get attestation by export run ID
|
||||
group.MapGet("/{id}/attestation", GetAttestationByExportRunAsync)
|
||||
.WithName("GetExportAttestation")
|
||||
.WithSummary("Get attestation for an export run")
|
||||
.WithDescription("Returns the DSSE attestation envelope for the specified export run.")
|
||||
.Produces<ExportAttestationResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /v1/exports/attestations/{attestationId} - Get attestation by ID
|
||||
group.MapGet("/attestations/{attestationId}", GetAttestationByIdAsync)
|
||||
.WithName("GetAttestationById")
|
||||
.WithSummary("Get attestation by ID")
|
||||
.WithDescription("Returns the DSSE attestation envelope for the specified attestation ID.")
|
||||
.Produces<ExportAttestationResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /v1/exports/{id}/attestation/verify - Verify attestation
|
||||
group.MapPost("/{id}/attestation/verify", VerifyAttestationAsync)
|
||||
.WithName("VerifyExportAttestation")
|
||||
.WithSummary("Verify attestation signature")
|
||||
.WithDescription("Verifies the cryptographic signature of the export attestation.")
|
||||
.Produces<AttestationVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportAttestationResponse>, NotFound>> GetAttestationByExportRunAsync(
|
||||
string id,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IExportAttestationService attestationService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var result = await attestationService.GetAttestationByExportRunAsync(id, tenantId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportAttestationResponse>, NotFound>> GetAttestationByIdAsync(
|
||||
string attestationId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IExportAttestationService attestationService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var result = await attestationService.GetAttestationAsync(attestationId, tenantId, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<AttestationVerifyResponse>, NotFound>> VerifyAttestationAsync(
|
||||
string id,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IExportAttestationService attestationService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var attestation = await attestationService.GetAttestationByExportRunAsync(id, tenantId, cancellationToken);
|
||||
if (attestation is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var isValid = await attestationService.VerifyAttestationAsync(
|
||||
attestation.AttestationId, tenantId, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new AttestationVerifyResponse
|
||||
{
|
||||
AttestationId = attestation.AttestationId,
|
||||
IsValid = isValid,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private static string? ResolveTenantId(string? header, HttpContext httpContext)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header;
|
||||
}
|
||||
|
||||
// Try to get from claims
|
||||
var tenantClaim = httpContext.User.FindFirst("tenant_id")
|
||||
?? httpContext.User.FindFirst("tid");
|
||||
|
||||
return tenantClaim?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for attestation verification.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerifyResponse
|
||||
{
|
||||
public required string AttestationId { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering attestation services.
|
||||
/// </summary>
|
||||
public static class AttestationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds export attestation services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional configuration for attestation options.</param>
|
||||
/// <param name="configureSignerOptions">Optional configuration for signer options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportAttestation(
|
||||
this IServiceCollection services,
|
||||
Action<ExportAttestationOptions>? configureOptions = null,
|
||||
Action<ExportAttestationSignerOptions>? configureSignerOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Configure options
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
if (configureSignerOptions is not null)
|
||||
{
|
||||
services.Configure(configureSignerOptions);
|
||||
}
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register signer
|
||||
services.TryAddSingleton<IExportAttestationSigner, ExportAttestationSigner>();
|
||||
|
||||
// Register attestation service
|
||||
services.TryAddSingleton<IExportAttestationService, ExportAttestationService>();
|
||||
|
||||
// Register promotion attestation assembler
|
||||
services.TryAddSingleton<IPromotionAttestationAssembler, PromotionAttestationAssembler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Export attestation payload types.
|
||||
/// </summary>
|
||||
public static class ExportAttestationPayloadTypes
|
||||
{
|
||||
public const string DssePayloadType = "application/vnd.in-toto+json";
|
||||
public const string ExportBundlePredicateType = "stella.ops/export-bundle@v1";
|
||||
public const string ExportArtifactPredicateType = "stella.ops/export-artifact@v1";
|
||||
public const string ExportProvenancePredicateType = "stella.ops/export-provenance@v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create attestation for an export artifact.
|
||||
/// </summary>
|
||||
public sealed record ExportAttestationRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string ExportRunId { get; init; }
|
||||
public string? ProfileId { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string ArtifactName { get; init; }
|
||||
public required string ArtifactMediaType { get; init; }
|
||||
public long ArtifactSizeBytes { get; init; }
|
||||
public string? BundleId { get; init; }
|
||||
public string? BundleRootHash { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation creation.
|
||||
/// </summary>
|
||||
public sealed record ExportAttestationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? AttestationId { get; init; }
|
||||
public ExportDsseEnvelope? Envelope { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static ExportAttestationResult Succeeded(string attestationId, ExportDsseEnvelope envelope) =>
|
||||
new() { Success = true, AttestationId = attestationId, Envelope = envelope };
|
||||
|
||||
public static ExportAttestationResult Failed(string errorMessage) =>
|
||||
new() { Success = false, ErrorMessage = errorMessage };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for export attestations.
|
||||
/// </summary>
|
||||
public sealed record ExportDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<ExportDsseEnvelopeSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature within a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record ExportDsseEnvelopeSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for export attestations.
|
||||
/// </summary>
|
||||
public sealed record ExportInTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "https://in-toto.io/Statement/v0.1";
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<ExportInTotoSubject> Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ExportBundlePredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject of an in-toto statement.
|
||||
/// </summary>
|
||||
public sealed record ExportInTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for export bundle attestation.
|
||||
/// </summary>
|
||||
public sealed record ExportBundlePredicate
|
||||
{
|
||||
[JsonPropertyName("exportRunId")]
|
||||
public required string ExportRunId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleRootHash")]
|
||||
public string? BundleRootHash { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("exporter")]
|
||||
public required ExportAttestationExporter Exporter { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exporter information for attestation.
|
||||
/// </summary>
|
||||
public sealed record ExportAttestationExporter
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "StellaOps.ExportCenter";
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("buildTimestamp")]
|
||||
public DateTimeOffset? BuildTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for attestation endpoint.
|
||||
/// </summary>
|
||||
public sealed record ExportAttestationResponse
|
||||
{
|
||||
[JsonPropertyName("attestation_id")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("export_run_id")]
|
||||
public required string ExportRunId { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_digest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("envelope")]
|
||||
public required ExportDsseEnvelope Envelope { get; init; }
|
||||
|
||||
[JsonPropertyName("verification")]
|
||||
public ExportAttestationVerification? Verification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification information for attestation.
|
||||
/// </summary>
|
||||
public sealed record ExportAttestationVerification
|
||||
{
|
||||
[JsonPropertyName("key_id")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public string? Provider { get; init; }
|
||||
|
||||
[JsonPropertyName("public_key_pem")]
|
||||
public string? PublicKeyPem { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for producing DSSE attestations for export artifacts.
|
||||
/// </summary>
|
||||
public sealed class ExportAttestationService : IExportAttestationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly string ExporterVersion = typeof(ExportAttestationService).Assembly
|
||||
.GetName().Version?.ToString() ?? "1.0.0";
|
||||
|
||||
private readonly IExportAttestationSigner _signer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ExportAttestationService> _logger;
|
||||
private readonly ExportAttestationOptions _options;
|
||||
|
||||
// In-memory storage for attestations (production would use persistent storage)
|
||||
private readonly ConcurrentDictionary<string, StoredAttestation> _attestations = new();
|
||||
private readonly ConcurrentDictionary<string, string> _runToAttestationMap = new();
|
||||
|
||||
public ExportAttestationService(
|
||||
IExportAttestationSigner signer,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ExportAttestationService> logger,
|
||||
IOptions<ExportAttestationOptions>? options = null)
|
||||
{
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? ExportAttestationOptions.Default;
|
||||
}
|
||||
|
||||
public async Task<ExportAttestationResult> CreateAttestationAsync(
|
||||
ExportAttestationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
using var activity = ExportTelemetry.ActivitySource.StartActivity("attestation.create");
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
activity?.SetTag("export_run_id", request.ExportRunId);
|
||||
|
||||
try
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var attestationId = GenerateAttestationId(request);
|
||||
|
||||
// Build in-toto statement
|
||||
var statement = BuildStatement(request, now);
|
||||
|
||||
// Serialize statement to canonical JSON
|
||||
var statementJson = JsonSerializer.SerializeToUtf8Bytes(statement, SerializerOptions);
|
||||
var payloadBase64 = ToBase64Url(statementJson);
|
||||
|
||||
// Sign using PAE (Pre-Authentication Encoding)
|
||||
var signResult = await _signer.SignAsync(
|
||||
ExportAttestationPayloadTypes.DssePayloadType,
|
||||
statementJson,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!signResult.Success)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to sign attestation for export {ExportRunId}: {Error}",
|
||||
request.ExportRunId, signResult.ErrorMessage);
|
||||
return ExportAttestationResult.Failed(signResult.ErrorMessage ?? "Signing failed");
|
||||
}
|
||||
|
||||
// Build DSSE envelope
|
||||
var envelope = new ExportDsseEnvelope
|
||||
{
|
||||
PayloadType = ExportAttestationPayloadTypes.DssePayloadType,
|
||||
Payload = payloadBase64,
|
||||
Signatures = signResult.Signatures.Select(s => new ExportDsseEnvelopeSignature
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Signature = s.Signature
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Store attestation
|
||||
var stored = new StoredAttestation(
|
||||
attestationId,
|
||||
request.TenantId,
|
||||
request.ExportRunId,
|
||||
request.ArtifactDigest,
|
||||
now,
|
||||
envelope,
|
||||
signResult.Verification);
|
||||
|
||||
_attestations[attestationId] = stored;
|
||||
_runToAttestationMap[BuildRunKey(request.TenantId, request.ExportRunId)] = attestationId;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created attestation {AttestationId} for export {ExportRunId}",
|
||||
attestationId, request.ExportRunId);
|
||||
|
||||
ExportTelemetry.ExportArtifactsTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("artifact_type", "attestation"),
|
||||
new KeyValuePair<string, object?>("tenant_id", request.TenantId));
|
||||
|
||||
return ExportAttestationResult.Succeeded(attestationId, envelope);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating attestation for export {ExportRunId}", request.ExportRunId);
|
||||
return ExportAttestationResult.Failed($"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ExportAttestationResponse?> GetAttestationAsync(
|
||||
string attestationId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_attestations.TryGetValue(attestationId, out var stored))
|
||||
{
|
||||
return Task.FromResult<ExportAttestationResponse?>(null);
|
||||
}
|
||||
|
||||
if (!string.Equals(stored.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<ExportAttestationResponse?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<ExportAttestationResponse?>(BuildResponse(stored));
|
||||
}
|
||||
|
||||
public Task<ExportAttestationResponse?> GetAttestationByExportRunAsync(
|
||||
string exportRunId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildRunKey(tenantId, exportRunId);
|
||||
if (!_runToAttestationMap.TryGetValue(key, out var attestationId))
|
||||
{
|
||||
return Task.FromResult<ExportAttestationResponse?>(null);
|
||||
}
|
||||
|
||||
return GetAttestationAsync(attestationId, tenantId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAttestationAsync(
|
||||
string attestationId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_attestations.TryGetValue(attestationId, out var stored))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(stored.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Decode payload
|
||||
var payloadBytes = FromBase64Url(stored.Envelope.Payload);
|
||||
|
||||
// Verify each signature
|
||||
foreach (var signature in stored.Envelope.Signatures)
|
||||
{
|
||||
var isValid = await _signer.VerifyAsync(
|
||||
stored.Envelope.PayloadType,
|
||||
payloadBytes,
|
||||
signature.Signature,
|
||||
signature.KeyId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Attestation {AttestationId} signature verification failed for key {KeyId}",
|
||||
attestationId, signature.KeyId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error verifying attestation {AttestationId}", attestationId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ExportInTotoStatement BuildStatement(ExportAttestationRequest request, DateTimeOffset now)
|
||||
{
|
||||
var subject = new ExportInTotoSubject
|
||||
{
|
||||
Name = request.ArtifactName,
|
||||
Digest = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["sha256"] = request.ArtifactDigest.ToLowerInvariant()
|
||||
}
|
||||
};
|
||||
|
||||
var predicate = new ExportBundlePredicate
|
||||
{
|
||||
ExportRunId = request.ExportRunId,
|
||||
TenantId = request.TenantId,
|
||||
ProfileId = request.ProfileId,
|
||||
BundleId = request.BundleId,
|
||||
BundleRootHash = request.BundleRootHash,
|
||||
CreatedAt = now,
|
||||
Exporter = new ExportAttestationExporter
|
||||
{
|
||||
Version = ExporterVersion
|
||||
},
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
return new ExportInTotoStatement
|
||||
{
|
||||
PredicateType = ExportAttestationPayloadTypes.ExportBundlePredicateType,
|
||||
Subject = [subject],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static ExportAttestationResponse BuildResponse(StoredAttestation stored)
|
||||
{
|
||||
return new ExportAttestationResponse
|
||||
{
|
||||
AttestationId = stored.AttestationId,
|
||||
ExportRunId = stored.ExportRunId,
|
||||
ArtifactDigest = stored.ArtifactDigest,
|
||||
CreatedAt = stored.CreatedAt,
|
||||
Envelope = stored.Envelope,
|
||||
Verification = stored.Verification
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateAttestationId(ExportAttestationRequest request)
|
||||
{
|
||||
var input = $"{request.TenantId}:{request.ExportRunId}:{request.ArtifactDigest}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"att-{Convert.ToHexStringLower(hash)[..16]}";
|
||||
}
|
||||
|
||||
private static string BuildRunKey(string tenantId, string exportRunId)
|
||||
{
|
||||
return $"{tenantId}:{exportRunId}";
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static byte[] FromBase64Url(string base64Url)
|
||||
{
|
||||
var base64 = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
switch (base64.Length % 4)
|
||||
{
|
||||
case 2: base64 += "=="; break;
|
||||
case 3: base64 += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
private sealed record StoredAttestation(
|
||||
string AttestationId,
|
||||
string TenantId,
|
||||
string ExportRunId,
|
||||
string ArtifactDigest,
|
||||
DateTimeOffset CreatedAt,
|
||||
ExportDsseEnvelope Envelope,
|
||||
ExportAttestationVerification? Verification);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for attestation service.
|
||||
/// </summary>
|
||||
public sealed class ExportAttestationOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string DefaultAlgorithm { get; set; } = "ECDSA-P256-SHA256";
|
||||
public string? KeyId { get; set; }
|
||||
public string? Provider { get; set; }
|
||||
|
||||
public static ExportAttestationOptions Default => new();
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of attestation signer using ECDSA.
|
||||
/// For production, this should route through ICryptoProviderRegistry.
|
||||
/// </summary>
|
||||
public sealed class ExportAttestationSigner : IExportAttestationSigner, IDisposable
|
||||
{
|
||||
private readonly ILogger<ExportAttestationSigner> _logger;
|
||||
private readonly ExportAttestationSignerOptions _options;
|
||||
private readonly ECDsa _signingKey;
|
||||
private readonly string _keyId;
|
||||
|
||||
public ExportAttestationSigner(
|
||||
ILogger<ExportAttestationSigner> logger,
|
||||
IOptions<ExportAttestationSignerOptions>? options = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? ExportAttestationSignerOptions.Default;
|
||||
|
||||
// Create or load signing key
|
||||
_signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
_keyId = ComputeKeyId(_signingKey);
|
||||
}
|
||||
|
||||
public Task<AttestationSignResult> SignAsync(
|
||||
string payloadType,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build PAE (Pre-Authentication Encoding) per DSSE spec
|
||||
var pae = BuildPae(payloadType, payload.Span);
|
||||
|
||||
// Sign PAE
|
||||
var signatureBytes = _signingKey.SignData(
|
||||
pae,
|
||||
HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.Rfc3279DerSequence);
|
||||
|
||||
var signatureBase64Url = ToBase64Url(signatureBytes);
|
||||
|
||||
var signatures = new List<AttestationSignatureInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Signature = signatureBase64Url,
|
||||
KeyId = _keyId,
|
||||
Algorithm = _options.Algorithm
|
||||
}
|
||||
};
|
||||
|
||||
var verification = new ExportAttestationVerification
|
||||
{
|
||||
KeyId = _keyId,
|
||||
Algorithm = _options.Algorithm,
|
||||
Provider = _options.Provider,
|
||||
PublicKeyPem = ExportPublicKeyPem()
|
||||
};
|
||||
|
||||
_logger.LogDebug("Signed attestation with key {KeyId}", _keyId);
|
||||
|
||||
return Task.FromResult(AttestationSignResult.Succeeded(signatures, verification));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sign attestation");
|
||||
return Task.FromResult(AttestationSignResult.Failed($"Signing failed: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(
|
||||
string payloadType,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
string signature,
|
||||
string? keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build PAE
|
||||
var pae = BuildPae(payloadType, payload.Span);
|
||||
|
||||
// Decode signature
|
||||
var signatureBytes = FromBase64Url(signature);
|
||||
|
||||
// Verify
|
||||
var isValid = _signingKey.VerifyData(
|
||||
pae,
|
||||
signatureBytes,
|
||||
HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.Rfc3279DerSequence);
|
||||
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to verify signature");
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds DSSE Pre-Authentication Encoding (PAE).
|
||||
/// PAE = "DSSEv1" || SP || LEN(payloadType) || SP || payloadType || SP || LEN(payload) || SP || payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
const string prefix = "DSSEv1";
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
// "DSSEv1 "
|
||||
ms.Write(Encoding.UTF8.GetBytes(prefix));
|
||||
ms.WriteByte(0x20); // space
|
||||
|
||||
// LEN(payloadType) + space + payloadType + space
|
||||
WriteLength(ms, payloadTypeBytes.Length);
|
||||
ms.WriteByte(0x20);
|
||||
ms.Write(payloadTypeBytes);
|
||||
ms.WriteByte(0x20);
|
||||
|
||||
// LEN(payload) + space + payload
|
||||
WriteLength(ms, payload.Length);
|
||||
ms.WriteByte(0x20);
|
||||
ms.Write(payload);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLength(MemoryStream ms, int length)
|
||||
{
|
||||
var lengthBytes = Encoding.UTF8.GetBytes(length.ToString());
|
||||
ms.Write(lengthBytes);
|
||||
}
|
||||
|
||||
private static string ComputeKeyId(ECDsa key)
|
||||
{
|
||||
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
|
||||
var hash = SHA256.HashData(publicKeyBytes);
|
||||
return Convert.ToHexStringLower(hash)[..16];
|
||||
}
|
||||
|
||||
private string ExportPublicKeyPem()
|
||||
{
|
||||
var publicKeyBytes = _signingKey.ExportSubjectPublicKeyInfo();
|
||||
var base64 = Convert.ToBase64String(publicKeyBytes);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("-----BEGIN PUBLIC KEY-----");
|
||||
|
||||
for (var i = 0; i < base64.Length; i += 64)
|
||||
{
|
||||
var length = Math.Min(64, base64.Length - i);
|
||||
sb.AppendLine(base64.Substring(i, length));
|
||||
}
|
||||
|
||||
sb.AppendLine("-----END PUBLIC KEY-----");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static byte[] FromBase64Url(string base64Url)
|
||||
{
|
||||
var base64 = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
switch (base64.Length % 4)
|
||||
{
|
||||
case 2: base64 += "=="; break;
|
||||
case 3: base64 += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_signingKey.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation signer.
|
||||
/// </summary>
|
||||
public sealed class ExportAttestationSignerOptions
|
||||
{
|
||||
public string Algorithm { get; set; } = "ECDSA-P256-SHA256";
|
||||
public string Provider { get; set; } = "StellaOps.ExportCenter";
|
||||
public string? KeyPath { get; set; }
|
||||
|
||||
public static ExportAttestationSignerOptions Default => new();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for producing DSSE attestations for export artifacts.
|
||||
/// </summary>
|
||||
public interface IExportAttestationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a DSSE attestation for an export artifact.
|
||||
/// </summary>
|
||||
/// <param name="request">The attestation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation result with DSSE envelope.</returns>
|
||||
Task<ExportAttestationResult> CreateAttestationAsync(
|
||||
ExportAttestationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an existing attestation by ID.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation response if found.</returns>
|
||||
Task<ExportAttestationResponse?> GetAttestationAsync(
|
||||
string attestationId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation for an export run.
|
||||
/// </summary>
|
||||
/// <param name="exportRunId">The export run ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attestation response if found.</returns>
|
||||
Task<ExportAttestationResponse?> GetAttestationByExportRunAsync(
|
||||
string exportRunId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an attestation signature.
|
||||
/// </summary>
|
||||
/// <param name="attestationId">The attestation ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if signature is valid.</returns>
|
||||
Task<bool> VerifyAttestationAsync(
|
||||
string attestationId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing export attestations.
|
||||
/// </summary>
|
||||
public interface IExportAttestationSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs payload using DSSE PAE (Pre-Authentication Encoding).
|
||||
/// </summary>
|
||||
/// <param name="payloadType">The payload MIME type.</param>
|
||||
/// <param name="payload">The payload bytes to sign.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signing result with signatures.</returns>
|
||||
Task<AttestationSignResult> SignAsync(
|
||||
string payloadType,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signature against a payload.
|
||||
/// </summary>
|
||||
/// <param name="payloadType">The payload MIME type.</param>
|
||||
/// <param name="payload">The payload bytes.</param>
|
||||
/// <param name="signature">The base64url-encoded signature.</param>
|
||||
/// <param name="keyId">Optional key ID for verification.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if signature is valid.</returns>
|
||||
Task<bool> VerifyAsync(
|
||||
string payloadType,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
string signature,
|
||||
string? keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation signing operation.
|
||||
/// </summary>
|
||||
public sealed record AttestationSignResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public IReadOnlyList<AttestationSignatureInfo> Signatures { get; init; } = [];
|
||||
public ExportAttestationVerification? Verification { get; init; }
|
||||
|
||||
public static AttestationSignResult Succeeded(
|
||||
IReadOnlyList<AttestationSignatureInfo> signatures,
|
||||
ExportAttestationVerification? verification = null) =>
|
||||
new() { Success = true, Signatures = signatures, Verification = verification };
|
||||
|
||||
public static AttestationSignResult Failed(string errorMessage) =>
|
||||
new() { Success = false, ErrorMessage = errorMessage };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a single signature.
|
||||
/// </summary>
|
||||
public sealed record AttestationSignatureInfo
|
||||
{
|
||||
public required string Signature { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? Algorithm { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for assembling promotion attestations with SBOM/VEX digests,
|
||||
/// Rekor proofs, and DSSE envelopes for Offline Kit delivery.
|
||||
/// </summary>
|
||||
public interface IPromotionAttestationAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembles a promotion attestation bundle from the provided artifacts.
|
||||
/// </summary>
|
||||
/// <param name="request">The assembly request containing all artifacts.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The assembled promotion attestation.</returns>
|
||||
Task<PromotionAttestationAssemblyResult> AssembleAsync(
|
||||
PromotionAttestationAssemblyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a previously assembled promotion attestation.
|
||||
/// </summary>
|
||||
/// <param name="assemblyId">The assembly identifier.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The assembly if found, null otherwise.</returns>
|
||||
Task<PromotionAttestationAssembly?> GetAssemblyAsync(
|
||||
string assemblyId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets assemblies for a specific promotion.
|
||||
/// </summary>
|
||||
/// <param name="promotionId">The promotion identifier.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of assemblies for the promotion.</returns>
|
||||
Task<IReadOnlyList<PromotionAttestationAssembly>> GetAssembliesForPromotionAsync(
|
||||
string promotionId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the signatures and integrity of an assembly.
|
||||
/// </summary>
|
||||
/// <param name="assemblyId">The assembly identifier.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the assembly is valid.</returns>
|
||||
Task<bool> VerifyAssemblyAsync(
|
||||
string assemblyId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an assembly to a portable bundle format for Offline Kit.
|
||||
/// </summary>
|
||||
/// <param name="assemblyId">The assembly identifier.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Stream containing the bundle, or null if not found.</returns>
|
||||
Task<PromotionBundleExportResult?> ExportBundleAsync(
|
||||
string assemblyId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exporting a promotion assembly to a bundle.
|
||||
/// </summary>
|
||||
public sealed record PromotionBundleExportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The exported bundle stream (gzipped tar).
|
||||
/// </summary>
|
||||
public required Stream BundleStream { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The bundle filename.
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the bundle.
|
||||
/// </summary>
|
||||
public required string BundleDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the bundle in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the bundle.
|
||||
/// </summary>
|
||||
public string MediaType { get; init; } = "application/gzip";
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles promotion attestations with SBOM/VEX digests, Rekor proofs,
|
||||
/// and DSSE envelopes for Offline Kit delivery.
|
||||
/// </summary>
|
||||
public sealed class PromotionAttestationAssembler : IPromotionAttestationAssembler
|
||||
{
|
||||
private const string BundleVersion = "promotion-bundle/v1";
|
||||
private const string AssemblyFileName = "promotion-assembly.json";
|
||||
private const string EnvelopeFileName = "promotion.dsse.json";
|
||||
private const string RekorProofsFileName = "rekor-proofs.ndjson";
|
||||
private const string DsseEnvelopesDir = "envelopes/";
|
||||
private const string ChecksumsFileName = "checksums.txt";
|
||||
private const string VerifyScriptFileName = "verify-promotion.sh";
|
||||
private const string MetadataFileName = "metadata.json";
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly UnixFileMode DefaultFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private static readonly UnixFileMode ExecutableFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
private readonly ILogger<PromotionAttestationAssembler> _logger;
|
||||
private readonly IExportAttestationSigner _signer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// In-memory store for development/testing; production would use persistent storage
|
||||
private readonly ConcurrentDictionary<string, PromotionAttestationAssembly> _assemblies = new();
|
||||
|
||||
public PromotionAttestationAssembler(
|
||||
ILogger<PromotionAttestationAssembler> logger,
|
||||
IExportAttestationSigner signer,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PromotionAttestationAssemblyResult> AssembleAsync(
|
||||
PromotionAttestationAssemblyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
var assemblyId = GenerateAssemblyId();
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Build the promotion predicate
|
||||
var predicate = BuildPromotionPredicate(request, createdAt);
|
||||
|
||||
// Build subjects from all artifacts
|
||||
var subjects = BuildSubjects(request);
|
||||
|
||||
// Build the in-toto statement
|
||||
var statement = new ExportInTotoStatement
|
||||
{
|
||||
PredicateType = PromotionAttestationPayloadTypes.PromotionPredicateType,
|
||||
Subject = subjects,
|
||||
Predicate = new ExportBundlePredicate
|
||||
{
|
||||
ExportRunId = request.PromotionId,
|
||||
TenantId = request.TenantId,
|
||||
ProfileId = request.ProfileId,
|
||||
BundleId = assemblyId,
|
||||
BundleRootHash = ComputeRootHash(request),
|
||||
CreatedAt = createdAt,
|
||||
Exporter = new ExportAttestationExporter
|
||||
{
|
||||
Version = GetAssemblyVersion(),
|
||||
BuildTimestamp = GetBuildTimestamp()
|
||||
},
|
||||
Metadata = request.Metadata
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize and sign
|
||||
var statementJson = JsonSerializer.Serialize(statement, SerializerOptions);
|
||||
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
|
||||
|
||||
var signResult = await _signer.SignAsync(
|
||||
ExportAttestationPayloadTypes.DssePayloadType,
|
||||
statementBytes,
|
||||
cancellationToken);
|
||||
|
||||
if (!signResult.Success)
|
||||
{
|
||||
_logger.LogError("Failed to sign promotion attestation: {Error}", signResult.ErrorMessage);
|
||||
return PromotionAttestationAssemblyResult.Failed(
|
||||
signResult.ErrorMessage ?? "Signing failed");
|
||||
}
|
||||
|
||||
// Build DSSE envelope
|
||||
var envelope = new ExportDsseEnvelope
|
||||
{
|
||||
PayloadType = ExportAttestationPayloadTypes.DssePayloadType,
|
||||
Payload = Convert.ToBase64String(statementBytes),
|
||||
Signatures = signResult.Signatures.Select(s => new ExportDsseEnvelopeSignature
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Signature = s.Signature
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Create the assembly
|
||||
var assembly = new PromotionAttestationAssembly
|
||||
{
|
||||
AssemblyId = assemblyId,
|
||||
PromotionId = request.PromotionId,
|
||||
TenantId = request.TenantId,
|
||||
ProfileId = request.ProfileId,
|
||||
SourceEnvironment = request.SourceEnvironment,
|
||||
TargetEnvironment = request.TargetEnvironment,
|
||||
CreatedAt = createdAt,
|
||||
PromotionEnvelope = envelope,
|
||||
SbomDigests = request.SbomDigests,
|
||||
VexDigests = request.VexDigests,
|
||||
RekorProofs = request.RekorProofs,
|
||||
DsseEnvelopes = request.DsseEnvelopes,
|
||||
Verification = signResult.Verification,
|
||||
RootHash = ComputeRootHash(request)
|
||||
};
|
||||
|
||||
// Store the assembly
|
||||
var storeKey = BuildStoreKey(assemblyId, request.TenantId);
|
||||
_assemblies[storeKey] = assembly;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created promotion attestation assembly {AssemblyId} for promotion {PromotionId}",
|
||||
assemblyId, request.PromotionId);
|
||||
|
||||
return PromotionAttestationAssemblyResult.Succeeded(assemblyId, assembly);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to assemble promotion attestation");
|
||||
return PromotionAttestationAssemblyResult.Failed($"Assembly failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PromotionAttestationAssembly?> GetAssemblyAsync(
|
||||
string assemblyId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildStoreKey(assemblyId, tenantId);
|
||||
_assemblies.TryGetValue(key, out var assembly);
|
||||
return Task.FromResult(assembly);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PromotionAttestationAssembly>> GetAssembliesForPromotionAsync(
|
||||
string promotionId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assemblies = _assemblies.Values
|
||||
.Where(a => a.PromotionId == promotionId && a.TenantId == tenantId)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PromotionAttestationAssembly>>(assemblies);
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAssemblyAsync(
|
||||
string assemblyId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assembly = await GetAssemblyAsync(assemblyId, tenantId, cancellationToken);
|
||||
if (assembly is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Verify the main promotion envelope
|
||||
var payloadBytes = Convert.FromBase64String(assembly.PromotionEnvelope.Payload);
|
||||
|
||||
foreach (var sig in assembly.PromotionEnvelope.Signatures)
|
||||
{
|
||||
var isValid = await _signer.VerifyAsync(
|
||||
assembly.PromotionEnvelope.PayloadType,
|
||||
payloadBytes,
|
||||
sig.Signature,
|
||||
sig.KeyId,
|
||||
cancellationToken);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Signature verification failed for assembly {AssemblyId} with key {KeyId}",
|
||||
assemblyId, sig.KeyId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error verifying assembly {AssemblyId}", assemblyId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PromotionBundleExportResult?> ExportBundleAsync(
|
||||
string assemblyId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assembly = await GetAssemblyAsync(assemblyId, tenantId, cancellationToken);
|
||||
if (assembly is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var stream = new MemoryStream();
|
||||
|
||||
using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
// Write assembly JSON
|
||||
var assemblyJson = JsonSerializer.Serialize(assembly, SerializerOptions);
|
||||
WriteTextEntry(tar, AssemblyFileName, assemblyJson, DefaultFileMode);
|
||||
|
||||
// Write promotion DSSE envelope
|
||||
var envelopeJson = JsonSerializer.Serialize(assembly.PromotionEnvelope, SerializerOptions);
|
||||
WriteTextEntry(tar, EnvelopeFileName, envelopeJson, DefaultFileMode);
|
||||
|
||||
// Write Rekor proofs as NDJSON
|
||||
if (assembly.RekorProofs.Count > 0)
|
||||
{
|
||||
var rekorNdjson = BuildRekorNdjson(assembly.RekorProofs);
|
||||
WriteTextEntry(tar, RekorProofsFileName, rekorNdjson, DefaultFileMode);
|
||||
}
|
||||
|
||||
// Write included DSSE envelopes
|
||||
foreach (var envelopeRef in assembly.DsseEnvelopes)
|
||||
{
|
||||
var envelopePath = $"{DsseEnvelopesDir}{envelopeRef.AttestationType}/{envelopeRef.AttestationId}.dsse.json";
|
||||
WriteTextEntry(tar, envelopePath, envelopeRef.EnvelopeJson, DefaultFileMode);
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
var metadata = BuildBundleMetadata(assembly);
|
||||
var metadataJson = JsonSerializer.Serialize(metadata, SerializerOptions);
|
||||
WriteTextEntry(tar, MetadataFileName, metadataJson, DefaultFileMode);
|
||||
|
||||
// Compute checksums and write
|
||||
var checksums = BuildChecksums(assembly, assemblyJson, envelopeJson, metadataJson);
|
||||
WriteTextEntry(tar, ChecksumsFileName, checksums, DefaultFileMode);
|
||||
|
||||
// Write verification script
|
||||
var verifyScript = BuildVerificationScript(assembly);
|
||||
WriteTextEntry(tar, VerifyScriptFileName, verifyScript, ExecutableFileMode);
|
||||
}
|
||||
|
||||
ApplyDeterministicGzipHeader(stream);
|
||||
|
||||
// Compute bundle digest
|
||||
stream.Position = 0;
|
||||
var bundleBytes = stream.ToArray();
|
||||
var bundleDigest = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(bundleBytes));
|
||||
|
||||
stream.Position = 0;
|
||||
|
||||
var fileName = $"promotion-{assembly.PromotionId}-{assembly.AssemblyId}.tar.gz";
|
||||
|
||||
return new PromotionBundleExportResult
|
||||
{
|
||||
BundleStream = stream,
|
||||
FileName = fileName,
|
||||
BundleDigest = bundleDigest,
|
||||
SizeBytes = bundleBytes.Length
|
||||
};
|
||||
}
|
||||
|
||||
private static PromotionPredicate BuildPromotionPredicate(
|
||||
PromotionAttestationAssemblyRequest request,
|
||||
DateTimeOffset promotedAt)
|
||||
{
|
||||
return new PromotionPredicate
|
||||
{
|
||||
PromotionId = request.PromotionId,
|
||||
TenantId = request.TenantId,
|
||||
ProfileId = request.ProfileId,
|
||||
SourceEnvironment = request.SourceEnvironment,
|
||||
TargetEnvironment = request.TargetEnvironment,
|
||||
PromotedAt = promotedAt,
|
||||
SbomDigests = request.SbomDigests.Select(d => new PromotionDigestEntry
|
||||
{
|
||||
Name = d.Name,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = d.Sha256Digest },
|
||||
ArtifactType = d.ArtifactType
|
||||
}).ToList(),
|
||||
VexDigests = request.VexDigests.Select(d => new PromotionDigestEntry
|
||||
{
|
||||
Name = d.Name,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = d.Sha256Digest },
|
||||
ArtifactType = d.ArtifactType
|
||||
}).ToList(),
|
||||
RekorProofs = request.RekorProofs.Select(p => new PromotionRekorReference
|
||||
{
|
||||
LogIndex = p.LogIndex,
|
||||
LogId = p.LogId,
|
||||
Uuid = p.Uuid
|
||||
}).ToList(),
|
||||
EnvelopeDigests = request.DsseEnvelopes.Select(e => new PromotionDigestEntry
|
||||
{
|
||||
Name = e.AttestationId,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = e.EnvelopeDigest },
|
||||
ArtifactType = e.AttestationType
|
||||
}).ToList(),
|
||||
Promoter = new PromotionPromoterInfo
|
||||
{
|
||||
Version = GetAssemblyVersion(),
|
||||
BuildTimestamp = GetBuildTimestamp()
|
||||
},
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExportInTotoSubject> BuildSubjects(
|
||||
PromotionAttestationAssemblyRequest request)
|
||||
{
|
||||
var subjects = new List<ExportInTotoSubject>();
|
||||
|
||||
// Add SBOM subjects
|
||||
foreach (var sbom in request.SbomDigests)
|
||||
{
|
||||
subjects.Add(new ExportInTotoSubject
|
||||
{
|
||||
Name = sbom.Name,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = sbom.Sha256Digest }
|
||||
});
|
||||
}
|
||||
|
||||
// Add VEX subjects
|
||||
foreach (var vex in request.VexDigests)
|
||||
{
|
||||
subjects.Add(new ExportInTotoSubject
|
||||
{
|
||||
Name = vex.Name,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = vex.Sha256Digest }
|
||||
});
|
||||
}
|
||||
|
||||
// Add envelope subjects
|
||||
foreach (var envelope in request.DsseEnvelopes)
|
||||
{
|
||||
subjects.Add(new ExportInTotoSubject
|
||||
{
|
||||
Name = $"envelope:{envelope.AttestationType}/{envelope.AttestationId}",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = envelope.EnvelopeDigest }
|
||||
});
|
||||
}
|
||||
|
||||
return subjects;
|
||||
}
|
||||
|
||||
private static string ComputeRootHash(PromotionAttestationAssemblyRequest request)
|
||||
{
|
||||
var hashes = new List<string>();
|
||||
|
||||
// Collect all digests
|
||||
foreach (var sbom in request.SbomDigests)
|
||||
{
|
||||
hashes.Add(sbom.Sha256Digest);
|
||||
}
|
||||
|
||||
foreach (var vex in request.VexDigests)
|
||||
{
|
||||
hashes.Add(vex.Sha256Digest);
|
||||
}
|
||||
|
||||
foreach (var envelope in request.DsseEnvelopes)
|
||||
{
|
||||
hashes.Add(envelope.EnvelopeDigest);
|
||||
}
|
||||
|
||||
if (hashes.Count == 0)
|
||||
{
|
||||
// Empty marker
|
||||
return "sha256:" + Convert.ToHexStringLower(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes("stellaops:promotion:empty")));
|
||||
}
|
||||
|
||||
// Sort and combine with null separator
|
||||
var builder = new StringBuilder();
|
||||
foreach (var hash in hashes.OrderBy(h => h, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(hash).Append('\0');
|
||||
}
|
||||
|
||||
var combined = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
return "sha256:" + Convert.ToHexStringLower(SHA256.HashData(combined));
|
||||
}
|
||||
|
||||
private static string BuildRekorNdjson(IReadOnlyList<RekorProofEntry> proofs)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var proof in proofs.OrderBy(p => p.LogIndex))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(proof, SerializerOptions);
|
||||
builder.AppendLine(json);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static object BuildBundleMetadata(PromotionAttestationAssembly assembly)
|
||||
{
|
||||
return new
|
||||
{
|
||||
version = BundleVersion,
|
||||
assembly_id = assembly.AssemblyId,
|
||||
promotion_id = assembly.PromotionId,
|
||||
tenant_id = assembly.TenantId,
|
||||
source_environment = assembly.SourceEnvironment,
|
||||
target_environment = assembly.TargetEnvironment,
|
||||
created_at = assembly.CreatedAt,
|
||||
root_hash = assembly.RootHash,
|
||||
sbom_count = assembly.SbomDigests.Count,
|
||||
vex_count = assembly.VexDigests.Count,
|
||||
rekor_proof_count = assembly.RekorProofs.Count,
|
||||
envelope_count = assembly.DsseEnvelopes.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildChecksums(
|
||||
PromotionAttestationAssembly assembly,
|
||||
string assemblyJson,
|
||||
string envelopeJson,
|
||||
string metadataJson)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Promotion attestation bundle checksums (sha256)");
|
||||
|
||||
// Calculate and append checksums in lexical order
|
||||
var files = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
files[AssemblyFileName] = Convert.ToHexStringLower(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(assemblyJson)));
|
||||
files[EnvelopeFileName] = Convert.ToHexStringLower(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(envelopeJson)));
|
||||
files[MetadataFileName] = Convert.ToHexStringLower(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(metadataJson)));
|
||||
|
||||
foreach (var (file, hash) in files)
|
||||
{
|
||||
builder.Append(hash).Append(" ").AppendLine(file);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildVerificationScript(PromotionAttestationAssembly assembly)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#!/usr/bin/env sh");
|
||||
builder.AppendLine("# Promotion Attestation Bundle Verification Script");
|
||||
builder.AppendLine("# No network access required");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("set -eu");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Verify checksums");
|
||||
builder.AppendLine("echo \"Verifying checksums...\"");
|
||||
builder.AppendLine("if command -v sha256sum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" sha256sum --check checksums.txt");
|
||||
builder.AppendLine("elif command -v shasum >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" shasum -a 256 --check checksums.txt");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" echo \"Error: sha256sum or shasum required\" >&2");
|
||||
builder.AppendLine(" exit 1");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine("echo \"Checksums verified successfully.\"");
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Promotion details");
|
||||
builder.Append("ASSEMBLY_ID=\"").Append(assembly.AssemblyId).AppendLine("\"");
|
||||
builder.Append("PROMOTION_ID=\"").Append(assembly.PromotionId).AppendLine("\"");
|
||||
builder.Append("SOURCE_ENV=\"").Append(assembly.SourceEnvironment).AppendLine("\"");
|
||||
builder.Append("TARGET_ENV=\"").Append(assembly.TargetEnvironment).AppendLine("\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("echo \"Promotion Details:\"");
|
||||
builder.AppendLine("echo \" Assembly ID: $ASSEMBLY_ID\"");
|
||||
builder.AppendLine("echo \" Promotion ID: $PROMOTION_ID\"");
|
||||
builder.AppendLine("echo \" Source: $SOURCE_ENV\"");
|
||||
builder.AppendLine("echo \" Target: $TARGET_ENV\"");
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("# Verify DSSE envelope");
|
||||
builder.AppendLine("DSSE_FILE=\"promotion.dsse.json\"");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("if command -v stella >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" echo \"Verifying promotion DSSE envelope with stella CLI...\"");
|
||||
builder.AppendLine(" stella attest verify --envelope \"$DSSE_FILE\"");
|
||||
builder.AppendLine("else");
|
||||
builder.AppendLine(" echo \"Note: stella CLI not found. Manual DSSE verification recommended.\"");
|
||||
builder.AppendLine(" echo \"Install stella CLI and run: stella attest verify --envelope $DSSE_FILE\"");
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
|
||||
// Verify included envelopes
|
||||
if (assembly.DsseEnvelopes.Count > 0)
|
||||
{
|
||||
builder.AppendLine("# Verify included attestation envelopes");
|
||||
builder.AppendLine("if command -v stella >/dev/null 2>&1; then");
|
||||
builder.AppendLine(" echo \"\"");
|
||||
builder.AppendLine(" echo \"Verifying included attestation envelopes...\"");
|
||||
foreach (var env in assembly.DsseEnvelopes)
|
||||
{
|
||||
var path = $"envelopes/{env.AttestationType}/{env.AttestationId}.dsse.json";
|
||||
builder.Append(" stella attest verify --envelope \"").Append(path).AppendLine("\" || echo \"Warning: Failed to verify envelope\"");
|
||||
}
|
||||
builder.AppendLine("fi");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine("echo \"\"");
|
||||
builder.AppendLine("echo \"Verification complete.\"");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var dataStream = new MemoryStream(bytes);
|
||||
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
|
||||
{
|
||||
Mode = mode,
|
||||
ModificationTime = FixedTimestamp,
|
||||
Uid = 0,
|
||||
Gid = 0,
|
||||
UserName = string.Empty,
|
||||
GroupName = string.Empty,
|
||||
DataStream = dataStream
|
||||
};
|
||||
writer.WriteEntry(entry);
|
||||
}
|
||||
|
||||
private static void ApplyDeterministicGzipHeader(MemoryStream stream)
|
||||
{
|
||||
if (stream.Length < 10)
|
||||
{
|
||||
throw new InvalidOperationException("GZip header not fully written.");
|
||||
}
|
||||
|
||||
var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds);
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds);
|
||||
|
||||
var originalPosition = stream.Position;
|
||||
stream.Position = 4;
|
||||
stream.Write(buffer);
|
||||
stream.Position = originalPosition;
|
||||
}
|
||||
|
||||
private static string GenerateAssemblyId()
|
||||
{
|
||||
return $"promo-{Guid.NewGuid():N}"[..24];
|
||||
}
|
||||
|
||||
private static string BuildStoreKey(string assemblyId, string tenantId)
|
||||
{
|
||||
return $"{tenantId}:{assemblyId}";
|
||||
}
|
||||
|
||||
private static string GetAssemblyVersion()
|
||||
{
|
||||
return Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "1.0.0";
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetBuildTimestamp()
|
||||
{
|
||||
var attr = Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyMetadataAttribute>();
|
||||
return attr?.Key == "BuildTimestamp" && DateTimeOffset.TryParse(attr.Value, out var ts)
|
||||
? ts
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping promotion attestation endpoints.
|
||||
/// </summary>
|
||||
public static class PromotionAttestationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps promotion attestation endpoints to the application.
|
||||
/// </summary>
|
||||
public static WebApplication MapPromotionAttestationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/v1/promotions")
|
||||
.WithTags("Promotion Attestations")
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator);
|
||||
|
||||
// POST /v1/promotions/attestations - Create promotion attestation assembly
|
||||
group.MapPost("/attestations", CreatePromotionAttestationAsync)
|
||||
.WithName("CreatePromotionAttestation")
|
||||
.WithSummary("Create promotion attestation assembly")
|
||||
.WithDescription("Creates a promotion attestation assembly bundling SBOM/VEX digests, Rekor proofs, and DSSE envelopes.")
|
||||
.Produces<PromotionAttestationAssemblyResult>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// GET /v1/promotions/attestations/{assemblyId} - Get promotion assembly by ID
|
||||
group.MapGet("/attestations/{assemblyId}", GetPromotionAssemblyAsync)
|
||||
.WithName("GetPromotionAssembly")
|
||||
.WithSummary("Get promotion attestation assembly")
|
||||
.WithDescription("Returns the promotion attestation assembly for the specified ID.")
|
||||
.Produces<PromotionAttestationAssembly>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /v1/promotions/{promotionId}/attestations - Get assemblies for promotion
|
||||
group.MapGet("/{promotionId}/attestations", GetAssembliesForPromotionAsync)
|
||||
.WithName("GetAssembliesForPromotion")
|
||||
.WithSummary("Get attestation assemblies for a promotion")
|
||||
.WithDescription("Returns all attestation assemblies for the specified promotion.")
|
||||
.Produces<IReadOnlyList<PromotionAttestationAssembly>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /v1/promotions/attestations/{assemblyId}/verify - Verify assembly
|
||||
group.MapPost("/attestations/{assemblyId}/verify", VerifyPromotionAssemblyAsync)
|
||||
.WithName("VerifyPromotionAssembly")
|
||||
.WithSummary("Verify promotion attestation assembly")
|
||||
.WithDescription("Verifies the cryptographic signatures of the promotion attestation assembly.")
|
||||
.Produces<PromotionAttestationVerifyResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /v1/promotions/attestations/{assemblyId}/bundle - Export bundle for Offline Kit
|
||||
group.MapGet("/attestations/{assemblyId}/bundle", ExportPromotionBundleAsync)
|
||||
.WithName("ExportPromotionBundle")
|
||||
.WithSummary("Export promotion bundle for Offline Kit")
|
||||
.WithDescription("Exports the promotion attestation assembly as a portable bundle for Offline Kit delivery.")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/gzip")
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<PromotionAttestationAssemblyResult>, BadRequest<string>>> CreatePromotionAttestationAsync(
|
||||
[FromBody] PromotionAttestationAssemblyRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IPromotionAttestationAssembler assembler,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.BadRequest("Tenant ID is required");
|
||||
}
|
||||
|
||||
// Ensure request has tenant ID
|
||||
var requestWithTenant = request with { TenantId = tenantId };
|
||||
|
||||
var result = await assembler.AssembleAsync(requestWithTenant, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.BadRequest(result.ErrorMessage ?? "Assembly failed");
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/promotions/attestations/{result.AssemblyId}", result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PromotionAttestationAssembly>, NotFound>> GetPromotionAssemblyAsync(
|
||||
string assemblyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IPromotionAttestationAssembler assembler,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var assembly = await assembler.GetAssemblyAsync(assemblyId, tenantId, cancellationToken);
|
||||
if (assembly is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(assembly);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<IReadOnlyList<PromotionAttestationAssembly>>, NotFound>> GetAssembliesForPromotionAsync(
|
||||
string promotionId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IPromotionAttestationAssembler assembler,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var assemblies = await assembler.GetAssembliesForPromotionAsync(promotionId, tenantId, cancellationToken);
|
||||
return TypedResults.Ok(assemblies);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<PromotionAttestationVerifyResponse>, NotFound>> VerifyPromotionAssemblyAsync(
|
||||
string assemblyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IPromotionAttestationAssembler assembler,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var assembly = await assembler.GetAssemblyAsync(assemblyId, tenantId, cancellationToken);
|
||||
if (assembly is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var isValid = await assembler.VerifyAssemblyAsync(assemblyId, tenantId, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new PromotionAttestationVerifyResponse
|
||||
{
|
||||
AssemblyId = assemblyId,
|
||||
PromotionId = assembly.PromotionId,
|
||||
IsValid = isValid,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<FileStreamHttpResult, NotFound>> ExportPromotionBundleAsync(
|
||||
string assemblyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IPromotionAttestationAssembler assembler,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(tenantIdHeader, httpContext);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var exportResult = await assembler.ExportBundleAsync(assemblyId, tenantId, cancellationToken);
|
||||
if (exportResult is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
// Set content disposition for download
|
||||
httpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{exportResult.FileName}\"";
|
||||
httpContext.Response.Headers["X-Bundle-Digest"] = exportResult.BundleDigest;
|
||||
|
||||
return TypedResults.File(
|
||||
exportResult.BundleStream,
|
||||
exportResult.MediaType,
|
||||
exportResult.FileName);
|
||||
}
|
||||
|
||||
private static string? ResolveTenantId(string? header, HttpContext httpContext)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header;
|
||||
}
|
||||
|
||||
// Try to get from claims
|
||||
var tenantClaim = httpContext.User.FindFirst("tenant_id")
|
||||
?? httpContext.User.FindFirst("tid");
|
||||
|
||||
return tenantClaim?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for promotion attestation verification.
|
||||
/// </summary>
|
||||
public sealed record PromotionAttestationVerifyResponse
|
||||
{
|
||||
public required string AssemblyId { get; init; }
|
||||
public required string PromotionId { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Payload types for promotion attestations.
|
||||
/// </summary>
|
||||
public static class PromotionAttestationPayloadTypes
|
||||
{
|
||||
public const string PromotionPredicateType = "stella.ops/promotion@v1";
|
||||
public const string PromotionBundlePredicateType = "stella.ops/promotion-bundle@v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a promotion attestation assembly.
|
||||
/// </summary>
|
||||
public sealed record PromotionAttestationAssemblyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the promotion.
|
||||
/// </summary>
|
||||
public required string PromotionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional profile identifier.
|
||||
/// </summary>
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source environment (e.g., "staging").
|
||||
/// </summary>
|
||||
public required string SourceEnvironment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target environment (e.g., "production").
|
||||
/// </summary>
|
||||
public required string TargetEnvironment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM digest references to include.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ArtifactDigestReference> SbomDigests { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// VEX digest references to include.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ArtifactDigestReference> VexDigests { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log proofs.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RekorProofEntry> RekorProofs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Existing DSSE envelopes to include in the bundle.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DsseEnvelopeReference> DsseEnvelopes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata for the promotion.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an artifact with its digest.
|
||||
/// </summary>
|
||||
public sealed record ArtifactDigestReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the artifact.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the artifact.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact (e.g., "application/spdx+json").
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the artifact.
|
||||
/// </summary>
|
||||
public required string Sha256Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the artifact in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional URI where the artifact can be retrieved.
|
||||
/// </summary>
|
||||
public string? Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type (sbom, vex, etc.).
|
||||
/// </summary>
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact version or format (e.g., "spdx-3.0.1", "cyclonedx-1.6", "openvex").
|
||||
/// </summary>
|
||||
public string? FormatVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log proof entry.
|
||||
/// </summary>
|
||||
public sealed record RekorProofEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Log index in Rekor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("logIndex")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log ID (tree ID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("logId")]
|
||||
public required string LogId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Integrated time (Unix timestamp).
|
||||
/// </summary>
|
||||
[JsonPropertyName("integratedTime")]
|
||||
public required long IntegratedTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry UUID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uuid")]
|
||||
public required string Uuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry body (base64-encoded).
|
||||
/// </summary>
|
||||
[JsonPropertyName("body")]
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inclusion proof for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
public RekorInclusionProof? InclusionProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle tree inclusion proof from Rekor.
|
||||
/// </summary>
|
||||
public sealed record RekorInclusionProof
|
||||
{
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public long TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyList<string> Hashes { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an existing DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Attestation ID.
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of attestation (e.g., "sbom", "vex", "slsa-provenance").
|
||||
/// </summary>
|
||||
public required string AttestationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialized DSSE envelope JSON.
|
||||
/// </summary>
|
||||
public required string EnvelopeJson { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of the envelope.
|
||||
/// </summary>
|
||||
public required string EnvelopeDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a promotion attestation assembly.
|
||||
/// </summary>
|
||||
public sealed record PromotionAttestationAssemblyResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? AssemblyId { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public PromotionAttestationAssembly? Assembly { get; init; }
|
||||
|
||||
public static PromotionAttestationAssemblyResult Succeeded(
|
||||
string assemblyId,
|
||||
PromotionAttestationAssembly assembly) =>
|
||||
new() { Success = true, AssemblyId = assemblyId, Assembly = assembly };
|
||||
|
||||
public static PromotionAttestationAssemblyResult Failed(string errorMessage) =>
|
||||
new() { Success = false, ErrorMessage = errorMessage };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete promotion attestation assembly.
|
||||
/// </summary>
|
||||
public sealed record PromotionAttestationAssembly
|
||||
{
|
||||
[JsonPropertyName("assembly_id")]
|
||||
public required string AssemblyId { get; init; }
|
||||
|
||||
[JsonPropertyName("promotion_id")]
|
||||
public required string PromotionId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("profile_id")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("source_environment")]
|
||||
public required string SourceEnvironment { get; init; }
|
||||
|
||||
[JsonPropertyName("target_environment")]
|
||||
public required string TargetEnvironment { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("promotion_envelope")]
|
||||
public required ExportDsseEnvelope PromotionEnvelope { get; init; }
|
||||
|
||||
[JsonPropertyName("sbom_digests")]
|
||||
public IReadOnlyList<ArtifactDigestReference> SbomDigests { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("vex_digests")]
|
||||
public IReadOnlyList<ArtifactDigestReference> VexDigests { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("rekor_proofs")]
|
||||
public IReadOnlyList<RekorProofEntry> RekorProofs { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("dsse_envelopes")]
|
||||
public IReadOnlyList<DsseEnvelopeReference> DsseEnvelopes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("verification")]
|
||||
public ExportAttestationVerification? Verification { get; init; }
|
||||
|
||||
[JsonPropertyName("root_hash")]
|
||||
public string? RootHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion predicate for in-toto statements.
|
||||
/// </summary>
|
||||
public sealed record PromotionPredicate
|
||||
{
|
||||
[JsonPropertyName("promotionId")]
|
||||
public required string PromotionId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceEnvironment")]
|
||||
public required string SourceEnvironment { get; init; }
|
||||
|
||||
[JsonPropertyName("targetEnvironment")]
|
||||
public required string TargetEnvironment { get; init; }
|
||||
|
||||
[JsonPropertyName("promotedAt")]
|
||||
public required DateTimeOffset PromotedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomDigests")]
|
||||
public IReadOnlyList<PromotionDigestEntry> SbomDigests { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("vexDigests")]
|
||||
public IReadOnlyList<PromotionDigestEntry> VexDigests { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("rekorProofs")]
|
||||
public IReadOnlyList<PromotionRekorReference> RekorProofs { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("envelopeDigests")]
|
||||
public IReadOnlyList<PromotionDigestEntry> EnvelopeDigests { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("promoter")]
|
||||
public required PromotionPromoterInfo Promoter { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Digest entry for promotion predicate.
|
||||
/// </summary>
|
||||
public sealed record PromotionDigestEntry
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor reference for promotion predicate.
|
||||
/// </summary>
|
||||
public sealed record PromotionRekorReference
|
||||
{
|
||||
[JsonPropertyName("logIndex")]
|
||||
public required long LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("logId")]
|
||||
public required string LogId { get; init; }
|
||||
|
||||
[JsonPropertyName("uuid")]
|
||||
public required string Uuid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the promoter.
|
||||
/// </summary>
|
||||
public sealed record PromotionPromoterInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "StellaOps.ExportCenter";
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("buildTimestamp")]
|
||||
public DateTimeOffset? BuildTimestamp { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of deprecated export endpoints with their migration paths.
|
||||
/// </summary>
|
||||
public static class DeprecatedEndpointsRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Date when legacy /exports endpoints were deprecated.
|
||||
/// </summary>
|
||||
public static readonly DateTimeOffset LegacyExportsDeprecationDate =
|
||||
new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Date when legacy /exports endpoints will be removed.
|
||||
/// </summary>
|
||||
public static readonly DateTimeOffset LegacyExportsSunsetDate =
|
||||
new(2025, 7, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL for API deprecation migration guide.
|
||||
/// </summary>
|
||||
public const string DeprecationDocumentationUrl =
|
||||
"https://docs.stellaops.io/api/export-center/migration";
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation info for GET /exports (list exports).
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo ListExports = new(
|
||||
DeprecatedAt: LegacyExportsDeprecationDate,
|
||||
SunsetAt: LegacyExportsSunsetDate,
|
||||
SuccessorPath: "/v1/exports/profiles",
|
||||
DocumentationUrl: DeprecationDocumentationUrl,
|
||||
Reason: "Legacy exports list endpoint replaced by profiles API");
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation info for POST /exports (create export).
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo CreateExport = new(
|
||||
DeprecatedAt: LegacyExportsDeprecationDate,
|
||||
SunsetAt: LegacyExportsSunsetDate,
|
||||
SuccessorPath: "/v1/exports/evidence",
|
||||
DocumentationUrl: DeprecationDocumentationUrl,
|
||||
Reason: "Legacy export creation endpoint replaced by typed export APIs");
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation info for DELETE /exports/{id} (delete export).
|
||||
/// </summary>
|
||||
public static readonly DeprecationInfo DeleteExport = new(
|
||||
DeprecatedAt: LegacyExportsDeprecationDate,
|
||||
SunsetAt: LegacyExportsSunsetDate,
|
||||
SuccessorPath: "/v1/exports/runs/{id}/cancel",
|
||||
DocumentationUrl: DeprecationDocumentationUrl,
|
||||
Reason: "Legacy export deletion replaced by run cancellation API");
|
||||
|
||||
/// <summary>
|
||||
/// Gets all deprecated endpoint registrations.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(string Method, string Pattern, DeprecationInfo Info)> GetAll()
|
||||
{
|
||||
return
|
||||
[
|
||||
("GET", "/exports", ListExports),
|
||||
("POST", "/exports", CreateExport),
|
||||
("DELETE", "/exports/{id}", DeleteExport)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding RFC 8594 deprecation headers to HTTP responses.
|
||||
/// </summary>
|
||||
public static class DeprecationHeaderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP header indicating the resource is deprecated (RFC 8594).
|
||||
/// </summary>
|
||||
public const string DeprecationHeader = "Deprecation";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP header indicating when the resource will be removed (RFC 8594).
|
||||
/// </summary>
|
||||
public const string SunsetHeader = "Sunset";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP Link header with relation type for successor resource.
|
||||
/// </summary>
|
||||
public const string LinkHeader = "Link";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP Warning header for additional deprecation notice (RFC 7234).
|
||||
/// </summary>
|
||||
public const string WarningHeader = "Warning";
|
||||
|
||||
/// <summary>
|
||||
/// Adds RFC 8594 deprecation headers to the response.
|
||||
/// </summary>
|
||||
/// <param name="context">The HTTP context.</param>
|
||||
/// <param name="info">Deprecation metadata.</param>
|
||||
public static void AddDeprecationHeaders(this HttpContext context, DeprecationInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
var response = context.Response;
|
||||
|
||||
// RFC 8594: Deprecation header with IMF-fixdate
|
||||
response.Headers[DeprecationHeader] = info.DeprecatedAt.ToUniversalTime().ToString("R");
|
||||
|
||||
// RFC 8594: Sunset header with IMF-fixdate
|
||||
response.Headers[SunsetHeader] = info.SunsetAt.ToUniversalTime().ToString("R");
|
||||
|
||||
// Link header pointing to successor and/or documentation
|
||||
var links = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(info.SuccessorPath))
|
||||
{
|
||||
links.Add($"<{info.SuccessorPath}>; rel=\"successor-version\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(info.DocumentationUrl))
|
||||
{
|
||||
links.Add($"<{info.DocumentationUrl}>; rel=\"deprecation\"");
|
||||
}
|
||||
|
||||
if (links.Count > 0)
|
||||
{
|
||||
response.Headers.Append(LinkHeader, string.Join(", ", links));
|
||||
}
|
||||
|
||||
// Warning header with deprecation notice
|
||||
var reason = info.Reason ?? "This endpoint is deprecated and will be removed.";
|
||||
var warning = $"299 - \"{reason} Use {info.SuccessorPath} instead. Sunset: {info.SunsetAt:yyyy-MM-dd}\"";
|
||||
response.Headers[WarningHeader] = warning;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an endpoint filter that adds deprecation headers and logs usage.
|
||||
/// </summary>
|
||||
/// <param name="info">Deprecation metadata.</param>
|
||||
/// <param name="loggerFactory">Logger factory for deprecation logging.</param>
|
||||
/// <returns>An endpoint filter delegate.</returns>
|
||||
public static Func<EndpointFilterInvocationContext, EndpointFilterDelegate, ValueTask<object?>>
|
||||
CreateDeprecationFilter(DeprecationInfo info, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
var logger = loggerFactory?.CreateLogger("DeprecatedEndpoint");
|
||||
|
||||
return async (context, next) =>
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
|
||||
// Add deprecation headers
|
||||
httpContext.AddDeprecationHeaders(info);
|
||||
|
||||
// Log deprecated endpoint usage
|
||||
logger?.LogWarning(
|
||||
"Deprecated endpoint accessed: {Method} {Path} - Successor: {Successor}, Sunset: {Sunset}, Client: {ClientIp}",
|
||||
httpContext.Request.Method,
|
||||
httpContext.Request.Path,
|
||||
info.SuccessorPath,
|
||||
info.SunsetAt,
|
||||
httpContext.Connection.RemoteIpAddress);
|
||||
|
||||
// If past sunset, optionally return 410 Gone
|
||||
if (info.IsPastSunset)
|
||||
{
|
||||
logger?.LogError(
|
||||
"Sunset endpoint accessed after removal date: {Method} {Path} - Was removed: {Sunset}",
|
||||
httpContext.Request.Method,
|
||||
httpContext.Request.Path,
|
||||
info.SunsetAt);
|
||||
|
||||
return Results.Problem(
|
||||
title: "Endpoint Removed",
|
||||
detail: $"This endpoint was deprecated on {info.DeprecatedAt:yyyy-MM-dd} and removed on {info.SunsetAt:yyyy-MM-dd}. Use {info.SuccessorPath} instead.",
|
||||
statusCode: StatusCodes.Status410Gone,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["successorPath"] = info.SuccessorPath,
|
||||
["documentationUrl"] = info.DocumentationUrl,
|
||||
["sunsetDate"] = info.SunsetAt.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Describes deprecation metadata for an API endpoint.
|
||||
/// </summary>
|
||||
/// <param name="DeprecatedAt">UTC date when the endpoint was deprecated.</param>
|
||||
/// <param name="SunsetAt">UTC date when the endpoint will be removed.</param>
|
||||
/// <param name="SuccessorPath">Path to the replacement endpoint (e.g., "/v1/exports").</param>
|
||||
/// <param name="DocumentationUrl">URL to deprecation documentation or migration guide.</param>
|
||||
/// <param name="Reason">Human-readable reason for deprecation.</param>
|
||||
public sealed record DeprecationInfo(
|
||||
DateTimeOffset DeprecatedAt,
|
||||
DateTimeOffset SunsetAt,
|
||||
string SuccessorPath,
|
||||
string? DocumentationUrl = null,
|
||||
string? Reason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the sunset date has passed.
|
||||
/// </summary>
|
||||
public bool IsPastSunset => DateTimeOffset.UtcNow >= SunsetAt;
|
||||
|
||||
/// <summary>
|
||||
/// Days remaining until sunset.
|
||||
/// </summary>
|
||||
public int DaysUntilSunset => Math.Max(0, (int)(SunsetAt - DateTimeOffset.UtcNow).TotalDays);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for emitting notifications when deprecated endpoints are accessed.
|
||||
/// </summary>
|
||||
public interface IDeprecationNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Records access to a deprecated endpoint.
|
||||
/// </summary>
|
||||
/// <param name="method">HTTP method.</param>
|
||||
/// <param name="path">Request path.</param>
|
||||
/// <param name="info">Deprecation metadata.</param>
|
||||
/// <param name="clientInfo">Client identification (IP, user agent, etc.).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordDeprecatedAccessAsync(
|
||||
string method,
|
||||
string path,
|
||||
DeprecationInfo info,
|
||||
DeprecationClientInfo clientInfo,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the client accessing a deprecated endpoint.
|
||||
/// </summary>
|
||||
/// <param name="ClientIp">Client IP address.</param>
|
||||
/// <param name="UserAgent">Client user agent string.</param>
|
||||
/// <param name="TenantId">Tenant ID if available.</param>
|
||||
/// <param name="UserId">User ID if authenticated.</param>
|
||||
/// <param name="TraceId">Distributed trace ID.</param>
|
||||
public sealed record DeprecationClientInfo(
|
||||
string? ClientIp,
|
||||
string? UserAgent,
|
||||
string? TenantId,
|
||||
string? UserId,
|
||||
string? TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that logs deprecation events.
|
||||
/// </summary>
|
||||
public sealed class DeprecationNotificationService : IDeprecationNotificationService
|
||||
{
|
||||
private readonly ILogger<DeprecationNotificationService> _logger;
|
||||
|
||||
public DeprecationNotificationService(ILogger<DeprecationNotificationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task RecordDeprecatedAccessAsync(
|
||||
string method,
|
||||
string path,
|
||||
DeprecationInfo info,
|
||||
DeprecationClientInfo clientInfo,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Log structured event for telemetry/audit
|
||||
_logger.LogWarning(
|
||||
"Deprecated endpoint access: Method={Method}, Path={Path}, " +
|
||||
"DeprecatedAt={DeprecatedAt}, SunsetAt={SunsetAt}, DaysUntilSunset={DaysUntilSunset}, " +
|
||||
"Successor={Successor}, ClientIp={ClientIp}, UserAgent={UserAgent}, " +
|
||||
"TenantId={TenantId}, UserId={UserId}, TraceId={TraceId}",
|
||||
method,
|
||||
path,
|
||||
info.DeprecatedAt,
|
||||
info.SunsetAt,
|
||||
info.DaysUntilSunset,
|
||||
info.SuccessorPath,
|
||||
clientInfo.ClientIp,
|
||||
clientInfo.UserAgent,
|
||||
clientInfo.TenantId,
|
||||
clientInfo.UserId,
|
||||
clientInfo.TraceId);
|
||||
|
||||
// Emit custom metric counter
|
||||
DeprecationMetrics.DeprecatedEndpointAccessCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("method", method),
|
||||
new KeyValuePair<string, object?>("path", path),
|
||||
new KeyValuePair<string, object?>("successor", info.SuccessorPath),
|
||||
new KeyValuePair<string, object?>("days_until_sunset", info.DaysUntilSunset));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for deprecation tracking.
|
||||
/// </summary>
|
||||
public static class DeprecationMetrics
|
||||
{
|
||||
private static readonly System.Diagnostics.Metrics.Meter Meter =
|
||||
new("StellaOps.ExportCenter.Deprecation", "1.0.0");
|
||||
|
||||
/// <summary>
|
||||
/// Counter for deprecated endpoint accesses.
|
||||
/// </summary>
|
||||
public static readonly System.Diagnostics.Metrics.Counter<long> DeprecatedEndpointAccessCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"export_center_deprecated_endpoint_access_total",
|
||||
"requests",
|
||||
"Total number of requests to deprecated endpoints");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for applying deprecation metadata to routes.
|
||||
/// </summary>
|
||||
public static class DeprecationRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks the endpoint as deprecated with RFC 8594 headers.
|
||||
/// </summary>
|
||||
/// <param name="builder">The route handler builder.</param>
|
||||
/// <param name="info">Deprecation metadata.</param>
|
||||
/// <returns>The route handler builder for chaining.</returns>
|
||||
public static RouteHandlerBuilder WithDeprecation(this RouteHandlerBuilder builder, DeprecationInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
return builder
|
||||
.AddEndpointFilter(DeprecationHeaderExtensions.CreateDeprecationFilter(info))
|
||||
.WithMetadata(info)
|
||||
.WithMetadata(new DeprecatedAttribute())
|
||||
.WithTags("Deprecated");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the endpoint as deprecated with standard sunset timeline.
|
||||
/// </summary>
|
||||
/// <param name="builder">The route handler builder.</param>
|
||||
/// <param name="successorPath">Path to the replacement endpoint.</param>
|
||||
/// <param name="deprecatedAt">When the endpoint was deprecated.</param>
|
||||
/// <param name="sunsetAt">When the endpoint will be removed.</param>
|
||||
/// <param name="documentationUrl">Optional documentation URL.</param>
|
||||
/// <param name="reason">Optional deprecation reason.</param>
|
||||
/// <returns>The route handler builder for chaining.</returns>
|
||||
public static RouteHandlerBuilder WithDeprecation(
|
||||
this RouteHandlerBuilder builder,
|
||||
string successorPath,
|
||||
DateTimeOffset deprecatedAt,
|
||||
DateTimeOffset sunsetAt,
|
||||
string? documentationUrl = null,
|
||||
string? reason = null)
|
||||
{
|
||||
return builder.WithDeprecation(new DeprecationInfo(
|
||||
deprecatedAt,
|
||||
sunsetAt,
|
||||
successorPath,
|
||||
documentationUrl,
|
||||
reason));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker attribute indicating an endpoint is deprecated.
|
||||
/// Used for OpenAPI documentation generation.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
|
||||
public sealed class DeprecatedAttribute : Attribute
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering evidence locker integration services.
|
||||
/// </summary>
|
||||
public static class EvidenceLockerServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds evidence locker integration services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional configuration for the evidence locker client.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportEvidenceLocker(
|
||||
this IServiceCollection services,
|
||||
Action<ExportEvidenceLockerOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Configure options
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
// Register Merkle tree calculator
|
||||
services.TryAddSingleton<IExportMerkleTreeCalculator, ExportMerkleTreeCalculator>();
|
||||
|
||||
// Register HTTP client for evidence locker
|
||||
services.AddHttpClient<IExportEvidenceLockerClient, ExportEvidenceLockerClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetService<Microsoft.Extensions.Options.IOptions<ExportEvidenceLockerOptions>>()?.Value
|
||||
?? ExportEvidenceLockerOptions.Default;
|
||||
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = options.Timeout;
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds evidence locker integration with in-memory implementation for testing.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportEvidenceLockerInMemory(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<IExportMerkleTreeCalculator, ExportMerkleTreeCalculator>();
|
||||
services.TryAddSingleton<IExportEvidenceLockerClient, InMemoryExportEvidenceLockerClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of evidence locker client for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryExportEvidenceLockerClient : IExportEvidenceLockerClient
|
||||
{
|
||||
private readonly IExportMerkleTreeCalculator _merkleCalculator;
|
||||
private readonly Dictionary<string, ExportBundleManifest> _bundles = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
private int _bundleCounter;
|
||||
|
||||
public InMemoryExportEvidenceLockerClient(IExportMerkleTreeCalculator merkleCalculator)
|
||||
{
|
||||
_merkleCalculator = merkleCalculator ?? throw new ArgumentNullException(nameof(merkleCalculator));
|
||||
}
|
||||
|
||||
public Task<ExportEvidenceSnapshotResult> PushSnapshotAsync(
|
||||
ExportEvidenceSnapshotRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var bundleId = Guid.NewGuid().ToString();
|
||||
var entries = request.Materials.Select(m => new ExportManifestEntry
|
||||
{
|
||||
Section = m.Section,
|
||||
CanonicalPath = $"{m.Section}/{m.Path}",
|
||||
Sha256 = m.Sha256.ToLowerInvariant(),
|
||||
SizeBytes = m.SizeBytes,
|
||||
MediaType = m.MediaType ?? "application/octet-stream",
|
||||
Attributes = m.Attributes
|
||||
}).ToList();
|
||||
|
||||
var rootHash = _merkleCalculator.CalculateRootHash(entries);
|
||||
|
||||
var manifest = new ExportBundleManifest
|
||||
{
|
||||
BundleId = bundleId,
|
||||
TenantId = request.TenantId,
|
||||
ProfileId = request.ProfileId,
|
||||
ExportRunId = request.ExportRunId,
|
||||
Kind = request.Kind,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
RootHash = rootHash,
|
||||
Metadata = request.Metadata ?? new Dictionary<string, string>(),
|
||||
Entries = entries,
|
||||
Distribution = request.Distribution
|
||||
};
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_bundles[bundleId] = manifest;
|
||||
_bundleCounter++;
|
||||
}
|
||||
|
||||
return Task.FromResult(ExportEvidenceSnapshotResult.Succeeded(bundleId, rootHash));
|
||||
}
|
||||
|
||||
public Task<bool> UpdateDistributionTranscriptAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
ExportDistributionInfo distribution,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_bundles.TryGetValue(bundleId, out var existing))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_bundles[bundleId] = existing with { Distribution = distribution };
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<ExportBundleManifest?> GetBundleAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_bundles.TryGetValue(bundleId, out var manifest);
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> VerifyRootHashAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string expectedRootHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_bundles.TryGetValue(bundleId, out var manifest))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(
|
||||
string.Equals(manifest.RootHash, expectedRootHash, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all stored bundles (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<ExportBundleManifest> GetAllBundles()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bundles.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all stored bundles (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_bundles.Clear();
|
||||
_bundleCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of stored bundles (for testing).
|
||||
/// </summary>
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) { return _bundles.Count; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation for pushing export manifests to evidence locker.
|
||||
/// </summary>
|
||||
public sealed class ExportEvidenceLockerClient : IExportEvidenceLockerClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IExportMerkleTreeCalculator _merkleCalculator;
|
||||
private readonly ILogger<ExportEvidenceLockerClient> _logger;
|
||||
private readonly ExportEvidenceLockerOptions _options;
|
||||
|
||||
public ExportEvidenceLockerClient(
|
||||
HttpClient httpClient,
|
||||
IExportMerkleTreeCalculator merkleCalculator,
|
||||
ILogger<ExportEvidenceLockerClient> logger,
|
||||
IOptions<ExportEvidenceLockerOptions> options)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_merkleCalculator = merkleCalculator ?? throw new ArgumentNullException(nameof(merkleCalculator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? ExportEvidenceLockerOptions.Default;
|
||||
}
|
||||
|
||||
public async Task<ExportEvidenceSnapshotResult> PushSnapshotAsync(
|
||||
ExportEvidenceSnapshotRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Evidence locker integration disabled; skipping snapshot push");
|
||||
return ExportEvidenceSnapshotResult.Failed("Evidence locker integration disabled");
|
||||
}
|
||||
|
||||
using var activity = ExportTelemetry.ActivitySource.StartActivity("evidence.push_snapshot");
|
||||
activity?.SetTag("tenant_id", request.TenantId);
|
||||
activity?.SetTag("export_run_id", request.ExportRunId);
|
||||
activity?.SetTag("kind", request.Kind.ToString());
|
||||
|
||||
try
|
||||
{
|
||||
// Build manifest entries for Merkle calculation
|
||||
var entries = request.Materials.Select(m => new ExportManifestEntry
|
||||
{
|
||||
Section = m.Section,
|
||||
CanonicalPath = $"{m.Section}/{m.Path}",
|
||||
Sha256 = m.Sha256.ToLowerInvariant(),
|
||||
SizeBytes = m.SizeBytes,
|
||||
MediaType = m.MediaType ?? "application/octet-stream",
|
||||
Attributes = m.Attributes
|
||||
}).ToList();
|
||||
|
||||
// Pre-calculate Merkle root for verification
|
||||
var expectedRootHash = _merkleCalculator.CalculateRootHash(entries);
|
||||
|
||||
// Build request payload
|
||||
var apiRequest = new EvidenceSnapshotApiRequest
|
||||
{
|
||||
Kind = MapKindToApi(request.Kind),
|
||||
Description = request.Description,
|
||||
Metadata = BuildMetadata(request),
|
||||
Materials = request.Materials.Select(m => new EvidenceSnapshotMaterialApiDto
|
||||
{
|
||||
Section = m.Section,
|
||||
Path = m.Path,
|
||||
Sha256 = m.Sha256.ToLowerInvariant(),
|
||||
SizeBytes = m.SizeBytes,
|
||||
MediaType = m.MediaType ?? "application/octet-stream",
|
||||
Attributes = m.Attributes?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
$"{_options.BaseUrl}/evidence/snapshot",
|
||||
apiRequest,
|
||||
SerializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Evidence locker snapshot push failed with status {StatusCode}: {Error}",
|
||||
response.StatusCode, errorBody);
|
||||
|
||||
return ExportEvidenceSnapshotResult.Failed(
|
||||
$"HTTP {(int)response.StatusCode}: {errorBody}");
|
||||
}
|
||||
|
||||
var apiResponse = await response.Content.ReadFromJsonAsync<EvidenceSnapshotApiResponse>(
|
||||
SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (apiResponse is null)
|
||||
{
|
||||
return ExportEvidenceSnapshotResult.Failed("Empty response from evidence locker");
|
||||
}
|
||||
|
||||
// Verify Merkle root matches
|
||||
if (!string.Equals(apiResponse.RootHash, expectedRootHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Merkle root mismatch for export {ExportRunId}: expected {Expected}, got {Actual}",
|
||||
request.ExportRunId, expectedRootHash, apiResponse.RootHash);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Pushed export manifest to evidence locker: bundle={BundleId}, root={RootHash}",
|
||||
apiResponse.BundleId, apiResponse.RootHash);
|
||||
|
||||
ExportTelemetry.ExportArtifactsTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("artifact_type", "evidence_bundle"),
|
||||
new KeyValuePair<string, object?>("tenant_id", request.TenantId));
|
||||
|
||||
return ExportEvidenceSnapshotResult.Succeeded(
|
||||
apiResponse.BundleId.ToString(),
|
||||
apiResponse.RootHash,
|
||||
MapSignatureFromApi(apiResponse.Signature));
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error pushing export manifest to evidence locker");
|
||||
return ExportEvidenceSnapshotResult.Failed($"HTTP error: {ex.Message}");
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error pushing export manifest to evidence locker");
|
||||
return ExportEvidenceSnapshotResult.Failed($"Unexpected error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateDistributionTranscriptAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
ExportDistributionInfo distribution,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleId);
|
||||
ArgumentNullException.ThrowIfNull(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(distribution);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new { distribution };
|
||||
var response = await _httpClient.PatchAsJsonAsync(
|
||||
$"{_options.BaseUrl}/evidence/{bundleId}/distribution",
|
||||
request,
|
||||
SerializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update distribution transcript for bundle {BundleId}", bundleId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExportBundleManifest?> GetBundleAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundleId);
|
||||
ArgumentNullException.ThrowIfNull(tenantId);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(
|
||||
$"{_options.BaseUrl}/evidence/{bundleId}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ExportBundleManifest>(
|
||||
SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get evidence bundle {BundleId}", bundleId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyRootHashAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string expectedRootHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundle = await GetBundleAsync(bundleId, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (bundle?.RootHash is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(bundle.RootHash, expectedRootHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int MapKindToApi(ExportBundleKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
ExportBundleKind.Evidence => 1,
|
||||
ExportBundleKind.Attestation => 2,
|
||||
ExportBundleKind.Mirror => 3,
|
||||
ExportBundleKind.Risk => 3, // Maps to Export=3 in evidence locker
|
||||
ExportBundleKind.OfflineKit => 3,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildMetadata(ExportEvidenceSnapshotRequest request)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["export_run_id"] = request.ExportRunId,
|
||||
["export_kind"] = request.Kind.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ProfileId))
|
||||
{
|
||||
metadata["profile_id"] = request.ProfileId;
|
||||
}
|
||||
|
||||
if (request.Metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in request.Metadata)
|
||||
{
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static ExportDsseSignatureInfo? MapSignatureFromApi(EvidenceSignatureApiDto? apiSignature)
|
||||
{
|
||||
if (apiSignature is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ExportDsseSignatureInfo
|
||||
{
|
||||
PayloadType = apiSignature.PayloadType,
|
||||
Payload = apiSignature.Payload,
|
||||
Signature = apiSignature.Signature,
|
||||
KeyId = apiSignature.KeyId,
|
||||
Algorithm = apiSignature.Algorithm,
|
||||
Provider = apiSignature.Provider,
|
||||
SignedAt = apiSignature.SignedAt,
|
||||
TimestampedAt = apiSignature.TimestampedAt,
|
||||
TimestampAuthority = apiSignature.TimestampAuthority
|
||||
};
|
||||
}
|
||||
|
||||
#region API DTOs
|
||||
|
||||
private sealed record EvidenceSnapshotApiRequest
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public int Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public List<EvidenceSnapshotMaterialApiDto>? Materials { get; init; }
|
||||
}
|
||||
|
||||
private sealed record EvidenceSnapshotMaterialApiDto
|
||||
{
|
||||
[JsonPropertyName("section")]
|
||||
public string? Section { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("media_type")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("attributes")]
|
||||
public Dictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
private sealed record EvidenceSnapshotApiResponse
|
||||
{
|
||||
[JsonPropertyName("bundle_id")]
|
||||
public Guid BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("root_hash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public EvidenceSignatureApiDto? Signature { get; init; }
|
||||
}
|
||||
|
||||
private sealed record EvidenceSignatureApiDto
|
||||
{
|
||||
[JsonPropertyName("payload_type")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public required string Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("key_id")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public required string Provider { get; init; }
|
||||
|
||||
[JsonPropertyName("signed_at")]
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamped_at")]
|
||||
public DateTimeOffset? TimestampedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp_authority")]
|
||||
public string? TimestampAuthority { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for evidence locker integration.
|
||||
/// </summary>
|
||||
public sealed class ExportEvidenceLockerOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string BaseUrl { get; set; } = "http://evidence-locker:8080";
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
public static ExportEvidenceLockerOptions Default => new();
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
|
||||
/// <summary>
|
||||
/// Export bundle manifest for evidence locker submission.
|
||||
/// Aligns with EvidenceLocker bundle-packaging.schema.json.
|
||||
/// </summary>
|
||||
public sealed record ExportBundleManifest
|
||||
{
|
||||
[JsonPropertyName("bundle_id")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("profile_id")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("export_run_id")]
|
||||
public required string ExportRunId { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public required ExportBundleKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("root_hash")]
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<ExportManifestEntry> Entries { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("distribution")]
|
||||
public ExportDistributionInfo? Distribution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export bundle kind for evidence locker categorization.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ExportBundleKind
|
||||
{
|
||||
Evidence = 1,
|
||||
Attestation = 2,
|
||||
Mirror = 3,
|
||||
Risk = 4,
|
||||
OfflineKit = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in export manifest representing a single artifact.
|
||||
/// </summary>
|
||||
public sealed record ExportManifestEntry
|
||||
{
|
||||
[JsonPropertyName("section")]
|
||||
public required string Section { get; init; }
|
||||
|
||||
[JsonPropertyName("canonical_path")]
|
||||
public required string CanonicalPath { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public required long SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("media_type")]
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("attributes")]
|
||||
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distribution information for export transcript.
|
||||
/// </summary>
|
||||
public sealed record ExportDistributionInfo
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("target_uri")]
|
||||
public string? TargetUri { get; init; }
|
||||
|
||||
[JsonPropertyName("distributed_at")]
|
||||
public DateTimeOffset? DistributedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("checksum")]
|
||||
public string? Checksum { get; init; }
|
||||
|
||||
[JsonPropertyName("size_bytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to push export manifest to evidence locker.
|
||||
/// </summary>
|
||||
public sealed record ExportEvidenceSnapshotRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string ExportRunId { get; init; }
|
||||
public string? ProfileId { get; init; }
|
||||
public required ExportBundleKind Kind { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public required IReadOnlyList<ExportMaterialInput> Materials { get; init; }
|
||||
public ExportDistributionInfo? Distribution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material input for evidence snapshot.
|
||||
/// </summary>
|
||||
public sealed record ExportMaterialInput
|
||||
{
|
||||
public required string Section { get; init; }
|
||||
public required string Path { get; init; }
|
||||
public required string Sha256 { get; init; }
|
||||
public required long SizeBytes { get; init; }
|
||||
public string? MediaType { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from evidence locker after snapshot creation.
|
||||
/// </summary>
|
||||
public sealed record ExportEvidenceSnapshotResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? BundleId { get; init; }
|
||||
public string? RootHash { get; init; }
|
||||
public ExportDsseSignatureInfo? Signature { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
public static ExportEvidenceSnapshotResult Succeeded(
|
||||
string bundleId,
|
||||
string rootHash,
|
||||
ExportDsseSignatureInfo? signature = null) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
BundleId = bundleId,
|
||||
RootHash = rootHash,
|
||||
Signature = signature
|
||||
};
|
||||
|
||||
public static ExportEvidenceSnapshotResult Failed(string errorMessage) =>
|
||||
new() { Success = false, ErrorMessage = errorMessage };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature information from evidence locker.
|
||||
/// </summary>
|
||||
public sealed record ExportDsseSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("payload_type")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public required string Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("key_id")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public required string Provider { get; init; }
|
||||
|
||||
[JsonPropertyName("signed_at")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamped_at")]
|
||||
public DateTimeOffset? TimestampedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp_authority")]
|
||||
public string? TimestampAuthority { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Merkle root hash for export manifest entries.
|
||||
/// Aligns with EvidenceLocker's MerkleTreeCalculator implementation.
|
||||
/// </summary>
|
||||
public interface IExportMerkleTreeCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the Merkle root hash from manifest entries.
|
||||
/// </summary>
|
||||
/// <param name="entries">The manifest entries with canonical paths and hashes.</param>
|
||||
/// <returns>The hex-encoded Merkle root hash.</returns>
|
||||
string CalculateRootHash(IEnumerable<ExportManifestEntry> entries);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Merkle root hash from canonical leaf values.
|
||||
/// </summary>
|
||||
/// <param name="canonicalLeafValues">Leaf values in format "canonicalPath|sha256".</param>
|
||||
/// <returns>The hex-encoded Merkle root hash.</returns>
|
||||
string CalculateRootHash(IEnumerable<string> canonicalLeafValues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of Merkle tree calculator for export manifests.
|
||||
/// Uses SHA-256 and follows EvidenceLocker's deterministic tree construction.
|
||||
/// </summary>
|
||||
public sealed class ExportMerkleTreeCalculator : IExportMerkleTreeCalculator
|
||||
{
|
||||
private const string EmptyTreeMarker = "stellaops:evidence:empty";
|
||||
|
||||
public string CalculateRootHash(IEnumerable<ExportManifestEntry> entries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
var canonicalLeaves = entries
|
||||
.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal)
|
||||
.Select(e => $"{e.CanonicalPath}|{e.Sha256.ToLowerInvariant()}");
|
||||
|
||||
return CalculateRootHash(canonicalLeaves);
|
||||
}
|
||||
|
||||
public string CalculateRootHash(IEnumerable<string> canonicalLeafValues)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(canonicalLeafValues);
|
||||
|
||||
var leaves = canonicalLeafValues
|
||||
.Select(HashString)
|
||||
.ToArray();
|
||||
|
||||
// Special case: empty tree
|
||||
if (leaves.Length == 0)
|
||||
{
|
||||
return HashString(EmptyTreeMarker);
|
||||
}
|
||||
|
||||
return BuildTree(leaves);
|
||||
}
|
||||
|
||||
private static string BuildTree(IReadOnlyList<string> currentLevel)
|
||||
{
|
||||
if (currentLevel.Count == 1)
|
||||
{
|
||||
return currentLevel[0]; // Root node
|
||||
}
|
||||
|
||||
var nextLevel = new List<string>((currentLevel.Count + 1) / 2);
|
||||
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
var left = currentLevel[i];
|
||||
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
|
||||
|
||||
// Sort siblings canonically before combining (deterministic ordering)
|
||||
var combined = string.CompareOrdinal(left, right) <= 0
|
||||
? $"{left}|{right}"
|
||||
: $"{right}|{left}";
|
||||
|
||||
nextLevel.Add(HashString(combined));
|
||||
}
|
||||
|
||||
return BuildTree(nextLevel);
|
||||
}
|
||||
|
||||
private static string HashString(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for pushing export manifests and transcripts to the evidence locker.
|
||||
/// </summary>
|
||||
public interface IExportEvidenceLockerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an export manifest snapshot to the evidence locker.
|
||||
/// Creates a new evidence bundle with the specified materials.
|
||||
/// </summary>
|
||||
/// <param name="request">The snapshot request containing materials and metadata.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing bundle ID, root hash, and optional DSSE signature.</returns>
|
||||
Task<ExportEvidenceSnapshotResult> PushSnapshotAsync(
|
||||
ExportEvidenceSnapshotRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing evidence bundle with distribution transcript information.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The evidence bundle ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="distribution">Distribution information to record.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if update succeeded.</returns>
|
||||
Task<bool> UpdateDistributionTranscriptAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
ExportDistributionInfo distribution,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence bundle details including signature.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The evidence bundle ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The bundle manifest if found, null otherwise.</returns>
|
||||
Task<ExportBundleManifest?> GetBundleAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a bundle's Merkle root matches expected value.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The evidence bundle ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="expectedRootHash">The expected Merkle root hash.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if root hash matches.</returns>
|
||||
Task<bool> VerifyRootHashAsync(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string expectedRootHash,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Incident;
|
||||
|
||||
/// <summary>
|
||||
/// Event types for export incidents.
|
||||
/// </summary>
|
||||
public static class ExportIncidentEventTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Incident activated event.
|
||||
/// </summary>
|
||||
public const string IncidentActivated = "export.incident.activated";
|
||||
|
||||
/// <summary>
|
||||
/// Incident updated event.
|
||||
/// </summary>
|
||||
public const string IncidentUpdated = "export.incident.updated";
|
||||
|
||||
/// <summary>
|
||||
/// Incident escalated event.
|
||||
/// </summary>
|
||||
public const string IncidentEscalated = "export.incident.escalated";
|
||||
|
||||
/// <summary>
|
||||
/// Incident de-escalated event.
|
||||
/// </summary>
|
||||
public const string IncidentDeescalated = "export.incident.deescalated";
|
||||
|
||||
/// <summary>
|
||||
/// Incident resolved event.
|
||||
/// </summary>
|
||||
public const string IncidentResolved = "export.incident.resolved";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for incident events.
|
||||
/// </summary>
|
||||
public abstract record ExportIncidentEventBase
|
||||
{
|
||||
[JsonPropertyName("event_type")]
|
||||
public abstract string EventType { get; }
|
||||
|
||||
[JsonPropertyName("incident_id")]
|
||||
public required string IncidentId { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public required ExportIncidentType Type { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required ExportIncidentSeverity Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required ExportIncidentStatus Status { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public required string Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("affected_tenants")]
|
||||
public IReadOnlyList<string>? AffectedTenants { get; init; }
|
||||
|
||||
[JsonPropertyName("affected_profiles")]
|
||||
public IReadOnlyList<string>? AffectedProfiles { get; init; }
|
||||
|
||||
[JsonPropertyName("correlation_id")]
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an incident is activated.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentActivatedEvent : ExportIncidentEventBase
|
||||
{
|
||||
public override string EventType => ExportIncidentEventTypes.IncidentActivated;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("activated_by")]
|
||||
public string? ActivatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an incident is updated.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentUpdatedEvent : ExportIncidentEventBase
|
||||
{
|
||||
public override string EventType => ExportIncidentEventTypes.IncidentUpdated;
|
||||
|
||||
[JsonPropertyName("previous_status")]
|
||||
public ExportIncidentStatus? PreviousStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("previous_severity")]
|
||||
public ExportIncidentSeverity? PreviousSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("update_message")]
|
||||
public required string UpdateMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("updated_by")]
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an incident is escalated.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentEscalatedEvent : ExportIncidentEventBase
|
||||
{
|
||||
public override string EventType => ExportIncidentEventTypes.IncidentEscalated;
|
||||
|
||||
[JsonPropertyName("previous_severity")]
|
||||
public required ExportIncidentSeverity PreviousSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("escalation_reason")]
|
||||
public required string EscalationReason { get; init; }
|
||||
|
||||
[JsonPropertyName("escalated_by")]
|
||||
public string? EscalatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an incident is de-escalated.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentDeescalatedEvent : ExportIncidentEventBase
|
||||
{
|
||||
public override string EventType => ExportIncidentEventTypes.IncidentDeescalated;
|
||||
|
||||
[JsonPropertyName("previous_severity")]
|
||||
public required ExportIncidentSeverity PreviousSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("deescalation_reason")]
|
||||
public required string DeescalationReason { get; init; }
|
||||
|
||||
[JsonPropertyName("deescalated_by")]
|
||||
public string? DeescalatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when an incident is resolved.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentResolvedEvent : ExportIncidentEventBase
|
||||
{
|
||||
public override string EventType => ExportIncidentEventTypes.IncidentResolved;
|
||||
|
||||
[JsonPropertyName("resolution_message")]
|
||||
public required string ResolutionMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("is_false_positive")]
|
||||
public bool IsFalsePositive { get; init; }
|
||||
|
||||
[JsonPropertyName("resolved_by")]
|
||||
public string? ResolvedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("activated_at")]
|
||||
public required DateTimeOffset ActivatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("duration_seconds")]
|
||||
public double DurationSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("post_incident_notes")]
|
||||
public string? PostIncidentNotes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
using StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Incident;
|
||||
|
||||
/// <summary>
|
||||
/// Manages export incidents and emits events to timeline and notifier.
|
||||
/// </summary>
|
||||
public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
private readonly ILogger<ExportIncidentManager> _logger;
|
||||
private readonly IExportTimelinePublisher _timelinePublisher;
|
||||
private readonly IExportNotificationEmitter _notificationEmitter;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// In-memory store for incidents (production would use persistent storage)
|
||||
private readonly ConcurrentDictionary<string, ExportIncident> _incidents = new();
|
||||
|
||||
public ExportIncidentManager(
|
||||
ILogger<ExportIncidentManager> logger,
|
||||
IExportTimelinePublisher timelinePublisher,
|
||||
IExportNotificationEmitter notificationEmitter,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
|
||||
_notificationEmitter = notificationEmitter ?? throw new ArgumentNullException(nameof(notificationEmitter));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ExportIncidentResult> ActivateIncidentAsync(
|
||||
ExportIncidentActivationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var incidentId = GenerateIncidentId();
|
||||
|
||||
var incident = new ExportIncident
|
||||
{
|
||||
IncidentId = incidentId,
|
||||
Type = request.Type,
|
||||
Severity = request.Severity,
|
||||
Status = ExportIncidentStatus.Active,
|
||||
Summary = request.Summary,
|
||||
Description = request.Description,
|
||||
AffectedTenants = request.AffectedTenants,
|
||||
AffectedProfiles = request.AffectedProfiles,
|
||||
ActivatedAt = now,
|
||||
LastUpdatedAt = now,
|
||||
ActivatedBy = request.ActivatedBy,
|
||||
CorrelationId = request.CorrelationId,
|
||||
Metadata = request.Metadata,
|
||||
Updates = new List<ExportIncidentUpdate>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UpdateId = GenerateUpdateId(),
|
||||
Timestamp = now,
|
||||
NewStatus = ExportIncidentStatus.Active,
|
||||
Message = $"Incident activated: {request.Summary}"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!_incidents.TryAdd(incidentId, incident))
|
||||
{
|
||||
return ExportIncidentResult.Failed("Failed to store incident");
|
||||
}
|
||||
|
||||
// Emit timeline event
|
||||
var timelineEvent = new ExportIncidentActivatedEvent
|
||||
{
|
||||
IncidentId = incidentId,
|
||||
Type = request.Type,
|
||||
Severity = request.Severity,
|
||||
Status = ExportIncidentStatus.Active,
|
||||
Summary = request.Summary,
|
||||
Description = request.Description,
|
||||
Timestamp = now,
|
||||
AffectedTenants = request.AffectedTenants,
|
||||
AffectedProfiles = request.AffectedProfiles,
|
||||
ActivatedBy = request.ActivatedBy,
|
||||
CorrelationId = request.CorrelationId,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
await PublishTimelineEventAsync(timelineEvent, cancellationToken);
|
||||
|
||||
// Emit notification
|
||||
await _notificationEmitter.EmitIncidentActivatedAsync(incident, cancellationToken);
|
||||
|
||||
// Record metric
|
||||
ExportTelemetry.IncidentsActivatedTotal.Add(1,
|
||||
new("severity", request.Severity.ToString().ToLowerInvariant()),
|
||||
new("type", request.Type.ToString().ToLowerInvariant()));
|
||||
|
||||
_logger.LogWarning(
|
||||
"Export incident activated: {IncidentId} [{Type}] [{Severity}] - {Summary}",
|
||||
incidentId, request.Type, request.Severity, request.Summary);
|
||||
|
||||
return ExportIncidentResult.Succeeded(incident);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to activate incident");
|
||||
return ExportIncidentResult.Failed($"Activation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExportIncidentResult> UpdateIncidentAsync(
|
||||
string incidentId,
|
||||
ExportIncidentUpdateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!_incidents.TryGetValue(incidentId, out var existingIncident))
|
||||
{
|
||||
return ExportIncidentResult.Failed("Incident not found");
|
||||
}
|
||||
|
||||
if (existingIncident.Status is ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive)
|
||||
{
|
||||
return ExportIncidentResult.Failed("Cannot update resolved incident");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var previousStatus = existingIncident.Status;
|
||||
var previousSeverity = existingIncident.Severity;
|
||||
|
||||
var newStatus = request.Status ?? existingIncident.Status;
|
||||
var newSeverity = request.Severity ?? existingIncident.Severity;
|
||||
|
||||
var update = new ExportIncidentUpdate
|
||||
{
|
||||
UpdateId = GenerateUpdateId(),
|
||||
Timestamp = now,
|
||||
PreviousStatus = previousStatus != newStatus ? previousStatus : null,
|
||||
NewStatus = newStatus,
|
||||
PreviousSeverity = previousSeverity != newSeverity ? previousSeverity : null,
|
||||
NewSeverity = previousSeverity != newSeverity ? newSeverity : null,
|
||||
Message = request.Message,
|
||||
UpdatedBy = request.UpdatedBy
|
||||
};
|
||||
|
||||
var updatedIncident = existingIncident with
|
||||
{
|
||||
Status = newStatus,
|
||||
Severity = newSeverity,
|
||||
LastUpdatedAt = now,
|
||||
Updates = [.. existingIncident.Updates, update]
|
||||
};
|
||||
|
||||
if (!_incidents.TryUpdate(incidentId, updatedIncident, existingIncident))
|
||||
{
|
||||
return ExportIncidentResult.Failed("Concurrent update conflict");
|
||||
}
|
||||
|
||||
// Determine event type based on severity change
|
||||
ExportIncidentEventBase timelineEvent;
|
||||
if (newSeverity > previousSeverity)
|
||||
{
|
||||
timelineEvent = new ExportIncidentEscalatedEvent
|
||||
{
|
||||
IncidentId = incidentId,
|
||||
Type = updatedIncident.Type,
|
||||
Severity = newSeverity,
|
||||
Status = newStatus,
|
||||
Summary = updatedIncident.Summary,
|
||||
Timestamp = now,
|
||||
AffectedTenants = updatedIncident.AffectedTenants,
|
||||
AffectedProfiles = updatedIncident.AffectedProfiles,
|
||||
CorrelationId = updatedIncident.CorrelationId,
|
||||
PreviousSeverity = previousSeverity,
|
||||
EscalationReason = request.Message,
|
||||
EscalatedBy = request.UpdatedBy
|
||||
};
|
||||
|
||||
ExportTelemetry.IncidentsEscalatedTotal.Add(1,
|
||||
new("from_severity", previousSeverity.ToString().ToLowerInvariant()),
|
||||
new("to_severity", newSeverity.ToString().ToLowerInvariant()));
|
||||
}
|
||||
else if (newSeverity < previousSeverity)
|
||||
{
|
||||
timelineEvent = new ExportIncidentDeescalatedEvent
|
||||
{
|
||||
IncidentId = incidentId,
|
||||
Type = updatedIncident.Type,
|
||||
Severity = newSeverity,
|
||||
Status = newStatus,
|
||||
Summary = updatedIncident.Summary,
|
||||
Timestamp = now,
|
||||
AffectedTenants = updatedIncident.AffectedTenants,
|
||||
AffectedProfiles = updatedIncident.AffectedProfiles,
|
||||
CorrelationId = updatedIncident.CorrelationId,
|
||||
PreviousSeverity = previousSeverity,
|
||||
DeescalationReason = request.Message,
|
||||
DeescalatedBy = request.UpdatedBy
|
||||
};
|
||||
|
||||
ExportTelemetry.IncidentsDeescalatedTotal.Add(1,
|
||||
new("from_severity", previousSeverity.ToString().ToLowerInvariant()),
|
||||
new("to_severity", newSeverity.ToString().ToLowerInvariant()));
|
||||
}
|
||||
else
|
||||
{
|
||||
timelineEvent = new ExportIncidentUpdatedEvent
|
||||
{
|
||||
IncidentId = incidentId,
|
||||
Type = updatedIncident.Type,
|
||||
Severity = newSeverity,
|
||||
Status = newStatus,
|
||||
Summary = updatedIncident.Summary,
|
||||
Timestamp = now,
|
||||
AffectedTenants = updatedIncident.AffectedTenants,
|
||||
AffectedProfiles = updatedIncident.AffectedProfiles,
|
||||
CorrelationId = updatedIncident.CorrelationId,
|
||||
PreviousStatus = previousStatus != newStatus ? previousStatus : null,
|
||||
PreviousSeverity = previousSeverity != newSeverity ? previousSeverity : null,
|
||||
UpdateMessage = request.Message,
|
||||
UpdatedBy = request.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
await PublishTimelineEventAsync(timelineEvent, cancellationToken);
|
||||
await _notificationEmitter.EmitIncidentUpdatedAsync(updatedIncident, request.Message, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Export incident updated: {IncidentId} [{Status}] [{Severity}] - {Message}",
|
||||
incidentId, newStatus, newSeverity, request.Message);
|
||||
|
||||
return ExportIncidentResult.Succeeded(updatedIncident);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update incident {IncidentId}", incidentId);
|
||||
return ExportIncidentResult.Failed($"Update failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExportIncidentResult> ResolveIncidentAsync(
|
||||
string incidentId,
|
||||
ExportIncidentResolutionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!_incidents.TryGetValue(incidentId, out var existingIncident))
|
||||
{
|
||||
return ExportIncidentResult.Failed("Incident not found");
|
||||
}
|
||||
|
||||
if (existingIncident.Status is ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive)
|
||||
{
|
||||
return ExportIncidentResult.Failed("Incident already resolved");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newStatus = request.IsFalsePositive
|
||||
? ExportIncidentStatus.FalsePositive
|
||||
: ExportIncidentStatus.Resolved;
|
||||
|
||||
var update = new ExportIncidentUpdate
|
||||
{
|
||||
UpdateId = GenerateUpdateId(),
|
||||
Timestamp = now,
|
||||
PreviousStatus = existingIncident.Status,
|
||||
NewStatus = newStatus,
|
||||
Message = request.ResolutionMessage,
|
||||
UpdatedBy = request.ResolvedBy
|
||||
};
|
||||
|
||||
var resolvedIncident = existingIncident with
|
||||
{
|
||||
Status = newStatus,
|
||||
LastUpdatedAt = now,
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = request.ResolvedBy,
|
||||
Updates = [.. existingIncident.Updates, update]
|
||||
};
|
||||
|
||||
if (!_incidents.TryUpdate(incidentId, resolvedIncident, existingIncident))
|
||||
{
|
||||
return ExportIncidentResult.Failed("Concurrent update conflict");
|
||||
}
|
||||
|
||||
var duration = now - existingIncident.ActivatedAt;
|
||||
|
||||
var timelineEvent = new ExportIncidentResolvedEvent
|
||||
{
|
||||
IncidentId = incidentId,
|
||||
Type = resolvedIncident.Type,
|
||||
Severity = resolvedIncident.Severity,
|
||||
Status = newStatus,
|
||||
Summary = resolvedIncident.Summary,
|
||||
Timestamp = now,
|
||||
AffectedTenants = resolvedIncident.AffectedTenants,
|
||||
AffectedProfiles = resolvedIncident.AffectedProfiles,
|
||||
CorrelationId = resolvedIncident.CorrelationId,
|
||||
ResolutionMessage = request.ResolutionMessage,
|
||||
IsFalsePositive = request.IsFalsePositive,
|
||||
ResolvedBy = request.ResolvedBy,
|
||||
ActivatedAt = existingIncident.ActivatedAt,
|
||||
DurationSeconds = duration.TotalSeconds,
|
||||
PostIncidentNotes = request.PostIncidentNotes
|
||||
};
|
||||
|
||||
await PublishTimelineEventAsync(timelineEvent, cancellationToken);
|
||||
await _notificationEmitter.EmitIncidentResolvedAsync(
|
||||
resolvedIncident, request.ResolutionMessage, request.IsFalsePositive, cancellationToken);
|
||||
|
||||
// Record metrics
|
||||
ExportTelemetry.IncidentsResolvedTotal.Add(1,
|
||||
new("severity", resolvedIncident.Severity.ToString().ToLowerInvariant()),
|
||||
new("type", resolvedIncident.Type.ToString().ToLowerInvariant()),
|
||||
new("is_false_positive", request.IsFalsePositive.ToString().ToLowerInvariant()));
|
||||
|
||||
ExportTelemetry.IncidentDurationSeconds.Record(duration.TotalSeconds,
|
||||
new("severity", resolvedIncident.Severity.ToString().ToLowerInvariant()),
|
||||
new("type", resolvedIncident.Type.ToString().ToLowerInvariant()));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Export incident resolved: {IncidentId} after {Duration:F1}s - {Message}",
|
||||
incidentId, duration.TotalSeconds, request.ResolutionMessage);
|
||||
|
||||
return ExportIncidentResult.Succeeded(resolvedIncident);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resolve incident {IncidentId}", incidentId);
|
||||
return ExportIncidentResult.Failed($"Resolution failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ExportIncidentModeStatus> GetIncidentModeStatusAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activeIncidents = _incidents.Values
|
||||
.Where(i => i.Status is not (ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive))
|
||||
.OrderByDescending(i => i.Severity)
|
||||
.ThenByDescending(i => i.ActivatedAt)
|
||||
.ToList();
|
||||
|
||||
var status = new ExportIncidentModeStatus
|
||||
{
|
||||
IncidentModeActive = activeIncidents.Count > 0,
|
||||
ActiveIncidents = activeIncidents,
|
||||
HighestSeverity = activeIncidents.Count > 0
|
||||
? activeIncidents.Max(i => i.Severity)
|
||||
: null,
|
||||
AsOf = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ExportIncident>> GetActiveIncidentsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activeIncidents = _incidents.Values
|
||||
.Where(i => i.Status is not (ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive))
|
||||
.OrderByDescending(i => i.Severity)
|
||||
.ThenByDescending(i => i.ActivatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ExportIncident>>(activeIncidents);
|
||||
}
|
||||
|
||||
public Task<ExportIncident?> GetIncidentAsync(
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_incidents.TryGetValue(incidentId, out var incident);
|
||||
return Task.FromResult(incident);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ExportIncident>> GetRecentIncidentsAsync(
|
||||
int limit = 50,
|
||||
bool includeResolved = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _incidents.Values.AsEnumerable();
|
||||
|
||||
if (!includeResolved)
|
||||
{
|
||||
query = query.Where(i => i.Status is not (ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive));
|
||||
}
|
||||
|
||||
var incidents = query
|
||||
.OrderByDescending(i => i.LastUpdatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ExportIncident>>(incidents);
|
||||
}
|
||||
|
||||
public Task<bool> IsIncidentModeActiveAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var isActive = _incidents.Values
|
||||
.Any(i => i.Status is not (ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive));
|
||||
|
||||
return Task.FromResult(isActive);
|
||||
}
|
||||
|
||||
public Task<ExportIncidentSeverity?> GetHighestActiveSeverityAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activeIncidents = _incidents.Values
|
||||
.Where(i => i.Status is not (ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive))
|
||||
.ToList();
|
||||
|
||||
var highestSeverity = activeIncidents.Count > 0
|
||||
? activeIncidents.Max(i => i.Severity)
|
||||
: (ExportIncidentSeverity?)null;
|
||||
|
||||
return Task.FromResult(highestSeverity);
|
||||
}
|
||||
|
||||
private async Task PublishTimelineEventAsync(
|
||||
ExportIncidentEventBase incidentEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var eventJson = JsonSerializer.Serialize(incidentEvent, incidentEvent.GetType(), SerializerOptions);
|
||||
|
||||
// Publish to timeline using the timeline publisher
|
||||
// Note: This creates a synthetic export started event to leverage existing publisher
|
||||
await _timelinePublisher.PublishIncidentEventAsync(
|
||||
incidentEvent.EventType,
|
||||
incidentEvent.IncidentId,
|
||||
eventJson,
|
||||
incidentEvent.CorrelationId,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to publish incident timeline event {EventType}", incidentEvent.EventType);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateIncidentId()
|
||||
{
|
||||
return $"inc-{Guid.NewGuid():N}"[..20];
|
||||
}
|
||||
|
||||
private static string GenerateUpdateId()
|
||||
{
|
||||
return $"upd-{Guid.NewGuid():N}"[..16];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for emitting incident notifications.
|
||||
/// </summary>
|
||||
public interface IExportNotificationEmitter
|
||||
{
|
||||
Task EmitIncidentActivatedAsync(ExportIncident incident, CancellationToken cancellationToken = default);
|
||||
Task EmitIncidentUpdatedAsync(ExportIncident incident, string updateMessage, CancellationToken cancellationToken = default);
|
||||
Task EmitIncidentResolvedAsync(ExportIncident incident, string resolutionMessage, bool isFalsePositive, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of notification emitter that logs notifications.
|
||||
/// Production would integrate with actual notification service (Email, Slack, Teams, PagerDuty).
|
||||
/// </summary>
|
||||
public sealed class LoggingNotificationEmitter : IExportNotificationEmitter
|
||||
{
|
||||
private readonly ILogger<LoggingNotificationEmitter> _logger;
|
||||
|
||||
public LoggingNotificationEmitter(ILogger<LoggingNotificationEmitter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task EmitIncidentActivatedAsync(ExportIncident incident, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"NOTIFICATION: Incident Activated [{Severity}] - {Summary}. ID: {IncidentId}",
|
||||
incident.Severity, incident.Summary, incident.IncidentId);
|
||||
|
||||
ExportTelemetry.NotificationsEmittedTotal.Add(1,
|
||||
new("type", "incident_activated"),
|
||||
new("severity", incident.Severity.ToString().ToLowerInvariant()));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task EmitIncidentUpdatedAsync(ExportIncident incident, string updateMessage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"NOTIFICATION: Incident Updated [{Severity}] - {Message}. ID: {IncidentId}",
|
||||
incident.Severity, updateMessage, incident.IncidentId);
|
||||
|
||||
ExportTelemetry.NotificationsEmittedTotal.Add(1,
|
||||
new("type", "incident_updated"),
|
||||
new("severity", incident.Severity.ToString().ToLowerInvariant()));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task EmitIncidentResolvedAsync(ExportIncident incident, string resolutionMessage, bool isFalsePositive, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"NOTIFICATION: Incident Resolved [{Status}] - {Message}. ID: {IncidentId}, FalsePositive: {IsFalsePositive}",
|
||||
incident.Status, resolutionMessage, incident.IncidentId, isFalsePositive);
|
||||
|
||||
ExportTelemetry.NotificationsEmittedTotal.Add(1,
|
||||
new("type", "incident_resolved"),
|
||||
new("is_false_positive", isFalsePositive.ToString().ToLowerInvariant()));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Incident;
|
||||
|
||||
/// <summary>
|
||||
/// Export incident severity levels.
|
||||
/// </summary>
|
||||
public enum ExportIncidentSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Informational - system operating normally.
|
||||
/// </summary>
|
||||
Info = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Warning - potential issues detected, exports may be delayed.
|
||||
/// </summary>
|
||||
Warning = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Error - export failures occurring, some exports unavailable.
|
||||
/// </summary>
|
||||
Error = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Critical - widespread export failures, service degraded.
|
||||
/// </summary>
|
||||
Critical = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Emergency - complete export service outage.
|
||||
/// </summary>
|
||||
Emergency = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export incident status.
|
||||
/// </summary>
|
||||
public enum ExportIncidentStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Incident is active.
|
||||
/// </summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Incident is being investigated.
|
||||
/// </summary>
|
||||
Investigating = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Incident is being mitigated.
|
||||
/// </summary>
|
||||
Mitigating = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Incident has been resolved.
|
||||
/// </summary>
|
||||
Resolved = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Incident was a false positive.
|
||||
/// </summary>
|
||||
FalsePositive = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export incident type.
|
||||
/// </summary>
|
||||
public enum ExportIncidentType
|
||||
{
|
||||
/// <summary>
|
||||
/// Export job failures.
|
||||
/// </summary>
|
||||
ExportFailure = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Export latency degradation.
|
||||
/// </summary>
|
||||
LatencyDegradation = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Storage capacity issues.
|
||||
/// </summary>
|
||||
StorageCapacity = 3,
|
||||
|
||||
/// <summary>
|
||||
/// External dependency failure (e.g., EvidenceLocker, signing service).
|
||||
/// </summary>
|
||||
DependencyFailure = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Data integrity issue detected.
|
||||
/// </summary>
|
||||
IntegrityIssue = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Security incident.
|
||||
/// </summary>
|
||||
SecurityIncident = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration error.
|
||||
/// </summary>
|
||||
ConfigurationError = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting or throttling activated.
|
||||
/// </summary>
|
||||
RateLimiting = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to activate incident mode.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentActivationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Incident type.
|
||||
/// </summary>
|
||||
public required ExportIncidentType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Incident severity.
|
||||
/// </summary>
|
||||
public required ExportIncidentSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of the incident.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description of the incident.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected tenant IDs (null/empty means all tenants).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AffectedTenants { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Affected export profile IDs.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AffectedProfiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operator or system that activated the incident.
|
||||
/// </summary>
|
||||
public string? ActivatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context/metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active export incident.
|
||||
/// </summary>
|
||||
public sealed record ExportIncident
|
||||
{
|
||||
[JsonPropertyName("incident_id")]
|
||||
public required string IncidentId { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public required ExportIncidentType Type { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public required ExportIncidentSeverity Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required ExportIncidentStatus Status { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public required string Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("affected_tenants")]
|
||||
public IReadOnlyList<string>? AffectedTenants { get; init; }
|
||||
|
||||
[JsonPropertyName("affected_profiles")]
|
||||
public IReadOnlyList<string>? AffectedProfiles { get; init; }
|
||||
|
||||
[JsonPropertyName("activated_at")]
|
||||
public required DateTimeOffset ActivatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("last_updated_at")]
|
||||
public required DateTimeOffset LastUpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("resolved_at")]
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("activated_by")]
|
||||
public string? ActivatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("resolved_by")]
|
||||
public string? ResolvedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("correlation_id")]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("updates")]
|
||||
public IReadOnlyList<ExportIncidentUpdate> Updates { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update to an incident.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentUpdate
|
||||
{
|
||||
[JsonPropertyName("update_id")]
|
||||
public required string UpdateId { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("previous_status")]
|
||||
public ExportIncidentStatus? PreviousStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("new_status")]
|
||||
public required ExportIncidentStatus NewStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("previous_severity")]
|
||||
public ExportIncidentSeverity? PreviousSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("new_severity")]
|
||||
public ExportIncidentSeverity? NewSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
[JsonPropertyName("updated_by")]
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an incident.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentResolutionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolution message/notes.
|
||||
/// </summary>
|
||||
public required string ResolutionMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this was a false positive.
|
||||
/// </summary>
|
||||
public bool IsFalsePositive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operator or system resolving the incident.
|
||||
/// </summary>
|
||||
public string? ResolvedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Post-incident review notes.
|
||||
/// </summary>
|
||||
public string? PostIncidentNotes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an incident.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentUpdateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// New status (optional).
|
||||
/// </summary>
|
||||
public ExportIncidentStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New severity (optional, for escalation/de-escalation).
|
||||
/// </summary>
|
||||
public ExportIncidentSeverity? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Update message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Operator or system making the update.
|
||||
/// </summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of incident operations.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public ExportIncident? Incident { get; init; }
|
||||
|
||||
public static ExportIncidentResult Succeeded(ExportIncident incident) =>
|
||||
new() { Success = true, Incident = incident };
|
||||
|
||||
public static ExportIncidentResult Failed(string errorMessage) =>
|
||||
new() { Success = false, ErrorMessage = errorMessage };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident mode status response.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentModeStatus
|
||||
{
|
||||
[JsonPropertyName("incident_mode_active")]
|
||||
public bool IncidentModeActive { get; init; }
|
||||
|
||||
[JsonPropertyName("active_incidents")]
|
||||
public IReadOnlyList<ExportIncident> ActiveIncidents { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("highest_severity")]
|
||||
public ExportIncidentSeverity? HighestSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("as_of")]
|
||||
public required DateTimeOffset AsOf { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Incident;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing export incidents and emitting events to timeline + notifier.
|
||||
/// </summary>
|
||||
public interface IExportIncidentManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Activates incident mode with the specified parameters.
|
||||
/// Emits incident activation events to timeline and notifier.
|
||||
/// </summary>
|
||||
/// <param name="request">The activation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the activation.</returns>
|
||||
Task<ExportIncidentResult> ActivateIncidentAsync(
|
||||
ExportIncidentActivationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing incident.
|
||||
/// Emits incident update events to timeline and notifier.
|
||||
/// </summary>
|
||||
/// <param name="incidentId">The incident identifier.</param>
|
||||
/// <param name="request">The update request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the update.</returns>
|
||||
Task<ExportIncidentResult> UpdateIncidentAsync(
|
||||
string incidentId,
|
||||
ExportIncidentUpdateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an incident.
|
||||
/// Emits incident resolution events to timeline and notifier.
|
||||
/// </summary>
|
||||
/// <param name="incidentId">The incident identifier.</param>
|
||||
/// <param name="request">The resolution request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The result of the resolution.</returns>
|
||||
Task<ExportIncidentResult> ResolveIncidentAsync(
|
||||
string incidentId,
|
||||
ExportIncidentResolutionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current incident mode status.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The current incident mode status.</returns>
|
||||
Task<ExportIncidentModeStatus> GetIncidentModeStatusAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active incidents.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of active incidents.</returns>
|
||||
Task<IReadOnlyList<ExportIncident>> GetActiveIncidentsAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an incident by ID.
|
||||
/// </summary>
|
||||
/// <param name="incidentId">The incident identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The incident if found, null otherwise.</returns>
|
||||
Task<ExportIncident?> GetIncidentAsync(
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent incidents (active and recently resolved).
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum number of incidents to return.</param>
|
||||
/// <param name="includeResolved">Whether to include resolved incidents.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of incidents.</returns>
|
||||
Task<IReadOnlyList<ExportIncident>> GetRecentIncidentsAsync(
|
||||
int limit = 50,
|
||||
bool includeResolved = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if incident mode is currently active.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if incident mode is active.</returns>
|
||||
Task<bool> IsIncidentModeActiveAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the highest active incident severity.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The highest severity, or null if no active incidents.</returns>
|
||||
Task<ExportIncidentSeverity?> GetHighestActiveSeverityAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Incident;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping incident management endpoints.
|
||||
/// </summary>
|
||||
public static class IncidentEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps incident management endpoints to the application.
|
||||
/// </summary>
|
||||
public static WebApplication MapIncidentEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/v1/incidents")
|
||||
.WithTags("Incident Management")
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator);
|
||||
|
||||
// GET /v1/incidents/status - Get incident mode status
|
||||
group.MapGet("/status", GetIncidentModeStatusAsync)
|
||||
.WithName("GetIncidentModeStatus")
|
||||
.WithSummary("Get incident mode status")
|
||||
.WithDescription("Returns the current incident mode status including all active incidents.")
|
||||
.Produces<ExportIncidentModeStatus>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /v1/incidents - Get all active incidents
|
||||
group.MapGet("", GetActiveIncidentsAsync)
|
||||
.WithName("GetActiveIncidents")
|
||||
.WithSummary("Get active incidents")
|
||||
.WithDescription("Returns all currently active incidents.")
|
||||
.Produces<IReadOnlyList<ExportIncident>>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /v1/incidents/recent - Get recent incidents
|
||||
group.MapGet("/recent", GetRecentIncidentsAsync)
|
||||
.WithName("GetRecentIncidents")
|
||||
.WithSummary("Get recent incidents")
|
||||
.WithDescription("Returns recent incidents including resolved ones.")
|
||||
.Produces<IReadOnlyList<ExportIncident>>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /v1/incidents/{id} - Get incident by ID
|
||||
group.MapGet("/{id}", GetIncidentAsync)
|
||||
.WithName("GetIncident")
|
||||
.WithSummary("Get incident by ID")
|
||||
.WithDescription("Returns the specified incident.")
|
||||
.Produces<ExportIncident>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /v1/incidents - Activate a new incident
|
||||
group.MapPost("", ActivateIncidentAsync)
|
||||
.WithName("ActivateIncident")
|
||||
.WithSummary("Activate a new incident")
|
||||
.WithDescription("Activates a new incident and emits events to timeline and notifier.")
|
||||
.Produces<ExportIncidentResult>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// PATCH /v1/incidents/{id} - Update an incident
|
||||
group.MapPatch("/{id}", UpdateIncidentAsync)
|
||||
.WithName("UpdateIncident")
|
||||
.WithSummary("Update an incident")
|
||||
.WithDescription("Updates an existing incident status or severity.")
|
||||
.Produces<ExportIncidentResult>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// POST /v1/incidents/{id}/resolve - Resolve an incident
|
||||
group.MapPost("/{id}/resolve", ResolveIncidentAsync)
|
||||
.WithName("ResolveIncident")
|
||||
.WithSummary("Resolve an incident")
|
||||
.WithDescription("Resolves an incident and emits resolution event.")
|
||||
.Produces<ExportIncidentResult>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<ExportIncidentModeStatus>> GetIncidentModeStatusAsync(
|
||||
[FromServices] IExportIncidentManager incidentManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await incidentManager.GetIncidentModeStatusAsync(cancellationToken);
|
||||
return TypedResults.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<Ok<IReadOnlyList<ExportIncident>>> GetActiveIncidentsAsync(
|
||||
[FromServices] IExportIncidentManager incidentManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incidents = await incidentManager.GetActiveIncidentsAsync(cancellationToken);
|
||||
return TypedResults.Ok(incidents);
|
||||
}
|
||||
|
||||
private static async Task<Ok<IReadOnlyList<ExportIncident>>> GetRecentIncidentsAsync(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] bool? includeResolved,
|
||||
[FromServices] IExportIncidentManager incidentManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incidents = await incidentManager.GetRecentIncidentsAsync(
|
||||
limit ?? 50,
|
||||
includeResolved ?? true,
|
||||
cancellationToken);
|
||||
return TypedResults.Ok(incidents);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportIncident>, NotFound>> GetIncidentAsync(
|
||||
string id,
|
||||
[FromServices] IExportIncidentManager incidentManager,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incident = await incidentManager.GetIncidentAsync(id, cancellationToken);
|
||||
if (incident is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
return TypedResults.Ok(incident);
|
||||
}
|
||||
|
||||
private static async Task<Results<Created<ExportIncidentResult>, BadRequest<string>>> ActivateIncidentAsync(
|
||||
[FromBody] ExportIncidentActivationRequest request,
|
||||
[FromServices] IExportIncidentManager incidentManager,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Add operator info from claims if not specified
|
||||
var requestWithOperator = request;
|
||||
if (string.IsNullOrWhiteSpace(request.ActivatedBy))
|
||||
{
|
||||
var operatorClaim = httpContext.User.FindFirst("sub")
|
||||
?? httpContext.User.FindFirst("preferred_username");
|
||||
if (operatorClaim is not null)
|
||||
{
|
||||
requestWithOperator = request with { ActivatedBy = operatorClaim.Value };
|
||||
}
|
||||
}
|
||||
|
||||
var result = await incidentManager.ActivateIncidentAsync(requestWithOperator, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.BadRequest(result.ErrorMessage ?? "Activation failed");
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/v1/incidents/{result.Incident?.IncidentId}", result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportIncidentResult>, NotFound, BadRequest<string>>> UpdateIncidentAsync(
|
||||
string id,
|
||||
[FromBody] ExportIncidentUpdateRequest request,
|
||||
[FromServices] IExportIncidentManager incidentManager,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingIncident = await incidentManager.GetIncidentAsync(id, cancellationToken);
|
||||
if (existingIncident is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
// Add operator info from claims if not specified
|
||||
var requestWithOperator = request;
|
||||
if (string.IsNullOrWhiteSpace(request.UpdatedBy))
|
||||
{
|
||||
var operatorClaim = httpContext.User.FindFirst("sub")
|
||||
?? httpContext.User.FindFirst("preferred_username");
|
||||
if (operatorClaim is not null)
|
||||
{
|
||||
requestWithOperator = request with { UpdatedBy = operatorClaim.Value };
|
||||
}
|
||||
}
|
||||
|
||||
var result = await incidentManager.UpdateIncidentAsync(id, requestWithOperator, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.BadRequest(result.ErrorMessage ?? "Update failed");
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportIncidentResult>, NotFound, BadRequest<string>>> ResolveIncidentAsync(
|
||||
string id,
|
||||
[FromBody] ExportIncidentResolutionRequest request,
|
||||
[FromServices] IExportIncidentManager incidentManager,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingIncident = await incidentManager.GetIncidentAsync(id, cancellationToken);
|
||||
if (existingIncident is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
// Add operator info from claims if not specified
|
||||
var requestWithOperator = request;
|
||||
if (string.IsNullOrWhiteSpace(request.ResolvedBy))
|
||||
{
|
||||
var operatorClaim = httpContext.User.FindFirst("sub")
|
||||
?? httpContext.User.FindFirst("preferred_username");
|
||||
if (operatorClaim is not null)
|
||||
{
|
||||
requestWithOperator = request with { ResolvedBy = operatorClaim.Value };
|
||||
}
|
||||
}
|
||||
|
||||
var result = await incidentManager.ResolveIncidentAsync(id, requestWithOperator, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.BadRequest(result.ErrorMessage ?? "Resolution failed");
|
||||
}
|
||||
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Incident;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering incident management services.
|
||||
/// </summary>
|
||||
public static class IncidentServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds export incident management services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportIncidentManagement(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register notification emitter
|
||||
services.TryAddSingleton<IExportNotificationEmitter, LoggingNotificationEmitter>();
|
||||
|
||||
// Register incident manager
|
||||
services.TryAddSingleton<IExportIncidentManager, ExportIncidentManager>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI discovery endpoints for ExportCenter.
|
||||
/// Per EXPORT-OAS-61-002.
|
||||
/// </summary>
|
||||
public static class OpenApiDiscoveryEndpoints
|
||||
{
|
||||
private const string OasVersion = "v1";
|
||||
private const string ServiceName = "export-center";
|
||||
private const string SpecFileName = "export-center.v1.yaml";
|
||||
private const string SpecVersion = "3.0.3";
|
||||
|
||||
private static readonly DateTimeOffset FixedGeneratedAt = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps the OpenAPI discovery endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapOpenApiDiscovery(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("")
|
||||
.AllowAnonymous()
|
||||
.WithTags("discovery");
|
||||
|
||||
group.MapGet("/.well-known/openapi", (Delegate)GetDiscoveryMetadata)
|
||||
.WithName("GetOpenApiDiscovery")
|
||||
.WithSummary("OpenAPI discovery metadata")
|
||||
.WithDescription("Returns service metadata and link to the OpenAPI specification.");
|
||||
|
||||
group.MapGet("/.well-known/openapi.json", (Delegate)GetDiscoveryMetadata)
|
||||
.WithName("GetOpenApiDiscoveryJson")
|
||||
.ExcludeFromDescription();
|
||||
|
||||
group.MapGet("/openapi/export-center.yaml", (Delegate)GetOpenApiSpec)
|
||||
.WithName("GetOpenApiSpec")
|
||||
.WithSummary("OpenAPI specification")
|
||||
.WithDescription("Returns the OpenAPI v3.0.3 specification for ExportCenter.");
|
||||
|
||||
group.MapGet("/openapi/export-center.json", (Delegate)GetOpenApiSpecJson)
|
||||
.WithName("GetOpenApiSpecJson")
|
||||
.WithSummary("OpenAPI specification (JSON)")
|
||||
.WithDescription("Returns the OpenAPI specification as JSON.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static Task<IResult> GetDiscoveryMetadata(HttpContext context)
|
||||
{
|
||||
var metadata = new OpenApiDiscoveryResponse
|
||||
{
|
||||
Service = ServiceName,
|
||||
Version = GetServiceVersion(),
|
||||
SpecVersion = SpecVersion,
|
||||
Format = "application/yaml",
|
||||
Url = "/openapi/export-center.yaml",
|
||||
JsonUrl = "/openapi/export-center.json",
|
||||
ErrorEnvelopeSchema = "#/components/schemas/ErrorEnvelope",
|
||||
GeneratedAt = FixedGeneratedAt,
|
||||
ProfilesSupported = new[] { "attestation", "mirror", "bootstrap", "airgap-evidence" }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(metadata, JsonOptions);
|
||||
var etag = ComputeEtag(json);
|
||||
|
||||
// Check If-None-Match
|
||||
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch) &&
|
||||
ifNoneMatch == etag)
|
||||
{
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=300";
|
||||
return Task.FromResult(Results.StatusCode(304));
|
||||
}
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=300";
|
||||
context.Response.Headers["X-Export-Oas-Version"] = OasVersion;
|
||||
context.Response.Headers["Last-Modified"] = FixedGeneratedAt.ToString("R");
|
||||
|
||||
return Task.FromResult(Results.Json(metadata, JsonOptions, contentType: "application/json"));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOpenApiSpec(HttpContext context)
|
||||
{
|
||||
var spec = await GetEmbeddedOpenApiYaml();
|
||||
if (spec is null)
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "SPEC_NOT_FOUND", message = "OpenAPI specification not available" } });
|
||||
}
|
||||
|
||||
var etag = ComputeEtag(spec);
|
||||
|
||||
// Check If-None-Match
|
||||
if (context.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch) &&
|
||||
ifNoneMatch == etag)
|
||||
{
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=300";
|
||||
return Results.StatusCode(304);
|
||||
}
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=300";
|
||||
context.Response.Headers["X-Export-Oas-Version"] = OasVersion;
|
||||
context.Response.Headers["Last-Modified"] = FixedGeneratedAt.ToString("R");
|
||||
|
||||
return Results.Content(spec, "application/yaml", Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOpenApiSpecJson(HttpContext context)
|
||||
{
|
||||
var yamlSpec = await GetEmbeddedOpenApiYaml();
|
||||
if (yamlSpec is null)
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "SPEC_NOT_FOUND", message = "OpenAPI specification not available" } });
|
||||
}
|
||||
|
||||
// For now, return a redirect to the YAML endpoint with Accept header hint
|
||||
// Full YAML-to-JSON conversion would require a YAML parser
|
||||
context.Response.Headers["X-Export-Oas-Version"] = OasVersion;
|
||||
return Results.Redirect("/openapi/export-center.yaml", permanent: false);
|
||||
}
|
||||
|
||||
private static string ComputeEtag(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"\"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}\"";
|
||||
}
|
||||
|
||||
private static string GetServiceVersion()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "1.0.0";
|
||||
return version;
|
||||
}
|
||||
|
||||
private static async Task<string?> GetEmbeddedOpenApiYaml()
|
||||
{
|
||||
// Try to read from embedded resource or file system
|
||||
// For now, return a placeholder that references the spec location
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = $"{assembly.GetName().Name}.OpenApi.export-center.v1.yaml";
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is not null)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
// Fall back to file system for development
|
||||
var basePath = AppContext.BaseDirectory;
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
Path.Combine(basePath, "OpenApi", SpecFileName),
|
||||
Path.Combine(basePath, "..", "..", "..", "OpenApi", SpecFileName),
|
||||
Path.Combine(basePath, "..", "..", "..", "..", "..", "..", "docs", "modules", "export-center", "openapi", SpecFileName)
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return await File.ReadAllTextAsync(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Return a minimal inline spec if file not found
|
||||
return GetMinimalOpenApiSpec();
|
||||
}
|
||||
|
||||
private static string GetMinimalOpenApiSpec()
|
||||
{
|
||||
return """
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: StellaOps ExportCenter API
|
||||
version: 1.0.0
|
||||
description: Export profiles, runs, and deterministic bundle downloads for air-gap deployments.
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/.well-known/openapi:
|
||||
get:
|
||||
summary: OpenAPI discovery
|
||||
responses:
|
||||
'200':
|
||||
description: Discovery metadata
|
||||
/v1/exports/profiles:
|
||||
get:
|
||||
summary: List export profiles
|
||||
responses:
|
||||
'200':
|
||||
description: List of profiles
|
||||
components:
|
||||
schemas:
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for OpenAPI discovery endpoint.
|
||||
/// </summary>
|
||||
public sealed record OpenApiDiscoveryResponse
|
||||
{
|
||||
[JsonPropertyName("service")]
|
||||
public required string Service { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("specVersion")]
|
||||
public required string SpecVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public required string Format { get; init; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[JsonPropertyName("jsonUrl")]
|
||||
public string? JsonUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("errorEnvelopeSchema")]
|
||||
public required string ErrorEnvelopeSchema { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("profilesSupported")]
|
||||
public IReadOnlyList<string>? ProfilesSupported { get; init; }
|
||||
|
||||
[JsonPropertyName("checksumSha256")]
|
||||
public string? ChecksumSha256 { get; init; }
|
||||
}
|
||||
@@ -2,6 +2,14 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.ExportCenter.WebService;
|
||||
using StellaOps.ExportCenter.WebService.Deprecation;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
using StellaOps.ExportCenter.WebService.Timeline;
|
||||
using StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
using StellaOps.ExportCenter.WebService.Attestation;
|
||||
using StellaOps.ExportCenter.WebService.Incident;
|
||||
using StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -24,6 +32,41 @@ builder.Services.AddAuthorization(options =>
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
// Deprecation notification service
|
||||
builder.Services.AddSingleton<IDeprecationNotificationService, DeprecationNotificationService>();
|
||||
|
||||
// Telemetry services
|
||||
builder.Services.AddExportCenterTelemetry();
|
||||
|
||||
// Timeline event publisher
|
||||
builder.Services.AddExportTimelinePublisher();
|
||||
|
||||
// Evidence locker integration (use in-memory for development)
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
builder.Services.AddExportEvidenceLockerInMemory();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddExportEvidenceLocker(options =>
|
||||
{
|
||||
var evidenceLockerUrl = builder.Configuration.GetValue<string>("EvidenceLocker:BaseUrl");
|
||||
if (!string.IsNullOrWhiteSpace(evidenceLockerUrl))
|
||||
{
|
||||
options.BaseUrl = evidenceLockerUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Attestation services (DSSE signing)
|
||||
builder.Services.AddExportAttestation();
|
||||
|
||||
// Incident management services
|
||||
builder.Services.AddExportIncidentManagement();
|
||||
|
||||
// Risk bundle job handler
|
||||
builder.Services.AddRiskBundleJobHandler();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -37,13 +80,38 @@ app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// OpenAPI discovery endpoints (anonymous)
|
||||
app.MapOpenApiDiscovery();
|
||||
|
||||
// Attestation endpoints
|
||||
app.MapAttestationEndpoints();
|
||||
|
||||
// Promotion attestation endpoints
|
||||
app.MapPromotionAttestationEndpoints();
|
||||
|
||||
// Incident management endpoints
|
||||
app.MapIncidentEndpoints();
|
||||
|
||||
// Risk bundle endpoints
|
||||
app.MapRiskBundleEndpoints();
|
||||
|
||||
// Legacy exports endpoints (deprecated, use /v1/exports/* instead)
|
||||
app.MapGet("/exports", () => Results.Ok(Array.Empty<object>()))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)
|
||||
.WithDeprecation(DeprecatedEndpointsRegistry.ListExports)
|
||||
.WithSummary("List exports (DEPRECATED)")
|
||||
.WithDescription("This endpoint is deprecated. Use GET /v1/exports/profiles instead.");
|
||||
|
||||
app.MapPost("/exports", () => Results.Accepted("/exports", new { status = "scheduled" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator);
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator)
|
||||
.WithDeprecation(DeprecatedEndpointsRegistry.CreateExport)
|
||||
.WithSummary("Create export (DEPRECATED)")
|
||||
.WithDescription("This endpoint is deprecated. Use POST /v1/exports/evidence or /v1/exports/attestations instead.");
|
||||
|
||||
app.MapDelete("/exports/{id}", (string id) => Results.NoContent())
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportAdmin);
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportAdmin)
|
||||
.WithDeprecation(DeprecatedEndpointsRegistry.DeleteExport)
|
||||
.WithSummary("Delete export (DEPRECATED)")
|
||||
.WithDescription("This endpoint is deprecated. Use POST /v1/exports/runs/{id}/cancel instead.");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for handling risk bundle job operations.
|
||||
/// </summary>
|
||||
public interface IRiskBundleJobHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets available providers for risk bundle generation.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Response containing available providers.</returns>
|
||||
Task<RiskBundleProvidersResponse> GetAvailableProvidersAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Submits a new risk bundle job.
|
||||
/// </summary>
|
||||
/// <param name="request">The job submission request.</param>
|
||||
/// <param name="actor">The actor submitting the job (from auth claims).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the job submission.</returns>
|
||||
Task<RiskBundleJobSubmitResult> SubmitJobAsync(
|
||||
RiskBundleJobSubmitRequest request,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a specific job.
|
||||
/// </summary>
|
||||
/// <param name="jobId">The job identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Job status details, or null if not found.</returns>
|
||||
Task<RiskBundleJobStatusDetail?> GetJobStatusAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent jobs, optionally filtered by tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant ID filter.</param>
|
||||
/// <param name="limit">Maximum number of jobs to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of recent job status details.</returns>
|
||||
Task<IReadOnlyList<RiskBundleJobStatusDetail>> GetRecentJobsAsync(
|
||||
string? tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending or running job.
|
||||
/// </summary>
|
||||
/// <param name="jobId">The job identifier.</param>
|
||||
/// <param name="actor">The actor cancelling the job.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if cancellation was successful.</returns>
|
||||
Task<bool> CancelJobAsync(string jobId, string? actor, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping risk bundle endpoints.
|
||||
/// </summary>
|
||||
public static class RiskBundleEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps risk bundle job endpoints to the application.
|
||||
/// </summary>
|
||||
public static WebApplication MapRiskBundleEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/v1/risk-bundles")
|
||||
.WithTags("Risk Bundles")
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator);
|
||||
|
||||
// GET /v1/risk-bundles/providers - Get available providers
|
||||
group.MapGet("/providers", GetAvailableProvidersAsync)
|
||||
.WithName("GetRiskBundleProviders")
|
||||
.WithSummary("Get available risk bundle providers")
|
||||
.WithDescription("Returns available providers for risk bundle generation, including mandatory and optional providers.")
|
||||
.Produces<RiskBundleProvidersResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// POST /v1/risk-bundles/jobs - Submit a new job
|
||||
group.MapPost("/jobs", SubmitJobAsync)
|
||||
.WithName("SubmitRiskBundleJob")
|
||||
.WithSummary("Submit a risk bundle job")
|
||||
.WithDescription("Submits a new risk bundle generation job with selected providers.")
|
||||
.Produces<RiskBundleJobSubmitResult>(StatusCodes.Status202Accepted)
|
||||
.Produces<RiskBundleJobSubmitResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
// GET /v1/risk-bundles/jobs - Get recent jobs
|
||||
group.MapGet("/jobs", GetRecentJobsAsync)
|
||||
.WithName("GetRecentRiskBundleJobs")
|
||||
.WithSummary("Get recent risk bundle jobs")
|
||||
.WithDescription("Returns recent risk bundle jobs, optionally filtered by tenant.")
|
||||
.Produces<IReadOnlyList<RiskBundleJobStatusDetail>>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /v1/risk-bundles/jobs/{jobId} - Get job status
|
||||
group.MapGet("/jobs/{jobId}", GetJobStatusAsync)
|
||||
.WithName("GetRiskBundleJobStatus")
|
||||
.WithSummary("Get risk bundle job status")
|
||||
.WithDescription("Returns the status of a specific risk bundle job.")
|
||||
.Produces<RiskBundleJobStatusDetail>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /v1/risk-bundles/jobs/{jobId}/cancel - Cancel a job
|
||||
group.MapPost("/jobs/{jobId}/cancel", CancelJobAsync)
|
||||
.WithName("CancelRiskBundleJob")
|
||||
.WithSummary("Cancel a risk bundle job")
|
||||
.WithDescription("Cancels a pending or running risk bundle job.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<Ok<RiskBundleProvidersResponse>> GetAvailableProvidersAsync(
|
||||
[FromServices] IRiskBundleJobHandler handler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var providers = await handler.GetAvailableProvidersAsync(cancellationToken);
|
||||
return TypedResults.Ok(providers);
|
||||
}
|
||||
|
||||
private static async Task<Results<Accepted<RiskBundleJobSubmitResult>, BadRequest<RiskBundleJobSubmitResult>>> SubmitJobAsync(
|
||||
[FromBody] RiskBundleJobSubmitRequest request,
|
||||
[FromServices] IRiskBundleJobHandler handler,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get actor from claims
|
||||
var actor = httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.FindFirst("preferred_username")?.Value;
|
||||
|
||||
var result = await handler.SubmitJobAsync(request, actor, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return TypedResults.BadRequest(result);
|
||||
}
|
||||
|
||||
return TypedResults.Accepted($"/v1/risk-bundles/jobs/{result.JobId}", result);
|
||||
}
|
||||
|
||||
private static async Task<Ok<IReadOnlyList<RiskBundleJobStatusDetail>>> GetRecentJobsAsync(
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] int? limit,
|
||||
[FromServices] IRiskBundleJobHandler handler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var jobs = await handler.GetRecentJobsAsync(tenantId, limit ?? 50, cancellationToken);
|
||||
return TypedResults.Ok(jobs);
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RiskBundleJobStatusDetail>, NotFound>> GetJobStatusAsync(
|
||||
string jobId,
|
||||
[FromServices] IRiskBundleJobHandler handler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await handler.GetJobStatusAsync(jobId, cancellationToken);
|
||||
if (status is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
return TypedResults.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<Results<NoContent, NotFound, Conflict<string>>> CancelJobAsync(
|
||||
string jobId,
|
||||
[FromServices] IRiskBundleJobHandler handler,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await handler.GetJobStatusAsync(jobId, cancellationToken);
|
||||
if (status is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (status.Status is not (RiskBundleJobStatus.Pending or RiskBundleJobStatus.Running))
|
||||
{
|
||||
return TypedResults.Conflict($"Job cannot be cancelled in status '{status.Status}'");
|
||||
}
|
||||
|
||||
var actor = httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.FindFirst("preferred_username")?.Value;
|
||||
|
||||
var cancelled = await handler.CancelJobAsync(jobId, actor, cancellationToken);
|
||||
if (!cancelled)
|
||||
{
|
||||
return TypedResults.Conflict("Failed to cancel job");
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
using StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of risk bundle job handler with provider selection and audit logging.
|
||||
/// </summary>
|
||||
public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly string[] MandatoryProviderIds = ["cisa-kev"];
|
||||
private static readonly string[] OptionalProviderIds = ["nvd", "osv", "ghsa", "epss"];
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RiskBundleJobHandler> _logger;
|
||||
private readonly IExportTimelinePublisher _timelinePublisher;
|
||||
private readonly RiskBundleJobHandlerOptions _options;
|
||||
|
||||
// In-memory job store (would be replaced with persistent storage in production)
|
||||
private readonly ConcurrentDictionary<string, RiskBundleJobState> _jobs = new();
|
||||
|
||||
public RiskBundleJobHandler(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RiskBundleJobHandler> logger,
|
||||
IExportTimelinePublisher timelinePublisher,
|
||||
IOptions<RiskBundleJobHandlerOptions>? options = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
|
||||
_options = options?.Value ?? RiskBundleJobHandlerOptions.Default;
|
||||
}
|
||||
|
||||
public Task<RiskBundleProvidersResponse> GetAvailableProvidersAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var providers = new List<RiskBundleAvailableProvider>();
|
||||
|
||||
// Add mandatory providers
|
||||
foreach (var providerId in MandatoryProviderIds)
|
||||
{
|
||||
providers.Add(CreateProviderInfo(providerId, mandatory: true));
|
||||
}
|
||||
|
||||
// Add optional providers
|
||||
foreach (var providerId in OptionalProviderIds)
|
||||
{
|
||||
providers.Add(CreateProviderInfo(providerId, mandatory: false));
|
||||
}
|
||||
|
||||
var response = new RiskBundleProvidersResponse
|
||||
{
|
||||
Providers = providers,
|
||||
MandatoryProviderIds = MandatoryProviderIds,
|
||||
OptionalProviderIds = OptionalProviderIds
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public async Task<RiskBundleJobSubmitResult> SubmitJobAsync(
|
||||
RiskBundleJobSubmitRequest request,
|
||||
string? actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var jobId = request.JobId?.ToString("N") ?? Guid.NewGuid().ToString("N");
|
||||
|
||||
// Validate provider selection
|
||||
var selectedProviders = ResolveSelectedProviders(request.SelectedProviders);
|
||||
var validationError = ValidateProviderSelection(selectedProviders);
|
||||
if (validationError is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Risk bundle job {JobId} submission rejected: {Error}",
|
||||
jobId, validationError);
|
||||
|
||||
return new RiskBundleJobSubmitResult
|
||||
{
|
||||
Success = false,
|
||||
JobId = jobId,
|
||||
Status = RiskBundleJobStatus.Failed,
|
||||
ErrorMessage = validationError,
|
||||
SubmittedAt = now,
|
||||
SelectedProviders = selectedProviders
|
||||
};
|
||||
}
|
||||
|
||||
// Create job state
|
||||
var jobState = new RiskBundleJobState
|
||||
{
|
||||
JobId = jobId,
|
||||
Status = RiskBundleJobStatus.Pending,
|
||||
TenantId = request.TenantId,
|
||||
CorrelationId = request.CorrelationId,
|
||||
Actor = actor,
|
||||
SubmittedAt = now,
|
||||
SelectedProviders = selectedProviders,
|
||||
Request = request
|
||||
};
|
||||
|
||||
if (!_jobs.TryAdd(jobId, jobState))
|
||||
{
|
||||
return new RiskBundleJobSubmitResult
|
||||
{
|
||||
Success = false,
|
||||
JobId = jobId,
|
||||
Status = RiskBundleJobStatus.Failed,
|
||||
ErrorMessage = "Job with this ID already exists",
|
||||
SubmittedAt = now,
|
||||
SelectedProviders = selectedProviders
|
||||
};
|
||||
}
|
||||
|
||||
// Emit audit event
|
||||
await EmitAuditEventAsync(
|
||||
"risk_bundle.job.submitted",
|
||||
jobId,
|
||||
request.TenantId,
|
||||
actor,
|
||||
request.CorrelationId,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["provider_count"] = selectedProviders.Count.ToString(),
|
||||
["providers"] = string.Join(",", selectedProviders),
|
||||
["include_osv"] = request.IncludeOsv.ToString().ToLowerInvariant()
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Record metrics
|
||||
ExportTelemetry.RiskBundleJobsSubmitted.Add(1,
|
||||
new KeyValuePair<string, object?>("tenant_id", request.TenantId ?? "unknown"));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Risk bundle job {JobId} submitted with {ProviderCount} providers by {Actor}",
|
||||
jobId, selectedProviders.Count, actor ?? "anonymous");
|
||||
|
||||
// Start background execution (in production, this would queue to a job processor)
|
||||
_ = ExecuteJobAsync(jobState, cancellationToken);
|
||||
|
||||
return new RiskBundleJobSubmitResult
|
||||
{
|
||||
Success = true,
|
||||
JobId = jobId,
|
||||
Status = RiskBundleJobStatus.Pending,
|
||||
SubmittedAt = now,
|
||||
SelectedProviders = selectedProviders
|
||||
};
|
||||
}
|
||||
|
||||
public Task<RiskBundleJobStatusDetail?> GetJobStatusAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_jobs.TryGetValue(jobId, out var state))
|
||||
{
|
||||
return Task.FromResult<RiskBundleJobStatusDetail?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<RiskBundleJobStatusDetail?>(CreateStatusDetail(state));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskBundleJobStatusDetail>> GetRecentJobsAsync(
|
||||
string? tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var query = _jobs.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
query = query.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var results = query
|
||||
.OrderByDescending(j => j.SubmittedAt)
|
||||
.Take(Math.Min(limit, 100))
|
||||
.Select(CreateStatusDetail)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<RiskBundleJobStatusDetail>>(results);
|
||||
}
|
||||
|
||||
public async Task<bool> CancelJobAsync(string jobId, string? actor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_jobs.TryGetValue(jobId, out var state))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Can only cancel pending or running jobs
|
||||
if (state.Status is not (RiskBundleJobStatus.Pending or RiskBundleJobStatus.Running))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
state.Status = RiskBundleJobStatus.Cancelled;
|
||||
state.CompletedAt = _timeProvider.GetUtcNow();
|
||||
state.CancellationSource?.Cancel();
|
||||
|
||||
// Emit audit event
|
||||
await EmitAuditEventAsync(
|
||||
"risk_bundle.job.cancelled",
|
||||
jobId,
|
||||
state.TenantId,
|
||||
actor,
|
||||
state.CorrelationId,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["original_status"] = state.Status.ToString()
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Risk bundle job {JobId} cancelled by {Actor}", jobId, actor ?? "anonymous");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task ExecuteJobAsync(RiskBundleJobState state, CancellationToken cancellationToken)
|
||||
{
|
||||
state.CancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var linkedToken = state.CancellationSource.Token;
|
||||
|
||||
try
|
||||
{
|
||||
state.Status = RiskBundleJobStatus.Running;
|
||||
state.StartedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
await EmitAuditEventAsync(
|
||||
"risk_bundle.job.started",
|
||||
state.JobId,
|
||||
state.TenantId,
|
||||
state.Actor,
|
||||
state.CorrelationId,
|
||||
null,
|
||||
linkedToken).ConfigureAwait(false);
|
||||
|
||||
// Simulate job execution (in production, this would call the actual RiskBundleJob)
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), linkedToken).ConfigureAwait(false);
|
||||
|
||||
linkedToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Create simulated outcome
|
||||
var bundleId = Guid.NewGuid();
|
||||
state.Outcome = new RiskBundleOutcomeSummary
|
||||
{
|
||||
BundleId = bundleId,
|
||||
RootHash = $"sha256:{Guid.NewGuid():N}",
|
||||
BundleStorageKey = $"risk-bundles/{bundleId:N}/risk-bundle.tar.gz",
|
||||
ManifestStorageKey = $"risk-bundles/{bundleId:N}/provider-manifest.json",
|
||||
ManifestSignatureStorageKey = $"risk-bundles/{bundleId:N}/signatures/provider-manifest.dsse",
|
||||
ProviderCount = state.SelectedProviders.Count,
|
||||
TotalSizeBytes = state.SelectedProviders.Count * 1024 * 1024 // Simulated
|
||||
};
|
||||
|
||||
state.IncludedProviders = state.SelectedProviders
|
||||
.Select(p => new RiskBundleProviderResult
|
||||
{
|
||||
ProviderId = p,
|
||||
Sha256 = $"sha256:{Guid.NewGuid():N}",
|
||||
SizeBytes = 1024 * 1024,
|
||||
Source = $"mirror://{p}/current",
|
||||
SnapshotDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().DateTime),
|
||||
Optional = !MandatoryProviderIds.Contains(p)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
state.Status = RiskBundleJobStatus.Completed;
|
||||
state.CompletedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
await EmitAuditEventAsync(
|
||||
"risk_bundle.job.completed",
|
||||
state.JobId,
|
||||
state.TenantId,
|
||||
state.Actor,
|
||||
state.CorrelationId,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["bundle_id"] = bundleId.ToString("N"),
|
||||
["root_hash"] = state.Outcome.RootHash,
|
||||
["provider_count"] = state.Outcome.ProviderCount.ToString(),
|
||||
["total_size_bytes"] = state.Outcome.TotalSizeBytes.ToString()
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Record metrics
|
||||
ExportTelemetry.RiskBundleJobsCompleted.Add(1,
|
||||
new KeyValuePair<string, object?>("tenant_id", state.TenantId ?? "unknown"),
|
||||
new KeyValuePair<string, object?>("status", "success"));
|
||||
|
||||
var durationSeconds = (state.CompletedAt.Value - state.StartedAt!.Value).TotalSeconds;
|
||||
ExportTelemetry.RiskBundleJobDurationSeconds.Record(durationSeconds,
|
||||
new KeyValuePair<string, object?>("tenant_id", state.TenantId ?? "unknown"));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Risk bundle job {JobId} completed with {ProviderCount} providers in {DurationMs:F0}ms",
|
||||
state.JobId, state.Outcome.ProviderCount, durationSeconds * 1000);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (state.Status != RiskBundleJobStatus.Cancelled)
|
||||
{
|
||||
state.Status = RiskBundleJobStatus.Cancelled;
|
||||
state.CompletedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
state.Status = RiskBundleJobStatus.Failed;
|
||||
state.CompletedAt = _timeProvider.GetUtcNow();
|
||||
state.ErrorMessage = ex.Message;
|
||||
|
||||
await EmitAuditEventAsync(
|
||||
"risk_bundle.job.failed",
|
||||
state.JobId,
|
||||
state.TenantId,
|
||||
state.Actor,
|
||||
state.CorrelationId,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["error"] = ex.Message,
|
||||
["error_type"] = ex.GetType().Name
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
ExportTelemetry.RiskBundleJobsCompleted.Add(1,
|
||||
new KeyValuePair<string, object?>("tenant_id", state.TenantId ?? "unknown"),
|
||||
new KeyValuePair<string, object?>("status", "failed"));
|
||||
|
||||
_logger.LogError(ex, "Risk bundle job {JobId} failed", state.JobId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.CancellationSource?.Dispose();
|
||||
state.CancellationSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EmitAuditEventAsync(
|
||||
string eventType,
|
||||
string jobId,
|
||||
string? tenantId,
|
||||
string? actor,
|
||||
string? correlationId,
|
||||
Dictionary<string, string>? attributes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var auditEvent = new RiskBundleAuditEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
JobId = jobId,
|
||||
TenantId = tenantId,
|
||||
OccurredAt = _timeProvider.GetUtcNow(),
|
||||
Actor = actor,
|
||||
CorrelationId = correlationId,
|
||||
Attributes = attributes
|
||||
};
|
||||
|
||||
var eventJson = JsonSerializer.Serialize(auditEvent, SerializerOptions);
|
||||
|
||||
try
|
||||
{
|
||||
await _timelinePublisher.PublishIncidentEventAsync(
|
||||
eventType,
|
||||
jobId,
|
||||
eventJson,
|
||||
correlationId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to publish audit event {EventType} for job {JobId}", eventType, jobId);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ResolveSelectedProviders(IReadOnlyList<string>? requestedProviders)
|
||||
{
|
||||
if (requestedProviders is null or { Count: 0 })
|
||||
{
|
||||
// Default: mandatory providers only
|
||||
return [.. MandatoryProviderIds];
|
||||
}
|
||||
|
||||
// Normalize and deduplicate
|
||||
var selected = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Always include mandatory providers
|
||||
foreach (var provider in MandatoryProviderIds)
|
||||
{
|
||||
selected.Add(provider);
|
||||
}
|
||||
|
||||
// Add requested providers
|
||||
foreach (var provider in requestedProviders)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
selected.Add(provider.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return [.. selected.OrderBy(p => p, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static string? ValidateProviderSelection(List<string> selectedProviders)
|
||||
{
|
||||
// Ensure all mandatory providers are included
|
||||
foreach (var mandatory in MandatoryProviderIds)
|
||||
{
|
||||
if (!selectedProviders.Contains(mandatory, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"Mandatory provider '{mandatory}' must be included";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that selected providers are known
|
||||
var allKnown = MandatoryProviderIds.Concat(OptionalProviderIds).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var provider in selectedProviders)
|
||||
{
|
||||
if (!allKnown.Contains(provider))
|
||||
{
|
||||
return $"Unknown provider '{provider}'";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RiskBundleAvailableProvider CreateProviderInfo(string providerId, bool mandatory)
|
||||
{
|
||||
var (displayName, description) = providerId switch
|
||||
{
|
||||
"cisa-kev" => ("CISA KEV", "CISA Known Exploited Vulnerabilities catalog"),
|
||||
"nvd" => ("NVD", "NIST National Vulnerability Database"),
|
||||
"osv" => ("OSV", "Open Source Vulnerabilities database"),
|
||||
"ghsa" => ("GitHub Security Advisories", "GitHub Security Advisory database"),
|
||||
"epss" => ("EPSS", "Exploit Prediction Scoring System"),
|
||||
_ => (providerId.ToUpperInvariant(), null)
|
||||
};
|
||||
|
||||
return new RiskBundleAvailableProvider
|
||||
{
|
||||
ProviderId = providerId,
|
||||
DisplayName = displayName,
|
||||
Description = description,
|
||||
Mandatory = mandatory,
|
||||
Available = true, // Would check actual availability in production
|
||||
LastSnapshotDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
|
||||
DefaultSourcePath = $"/data/providers/{providerId}/current"
|
||||
};
|
||||
}
|
||||
|
||||
private static RiskBundleJobStatusDetail CreateStatusDetail(RiskBundleJobState state)
|
||||
{
|
||||
return new RiskBundleJobStatusDetail
|
||||
{
|
||||
JobId = state.JobId,
|
||||
Status = state.Status,
|
||||
TenantId = state.TenantId,
|
||||
SubmittedAt = state.SubmittedAt,
|
||||
StartedAt = state.StartedAt,
|
||||
CompletedAt = state.CompletedAt,
|
||||
SelectedProviders = state.SelectedProviders,
|
||||
IncludedProviders = state.IncludedProviders,
|
||||
ErrorMessage = state.ErrorMessage,
|
||||
Outcome = state.Outcome
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class RiskBundleJobState
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public RiskBundleJobStatus Status { get; set; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public required DateTimeOffset SubmittedAt { get; init; }
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public required IReadOnlyList<string> SelectedProviders { get; init; }
|
||||
public IReadOnlyList<RiskBundleProviderResult>? IncludedProviders { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public RiskBundleOutcomeSummary? Outcome { get; set; }
|
||||
public RiskBundleJobSubmitRequest? Request { get; init; }
|
||||
public CancellationTokenSource? CancellationSource { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for the risk bundle job handler.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleJobHandlerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of concurrent jobs.
|
||||
/// </summary>
|
||||
public int MaxConcurrentJobs { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Job timeout duration.
|
||||
/// </summary>
|
||||
public TimeSpan JobTimeout { get; init; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long to retain completed jobs in memory.
|
||||
/// </summary>
|
||||
public TimeSpan JobRetentionPeriod { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Default storage prefix for bundles.
|
||||
/// </summary>
|
||||
public string DefaultStoragePrefix { get; init; } = "risk-bundles";
|
||||
|
||||
public static RiskBundleJobHandlerOptions Default => new();
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Request to submit a risk bundle job.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleJobSubmitRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the job. Generated if not provided.
|
||||
/// </summary>
|
||||
public Guid? JobId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier for audit logging.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Selected provider IDs to include in the bundle.
|
||||
/// If empty, uses default providers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SelectedProviders { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Provider-specific overrides for source paths and options.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RiskBundleProviderOverride>? ProviderOverrides { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include OSV data in the bundle.
|
||||
/// </summary>
|
||||
public bool IncludeOsv { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage prefix for the generated bundle.
|
||||
/// </summary>
|
||||
public string? StoragePrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom bundle filename. Defaults to risk-bundle.tar.gz.
|
||||
/// </summary>
|
||||
public string? BundleFileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow missing optional providers without failing.
|
||||
/// </summary>
|
||||
public bool AllowMissingOptional { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allow stale optional provider data without failing.
|
||||
/// </summary>
|
||||
public bool AllowStaleOptional { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata to include in audit logs.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider-specific override for a risk bundle job.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleProviderOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider identifier (e.g., "cisa-kev", "nvd", "osv").
|
||||
/// </summary>
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for the source path.
|
||||
/// </summary>
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source descriptor (e.g., "mirror://kev/current").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional signature file path.
|
||||
/// </summary>
|
||||
public string? SignaturePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Override for the snapshot date.
|
||||
/// </summary>
|
||||
public DateOnly? SnapshotDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this provider is optional.
|
||||
/// </summary>
|
||||
public bool? Optional { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of submitting a risk bundle job.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleJobSubmitResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the job was successfully submitted.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The job identifier.
|
||||
/// </summary>
|
||||
public required string JobId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current job status.
|
||||
/// </summary>
|
||||
public required RiskBundleJobStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if submission failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the job was submitted.
|
||||
/// </summary>
|
||||
public required DateTimeOffset SubmittedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Selected providers for this job.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SelectedProviders { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a risk bundle job.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RiskBundleJobStatus
|
||||
{
|
||||
/// <summary>Job is pending execution.</summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>Job is currently running.</summary>
|
||||
Running = 1,
|
||||
|
||||
/// <summary>Job completed successfully.</summary>
|
||||
Completed = 2,
|
||||
|
||||
/// <summary>Job failed.</summary>
|
||||
Failed = 3,
|
||||
|
||||
/// <summary>Job was cancelled.</summary>
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed status of a risk bundle job.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleJobStatusDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// The job identifier.
|
||||
/// </summary>
|
||||
public required string JobId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current job status.
|
||||
/// </summary>
|
||||
public required RiskBundleJobStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the job was submitted.
|
||||
/// </summary>
|
||||
public required DateTimeOffset SubmittedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the job started executing.
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the job completed or failed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Selected providers for this job.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SelectedProviders { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Providers that were successfully included.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RiskBundleProviderResult>? IncludedProviders { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the job failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle outcome details if completed.
|
||||
/// </summary>
|
||||
public RiskBundleOutcomeSummary? Outcome { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for an individual provider in a risk bundle.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleProviderResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider identifier.
|
||||
/// </summary>
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the provider data.
|
||||
/// </summary>
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source descriptor.
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot date if available.
|
||||
/// </summary>
|
||||
public DateOnly? SnapshotDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this provider is optional.
|
||||
/// </summary>
|
||||
public required bool Optional { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a completed risk bundle job outcome.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleOutcomeSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier.
|
||||
/// </summary>
|
||||
public required Guid BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Root hash (SHA-256 of manifest).
|
||||
/// </summary>
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage key for the bundle.
|
||||
/// </summary>
|
||||
public required string BundleStorageKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage key for the manifest.
|
||||
/// </summary>
|
||||
public required string ManifestStorageKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage key for the manifest signature.
|
||||
/// </summary>
|
||||
public required string ManifestSignatureStorageKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of providers included.
|
||||
/// </summary>
|
||||
public required int ProviderCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bundle size in bytes.
|
||||
/// </summary>
|
||||
public required long TotalSizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit event for risk bundle job lifecycle.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleAuditEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event type (e.g., "risk_bundle.job.submitted", "risk_bundle.job.completed").
|
||||
/// </summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Job identifier.
|
||||
/// </summary>
|
||||
public required string JobId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor who triggered the event.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional event attributes.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Available provider information.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleAvailableProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider identifier.
|
||||
/// </summary>
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this provider is mandatory.
|
||||
/// </summary>
|
||||
public required bool Mandatory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this provider is currently available.
|
||||
/// </summary>
|
||||
public required bool Available { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last known snapshot date.
|
||||
/// </summary>
|
||||
public DateOnly? LastSnapshotDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default source path.
|
||||
/// </summary>
|
||||
public string? DefaultSourcePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing available providers.
|
||||
/// </summary>
|
||||
public sealed record RiskBundleProvidersResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// List of available providers.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RiskBundleAvailableProvider> Providers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mandatory provider IDs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> MandatoryProviderIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional provider IDs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> OptionalProviderIds { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering risk bundle services.
|
||||
/// </summary>
|
||||
public static class RiskBundleServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds risk bundle job handler services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddRiskBundleJobHandler(
|
||||
this IServiceCollection services,
|
||||
Action<RiskBundleJobHandlerOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Configure options if provided
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
// Register the job handler
|
||||
services.TryAddSingleton<IRiskBundleJobHandler, RiskBundleJobHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Api" Version="1.11.2" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj" />
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for creating export-related activities (spans).
|
||||
/// </summary>
|
||||
public static class ExportActivityExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts an activity for an export run.
|
||||
/// </summary>
|
||||
public static Activity? StartExportRunActivity(
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
string exportType)
|
||||
{
|
||||
var activity = ExportTelemetry.ActivitySource.StartActivity(
|
||||
"export.run",
|
||||
ActivityKind.Internal);
|
||||
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag(ExportTelemetryTags.RunId, runId);
|
||||
activity.SetTag(ExportTelemetryTags.ProfileId, profileId);
|
||||
activity.SetTag(ExportTelemetryTags.TenantId, tenantId);
|
||||
activity.SetTag(ExportTelemetryTags.ExportType, exportType);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for the planning phase.
|
||||
/// </summary>
|
||||
public static Activity? StartExportPlanActivity(
|
||||
string runId,
|
||||
string profileId)
|
||||
{
|
||||
var activity = ExportTelemetry.ActivitySource.StartActivity(
|
||||
"export.plan",
|
||||
ActivityKind.Internal);
|
||||
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag(ExportTelemetryTags.RunId, runId);
|
||||
activity.SetTag(ExportTelemetryTags.ProfileId, profileId);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for the write/build phase.
|
||||
/// </summary>
|
||||
public static Activity? StartExportWriteActivity(
|
||||
string runId,
|
||||
string artifactType)
|
||||
{
|
||||
var activity = ExportTelemetry.ActivitySource.StartActivity(
|
||||
"export.write",
|
||||
ActivityKind.Internal);
|
||||
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag(ExportTelemetryTags.RunId, runId);
|
||||
activity.SetTag(ExportTelemetryTags.ArtifactType, artifactType);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for bundle distribution.
|
||||
/// </summary>
|
||||
public static Activity? StartExportDistributeActivity(
|
||||
string runId,
|
||||
string distributionType)
|
||||
{
|
||||
var activity = ExportTelemetry.ActivitySource.StartActivity(
|
||||
"export.distribute",
|
||||
ActivityKind.Internal);
|
||||
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag(ExportTelemetryTags.RunId, runId);
|
||||
activity.SetTag(ExportTelemetryTags.DistributionType, distributionType);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds artifact count to an activity.
|
||||
/// </summary>
|
||||
public static Activity? SetArtifactCount(this Activity? activity, int count)
|
||||
{
|
||||
activity?.SetTag("artifact_count", count);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds bundle size to an activity.
|
||||
/// </summary>
|
||||
public static Activity? SetBundleSize(this Activity? activity, long sizeBytes)
|
||||
{
|
||||
activity?.SetTag("bundle_size_bytes", sizeBytes);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds export status to an activity.
|
||||
/// </summary>
|
||||
public static Activity? SetExportStatus(this Activity? activity, string status)
|
||||
{
|
||||
activity?.SetTag(ExportTelemetryTags.Status, status);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an activity as failed with an error.
|
||||
/// </summary>
|
||||
public static Activity? SetError(this Activity? activity, Exception exception, string? errorCode = null)
|
||||
{
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetStatus(ActivityStatusCode.Error, exception.Message);
|
||||
activity.SetTag(ExportTelemetryTags.Status, ExportStatuses.Failed);
|
||||
activity.SetTag("exception.type", exception.GetType().Name);
|
||||
activity.SetTag("exception.message", exception.Message);
|
||||
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
activity.SetTag(ExportTelemetryTags.ErrorCode, errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an activity as successful.
|
||||
/// </summary>
|
||||
public static Activity? SetSuccess(this Activity? activity)
|
||||
{
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
activity.SetTag(ExportTelemetryTags.Status, ExportStatuses.Success);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// High-performance structured logging extensions for export operations.
|
||||
/// Uses LoggerMessage source generators for optimal performance.
|
||||
/// </summary>
|
||||
public static partial class ExportLoggerExtensions
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 1000,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Export run started: RunId={RunId}, ProfileId={ProfileId}, TenantId={TenantId}, ExportType={ExportType}")]
|
||||
public static partial void LogExportRunStarted(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
string exportType);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1001,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Export run completed: RunId={RunId}, ProfileId={ProfileId}, TenantId={TenantId}, DurationMs={DurationMs}, ArtifactCount={ArtifactCount}, BundleSizeBytes={BundleSizeBytes}")]
|
||||
public static partial void LogExportRunCompleted(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
long durationMs,
|
||||
int artifactCount,
|
||||
long bundleSizeBytes);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1002,
|
||||
Level = LogLevel.Error,
|
||||
Message = "Export run failed: RunId={RunId}, ProfileId={ProfileId}, TenantId={TenantId}, DurationMs={DurationMs}, ErrorCode={ErrorCode}")]
|
||||
public static partial void LogExportRunFailed(
|
||||
this ILogger logger,
|
||||
Exception? exception,
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
long durationMs,
|
||||
string? errorCode);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1003,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Export run cancelled: RunId={RunId}, ProfileId={ProfileId}, TenantId={TenantId}, DurationMs={DurationMs}")]
|
||||
public static partial void LogExportRunCancelled(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
long durationMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1010,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Export planning started: RunId={RunId}, ProfileId={ProfileId}")]
|
||||
public static partial void LogExportPlanningStarted(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string profileId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1011,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Export planning completed: RunId={RunId}, ProfileId={ProfileId}, DurationMs={DurationMs}, ItemCount={ItemCount}")]
|
||||
public static partial void LogExportPlanningCompleted(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string profileId,
|
||||
long durationMs,
|
||||
int itemCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1020,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Export artifact written: RunId={RunId}, ArtifactType={ArtifactType}, SizeBytes={SizeBytes}")]
|
||||
public static partial void LogExportArtifactWritten(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string artifactType,
|
||||
long sizeBytes);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1030,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Export bundle created: RunId={RunId}, BundleHash={BundleHash}, SizeBytes={SizeBytes}")]
|
||||
public static partial void LogExportBundleCreated(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string bundleHash,
|
||||
long sizeBytes);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1040,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Export distribution started: RunId={RunId}, DistributionType={DistributionType}")]
|
||||
public static partial void LogExportDistributionStarted(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string distributionType);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1041,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Export distribution completed: RunId={RunId}, DistributionType={DistributionType}, DurationMs={DurationMs}")]
|
||||
public static partial void LogExportDistributionCompleted(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
string distributionType,
|
||||
long durationMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1050,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Export profile loaded: ProfileId={ProfileId}, TenantId={TenantId}, Adapter={Adapter}")]
|
||||
public static partial void LogExportProfileLoaded(
|
||||
this ILogger logger,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
string adapter);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1060,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Export retry scheduled: RunId={RunId}, Attempt={Attempt}, MaxAttempts={MaxAttempts}, RetryAfterMs={RetryAfterMs}")]
|
||||
public static partial void LogExportRetryScheduled(
|
||||
this ILogger logger,
|
||||
string runId,
|
||||
int attempt,
|
||||
int maxAttempts,
|
||||
long retryAfterMs);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry context for tracking an export run lifecycle.
|
||||
/// Encapsulates metrics recording, activity tracking, and structured logging.
|
||||
/// </summary>
|
||||
public sealed class ExportRunTelemetryContext : IDisposable
|
||||
{
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private readonly Activity? _activity;
|
||||
private readonly string _runId;
|
||||
private readonly string _profileId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _exportType;
|
||||
private readonly KeyValuePair<string, object?>[] _baseTags;
|
||||
|
||||
private bool _completed;
|
||||
private int _artifactCount;
|
||||
private long _bundleSizeBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new telemetry context for an export run.
|
||||
/// </summary>
|
||||
public ExportRunTelemetryContext(
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
string exportType)
|
||||
{
|
||||
_runId = runId;
|
||||
_profileId = profileId;
|
||||
_tenantId = tenantId;
|
||||
_exportType = exportType;
|
||||
|
||||
_baseTags =
|
||||
[
|
||||
new(ExportTelemetryTags.Profile, profileId),
|
||||
new(ExportTelemetryTags.Tenant, tenantId),
|
||||
new(ExportTelemetryTags.ExportType, exportType)
|
||||
];
|
||||
|
||||
// Record run start
|
||||
ExportTelemetry.ExportRunsTotal.Add(1, _baseTags);
|
||||
ExportTelemetry.ExportRunsInProgress.Add(1, new KeyValuePair<string, object?>(ExportTelemetryTags.Tenant, tenantId));
|
||||
|
||||
// Start activity and stopwatch
|
||||
_activity = ExportActivityExtensions.StartExportRunActivity(runId, profileId, tenantId, exportType);
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The run ID.
|
||||
/// </summary>
|
||||
public string RunId => _runId;
|
||||
|
||||
/// <summary>
|
||||
/// The profile ID.
|
||||
/// </summary>
|
||||
public string ProfileId => _profileId;
|
||||
|
||||
/// <summary>
|
||||
/// The tenant ID.
|
||||
/// </summary>
|
||||
public string TenantId => _tenantId;
|
||||
|
||||
/// <summary>
|
||||
/// The export type.
|
||||
/// </summary>
|
||||
public string ExportType => _exportType;
|
||||
|
||||
/// <summary>
|
||||
/// The current activity (span).
|
||||
/// </summary>
|
||||
public Activity? Activity => _activity;
|
||||
|
||||
/// <summary>
|
||||
/// Records an artifact being exported.
|
||||
/// </summary>
|
||||
public void RecordArtifact(string artifactType, long sizeBytes = 0)
|
||||
{
|
||||
_artifactCount++;
|
||||
_bundleSizeBytes += sizeBytes;
|
||||
|
||||
var tags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new(ExportTelemetryTags.Profile, _profileId),
|
||||
new(ExportTelemetryTags.Tenant, _tenantId),
|
||||
new(ExportTelemetryTags.ArtifactType, artifactType)
|
||||
};
|
||||
|
||||
ExportTelemetry.ExportArtifactsTotal.Add(1, tags);
|
||||
|
||||
if (sizeBytes > 0)
|
||||
{
|
||||
ExportTelemetry.ExportBytesTotal.Add(sizeBytes, _baseTags);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the final bundle size.
|
||||
/// </summary>
|
||||
public void SetBundleSize(long sizeBytes)
|
||||
{
|
||||
_bundleSizeBytes = sizeBytes;
|
||||
_activity?.SetBundleSize(sizeBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the export run as successful.
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (_completed) return;
|
||||
_completed = true;
|
||||
|
||||
_stopwatch.Stop();
|
||||
var duration = _stopwatch.Elapsed.TotalSeconds;
|
||||
|
||||
// Record success metrics
|
||||
ExportTelemetry.ExportRunsSuccessTotal.Add(1, _baseTags);
|
||||
|
||||
var durationTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new(ExportTelemetryTags.Profile, _profileId),
|
||||
new(ExportTelemetryTags.Tenant, _tenantId),
|
||||
new(ExportTelemetryTags.ExportType, _exportType),
|
||||
new(ExportTelemetryTags.Status, ExportStatuses.Success)
|
||||
};
|
||||
ExportTelemetry.ExportRunDurationSeconds.Record(duration, durationTags);
|
||||
|
||||
if (_bundleSizeBytes > 0)
|
||||
{
|
||||
ExportTelemetry.ExportBundleSizeBytes.Record(_bundleSizeBytes, _baseTags);
|
||||
}
|
||||
|
||||
_activity?.SetArtifactCount(_artifactCount);
|
||||
_activity?.SetSuccess();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the export run as failed.
|
||||
/// </summary>
|
||||
public void Fail(Exception? exception = null, string? errorCode = null)
|
||||
{
|
||||
if (_completed) return;
|
||||
_completed = true;
|
||||
|
||||
_stopwatch.Stop();
|
||||
var duration = _stopwatch.Elapsed.TotalSeconds;
|
||||
|
||||
// Record failure metrics
|
||||
var failureTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new(ExportTelemetryTags.Profile, _profileId),
|
||||
new(ExportTelemetryTags.Tenant, _tenantId),
|
||||
new(ExportTelemetryTags.ExportType, _exportType),
|
||||
new(ExportTelemetryTags.ErrorCode, errorCode ?? "unknown")
|
||||
};
|
||||
ExportTelemetry.ExportRunsFailedTotal.Add(1, failureTags);
|
||||
|
||||
var durationTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new(ExportTelemetryTags.Profile, _profileId),
|
||||
new(ExportTelemetryTags.Tenant, _tenantId),
|
||||
new(ExportTelemetryTags.ExportType, _exportType),
|
||||
new(ExportTelemetryTags.Status, ExportStatuses.Failed)
|
||||
};
|
||||
ExportTelemetry.ExportRunDurationSeconds.Record(duration, durationTags);
|
||||
|
||||
if (exception is not null)
|
||||
{
|
||||
_activity?.SetError(exception, errorCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activity?.SetExportStatus(ExportStatuses.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the export run as cancelled.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (_completed) return;
|
||||
_completed = true;
|
||||
|
||||
_stopwatch.Stop();
|
||||
var duration = _stopwatch.Elapsed.TotalSeconds;
|
||||
|
||||
var durationTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new(ExportTelemetryTags.Profile, _profileId),
|
||||
new(ExportTelemetryTags.Tenant, _tenantId),
|
||||
new(ExportTelemetryTags.ExportType, _exportType),
|
||||
new(ExportTelemetryTags.Status, ExportStatuses.Cancelled)
|
||||
};
|
||||
ExportTelemetry.ExportRunDurationSeconds.Record(duration, durationTags);
|
||||
|
||||
_activity?.SetExportStatus(ExportStatuses.Cancelled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the telemetry context, ensuring metrics are recorded.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// Decrement in-progress counter
|
||||
ExportTelemetry.ExportRunsInProgress.Add(-1, new KeyValuePair<string, object?>(ExportTelemetryTags.Tenant, _tenantId));
|
||||
|
||||
// If not explicitly completed, mark as failed
|
||||
if (!_completed)
|
||||
{
|
||||
Fail(errorCode: "incomplete");
|
||||
}
|
||||
|
||||
_activity?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry instrumentation for ExportCenter service.
|
||||
/// Provides metrics, activity sources, and structured logging support.
|
||||
/// </summary>
|
||||
public static class ExportTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Service name used for telemetry identification.
|
||||
/// </summary>
|
||||
public const string ServiceName = "StellaOps.ExportCenter";
|
||||
|
||||
/// <summary>
|
||||
/// Service version.
|
||||
/// </summary>
|
||||
public const string ServiceVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Meter for export center metrics.
|
||||
/// </summary>
|
||||
public static readonly Meter Meter = new(ServiceName, ServiceVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for distributed tracing.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ServiceName, ServiceVersion);
|
||||
|
||||
#region Counters
|
||||
|
||||
/// <summary>
|
||||
/// Total number of export runs initiated.
|
||||
/// Tags: profile, tenant, type (evidence|attestation|mirror|risk)
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ExportRunsTotal = Meter.CreateCounter<long>(
|
||||
"export_runs_total",
|
||||
"runs",
|
||||
"Total number of export runs initiated");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of successful export runs.
|
||||
/// Tags: profile, tenant, type
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ExportRunsSuccessTotal = Meter.CreateCounter<long>(
|
||||
"export_runs_success_total",
|
||||
"runs",
|
||||
"Total number of successful export runs");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of failed export runs.
|
||||
/// Tags: profile, tenant, type, error_code
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ExportRunsFailedTotal = Meter.CreateCounter<long>(
|
||||
"export_runs_failed_total",
|
||||
"runs",
|
||||
"Total number of failed export runs");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of artifacts exported.
|
||||
/// Tags: profile, tenant, artifact_type (sbom|vex|attestation|policy|evidence)
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ExportArtifactsTotal = Meter.CreateCounter<long>(
|
||||
"export_artifacts_total",
|
||||
"artifacts",
|
||||
"Total number of artifacts exported");
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes exported.
|
||||
/// Tags: profile, tenant, type
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ExportBytesTotal = Meter.CreateCounter<long>(
|
||||
"export_bytes_total",
|
||||
"bytes",
|
||||
"Total bytes exported");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of timeline events published.
|
||||
/// Tags: event_type, tenant_id
|
||||
/// </summary>
|
||||
public static readonly Counter<long> TimelineEventsPublished = Meter.CreateCounter<long>(
|
||||
"export_timeline_events_published_total",
|
||||
"events",
|
||||
"Total number of timeline events published");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of timeline event publish failures.
|
||||
/// Tags: event_type, tenant_id, error_code
|
||||
/// </summary>
|
||||
public static readonly Counter<long> TimelineEventsFailedTotal = Meter.CreateCounter<long>(
|
||||
"export_timeline_events_failed_total",
|
||||
"events",
|
||||
"Total number of timeline event publish failures");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of deduplicated timeline events.
|
||||
/// Tags: event_type, tenant_id
|
||||
/// </summary>
|
||||
public static readonly Counter<long> TimelineEventsDeduplicated = Meter.CreateCounter<long>(
|
||||
"export_timeline_events_deduplicated_total",
|
||||
"events",
|
||||
"Total number of deduplicated timeline events");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of incidents activated.
|
||||
/// Tags: severity, type
|
||||
/// </summary>
|
||||
public static readonly Counter<long> IncidentsActivatedTotal = Meter.CreateCounter<long>(
|
||||
"export_incidents_activated_total",
|
||||
"incidents",
|
||||
"Total number of incidents activated");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of incidents resolved.
|
||||
/// Tags: severity, type, is_false_positive
|
||||
/// </summary>
|
||||
public static readonly Counter<long> IncidentsResolvedTotal = Meter.CreateCounter<long>(
|
||||
"export_incidents_resolved_total",
|
||||
"incidents",
|
||||
"Total number of incidents resolved");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of incidents escalated.
|
||||
/// Tags: from_severity, to_severity
|
||||
/// </summary>
|
||||
public static readonly Counter<long> IncidentsEscalatedTotal = Meter.CreateCounter<long>(
|
||||
"export_incidents_escalated_total",
|
||||
"incidents",
|
||||
"Total number of incidents escalated");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of incidents de-escalated.
|
||||
/// Tags: from_severity, to_severity
|
||||
/// </summary>
|
||||
public static readonly Counter<long> IncidentsDeescalatedTotal = Meter.CreateCounter<long>(
|
||||
"export_incidents_deescalated_total",
|
||||
"incidents",
|
||||
"Total number of incidents de-escalated");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of notifications emitted.
|
||||
/// Tags: type (incident_activated|incident_updated|incident_resolved), severity
|
||||
/// </summary>
|
||||
public static readonly Counter<long> NotificationsEmittedTotal = Meter.CreateCounter<long>(
|
||||
"export_notifications_emitted_total",
|
||||
"notifications",
|
||||
"Total number of notifications emitted");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of risk bundle jobs submitted.
|
||||
/// Tags: tenant_id
|
||||
/// </summary>
|
||||
public static readonly Counter<long> RiskBundleJobsSubmitted = Meter.CreateCounter<long>(
|
||||
"export_risk_bundle_jobs_submitted_total",
|
||||
"jobs",
|
||||
"Total number of risk bundle jobs submitted");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of risk bundle jobs completed.
|
||||
/// Tags: tenant_id, status (success|failed|cancelled)
|
||||
/// </summary>
|
||||
public static readonly Counter<long> RiskBundleJobsCompleted = Meter.CreateCounter<long>(
|
||||
"export_risk_bundle_jobs_completed_total",
|
||||
"jobs",
|
||||
"Total number of risk bundle jobs completed");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Histograms
|
||||
|
||||
/// <summary>
|
||||
/// Export run duration in seconds.
|
||||
/// Tags: profile, tenant, type, status (success|failed|cancelled)
|
||||
/// </summary>
|
||||
public static readonly Histogram<double> ExportRunDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"export_run_duration_seconds",
|
||||
"seconds",
|
||||
"Export run duration in seconds");
|
||||
|
||||
/// <summary>
|
||||
/// Export planning phase duration in seconds.
|
||||
/// Tags: profile, tenant
|
||||
/// </summary>
|
||||
public static readonly Histogram<double> ExportPlanDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"export_plan_duration_seconds",
|
||||
"seconds",
|
||||
"Export planning phase duration in seconds");
|
||||
|
||||
/// <summary>
|
||||
/// Export bundle size in bytes.
|
||||
/// Tags: profile, tenant, type
|
||||
/// </summary>
|
||||
public static readonly Histogram<long> ExportBundleSizeBytes = Meter.CreateHistogram<long>(
|
||||
"export_bundle_size_bytes",
|
||||
"bytes",
|
||||
"Export bundle size in bytes");
|
||||
|
||||
/// <summary>
|
||||
/// Incident duration in seconds.
|
||||
/// Tags: severity, type
|
||||
/// </summary>
|
||||
public static readonly Histogram<double> IncidentDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"export_incident_duration_seconds",
|
||||
"seconds",
|
||||
"Incident duration in seconds");
|
||||
|
||||
/// <summary>
|
||||
/// Risk bundle job duration in seconds.
|
||||
/// Tags: tenant_id
|
||||
/// </summary>
|
||||
public static readonly Histogram<double> RiskBundleJobDurationSeconds = Meter.CreateHistogram<double>(
|
||||
"export_risk_bundle_job_duration_seconds",
|
||||
"seconds",
|
||||
"Risk bundle job duration in seconds");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gauges
|
||||
|
||||
/// <summary>
|
||||
/// Number of export runs currently in progress.
|
||||
/// Tags: tenant
|
||||
/// </summary>
|
||||
public static readonly UpDownCounter<long> ExportRunsInProgress = Meter.CreateUpDownCounter<long>(
|
||||
"export_runs_in_progress",
|
||||
"runs",
|
||||
"Number of export runs currently in progress");
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tag names for export telemetry.
|
||||
/// </summary>
|
||||
public static class ExportTelemetryTags
|
||||
{
|
||||
public const string Profile = "profile";
|
||||
public const string ProfileId = "profile_id";
|
||||
public const string Tenant = "tenant";
|
||||
public const string TenantId = "tenant_id";
|
||||
public const string ExportType = "export_type";
|
||||
public const string ArtifactType = "artifact_type";
|
||||
public const string Status = "status";
|
||||
public const string ErrorCode = "error_code";
|
||||
public const string RunId = "run_id";
|
||||
public const string DistributionType = "distribution_type";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export type values.
|
||||
/// </summary>
|
||||
public static class ExportTypes
|
||||
{
|
||||
public const string Evidence = "evidence";
|
||||
public const string Attestation = "attestation";
|
||||
public const string Mirror = "mirror";
|
||||
public const string Risk = "risk";
|
||||
public const string DevPortal = "devportal";
|
||||
public const string OfflineKit = "offline_kit";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type values.
|
||||
/// </summary>
|
||||
public static class ArtifactTypes
|
||||
{
|
||||
public const string Sbom = "sbom";
|
||||
public const string Vex = "vex";
|
||||
public const string Attestation = "attestation";
|
||||
public const string Policy = "policy";
|
||||
public const string Evidence = "evidence";
|
||||
public const string Manifest = "manifest";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export status values.
|
||||
/// </summary>
|
||||
public static class ExportStatuses
|
||||
{
|
||||
public const string Success = "success";
|
||||
public const string Failed = "failed";
|
||||
public const string Cancelled = "cancelled";
|
||||
public const string Timeout = "timeout";
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring export center telemetry.
|
||||
/// </summary>
|
||||
public static class TelemetryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds export center metrics instrumentation to the OpenTelemetry meter provider.
|
||||
/// </summary>
|
||||
public static MeterProviderBuilder AddExportCenterInstrumentation(this MeterProviderBuilder builder)
|
||||
{
|
||||
return builder.AddMeter(ExportTelemetry.ServiceName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds export center tracing instrumentation to the OpenTelemetry tracer provider.
|
||||
/// </summary>
|
||||
public static TracerProviderBuilder AddExportCenterInstrumentation(this TracerProviderBuilder builder)
|
||||
{
|
||||
return builder.AddSource(ExportTelemetry.ServiceName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures export center telemetry for the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExportCenterTelemetry(this IServiceCollection services)
|
||||
{
|
||||
// Register telemetry context factory if needed
|
||||
services.AddSingleton<IExportTelemetryFactory, ExportTelemetryFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating export telemetry contexts.
|
||||
/// </summary>
|
||||
public interface IExportTelemetryFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new telemetry context for an export run.
|
||||
/// </summary>
|
||||
ExportRunTelemetryContext CreateRunContext(
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
string exportType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the export telemetry factory.
|
||||
/// </summary>
|
||||
public sealed class ExportTelemetryFactory : IExportTelemetryFactory
|
||||
{
|
||||
public ExportRunTelemetryContext CreateRunContext(
|
||||
string runId,
|
||||
string profileId,
|
||||
string tenantId,
|
||||
string exportType)
|
||||
{
|
||||
return new ExportRunTelemetryContext(runId, profileId, tenantId, exportType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event types for export lifecycle.
|
||||
/// </summary>
|
||||
public static class ExportTimelineEventTypes
|
||||
{
|
||||
public const string ExportStarted = "export.started";
|
||||
public const string ExportCompleted = "export.completed";
|
||||
public const string ExportFailed = "export.failed";
|
||||
public const string ExportCancelled = "export.cancelled";
|
||||
public const string ArtifactCreated = "export.artifact.created";
|
||||
public const string ManifestSigned = "export.manifest.signed";
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
/// <summary>
|
||||
/// Base timeline event for export lifecycle events.
|
||||
/// </summary>
|
||||
public abstract record ExportTimelineEventBase
|
||||
{
|
||||
[JsonPropertyName("run_id")]
|
||||
public required string RunId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant_id")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
[JsonPropertyName("profile_id")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("export_type")]
|
||||
public required string ExportType { get; init; }
|
||||
|
||||
[JsonPropertyName("correlation_id")]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
[JsonPropertyName("trace_id")]
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
[JsonPropertyName("occurred_at")]
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event type identifier for routing.
|
||||
/// </summary>
|
||||
public abstract string EventType { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event emitted when an export run begins.
|
||||
/// </summary>
|
||||
public sealed record ExportStartedEvent : ExportTimelineEventBase
|
||||
{
|
||||
public override string EventType => ExportTimelineEventTypes.ExportStarted;
|
||||
|
||||
[JsonPropertyName("requested_at")]
|
||||
public required DateTimeOffset RequestedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("requested_by")]
|
||||
public string? RequestedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ExportScopeInfo? Scope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event emitted when an export run completes successfully.
|
||||
/// </summary>
|
||||
public sealed record ExportCompletedEvent : ExportTimelineEventBase
|
||||
{
|
||||
public override string EventType => ExportTimelineEventTypes.ExportCompleted;
|
||||
|
||||
[JsonPropertyName("bundle_id")]
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("manifest_uri")]
|
||||
public string? ManifestUri { get; init; }
|
||||
|
||||
[JsonPropertyName("manifest_digest")]
|
||||
public string? ManifestDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("bundle_digest")]
|
||||
public string? BundleDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_count")]
|
||||
public int ArtifactCount { get; init; }
|
||||
|
||||
[JsonPropertyName("total_size_bytes")]
|
||||
public long? TotalSizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("duration_seconds")]
|
||||
public double DurationSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_refs")]
|
||||
public IReadOnlyList<ExportEvidenceRef>? EvidenceRefs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event emitted when an export run fails.
|
||||
/// </summary>
|
||||
public sealed record ExportFailedEvent : ExportTimelineEventBase
|
||||
{
|
||||
public override string EventType => ExportTimelineEventTypes.ExportFailed;
|
||||
|
||||
[JsonPropertyName("error_code")]
|
||||
public required string ErrorCode { get; init; }
|
||||
|
||||
[JsonPropertyName("error_message")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("failed_at_stage")]
|
||||
public string? FailedAtStage { get; init; }
|
||||
|
||||
[JsonPropertyName("duration_seconds")]
|
||||
public double DurationSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("is_retriable")]
|
||||
public bool IsRetriable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event emitted when an export run is cancelled.
|
||||
/// </summary>
|
||||
public sealed record ExportCancelledEvent : ExportTimelineEventBase
|
||||
{
|
||||
public override string EventType => ExportTimelineEventTypes.ExportCancelled;
|
||||
|
||||
[JsonPropertyName("cancelled_by")]
|
||||
public string? CancelledBy { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("duration_seconds")]
|
||||
public double DurationSeconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event emitted when an artifact is created during export.
|
||||
/// </summary>
|
||||
public sealed record ExportArtifactCreatedEvent : ExportTimelineEventBase
|
||||
{
|
||||
public override string EventType => ExportTimelineEventTypes.ArtifactCreated;
|
||||
|
||||
[JsonPropertyName("artifact_id")]
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_type")]
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_digest")]
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_size_bytes")]
|
||||
public long ArtifactSizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_uri")]
|
||||
public string? ArtifactUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope information for an export run.
|
||||
/// </summary>
|
||||
public sealed record ExportScopeInfo
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("policy_id")]
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
[JsonPropertyName("filters")]
|
||||
public IReadOnlyDictionary<string, string>? Filters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to evidence produced by an export.
|
||||
/// </summary>
|
||||
public sealed record ExportEvidenceRef
|
||||
{
|
||||
[JsonPropertyName("evidence_type")]
|
||||
public required string EvidenceType { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_uri")]
|
||||
public string? EvidenceUri { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_digest")]
|
||||
public string? EvidenceDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.Core.Notifications;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes export lifecycle events to the timeline service.
|
||||
/// Implements idempotency through hash-based deduplication and exponential backoff retry.
|
||||
/// </summary>
|
||||
public sealed class ExportTimelinePublisher : IExportTimelinePublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(100),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2)
|
||||
];
|
||||
|
||||
private readonly IExportNotificationSink _sink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ExportTimelinePublisher> _logger;
|
||||
private readonly ExportTimelinePublisherOptions _options;
|
||||
|
||||
// In-memory dedupe cache with sliding expiration
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _dedupeCache = new();
|
||||
private readonly object _cleanupLock = new();
|
||||
private DateTimeOffset _lastCleanup;
|
||||
|
||||
public ExportTimelinePublisher(
|
||||
IExportNotificationSink sink,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ExportTimelinePublisher> logger,
|
||||
IOptions<ExportTimelinePublisherOptions>? options = null)
|
||||
{
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? ExportTimelinePublisherOptions.Default;
|
||||
_lastCleanup = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public Task<TimelinePublishResult> PublishStartedAsync(
|
||||
ExportStartedEvent @event,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PublishEventAsync(@event, cancellationToken);
|
||||
|
||||
public Task<TimelinePublishResult> PublishCompletedAsync(
|
||||
ExportCompletedEvent @event,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PublishEventAsync(@event, cancellationToken);
|
||||
|
||||
public Task<TimelinePublishResult> PublishFailedAsync(
|
||||
ExportFailedEvent @event,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PublishEventAsync(@event, cancellationToken);
|
||||
|
||||
public Task<TimelinePublishResult> PublishCancelledAsync(
|
||||
ExportCancelledEvent @event,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PublishEventAsync(@event, cancellationToken);
|
||||
|
||||
public Task<TimelinePublishResult> PublishArtifactCreatedAsync(
|
||||
ExportArtifactCreatedEvent @event,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
PublishEventAsync(@event, cancellationToken);
|
||||
|
||||
public async Task<TimelinePublishResult> PublishEventAsync(
|
||||
ExportTimelineEventBase @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
PeriodicCleanup();
|
||||
|
||||
var eventId = GenerateEventId(@event);
|
||||
var idempotencyKey = ComputeIdempotencyKey(@event);
|
||||
|
||||
// Check dedupe cache
|
||||
if (_options.EnableDeduplication && IsDuplicate(idempotencyKey))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Deduplicated timeline event {EventType} for run {RunId}",
|
||||
@event.EventType, @event.RunId);
|
||||
return TimelinePublishResult.Deduplicated(eventId);
|
||||
}
|
||||
|
||||
var envelope = BuildEnvelope(@event, eventId);
|
||||
var channel = GetChannel(@event.EventType);
|
||||
var payload = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
|
||||
var result = await PublishWithRetryAsync(
|
||||
channel,
|
||||
payload,
|
||||
@event.RunId,
|
||||
@event.TenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Success && _options.EnableDeduplication)
|
||||
{
|
||||
RecordDelivery(idempotencyKey);
|
||||
}
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
ExportTelemetry.TimelineEventsPublished.Add(1,
|
||||
new KeyValuePair<string, object?>("event_type", @event.EventType),
|
||||
new KeyValuePair<string, object?>("tenant_id", @event.TenantId));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published timeline event {EventType} for run {RunId}",
|
||||
@event.EventType, @event.RunId);
|
||||
}
|
||||
|
||||
return result.Success
|
||||
? TimelinePublishResult.Succeeded(eventId, result.AttemptCount)
|
||||
: TimelinePublishResult.Failed(result.ErrorMessage ?? "Unknown error", result.AttemptCount);
|
||||
}
|
||||
|
||||
public async Task<TimelinePublishResult> PublishIncidentEventAsync(
|
||||
string eventType,
|
||||
string incidentId,
|
||||
string eventJson,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(incidentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventJson);
|
||||
|
||||
PeriodicCleanup();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var eventId = $"inc-{ComputeHash($"{incidentId}:{eventType}:{now:O}")[..16]}";
|
||||
var payloadHash = ComputeHash(eventJson);
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = eventId,
|
||||
TenantId = "system", // Incident events are system-level
|
||||
EventType = eventType,
|
||||
Source = "stellaops.export-center.incident",
|
||||
OccurredAt = now,
|
||||
CorrelationId = correlationId,
|
||||
Severity = GetIncidentSeverity(eventType),
|
||||
PayloadHash = payloadHash,
|
||||
RawPayloadJson = eventJson,
|
||||
Attributes = new Dictionary<string, string>
|
||||
{
|
||||
["incident_id"] = incidentId
|
||||
}
|
||||
};
|
||||
|
||||
var channel = GetChannel(eventType);
|
||||
var payload = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
|
||||
var result = await PublishWithRetryAsync(
|
||||
channel,
|
||||
payload,
|
||||
incidentId,
|
||||
"system",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
ExportTelemetry.TimelineEventsPublished.Add(1,
|
||||
new KeyValuePair<string, object?>("event_type", eventType),
|
||||
new KeyValuePair<string, object?>("tenant_id", "system"));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published incident timeline event {EventType} for incident {IncidentId}",
|
||||
eventType, incidentId);
|
||||
}
|
||||
else
|
||||
{
|
||||
ExportTelemetry.TimelineEventsFailedTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("event_type", eventType),
|
||||
new KeyValuePair<string, object?>("error_code", "publish_failed"));
|
||||
}
|
||||
|
||||
return result.Success
|
||||
? TimelinePublishResult.Succeeded(eventId, result.AttemptCount)
|
||||
: TimelinePublishResult.Failed(result.ErrorMessage ?? "Unknown error", result.AttemptCount);
|
||||
}
|
||||
|
||||
private static string GetIncidentSeverity(string eventType)
|
||||
{
|
||||
return eventType switch
|
||||
{
|
||||
"export.incident.activated" => "warning",
|
||||
"export.incident.escalated" => "error",
|
||||
"export.incident.deescalated" => "info",
|
||||
"export.incident.resolved" => "info",
|
||||
_ => "warning"
|
||||
};
|
||||
}
|
||||
|
||||
private TimelineEventEnvelope BuildEnvelope(ExportTimelineEventBase @event, string eventId)
|
||||
{
|
||||
var rawPayload = JsonSerializer.Serialize(@event, @event.GetType(), SerializerOptions);
|
||||
var payloadHash = ComputeHash(rawPayload);
|
||||
|
||||
return new TimelineEventEnvelope
|
||||
{
|
||||
EventId = eventId,
|
||||
TenantId = @event.TenantId,
|
||||
EventType = @event.EventType,
|
||||
Source = "stellaops.export-center",
|
||||
OccurredAt = @event.OccurredAt,
|
||||
CorrelationId = @event.CorrelationId,
|
||||
TraceId = @event.TraceId,
|
||||
Severity = GetSeverity(@event),
|
||||
PayloadHash = payloadHash,
|
||||
RawPayloadJson = rawPayload,
|
||||
Attributes = BuildAttributes(@event)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildAttributes(ExportTimelineEventBase @event)
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["run_id"] = @event.RunId,
|
||||
["export_type"] = @event.ExportType
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(@event.ProfileId))
|
||||
{
|
||||
attributes["profile_id"] = @event.ProfileId;
|
||||
}
|
||||
|
||||
// Add type-specific attributes
|
||||
switch (@event)
|
||||
{
|
||||
case ExportCompletedEvent completed:
|
||||
attributes["bundle_id"] = completed.BundleId;
|
||||
if (!string.IsNullOrWhiteSpace(completed.BundleDigest))
|
||||
{
|
||||
attributes["bundle_digest"] = completed.BundleDigest;
|
||||
}
|
||||
attributes["artifact_count"] = completed.ArtifactCount.ToString();
|
||||
break;
|
||||
|
||||
case ExportFailedEvent failed:
|
||||
attributes["error_code"] = failed.ErrorCode;
|
||||
attributes["is_retriable"] = failed.IsRetriable.ToString().ToLowerInvariant();
|
||||
break;
|
||||
|
||||
case ExportArtifactCreatedEvent artifact:
|
||||
attributes["artifact_id"] = artifact.ArtifactId;
|
||||
attributes["artifact_type"] = artifact.ArtifactType;
|
||||
attributes["artifact_digest"] = artifact.ArtifactDigest;
|
||||
break;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static string GetSeverity(ExportTimelineEventBase @event)
|
||||
{
|
||||
return @event switch
|
||||
{
|
||||
ExportFailedEvent => "error",
|
||||
ExportCancelledEvent => "warning",
|
||||
_ => "info"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetChannel(string eventType)
|
||||
{
|
||||
return $"{_options.ChannelPrefix}.{eventType}";
|
||||
}
|
||||
|
||||
private async Task<PublishAttemptResult> PublishWithRetryAsync(
|
||||
string channel,
|
||||
string payload,
|
||||
string runId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = 0;
|
||||
string? lastError = null;
|
||||
|
||||
while (attempt < _options.MaxRetries)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sink.PublishAsync(channel, payload, cancellationToken).ConfigureAwait(false);
|
||||
return new PublishAttemptResult(Success: true, AttemptCount: attempt + 1);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (IsTransient(ex) && attempt < _options.MaxRetries - 1)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
attempt++;
|
||||
|
||||
var delay = attempt <= RetryDelays.Length
|
||||
? RetryDelays[attempt - 1]
|
||||
: RetryDelays[^1];
|
||||
|
||||
_logger.LogWarning(ex,
|
||||
"Transient failure publishing timeline event for run {RunId}, attempt {Attempt}/{MaxRetries}",
|
||||
runId, attempt, _options.MaxRetries);
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Non-transient failure publishing timeline event for run {RunId}",
|
||||
runId);
|
||||
|
||||
return new PublishAttemptResult(
|
||||
Success: false,
|
||||
ErrorMessage: ex.Message,
|
||||
AttemptCount: attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return new PublishAttemptResult(
|
||||
Success: false,
|
||||
ErrorMessage: lastError ?? "Max retries exceeded",
|
||||
AttemptCount: attempt);
|
||||
}
|
||||
|
||||
private static string GenerateEventId(ExportTimelineEventBase @event)
|
||||
{
|
||||
// Event ID combines run ID, event type, and timestamp for uniqueness
|
||||
var components = $"{@event.RunId}:{@event.EventType}:{@event.OccurredAt:O}";
|
||||
var hash = ComputeHash(components);
|
||||
return $"exp-{hash[..16]}";
|
||||
}
|
||||
|
||||
private static string ComputeIdempotencyKey(ExportTimelineEventBase @event)
|
||||
{
|
||||
// Idempotency key based on run ID + event type + time window
|
||||
var timeWindow = @event.OccurredAt.ToUnixTimeSeconds() / 60; // 1-minute windows
|
||||
var components = $"{@event.RunId}:{@event.EventType}:{timeWindow}";
|
||||
return ComputeHash(components);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
private bool IsDuplicate(string idempotencyKey)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (_dedupeCache.TryGetValue(idempotencyKey, out var expiresAt))
|
||||
{
|
||||
return now < expiresAt;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void RecordDelivery(string idempotencyKey)
|
||||
{
|
||||
var expiresAt = _timeProvider.GetUtcNow().Add(_options.DedupeWindow);
|
||||
_dedupeCache[idempotencyKey] = expiresAt;
|
||||
}
|
||||
|
||||
private void PeriodicCleanup()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now - _lastCleanup < _options.CleanupInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_cleanupLock)
|
||||
{
|
||||
if (now - _lastCleanup < _options.CleanupInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var keysToRemove = _dedupeCache
|
||||
.Where(kvp => now >= kvp.Value)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_dedupeCache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
_lastCleanup = now;
|
||||
|
||||
if (keysToRemove.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} expired dedupe entries", keysToRemove.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTransient(Exception ex)
|
||||
{
|
||||
return ex is TimeoutException or
|
||||
TaskCanceledException or
|
||||
IOException;
|
||||
}
|
||||
|
||||
private sealed record PublishAttemptResult(
|
||||
bool Success,
|
||||
string? ErrorMessage = null,
|
||||
int AttemptCount = 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event envelope for publishing to timeline indexer.
|
||||
/// Matches TimelineIndexer.Core.Models.TimelineEventEnvelope structure.
|
||||
/// </summary>
|
||||
public sealed class TimelineEventEnvelope
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string EventType { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string Severity { get; init; } = "info";
|
||||
public string? PayloadHash { get; set; }
|
||||
public string RawPayloadJson { get; init; } = "{}";
|
||||
public string? NormalizedPayloadJson { get; init; }
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? BundleDigest { get; init; }
|
||||
public Guid? BundleId { get; init; }
|
||||
public string? AttestationSubject { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
public string? ManifestUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for the export timeline publisher.
|
||||
/// </summary>
|
||||
public sealed record ExportTimelinePublisherOptions
|
||||
{
|
||||
public int MaxRetries { get; init; } = 5;
|
||||
public bool EnableDeduplication { get; init; } = true;
|
||||
public TimeSpan DedupeWindow { get; init; } = TimeSpan.FromMinutes(5);
|
||||
public TimeSpan CleanupInterval { get; init; } = TimeSpan.FromMinutes(1);
|
||||
public string ChannelPrefix { get; init; } = "timeline.export";
|
||||
|
||||
public static ExportTimelinePublisherOptions Default => new();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for publishing export lifecycle events to the timeline.
|
||||
/// </summary>
|
||||
public interface IExportTimelinePublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an export started event.
|
||||
/// </summary>
|
||||
Task<TimelinePublishResult> PublishStartedAsync(
|
||||
ExportStartedEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an export completed event.
|
||||
/// </summary>
|
||||
Task<TimelinePublishResult> PublishCompletedAsync(
|
||||
ExportCompletedEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an export failed event.
|
||||
/// </summary>
|
||||
Task<TimelinePublishResult> PublishFailedAsync(
|
||||
ExportFailedEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an export cancelled event.
|
||||
/// </summary>
|
||||
Task<TimelinePublishResult> PublishCancelledAsync(
|
||||
ExportCancelledEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an artifact created event.
|
||||
/// </summary>
|
||||
Task<TimelinePublishResult> PublishArtifactCreatedAsync(
|
||||
ExportArtifactCreatedEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a generic timeline event.
|
||||
/// </summary>
|
||||
Task<TimelinePublishResult> PublishEventAsync(
|
||||
ExportTimelineEventBase @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an incident event to the timeline.
|
||||
/// </summary>
|
||||
/// <param name="eventType">The incident event type.</param>
|
||||
/// <param name="incidentId">The incident identifier.</param>
|
||||
/// <param name="eventJson">The serialized event JSON.</param>
|
||||
/// <param name="correlationId">Optional correlation ID for tracing.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The publish result.</returns>
|
||||
Task<TimelinePublishResult> PublishIncidentEventAsync(
|
||||
string eventType,
|
||||
string incidentId,
|
||||
string eventJson,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a timeline publish operation.
|
||||
/// </summary>
|
||||
public sealed record TimelinePublishResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? EventId { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int AttemptCount { get; init; } = 1;
|
||||
public bool WasDeduplicated { get; init; }
|
||||
|
||||
public static TimelinePublishResult Succeeded(string eventId, int attempts = 1) =>
|
||||
new() { Success = true, EventId = eventId, AttemptCount = attempts };
|
||||
|
||||
public static TimelinePublishResult Failed(string errorMessage, int attempts = 1) =>
|
||||
new() { Success = false, ErrorMessage = errorMessage, AttemptCount = attempts };
|
||||
|
||||
public static TimelinePublishResult Deduplicated(string eventId) =>
|
||||
new() { Success = true, EventId = eventId, WasDeduplicated = true };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.ExportCenter.Core.Notifications;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering export timeline services.
|
||||
/// </summary>
|
||||
public static class TimelineServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds export timeline publisher services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional configuration for the timeline publisher.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportTimelinePublisher(
|
||||
this IServiceCollection services,
|
||||
Action<ExportTimelinePublisherOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Configure options
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register notification sink if not already registered (use in-memory for development)
|
||||
services.TryAddSingleton<IExportNotificationSink, InMemoryExportNotificationSink>();
|
||||
|
||||
// Register timeline publisher
|
||||
services.TryAddSingleton<IExportTimelinePublisher, ExportTimelinePublisher>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds export timeline publisher with a custom notification sink.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSink">The notification sink implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional configuration for the timeline publisher.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExportTimelinePublisher<TSink>(
|
||||
this IServiceCollection services,
|
||||
Action<ExportTimelinePublisherOptions>? configureOptions = null)
|
||||
where TSink : class, IExportNotificationSink
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Configure options
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register the specified sink type
|
||||
services.TryAddSingleton<IExportNotificationSink, TSink>();
|
||||
|
||||
// Register timeline publisher
|
||||
services.TryAddSingleton<IExportTimelinePublisher, ExportTimelinePublisher>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user