save dev progress
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Federation.Export;
|
||||
using StellaOps.Concelier.Federation.Import;
|
||||
using StellaOps.Concelier.Federation.Models;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
@@ -128,5 +129,332 @@ internal static class FederationEndpointExtensions
|
||||
.WithName("GetFederationStatus")
|
||||
.WithSummary("Get federation configuration status")
|
||||
.Produces<object>(200);
|
||||
|
||||
// POST /api/v1/federation/import - Import a bundle
|
||||
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 25-26.
|
||||
group.MapPost("/import", async (
|
||||
HttpContext context,
|
||||
IBundleImportService importService,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery(Name = "dry_run")] bool dryRun = false,
|
||||
[FromQuery(Name = "skip_signature")] bool skipSignature = false,
|
||||
[FromQuery(Name = "on_conflict")] string? onConflict = null,
|
||||
[FromQuery] bool force = false) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
var contentType = context.Request.ContentType;
|
||||
if (string.IsNullOrEmpty(contentType) ||
|
||||
(!contentType.Contains("application/zstd") &&
|
||||
!contentType.Contains("application/octet-stream")))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "Content-Type must be application/zstd or application/octet-stream" });
|
||||
}
|
||||
|
||||
// Parse conflict resolution
|
||||
var conflictResolution = ConflictResolution.PreferRemote;
|
||||
if (!string.IsNullOrEmpty(onConflict))
|
||||
{
|
||||
if (!Enum.TryParse<ConflictResolution>(onConflict, ignoreCase: true, out conflictResolution))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "on_conflict must be one of: PreferRemote, PreferLocal, Fail" });
|
||||
}
|
||||
}
|
||||
|
||||
var importOptions = new BundleImportOptions
|
||||
{
|
||||
DryRun = dryRun,
|
||||
SkipSignatureVerification = skipSignature,
|
||||
OnConflict = conflictResolution,
|
||||
Force = force
|
||||
};
|
||||
|
||||
// Stream request body directly to import service
|
||||
var result = await importService.ImportAsync(
|
||||
context.Request.Body,
|
||||
importOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return HttpResults.UnprocessableEntity(new
|
||||
{
|
||||
success = false,
|
||||
bundle_hash = result.BundleHash,
|
||||
failure_reason = result.FailureReason,
|
||||
duration_ms = result.Duration.TotalMilliseconds
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
success = true,
|
||||
bundle_hash = result.BundleHash,
|
||||
imported_cursor = result.ImportedCursor,
|
||||
counts = new
|
||||
{
|
||||
canonical_created = result.Counts.CanonicalCreated,
|
||||
canonical_updated = result.Counts.CanonicalUpdated,
|
||||
canonical_skipped = result.Counts.CanonicalSkipped,
|
||||
edges_added = result.Counts.EdgesAdded,
|
||||
deletions_processed = result.Counts.DeletionsProcessed,
|
||||
total = result.Counts.Total
|
||||
},
|
||||
conflicts = result.Conflicts.Select(c => new
|
||||
{
|
||||
merge_hash = c.MergeHash,
|
||||
field = c.Field,
|
||||
local_value = c.LocalValue,
|
||||
remote_value = c.RemoteValue,
|
||||
resolution = c.Resolution.ToString().ToLowerInvariant()
|
||||
}),
|
||||
duration_ms = result.Duration.TotalMilliseconds,
|
||||
dry_run = dryRun
|
||||
});
|
||||
})
|
||||
.WithName("ImportFederationBundle")
|
||||
.WithSummary("Import a federation bundle")
|
||||
.Accepts<Stream>("application/zstd")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(400)
|
||||
.ProducesProblem(422)
|
||||
.ProducesProblem(503)
|
||||
.DisableAntiforgery();
|
||||
|
||||
// POST /api/v1/federation/import/validate - Validate bundle without importing
|
||||
group.MapPost("/import/validate", async (
|
||||
HttpContext context,
|
||||
IBundleImportService importService,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
var result = await importService.ValidateAsync(
|
||||
context.Request.Body,
|
||||
cancellationToken);
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
is_valid = result.IsValid,
|
||||
errors = result.Errors,
|
||||
warnings = result.Warnings,
|
||||
hash_valid = result.HashValid,
|
||||
signature_valid = result.SignatureValid,
|
||||
cursor_valid = result.CursorValid
|
||||
});
|
||||
})
|
||||
.WithName("ValidateFederationBundle")
|
||||
.WithSummary("Validate a bundle without importing")
|
||||
.Accepts<Stream>("application/zstd")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503)
|
||||
.DisableAntiforgery();
|
||||
|
||||
// POST /api/v1/federation/import/preview - Preview import
|
||||
group.MapPost("/import/preview", async (
|
||||
HttpContext context,
|
||||
IBundleImportService importService,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
var preview = await importService.PreviewAsync(
|
||||
context.Request.Body,
|
||||
cancellationToken);
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
is_valid = preview.IsValid,
|
||||
is_duplicate = preview.IsDuplicate,
|
||||
current_cursor = preview.CurrentCursor,
|
||||
manifest = new
|
||||
{
|
||||
version = preview.Manifest.Version,
|
||||
site_id = preview.Manifest.SiteId,
|
||||
export_cursor = preview.Manifest.ExportCursor,
|
||||
bundle_hash = preview.Manifest.BundleHash,
|
||||
exported_at = preview.Manifest.ExportedAt,
|
||||
counts = new
|
||||
{
|
||||
canonicals = preview.Manifest.Counts?.Canonicals ?? 0,
|
||||
edges = preview.Manifest.Counts?.Edges ?? 0,
|
||||
deletions = preview.Manifest.Counts?.Deletions ?? 0,
|
||||
total = preview.Manifest.Counts?.Total ?? 0
|
||||
}
|
||||
},
|
||||
errors = preview.Errors,
|
||||
warnings = preview.Warnings
|
||||
});
|
||||
})
|
||||
.WithName("PreviewFederationImport")
|
||||
.WithSummary("Preview what import would do")
|
||||
.Accepts<Stream>("application/zstd")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503)
|
||||
.DisableAntiforgery();
|
||||
|
||||
// GET /api/v1/federation/sites - List all federation sites
|
||||
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 30.
|
||||
group.MapGet("/sites", async (
|
||||
HttpContext context,
|
||||
ISyncLedgerRepository ledgerRepository,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery(Name = "enabled_only")] bool enabledOnly = false) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
var sites = await ledgerRepository.GetAllPoliciesAsync(enabledOnly, cancellationToken);
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
sites = sites.Select(s => new
|
||||
{
|
||||
site_id = s.SiteId,
|
||||
display_name = s.DisplayName,
|
||||
enabled = s.Enabled,
|
||||
last_sync_at = s.LastSyncAt,
|
||||
last_cursor = s.LastCursor,
|
||||
total_imports = s.TotalImports,
|
||||
allowed_sources = s.AllowedSources,
|
||||
max_bundle_size_bytes = s.MaxBundleSizeBytes
|
||||
}),
|
||||
count = sites.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListFederationSites")
|
||||
.WithSummary("List all federation sites")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503);
|
||||
|
||||
// GET /api/v1/federation/sites/{siteId} - Get site details
|
||||
group.MapGet("/sites/{siteId}", async (
|
||||
HttpContext context,
|
||||
ISyncLedgerRepository ledgerRepository,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
string siteId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
var site = await ledgerRepository.GetPolicyAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
{
|
||||
return HttpResults.NotFound(new { error = $"Site '{siteId}' not found" });
|
||||
}
|
||||
|
||||
// Get recent sync history
|
||||
var history = new List<object>();
|
||||
await foreach (var entry in ledgerRepository.GetHistoryAsync(siteId, 10, cancellationToken))
|
||||
{
|
||||
history.Add(new
|
||||
{
|
||||
cursor = entry.Cursor,
|
||||
bundle_hash = entry.BundleHash,
|
||||
item_count = entry.ItemCount,
|
||||
exported_at = entry.ExportedAt,
|
||||
imported_at = entry.ImportedAt
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
site_id = site.SiteId,
|
||||
display_name = site.DisplayName,
|
||||
enabled = site.Enabled,
|
||||
last_sync_at = site.LastSyncAt,
|
||||
last_cursor = site.LastCursor,
|
||||
total_imports = site.TotalImports,
|
||||
allowed_sources = site.AllowedSources,
|
||||
max_bundle_size_bytes = site.MaxBundleSizeBytes,
|
||||
recent_history = history
|
||||
});
|
||||
})
|
||||
.WithName("GetFederationSite")
|
||||
.WithSummary("Get federation site details")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(404)
|
||||
.ProducesProblem(503);
|
||||
|
||||
// PUT /api/v1/federation/sites/{siteId}/policy - Update site policy
|
||||
// Per SPRINT_8200_0014_0003_CONCEL_bundle_import_merge Task 31.
|
||||
group.MapPut("/sites/{siteId}/policy", async (
|
||||
HttpContext context,
|
||||
ISyncLedgerRepository ledgerRepository,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
string siteId,
|
||||
[FromBody] SitePolicyUpdateRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
var existing = await ledgerRepository.GetPolicyAsync(siteId, cancellationToken);
|
||||
var policy = new SitePolicy
|
||||
{
|
||||
SiteId = siteId,
|
||||
DisplayName = request.DisplayName ?? existing?.DisplayName,
|
||||
Enabled = request.Enabled ?? existing?.Enabled ?? true,
|
||||
AllowedSources = request.AllowedSources ?? existing?.AllowedSources,
|
||||
MaxBundleSizeBytes = request.MaxBundleSizeBytes ?? existing?.MaxBundleSizeBytes,
|
||||
LastSyncAt = existing?.LastSyncAt,
|
||||
LastCursor = existing?.LastCursor,
|
||||
TotalImports = existing?.TotalImports ?? 0
|
||||
};
|
||||
|
||||
await ledgerRepository.UpsertPolicyAsync(policy, cancellationToken);
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
site_id = policy.SiteId,
|
||||
display_name = policy.DisplayName,
|
||||
enabled = policy.Enabled,
|
||||
allowed_sources = policy.AllowedSources,
|
||||
max_bundle_size_bytes = policy.MaxBundleSizeBytes
|
||||
});
|
||||
})
|
||||
.WithName("UpdateFederationSitePolicy")
|
||||
.WithSummary("Update federation site policy")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(400)
|
||||
.ProducesProblem(503);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request body for updating site policy.
|
||||
/// </summary>
|
||||
public sealed record SitePolicyUpdateRequest
|
||||
{
|
||||
public string? DisplayName { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public List<string>? AllowedSources { get; init; }
|
||||
public long? MaxBundleSizeBytes { get; init; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user