445 lines
15 KiB
C#
445 lines
15 KiB
C#
// -----------------------------------------------------------------------------
|
|
// FeedSnapshotEndpointExtensions.cs
|
|
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
|
// Task: DET-GAP-03
|
|
// Description: Feed snapshot endpoint for atomic multi-source snapshots
|
|
// -----------------------------------------------------------------------------
|
|
|
|
#pragma warning disable ASPDEPR002 // WithOpenApi is deprecated - will migrate to new OpenAPI approach
|
|
|
|
using System.Net.Mime;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Concelier.WebService.Options;
|
|
using StellaOps.Concelier.WebService.Results;
|
|
using StellaOps.Replay.Core.FeedSnapshot;
|
|
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
|
|
|
namespace StellaOps.Concelier.WebService.Extensions;
|
|
|
|
/// <summary>
|
|
/// Endpoint extensions for feed snapshot functionality.
|
|
/// Provides atomic multi-source snapshots with composite digest.
|
|
/// Per DET-GAP-03 in SPRINT_20251226_007_BE_determinism_gaps.
|
|
/// </summary>
|
|
internal static class FeedSnapshotEndpointExtensions
|
|
{
|
|
/// <summary>
|
|
/// Maps feed snapshot endpoints to the application.
|
|
/// </summary>
|
|
public static void MapFeedSnapshotEndpoints(this WebApplication app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/feeds/snapshot")
|
|
.WithTags("FeedSnapshot")
|
|
.WithOpenApi();
|
|
|
|
// POST /api/v1/feeds/snapshot - Create atomic snapshot
|
|
group.MapPost("/", CreateSnapshotAsync)
|
|
.WithName("CreateFeedSnapshot")
|
|
.WithSummary("Create an atomic feed snapshot")
|
|
.WithDescription("Creates an atomic snapshot of all registered feed sources with a composite digest.");
|
|
|
|
// GET /api/v1/feeds/snapshot - List available snapshots
|
|
group.MapGet("/", ListSnapshotsAsync)
|
|
.WithName("ListFeedSnapshots")
|
|
.WithSummary("List available feed snapshots")
|
|
.WithDescription("Returns a list of available feed snapshots with metadata.");
|
|
|
|
// GET /api/v1/feeds/snapshot/{snapshotId} - Get snapshot details
|
|
group.MapGet("/{snapshotId}", GetSnapshotAsync)
|
|
.WithName("GetFeedSnapshot")
|
|
.WithSummary("Get feed snapshot details")
|
|
.WithDescription("Returns detailed information about a specific feed snapshot.");
|
|
|
|
// GET /api/v1/feeds/snapshot/{snapshotId}/export - Export snapshot bundle
|
|
group.MapGet("/{snapshotId}/export", ExportSnapshotAsync)
|
|
.WithName("ExportFeedSnapshot")
|
|
.WithSummary("Export feed snapshot bundle")
|
|
.WithDescription("Downloads the snapshot bundle as a compressed archive for offline use.");
|
|
|
|
// POST /api/v1/feeds/snapshot/import - Import snapshot bundle
|
|
group.MapPost("/import", ImportSnapshotAsync)
|
|
.WithName("ImportFeedSnapshot")
|
|
.WithSummary("Import feed snapshot bundle")
|
|
.WithDescription("Imports a snapshot bundle from a compressed archive.");
|
|
|
|
// GET /api/v1/feeds/snapshot/{snapshotId}/validate - Validate snapshot
|
|
group.MapGet("/{snapshotId}/validate", ValidateSnapshotAsync)
|
|
.WithName("ValidateFeedSnapshot")
|
|
.WithSummary("Validate feed snapshot integrity")
|
|
.WithDescription("Validates the integrity of a feed snapshot against current feed state.");
|
|
|
|
// GET /api/v1/feeds/sources - List registered feed sources
|
|
group.MapGet("/sources", ListSourcesAsync)
|
|
.WithName("ListFeedSources")
|
|
.WithSummary("List registered feed sources")
|
|
.WithDescription("Returns a list of registered feed sources available for snapshots.");
|
|
}
|
|
|
|
private static async Task<IResult> CreateSnapshotAsync(
|
|
HttpContext context,
|
|
[FromServices] IFeedSnapshotCoordinator coordinator,
|
|
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
|
[FromBody] CreateSnapshotRequest? request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var options = optionsMonitor.CurrentValue;
|
|
|
|
// Check if feed snapshot feature is enabled
|
|
if (!options.FeedSnapshot.Enabled)
|
|
{
|
|
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
|
|
}
|
|
|
|
// Validate requested sources if provided
|
|
if (request?.Sources is { Length: > 0 })
|
|
{
|
|
var registeredSources = coordinator.RegisteredSources;
|
|
var invalidSources = request.Sources
|
|
.Where(s => !registeredSources.Contains(s, StringComparer.OrdinalIgnoreCase))
|
|
.ToArray();
|
|
|
|
if (invalidSources.Length > 0)
|
|
{
|
|
return ConcelierProblemResultFactory.InvalidSources(
|
|
context,
|
|
invalidSources,
|
|
registeredSources);
|
|
}
|
|
}
|
|
|
|
var bundle = await coordinator.CreateSnapshotAsync(
|
|
request?.Label,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new CreateSnapshotResponse(
|
|
bundle.SnapshotId,
|
|
bundle.CompositeDigest,
|
|
bundle.CreatedAt,
|
|
bundle.Sources.Select(s => new SourceSnapshotSummary(
|
|
s.SourceId,
|
|
s.Digest,
|
|
s.ItemCount)).ToArray());
|
|
|
|
return HttpResults.Created(
|
|
$"/api/v1/feeds/snapshot/{bundle.SnapshotId}",
|
|
response);
|
|
}
|
|
|
|
private static async Task<IResult> ListSnapshotsAsync(
|
|
HttpContext context,
|
|
[FromServices] IFeedSnapshotCoordinator coordinator,
|
|
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
|
[FromQuery] int? limit,
|
|
[FromQuery] string? cursor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var options = optionsMonitor.CurrentValue;
|
|
|
|
if (!options.FeedSnapshot.Enabled)
|
|
{
|
|
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
|
|
}
|
|
|
|
var effectiveLimit = Math.Min(
|
|
Math.Max(limit ?? 50, 1),
|
|
options.FeedSnapshot.MaxListPageSize);
|
|
|
|
var summaries = await coordinator.ListSnapshotsAsync(
|
|
cursor,
|
|
effectiveLimit,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new ListSnapshotsResponse(
|
|
summaries.Select(s => new SnapshotListItem(
|
|
s.SnapshotId,
|
|
s.CompositeDigest,
|
|
s.CreatedAt,
|
|
s.Label,
|
|
s.SourceCount,
|
|
s.TotalItemCount)).ToArray());
|
|
|
|
return HttpResults.Ok(response);
|
|
}
|
|
|
|
private static async Task<IResult> GetSnapshotAsync(
|
|
HttpContext context,
|
|
[FromServices] IFeedSnapshotCoordinator coordinator,
|
|
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
|
string snapshotId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var options = optionsMonitor.CurrentValue;
|
|
|
|
if (!options.FeedSnapshot.Enabled)
|
|
{
|
|
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
|
|
}
|
|
|
|
var bundle = await coordinator.GetSnapshotAsync(snapshotId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (bundle is null)
|
|
{
|
|
return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId);
|
|
}
|
|
|
|
var response = new GetSnapshotResponse(
|
|
bundle.SnapshotId,
|
|
bundle.CompositeDigest,
|
|
bundle.CreatedAt,
|
|
bundle.Label,
|
|
bundle.Sources.Select(s => new SourceSnapshotDetail(
|
|
s.SourceId,
|
|
s.Digest,
|
|
s.ItemCount,
|
|
s.CreatedAt)).ToArray());
|
|
|
|
return HttpResults.Ok(response);
|
|
}
|
|
|
|
private static async Task<IResult> ExportSnapshotAsync(
|
|
HttpContext context,
|
|
[FromServices] IFeedSnapshotCoordinator coordinator,
|
|
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
|
string snapshotId,
|
|
[FromQuery] string? format,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var options = optionsMonitor.CurrentValue;
|
|
|
|
if (!options.FeedSnapshot.Enabled)
|
|
{
|
|
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
|
|
}
|
|
|
|
var exportOptions = new ExportBundleOptions
|
|
{
|
|
Compression = ParseCompression(format),
|
|
IncludeManifest = true,
|
|
IncludeChecksums = true
|
|
};
|
|
|
|
var metadata = await coordinator.ExportBundleAsync(
|
|
snapshotId,
|
|
exportOptions,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (metadata is null || metadata.ExportPath is null)
|
|
{
|
|
return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId);
|
|
}
|
|
|
|
context.Response.Headers.ContentDisposition =
|
|
$"attachment; filename=\"snapshot-{snapshotId}.tar.{GetExtension(exportOptions.Compression)}\"";
|
|
|
|
return HttpResults.File(
|
|
metadata.ExportPath,
|
|
MediaTypeNames.Application.Octet,
|
|
$"snapshot-{snapshotId}.tar.{GetExtension(exportOptions.Compression)}");
|
|
}
|
|
|
|
private static async Task<IResult> ImportSnapshotAsync(
|
|
HttpContext context,
|
|
[FromServices] IFeedSnapshotCoordinator coordinator,
|
|
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
|
IFormFile file,
|
|
[FromQuery] bool? validate,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var options = optionsMonitor.CurrentValue;
|
|
|
|
if (!options.FeedSnapshot.Enabled)
|
|
{
|
|
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
|
|
}
|
|
|
|
if (file.Length == 0)
|
|
{
|
|
return ConcelierProblemResultFactory.EmptyFile(context);
|
|
}
|
|
|
|
if (file.Length > options.FeedSnapshot.MaxBundleSizeBytes)
|
|
{
|
|
return ConcelierProblemResultFactory.FileTooLarge(
|
|
context,
|
|
file.Length,
|
|
options.FeedSnapshot.MaxBundleSizeBytes);
|
|
}
|
|
|
|
await using var stream = file.OpenReadStream();
|
|
|
|
var importOptions = new ImportBundleOptions
|
|
{
|
|
ValidateDigests = validate ?? true
|
|
};
|
|
|
|
var bundle = await coordinator.ImportBundleAsync(
|
|
stream,
|
|
importOptions,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new ImportSnapshotResponse(
|
|
bundle.SnapshotId,
|
|
bundle.CompositeDigest,
|
|
bundle.CreatedAt,
|
|
bundle.Sources.Count);
|
|
|
|
return HttpResults.Created(
|
|
$"/api/v1/feeds/snapshot/{bundle.SnapshotId}",
|
|
response);
|
|
}
|
|
|
|
private static async Task<IResult> ValidateSnapshotAsync(
|
|
HttpContext context,
|
|
[FromServices] IFeedSnapshotCoordinator coordinator,
|
|
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
|
string snapshotId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var options = optionsMonitor.CurrentValue;
|
|
|
|
if (!options.FeedSnapshot.Enabled)
|
|
{
|
|
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
|
|
}
|
|
|
|
var result = await coordinator.ValidateSnapshotAsync(snapshotId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (result is null)
|
|
{
|
|
return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId);
|
|
}
|
|
|
|
var response = new ValidateSnapshotResponse(
|
|
result.IsValid,
|
|
result.SnapshotDigest,
|
|
result.CurrentDigest,
|
|
result.DriftedSources.Select(d => new DriftedSourceInfo(
|
|
d.SourceId,
|
|
d.SnapshotDigest,
|
|
d.CurrentDigest,
|
|
d.AddedItems,
|
|
d.RemovedItems,
|
|
d.ModifiedItems)).ToArray());
|
|
|
|
return HttpResults.Ok(response);
|
|
}
|
|
|
|
private static IResult ListSourcesAsync(
|
|
HttpContext context,
|
|
[FromServices] IFeedSnapshotCoordinator coordinator,
|
|
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor)
|
|
{
|
|
var options = optionsMonitor.CurrentValue;
|
|
|
|
if (!options.FeedSnapshot.Enabled)
|
|
{
|
|
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
|
|
}
|
|
|
|
var sources = coordinator.RegisteredSources;
|
|
return HttpResults.Ok(new ListSourcesResponse(sources.ToArray()));
|
|
}
|
|
|
|
private static CompressionAlgorithm ParseCompression(string? format)
|
|
{
|
|
return format?.ToUpperInvariant() switch
|
|
{
|
|
"ZSTD" => CompressionAlgorithm.Zstd,
|
|
"GZIP" or "GZ" => CompressionAlgorithm.Gzip,
|
|
"NONE" => CompressionAlgorithm.None,
|
|
_ => CompressionAlgorithm.Zstd // Default to Zstd
|
|
};
|
|
}
|
|
|
|
private static string GetExtension(CompressionAlgorithm compression)
|
|
{
|
|
return compression switch
|
|
{
|
|
CompressionAlgorithm.Zstd => "zst",
|
|
CompressionAlgorithm.Gzip => "gz",
|
|
_ => "tar"
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---- Request/Response DTOs ----
|
|
|
|
/// <summary>Request to create a feed snapshot.</summary>
|
|
/// <param name="Label">Optional human-readable label for the snapshot.</param>
|
|
/// <param name="Sources">Optional list of source IDs to include. If null, all registered sources are included.</param>
|
|
public sealed record CreateSnapshotRequest(
|
|
string? Label,
|
|
string[]? Sources);
|
|
|
|
/// <summary>Response after creating a feed snapshot.</summary>
|
|
public sealed record CreateSnapshotResponse(
|
|
string SnapshotId,
|
|
string CompositeDigest,
|
|
DateTimeOffset CreatedAt,
|
|
SourceSnapshotSummary[] Sources);
|
|
|
|
/// <summary>Summary of a source in a snapshot.</summary>
|
|
public sealed record SourceSnapshotSummary(
|
|
string SourceId,
|
|
string Digest,
|
|
int ItemCount);
|
|
|
|
/// <summary>Response listing available snapshots.</summary>
|
|
public sealed record ListSnapshotsResponse(
|
|
SnapshotListItem[] Snapshots);
|
|
|
|
/// <summary>Item in the snapshot list.</summary>
|
|
public sealed record SnapshotListItem(
|
|
string SnapshotId,
|
|
string CompositeDigest,
|
|
DateTimeOffset CreatedAt,
|
|
string? Label,
|
|
int SourceCount,
|
|
int TotalItemCount);
|
|
|
|
/// <summary>Response with snapshot details.</summary>
|
|
public sealed record GetSnapshotResponse(
|
|
string SnapshotId,
|
|
string CompositeDigest,
|
|
DateTimeOffset CreatedAt,
|
|
string? Label,
|
|
SourceSnapshotDetail[] Sources);
|
|
|
|
/// <summary>Detailed info about a source in a snapshot.</summary>
|
|
public sealed record SourceSnapshotDetail(
|
|
string SourceId,
|
|
string Digest,
|
|
int ItemCount,
|
|
DateTimeOffset CreatedAt);
|
|
|
|
/// <summary>Response after importing a snapshot.</summary>
|
|
public sealed record ImportSnapshotResponse(
|
|
string SnapshotId,
|
|
string CompositeDigest,
|
|
DateTimeOffset CreatedAt,
|
|
int SourceCount);
|
|
|
|
/// <summary>Response from snapshot validation.</summary>
|
|
public sealed record ValidateSnapshotResponse(
|
|
bool IsValid,
|
|
string SnapshotDigest,
|
|
string CurrentDigest,
|
|
DriftedSourceInfo[] DriftedSources);
|
|
|
|
/// <summary>Info about a drifted source.</summary>
|
|
public sealed record DriftedSourceInfo(
|
|
string SourceId,
|
|
string SnapshotDigest,
|
|
string CurrentDigest,
|
|
int AddedItems,
|
|
int RemovedItems,
|
|
int ModifiedItems);
|
|
|
|
/// <summary>Response listing registered sources.</summary>
|
|
public sealed record ListSourcesResponse(
|
|
string[] Sources);
|