Files
git.stella-ops.org/src/Concelier/StellaOps.Concelier.WebService/Extensions/FeedSnapshotEndpointExtensions.cs
2026-01-08 20:46:43 +02:00

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);