Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/CommandHandlers.Federation.cs
2026-02-01 21:37:40 +02:00

820 lines
30 KiB
C#

// -----------------------------------------------------------------------------
// 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<int> 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<IHttpClientFactory>();
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<string>();
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<int> 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<IHttpClientFactory>();
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<PreviewResponse>(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<int> 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<IHttpClientFactory>();
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<string>();
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<ImportErrorResponse>(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<ImportSuccessResponse>(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<int> 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<IHttpClientFactory>();
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<ValidateResponse>(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<object>? 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<string>? Errors { get; set; }
public List<string>? Warnings { get; set; }
public bool HashValid { get; set; }
public bool SignatureValid { get; set; }
public bool CursorValid { get; set; }
}
internal static async Task<int> HandleFederationSitesListAsync(
IServiceProvider services,
bool enabledOnly,
bool json,
bool verbose,
CancellationToken cancellationToken)
{
if (verbose)
{
AnsiConsole.MarkupLine("[blue]Listing federation sites...[/]");
}
try
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<SitesListResponse>(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<int> 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<IHttpClientFactory>();
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<SiteDetailsResponse>(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<int> 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<IHttpClientFactory>();
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<SiteInfo>? 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<SyncHistoryEntry>? 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; }
}
}