// ----------------------------------------------------------------------------- // CommandHandlers.Federation.cs // Sprint: SPRINT_8200_0014_0002 (Delta Bundle Export), SPRINT_8200_0014_0003 (Bundle Import) // Description: Command handlers for federation bundle export and import operations. // ----------------------------------------------------------------------------- using Microsoft.Extensions.DependencyInjection; using Spectre.Console; using System.Net.Http.Headers; using System.Text.Json; namespace StellaOps.Cli.Commands; internal static partial class CommandHandlers { internal static async Task HandleFederationBundleExportAsync( IServiceProvider services, string? sinceCursor, string? output, bool sign, int compressLevel, int maxItems, bool json, bool verbose, CancellationToken cancellationToken) { if (verbose) { AnsiConsole.MarkupLine("[blue]Exporting federation bundle...[/]"); AnsiConsole.MarkupLine($" Since Cursor: [bold]{Markup.Escape(sinceCursor ?? "(none - full export)")}[/]"); AnsiConsole.MarkupLine($" Sign: {sign}"); AnsiConsole.MarkupLine($" Compression Level: {compressLevel}"); AnsiConsole.MarkupLine($" Max Items: {maxItems}"); } try { var httpClientFactory = services.GetService(); if (httpClientFactory == null) { AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]"); return 1; } var client = httpClientFactory.CreateClient("Concelier"); // Build query string var queryParams = new List(); if (!string.IsNullOrEmpty(sinceCursor)) queryParams.Add($"since_cursor={Uri.EscapeDataString(sinceCursor)}"); queryParams.Add($"sign={sign.ToString().ToLowerInvariant()}"); queryParams.Add($"max_items={maxItems}"); queryParams.Add($"compress_level={compressLevel}"); var url = $"/api/v1/federation/export?{string.Join("&", queryParams)}"; using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(cancellationToken); AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]"); if (verbose) { AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]"); } return 1; } // Extract metadata from headers var bundleHash = response.Headers.TryGetValues("X-Bundle-Hash", out var hashValues) ? hashValues.FirstOrDefault() : null; var exportCursor = response.Headers.TryGetValues("X-Export-Cursor", out var cursorValues) ? cursorValues.FirstOrDefault() : null; var itemsCount = response.Headers.TryGetValues("X-Items-Count", out var countValues) ? countValues.FirstOrDefault() : null; // Determine output stream Stream outputStream; bool disposeStream; if (string.IsNullOrEmpty(output)) { outputStream = Console.OpenStandardOutput(); disposeStream = false; } else { outputStream = File.Create(output); disposeStream = true; } try { await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); await contentStream.CopyToAsync(outputStream, cancellationToken); } finally { if (disposeStream) { await outputStream.DisposeAsync(); } } // Output metadata if (!string.IsNullOrEmpty(output)) { if (json) { var metadata = new { bundle_hash = bundleHash, export_cursor = exportCursor, since_cursor = sinceCursor, items_count = int.TryParse(itemsCount, out var count) ? count : 0, output_path = output }; AnsiConsole.WriteLine(JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true })); } else { AnsiConsole.MarkupLine("[green]Bundle exported successfully.[/]"); AnsiConsole.MarkupLine($" Output: [bold]{Markup.Escape(output)}[/]"); if (bundleHash != null) AnsiConsole.MarkupLine($" Hash: [dim]{bundleHash}[/]"); if (exportCursor != null) AnsiConsole.MarkupLine($" New Cursor: [bold]{exportCursor}[/]"); if (itemsCount != null) AnsiConsole.MarkupLine($" Items: {itemsCount}"); } } return 0; } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]"); return 1; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); if (verbose) { AnsiConsole.WriteException(ex); } return 1; } } internal static async Task HandleFederationBundlePreviewAsync( IServiceProvider services, string? sinceCursor, bool json, bool verbose, CancellationToken cancellationToken) { if (verbose) { AnsiConsole.MarkupLine("[blue]Previewing federation export...[/]"); AnsiConsole.MarkupLine($" Since Cursor: [bold]{Markup.Escape(sinceCursor ?? "(none - full export)")}[/]"); } try { var httpClientFactory = services.GetService(); if (httpClientFactory == null) { AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]"); return 1; } var client = httpClientFactory.CreateClient("Concelier"); var url = "/api/v1/federation/export/preview"; if (!string.IsNullOrEmpty(sinceCursor)) { url += $"?since_cursor={Uri.EscapeDataString(sinceCursor)}"; } using var response = await client.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadAsStringAsync(cancellationToken); AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]"); if (verbose) { AnsiConsole.MarkupLine($"[grey]{Markup.Escape(error)}[/]"); } return 1; } var content = await response.Content.ReadAsStringAsync(cancellationToken); if (json) { AnsiConsole.WriteLine(content); } else { var preview = JsonSerializer.Deserialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (preview != null) { AnsiConsole.MarkupLine("[green]Export Preview[/]"); AnsiConsole.MarkupLine($" Since Cursor: [dim]{sinceCursor ?? "(full export)"}[/]"); AnsiConsole.MarkupLine($" Estimated Canonicals: [bold]{preview.EstimatedCanonicals:N0}[/]"); AnsiConsole.MarkupLine($" Estimated Edges: [bold]{preview.EstimatedEdges:N0}[/]"); AnsiConsole.MarkupLine($" Estimated Deletions: [bold]{preview.EstimatedDeletions:N0}[/]"); AnsiConsole.MarkupLine($" Estimated Size: [bold]{preview.EstimatedSizeMb:F2} MB[/]"); } else { AnsiConsole.WriteLine(content); } } return 0; } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]"); return 1; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); if (verbose) { AnsiConsole.WriteException(ex); } return 1; } } private sealed class PreviewResponse { public string? SinceCursor { get; set; } public int EstimatedCanonicals { get; set; } public int EstimatedEdges { get; set; } public int EstimatedDeletions { get; set; } public long EstimatedSizeBytes { get; set; } public double EstimatedSizeMb { get; set; } } internal static async Task HandleFederationBundleImportAsync( IServiceProvider services, string inputPath, bool dryRun, bool skipSignature, string? onConflict, bool force, bool json, bool verbose, CancellationToken cancellationToken) { if (verbose) { AnsiConsole.MarkupLine("[blue]Importing federation bundle...[/]"); AnsiConsole.MarkupLine($" File: [bold]{Markup.Escape(inputPath)}[/]"); AnsiConsole.MarkupLine($" Dry Run: {dryRun}"); AnsiConsole.MarkupLine($" Skip Signature: {skipSignature}"); AnsiConsole.MarkupLine($" On Conflict: {onConflict ?? "PreferRemote"}"); AnsiConsole.MarkupLine($" Force: {force}"); } if (!File.Exists(inputPath)) { AnsiConsole.MarkupLine($"[red]Error: File not found: {Markup.Escape(inputPath)}[/]"); return 1; } try { var httpClientFactory = services.GetService(); if (httpClientFactory == null) { AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]"); return 1; } var client = httpClientFactory.CreateClient("Concelier"); // Build query string var queryParams = new List(); if (dryRun) queryParams.Add("dry_run=true"); if (skipSignature) queryParams.Add("skip_signature=true"); if (!string.IsNullOrEmpty(onConflict)) queryParams.Add($"on_conflict={Uri.EscapeDataString(onConflict)}"); if (force) queryParams.Add("force=true"); var url = "/api/v1/federation/import"; if (queryParams.Count > 0) url += $"?{string.Join("&", queryParams)}"; await using var fileStream = File.OpenRead(inputPath); using var content = new StreamContent(fileStream); content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd"); using var response = await client.PostAsync(url, content, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { if (json) { AnsiConsole.WriteLine(responseContent); } else { AnsiConsole.MarkupLine($"[red]Import failed: {response.StatusCode}[/]"); try { var errorResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (errorResponse?.FailureReason != null) { AnsiConsole.MarkupLine($" Reason: [yellow]{Markup.Escape(errorResponse.FailureReason)}[/]"); } } catch { if (verbose) AnsiConsole.MarkupLine($"[grey]{Markup.Escape(responseContent)}[/]"); } } return 1; } if (json) { AnsiConsole.WriteLine(responseContent); } else { var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (result != null) { var status = dryRun ? "[yellow]DRY RUN[/]" : "[green]SUCCESS[/]"; AnsiConsole.MarkupLine($"{status} Bundle import completed."); AnsiConsole.MarkupLine($" Bundle Hash: [dim]{result.BundleHash}[/]"); AnsiConsole.MarkupLine($" Cursor: [bold]{result.ImportedCursor}[/]"); if (result.Counts != null) { AnsiConsole.MarkupLine($" Created: [green]{result.Counts.CanonicalCreated:N0}[/]"); AnsiConsole.MarkupLine($" Updated: [blue]{result.Counts.CanonicalUpdated:N0}[/]"); AnsiConsole.MarkupLine($" Skipped: [dim]{result.Counts.CanonicalSkipped:N0}[/]"); AnsiConsole.MarkupLine($" Edges: [blue]{result.Counts.EdgesAdded:N0}[/]"); AnsiConsole.MarkupLine($" Deletions: [yellow]{result.Counts.DeletionsProcessed:N0}[/]"); } if (result.Conflicts?.Count > 0) { AnsiConsole.MarkupLine($" Conflicts: [yellow]{result.Conflicts.Count}[/]"); } AnsiConsole.MarkupLine($" Duration: {result.DurationMs:F0}ms"); } } return 0; } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]"); return 1; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); if (verbose) { AnsiConsole.WriteException(ex); } return 1; } } internal static async Task HandleFederationBundleValidateAsync( IServiceProvider services, string inputPath, bool json, bool verbose, CancellationToken cancellationToken) { if (verbose) { AnsiConsole.MarkupLine("[blue]Validating federation bundle...[/]"); AnsiConsole.MarkupLine($" File: [bold]{Markup.Escape(inputPath)}[/]"); } if (!File.Exists(inputPath)) { AnsiConsole.MarkupLine($"[red]Error: File not found: {Markup.Escape(inputPath)}[/]"); return 1; } try { var httpClientFactory = services.GetService(); if (httpClientFactory == null) { AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]"); return 1; } var client = httpClientFactory.CreateClient("Concelier"); await using var fileStream = File.OpenRead(inputPath); using var content = new StreamContent(fileStream); content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd"); using var response = await client.PostAsync("/api/v1/federation/import/validate", content, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (json) { AnsiConsole.WriteLine(responseContent); } else { var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (result != null) { var status = result.IsValid ? "[green]VALID[/]" : "[red]INVALID[/]"; AnsiConsole.MarkupLine($"{status} Bundle validation result"); AnsiConsole.MarkupLine($" Hash Valid: {(result.HashValid ? "[green]Yes[/]" : "[red]No[/]")}"); AnsiConsole.MarkupLine($" Signature Valid: {(result.SignatureValid ? "[green]Yes[/]" : "[yellow]No/Skipped[/]")}"); AnsiConsole.MarkupLine($" Cursor Valid: {(result.CursorValid ? "[green]Yes[/]" : "[yellow]No[/]")}"); if (result.Errors?.Count > 0) { AnsiConsole.MarkupLine("[red]Errors:[/]"); foreach (var error in result.Errors) { AnsiConsole.MarkupLine($" - {Markup.Escape(error)}"); } } if (result.Warnings?.Count > 0) { AnsiConsole.MarkupLine("[yellow]Warnings:[/]"); foreach (var warning in result.Warnings) { AnsiConsole.MarkupLine($" - {Markup.Escape(warning)}"); } } } } return response.IsSuccessStatusCode ? 0 : 1; } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]"); return 1; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); if (verbose) { AnsiConsole.WriteException(ex); } return 1; } } private sealed class ImportErrorResponse { public bool Success { get; set; } public string? BundleHash { get; set; } public string? FailureReason { get; set; } public double DurationMs { get; set; } } private sealed class ImportSuccessResponse { public bool Success { get; set; } public string? BundleHash { get; set; } public string? ImportedCursor { get; set; } public ImportCountsResponse? Counts { get; set; } public List? Conflicts { get; set; } public double DurationMs { get; set; } public bool DryRun { get; set; } } private sealed class ImportCountsResponse { public int CanonicalCreated { get; set; } public int CanonicalUpdated { get; set; } public int CanonicalSkipped { get; set; } public int EdgesAdded { get; set; } public int DeletionsProcessed { get; set; } public int Total { get; set; } } private sealed class ValidateResponse { public bool IsValid { get; set; } public List? Errors { get; set; } public List? Warnings { get; set; } public bool HashValid { get; set; } public bool SignatureValid { get; set; } public bool CursorValid { get; set; } } internal static async Task HandleFederationSitesListAsync( IServiceProvider services, bool enabledOnly, bool json, bool verbose, CancellationToken cancellationToken) { if (verbose) { AnsiConsole.MarkupLine("[blue]Listing federation sites...[/]"); } try { var httpClientFactory = services.GetService(); if (httpClientFactory == null) { AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]"); return 1; } var client = httpClientFactory.CreateClient("Concelier"); var url = "/api/v1/federation/sites"; if (enabledOnly) url += "?enabled_only=true"; using var response = await client.GetAsync(url, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]"); if (verbose) AnsiConsole.MarkupLine($"[grey]{Markup.Escape(responseContent)}[/]"); return 1; } if (json) { AnsiConsole.WriteLine(responseContent); } else { var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (result?.Sites != null && result.Sites.Count > 0) { var table = new Table(); table.AddColumn("Site ID"); table.AddColumn("Display Name"); table.AddColumn("Enabled"); table.AddColumn("Last Sync"); table.AddColumn("Imports"); foreach (var site in result.Sites) { var enabledMark = site.Enabled ? "[green]Yes[/]" : "[red]No[/]"; var lastSync = site.LastSyncAt?.ToString("g") ?? "-"; table.AddRow( site.SiteId ?? "-", site.DisplayName ?? "-", enabledMark, lastSync, site.TotalImports.ToString()); } AnsiConsole.Write(table); AnsiConsole.MarkupLine($"\n[dim]{result.Count} site(s)[/]"); } else { AnsiConsole.MarkupLine("[dim]No sites found.[/]"); } } return 0; } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]"); return 1; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); if (verbose) AnsiConsole.WriteException(ex); return 1; } } internal static async Task HandleFederationSitesShowAsync( IServiceProvider services, string siteId, bool json, bool verbose, CancellationToken cancellationToken) { if (verbose) { AnsiConsole.MarkupLine($"[blue]Fetching site details for: {Markup.Escape(siteId)}[/]"); } try { var httpClientFactory = services.GetService(); if (httpClientFactory == null) { AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]"); return 1; } var client = httpClientFactory.CreateClient("Concelier"); using var response = await client.GetAsync($"/api/v1/federation/sites/{Uri.EscapeDataString(siteId)}", cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { AnsiConsole.MarkupLine($"[yellow]Site '{Markup.Escape(siteId)}' not found.[/]"); } else { AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]"); } return 1; } if (json) { AnsiConsole.WriteLine(responseContent); } else { var site = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (site != null) { AnsiConsole.MarkupLine($"[bold]Site: {Markup.Escape(site.SiteId ?? "")}[/]"); AnsiConsole.MarkupLine($" Display Name: {site.DisplayName ?? "(none)"}"); AnsiConsole.MarkupLine($" Enabled: {(site.Enabled ? "[green]Yes[/]" : "[red]No[/]")}"); AnsiConsole.MarkupLine($" Last Sync: {site.LastSyncAt?.ToString("g") ?? "(never)"}"); AnsiConsole.MarkupLine($" Last Cursor: [dim]{site.LastCursor ?? "(none)"}[/]"); AnsiConsole.MarkupLine($" Total Imports: {site.TotalImports}"); if (site.RecentHistory?.Count > 0) { AnsiConsole.MarkupLine("\n[bold]Recent Sync History:[/]"); var table = new Table(); table.AddColumn("Imported At"); table.AddColumn("Items"); table.AddColumn("Bundle Hash"); foreach (var entry in site.RecentHistory) { table.AddRow( entry.ImportedAt.ToString("g"), entry.ItemCount.ToString(), entry.BundleHash?.Length > 16 ? entry.BundleHash[..16] + "..." : entry.BundleHash ?? "-" ); } AnsiConsole.Write(table); } } } return 0; } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]"); return 1; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); if (verbose) AnsiConsole.WriteException(ex); return 1; } } internal static async Task HandleFederationSitesSetEnabledAsync( IServiceProvider services, string siteId, bool enabled, bool verbose, CancellationToken cancellationToken) { var action = enabled ? "Enabling" : "Disabling"; if (verbose) { AnsiConsole.MarkupLine($"[blue]{action} site: {Markup.Escape(siteId)}[/]"); } try { var httpClientFactory = services.GetService(); if (httpClientFactory == null) { AnsiConsole.MarkupLine("[red]Error: HTTP client factory not available.[/]"); return 1; } var client = httpClientFactory.CreateClient("Concelier"); var payload = new { enabled }; var content = new StringContent( JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json"); using var response = await client.PutAsync( $"/api/v1/federation/sites/{Uri.EscapeDataString(siteId)}/policy", content, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); AnsiConsole.MarkupLine($"[red]Error: {response.StatusCode}[/]"); if (verbose) AnsiConsole.MarkupLine($"[grey]{Markup.Escape(errorContent)}[/]"); return 1; } var result = enabled ? "[green]enabled[/]" : "[yellow]disabled[/]"; AnsiConsole.MarkupLine($"Site '{Markup.Escape(siteId)}' {result}."); return 0; } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Connection error: {Markup.Escape(ex.Message)}[/]"); return 1; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]"); if (verbose) AnsiConsole.WriteException(ex); return 1; } } private sealed class SitesListResponse { public List? Sites { get; set; } public int Count { get; set; } } private class SiteInfo { public string? SiteId { get; set; } public string? DisplayName { get; set; } public bool Enabled { get; set; } public DateTimeOffset? LastSyncAt { get; set; } public string? LastCursor { get; set; } public int TotalImports { get; set; } } private sealed class SiteDetailsResponse : SiteInfo { public List? RecentHistory { get; set; } } private sealed class SyncHistoryEntry { public string? Cursor { get; set; } public string? BundleHash { get; set; } public int ItemCount { get; set; } public DateTimeOffset ExportedAt { get; set; } public DateTimeOffset ImportedAt { get; set; } } }