save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

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

View 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; }
}
}

View 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;
}
}

View File

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