save development progress
This commit is contained in:
@@ -99,6 +99,9 @@ internal static class CommandFactory
|
||||
root.Add(DeltaCommandGroup.BuildDeltaCommand(verboseOption, cancellationToken));
|
||||
root.Add(ReachabilityCommandGroup.BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Sprint: SPRINT_8200_0014_0002 - Federation bundle export
|
||||
root.Add(FederationCommandGroup.BuildFeedserCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
// Add scan graph subcommand to existing scan command
|
||||
var scanCommand = root.Children.OfType<Command>().FirstOrDefault(c => c.Name == "scan");
|
||||
if (scanCommand is not null)
|
||||
|
||||
256
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Federation.cs
Normal file
256
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Federation.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CommandHandlers.Federation.cs
|
||||
// Sprint: SPRINT_8200_0014_0002 (Delta Bundle Export)
|
||||
// Description: Command handlers for federation bundle operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
152
src/Cli/StellaOps.Cli/Commands/FederationCommandGroup.cs
Normal file
152
src/Cli/StellaOps.Cli/Commands/FederationCommandGroup.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FederationCommandGroup.cs
|
||||
// Sprint: SPRINT_8200_0014_0002 (Delta Bundle Export)
|
||||
// Tasks: EXPORT-8200-025, EXPORT-8200-026 - CLI commands for federation bundle export.
|
||||
// Description: CLI commands for federation bundle export to support air-gapped sync.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Extensions;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class FederationCommandGroup
|
||||
{
|
||||
internal static Command BuildFeedserCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var feedser = new Command("feedser", "Federation bundle operations for multi-site sync.");
|
||||
|
||||
feedser.Add(BuildBundleCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return feedser;
|
||||
}
|
||||
|
||||
private static Command BuildBundleCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = new Command("bundle", "Federation bundle operations.");
|
||||
|
||||
bundle.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
bundle.Add(BuildPreviewCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private static Command BuildExportCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sinceCursorOption = new Option<string?>("--since-cursor", new[] { "-c" })
|
||||
{
|
||||
Description = "Export changes since this cursor position."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output file path (default: stdout)."
|
||||
};
|
||||
|
||||
var signOption = new Option<bool>("--sign", new[] { "-s" })
|
||||
{
|
||||
Description = "Sign the bundle with Authority key."
|
||||
};
|
||||
signOption.SetDefaultValue(true);
|
||||
|
||||
var compressLevelOption = new Option<int>("--compress-level", new[] { "-l" })
|
||||
{
|
||||
Description = "ZST compression level (1-19)."
|
||||
};
|
||||
compressLevelOption.SetDefaultValue(3);
|
||||
|
||||
var maxItemsOption = new Option<int>("--max-items", new[] { "-m" })
|
||||
{
|
||||
Description = "Maximum items per bundle (default: 10000)."
|
||||
};
|
||||
maxItemsOption.SetDefaultValue(10000);
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output metadata as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("export", "Export federation bundle for air-gapped transfer.")
|
||||
{
|
||||
sinceCursorOption,
|
||||
outputOption,
|
||||
signOption,
|
||||
compressLevelOption,
|
||||
maxItemsOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var sinceCursor = parseResult.GetValue(sinceCursorOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var sign = parseResult.GetValue(signOption);
|
||||
var compressLevel = parseResult.GetValue(compressLevelOption);
|
||||
var maxItems = parseResult.GetValue(maxItemsOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleFederationBundleExportAsync(
|
||||
services,
|
||||
sinceCursor,
|
||||
output,
|
||||
sign,
|
||||
compressLevel,
|
||||
maxItems,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildPreviewCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sinceCursorOption = new Option<string?>("--since-cursor", new[] { "-c" })
|
||||
{
|
||||
Description = "Preview changes since this cursor position."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Output as JSON."
|
||||
};
|
||||
|
||||
var command = new Command("preview", "Preview export statistics without creating bundle.")
|
||||
{
|
||||
sinceCursorOption,
|
||||
jsonOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var sinceCursor = parseResult.GetValue(sinceCursorOption);
|
||||
var json = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleFederationBundlePreviewAsync(
|
||||
services,
|
||||
sinceCursor,
|
||||
json,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.ExportCenter.Client;
|
||||
using StellaOps.ExportCenter.Core.EvidenceCache;
|
||||
using StellaOps.Cryptography.Plugin.SimRemote.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user