audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExportEndpoints.cs
|
||||
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
|
||||
// Tasks: T020, T021
|
||||
// Description: Minimal API endpoints for evidence bundle export.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoints for evidence bundle export.
|
||||
/// </summary>
|
||||
public static class ExportEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps export endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapExportEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/bundles")
|
||||
.WithTags("Export");
|
||||
|
||||
// POST /api/v1/bundles/{bundleId}/export
|
||||
group.MapPost("/{bundleId}/export", TriggerExportAsync)
|
||||
.WithName("TriggerExport")
|
||||
.WithSummary("Trigger an async evidence bundle export")
|
||||
.Produces<ExportTriggerResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
|
||||
// GET /api/v1/bundles/{bundleId}/export/{exportId}
|
||||
group.MapGet("/{bundleId}/export/{exportId}", GetExportStatusAsync)
|
||||
.WithName("GetExportStatus")
|
||||
.WithSummary("Get export status or download exported bundle")
|
||||
.Produces<ExportStatusResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization();
|
||||
|
||||
// GET /api/v1/bundles/{bundleId}/export/{exportId}/download
|
||||
group.MapGet("/{bundleId}/export/{exportId}/download", DownloadExportAsync)
|
||||
.WithName("DownloadExport")
|
||||
.WithSummary("Download the exported bundle")
|
||||
.Produces(StatusCodes.Status200OK, contentType: "application/gzip")
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict)
|
||||
.RequireAuthorization();
|
||||
}
|
||||
|
||||
private static async Task<IResult> TriggerExportAsync(
|
||||
string bundleId,
|
||||
[FromBody] ExportTriggerRequest request,
|
||||
[FromServices] IExportJobService exportJobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await exportJobService.CreateExportJobAsync(bundleId, request, cancellationToken);
|
||||
|
||||
if (result.IsNotFound)
|
||||
{
|
||||
return Results.NotFound(new { message = $"Bundle '{bundleId}' not found" });
|
||||
}
|
||||
|
||||
return Results.Accepted(
|
||||
$"/api/v1/bundles/{bundleId}/export/{result.ExportId}",
|
||||
new ExportTriggerResponse
|
||||
{
|
||||
ExportId = result.ExportId,
|
||||
Status = result.Status,
|
||||
EstimatedSize = result.EstimatedSize,
|
||||
StatusUrl = $"/api/v1/bundles/{bundleId}/export/{result.ExportId}"
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetExportStatusAsync(
|
||||
string bundleId,
|
||||
string exportId,
|
||||
[FromServices] IExportJobService exportJobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await exportJobService.GetExportStatusAsync(bundleId, exportId, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return Results.NotFound(new { message = $"Export '{exportId}' not found" });
|
||||
}
|
||||
|
||||
if (result.Status == ExportJobStatusEnum.Ready)
|
||||
{
|
||||
return Results.Ok(new ExportStatusResponse
|
||||
{
|
||||
ExportId = result.ExportId,
|
||||
Status = result.Status.ToString().ToLowerInvariant(),
|
||||
DownloadUrl = $"/api/v1/bundles/{bundleId}/export/{exportId}/download",
|
||||
FileSize = result.FileSize,
|
||||
CompletedAt = result.CompletedAt
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Accepted(value: new ExportStatusResponse
|
||||
{
|
||||
ExportId = result.ExportId,
|
||||
Status = result.Status.ToString().ToLowerInvariant(),
|
||||
Progress = result.Progress,
|
||||
EstimatedTimeRemaining = result.EstimatedTimeRemaining
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadExportAsync(
|
||||
string bundleId,
|
||||
string exportId,
|
||||
[FromServices] IExportJobService exportJobService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await exportJobService.GetExportFileAsync(bundleId, exportId, cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return Results.NotFound(new { message = $"Export '{exportId}' not found" });
|
||||
}
|
||||
|
||||
if (result.Status != ExportJobStatusEnum.Ready)
|
||||
{
|
||||
return Results.Conflict(new { message = "Export is not ready for download", status = result.Status.ToString().ToLowerInvariant() });
|
||||
}
|
||||
|
||||
return Results.File(
|
||||
result.FileStream!,
|
||||
contentType: "application/gzip",
|
||||
fileDownloadName: result.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to trigger an evidence bundle export.
|
||||
/// </summary>
|
||||
public sealed record ExportTriggerRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Export format (default: tar.gz).
|
||||
/// </summary>
|
||||
public string Format { get; init; } = "tar.gz";
|
||||
|
||||
/// <summary>
|
||||
/// Compression type (gzip, brotli).
|
||||
/// </summary>
|
||||
public string Compression { get; init; } = "gzip";
|
||||
|
||||
/// <summary>
|
||||
/// Compression level (1-9).
|
||||
/// </summary>
|
||||
public int CompressionLevel { get; init; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include Rekor transparency proofs.
|
||||
/// </summary>
|
||||
public bool IncludeRekorProofs { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include per-layer SBOMs.
|
||||
/// </summary>
|
||||
public bool IncludeLayerSboms { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response after triggering an export.
|
||||
/// </summary>
|
||||
public sealed record ExportTriggerResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique export job ID.
|
||||
/// </summary>
|
||||
public required string ExportId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current export status.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated final bundle size in bytes.
|
||||
/// </summary>
|
||||
public long? EstimatedSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to poll for status updates.
|
||||
/// </summary>
|
||||
public required string StatusUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for export status queries.
|
||||
/// </summary>
|
||||
public sealed record ExportStatusResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Export job ID.
|
||||
/// </summary>
|
||||
public required string ExportId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status: pending, processing, ready, failed.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Progress percentage (0-100) when processing.
|
||||
/// </summary>
|
||||
public int? Progress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated time remaining when processing.
|
||||
/// </summary>
|
||||
public string? EstimatedTimeRemaining { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL when ready.
|
||||
/// </summary>
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final file size in bytes when ready.
|
||||
/// </summary>
|
||||
public long? FileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completion timestamp when ready.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user