417 lines
14 KiB
C#
417 lines
14 KiB
C#
using System.CommandLine;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Spectre.Console;
|
|
using StellaOps.Symbols.Client;
|
|
using StellaOps.Symbols.Core.Models;
|
|
using StellaOps.Symbols.Ingestor.Cli;
|
|
|
|
return await RunAsync(args).ConfigureAwait(false);
|
|
|
|
static async Task<int> RunAsync(string[] args)
|
|
{
|
|
// Build command structure
|
|
var rootCommand = new RootCommand("StellaOps Symbol Ingestor CLI - Ingest and publish symbol manifests");
|
|
|
|
// Global options
|
|
var verboseOption = new Option<bool>("--verbose")
|
|
{
|
|
Description = "Enable verbose output"
|
|
};
|
|
var dryRunOption = new Option<bool>("--dry-run")
|
|
{
|
|
Description = "Dry run mode - generate manifest without uploading"
|
|
};
|
|
|
|
rootCommand.Add(verboseOption);
|
|
rootCommand.Add(dryRunOption);
|
|
|
|
// ingest command
|
|
var ingestCommand = new Command("ingest", "Ingest symbols from a binary file");
|
|
|
|
var binaryOption = new Option<string>("--binary")
|
|
{
|
|
Description = "Path to the binary file",
|
|
Required = true
|
|
};
|
|
var debugOption = new Option<string?>("--debug")
|
|
{
|
|
Description = "Path to debug symbols file (PDB, DWARF, dSYM)"
|
|
};
|
|
var debugIdOption = new Option<string?>("--debug-id")
|
|
{
|
|
Description = "Override debug ID"
|
|
};
|
|
var codeIdOption = new Option<string?>("--code-id")
|
|
{
|
|
Description = "Override code ID"
|
|
};
|
|
var nameOption = new Option<string?>("--name")
|
|
{
|
|
Description = "Override binary name"
|
|
};
|
|
var platformOption = new Option<string?>("--platform")
|
|
{
|
|
Description = "Platform identifier (linux-x64, win-x64, osx-arm64, etc.)"
|
|
};
|
|
var outputOption = new Option<string?>("--output")
|
|
{
|
|
Description = "Output directory for manifest files (default: current directory)"
|
|
};
|
|
var serverOption = new Option<string?>("--server")
|
|
{
|
|
Description = "Symbols server URL for upload"
|
|
};
|
|
var tenantOption = new Option<string?>("--tenant")
|
|
{
|
|
Description = "Tenant ID for multi-tenant uploads"
|
|
};
|
|
|
|
ingestCommand.Add(binaryOption);
|
|
ingestCommand.Add(debugOption);
|
|
ingestCommand.Add(debugIdOption);
|
|
ingestCommand.Add(codeIdOption);
|
|
ingestCommand.Add(nameOption);
|
|
ingestCommand.Add(platformOption);
|
|
ingestCommand.Add(outputOption);
|
|
ingestCommand.Add(serverOption);
|
|
ingestCommand.Add(tenantOption);
|
|
|
|
ingestCommand.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
var dryRun = parseResult.GetValue(dryRunOption);
|
|
var binary = parseResult.GetValue(binaryOption)!;
|
|
var debug = parseResult.GetValue(debugOption);
|
|
var debugId = parseResult.GetValue(debugIdOption);
|
|
var codeId = parseResult.GetValue(codeIdOption);
|
|
var name = parseResult.GetValue(nameOption);
|
|
var platform = parseResult.GetValue(platformOption);
|
|
var output = parseResult.GetValue(outputOption) ?? ".";
|
|
var server = parseResult.GetValue(serverOption);
|
|
var tenant = parseResult.GetValue(tenantOption);
|
|
|
|
var options = new SymbolIngestOptions
|
|
{
|
|
BinaryPath = binary,
|
|
DebugPath = debug,
|
|
DebugId = debugId,
|
|
CodeId = codeId,
|
|
BinaryName = name,
|
|
Platform = platform,
|
|
OutputDir = output,
|
|
ServerUrl = server,
|
|
TenantId = tenant,
|
|
Verbose = verbose,
|
|
DryRun = dryRun
|
|
};
|
|
|
|
await IngestAsync(options, cancellationToken).ConfigureAwait(false);
|
|
});
|
|
|
|
// upload command
|
|
var uploadCommand = new Command("upload", "Upload a symbol manifest to the server");
|
|
|
|
var manifestOption = new Option<string>("--manifest")
|
|
{
|
|
Description = "Path to manifest JSON file",
|
|
Required = true
|
|
};
|
|
var uploadServerOption = new Option<string>("--server")
|
|
{
|
|
Description = "Symbols server URL",
|
|
Required = true
|
|
};
|
|
var uploadTenantOption = new Option<string?>("--tenant")
|
|
{
|
|
Description = "Tenant ID for multi-tenant uploads"
|
|
};
|
|
|
|
uploadCommand.Add(manifestOption);
|
|
uploadCommand.Add(uploadServerOption);
|
|
uploadCommand.Add(uploadTenantOption);
|
|
|
|
uploadCommand.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
var dryRun = parseResult.GetValue(dryRunOption);
|
|
var manifestPath = parseResult.GetValue(manifestOption)!;
|
|
var server = parseResult.GetValue(uploadServerOption)!;
|
|
var tenant = parseResult.GetValue(uploadTenantOption);
|
|
|
|
await UploadAsync(manifestPath, server, tenant, verbose, dryRun, cancellationToken).ConfigureAwait(false);
|
|
});
|
|
|
|
// verify command
|
|
var verifyCommand = new Command("verify", "Verify a symbol manifest or DSSE envelope");
|
|
|
|
var verifyPathOption = new Option<string>("--path")
|
|
{
|
|
Description = "Path to manifest or DSSE file",
|
|
Required = true
|
|
};
|
|
|
|
verifyCommand.Add(verifyPathOption);
|
|
|
|
verifyCommand.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
var verbose = parseResult.GetValue(verboseOption);
|
|
var path = parseResult.GetValue(verifyPathOption)!;
|
|
|
|
await VerifyAsync(path, verbose, cancellationToken).ConfigureAwait(false);
|
|
});
|
|
|
|
// health command
|
|
var healthCommand = new Command("health", "Check symbols server health");
|
|
|
|
var healthServerOption = new Option<string>("--server")
|
|
{
|
|
Description = "Symbols server URL",
|
|
Required = true
|
|
};
|
|
|
|
healthCommand.Add(healthServerOption);
|
|
|
|
healthCommand.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
var server = parseResult.GetValue(healthServerOption)!;
|
|
await HealthCheckAsync(server, cancellationToken).ConfigureAwait(false);
|
|
});
|
|
|
|
rootCommand.Add(ingestCommand);
|
|
rootCommand.Add(uploadCommand);
|
|
rootCommand.Add(verifyCommand);
|
|
rootCommand.Add(healthCommand);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, eventArgs) =>
|
|
{
|
|
eventArgs.Cancel = true;
|
|
cts.Cancel();
|
|
};
|
|
|
|
var parseResult = rootCommand.Parse(args);
|
|
return await parseResult.InvokeAsync(cts.Token).ConfigureAwait(false);
|
|
}
|
|
|
|
// Command implementations
|
|
static async Task IngestAsync(SymbolIngestOptions options, CancellationToken cancellationToken)
|
|
{
|
|
AnsiConsole.MarkupLine("[bold blue]StellaOps Symbol Ingestor[/]");
|
|
AnsiConsole.WriteLine();
|
|
|
|
// Validate binary exists
|
|
if (!File.Exists(options.BinaryPath))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Binary file not found: {options.BinaryPath}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Detect format
|
|
var format = SymbolExtractor.DetectFormat(options.BinaryPath);
|
|
AnsiConsole.MarkupLine($"[green]Binary format:[/] {format}");
|
|
|
|
if (format == BinaryFormat.Unknown)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Unknown binary format");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Create manifest
|
|
SymbolManifest manifest;
|
|
try
|
|
{
|
|
manifest = SymbolExtractor.CreateManifest(options.BinaryPath, options.DebugPath, options);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error creating manifest:[/] {ex.Message}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
|
|
if (!string.IsNullOrEmpty(manifest.CodeId))
|
|
AnsiConsole.MarkupLine($"[green]Code ID:[/] {manifest.CodeId}");
|
|
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
|
|
AnsiConsole.MarkupLine($"[green]Platform:[/] {manifest.Platform}");
|
|
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}");
|
|
|
|
// Write manifest
|
|
var manifestPath = await ManifestWriter.WriteJsonAsync(manifest, options.OutputDir, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine($"[green]Manifest written:[/] {manifestPath}");
|
|
|
|
// Upload if server specified and not dry-run
|
|
if (!string.IsNullOrEmpty(options.ServerUrl) && !options.DryRun)
|
|
{
|
|
await UploadAsync(manifestPath, options.ServerUrl, options.TenantId, options.Verbose, false, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
else if (options.DryRun)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Dry run mode - skipping upload[/]");
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.MarkupLine("[bold green]Done![/]");
|
|
}
|
|
|
|
static async Task UploadAsync(
|
|
string manifestPath,
|
|
string serverUrl,
|
|
string? tenantId,
|
|
bool verbose,
|
|
bool dryRun,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (dryRun)
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Dry run mode - would upload to:[/] {0}", serverUrl);
|
|
return;
|
|
}
|
|
|
|
var manifest = await ManifestWriter.ReadJsonAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
|
if (manifest is null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] Failed to read manifest: {manifestPath}");
|
|
Environment.ExitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Set up HTTP client and symbols client
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(builder =>
|
|
{
|
|
if (verbose)
|
|
builder.AddConsole().SetMinimumLevel(LogLevel.Debug);
|
|
});
|
|
services.AddSymbolsClient(opts =>
|
|
{
|
|
opts.BaseUrl = serverUrl;
|
|
opts.TenantId = tenantId;
|
|
});
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
var client = provider.GetRequiredService<ISymbolsClient>();
|
|
|
|
AnsiConsole.MarkupLine($"[blue]Uploading to:[/] {serverUrl}");
|
|
|
|
try
|
|
{
|
|
var result = await client.UploadManifestAsync(manifest, cancellationToken).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine($"[green]Uploaded:[/] {result.ManifestId}");
|
|
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {result.SymbolCount}");
|
|
if (!string.IsNullOrEmpty(result.BlobUri))
|
|
AnsiConsole.MarkupLine($"[green]Blob URI:[/] {result.BlobUri}");
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Upload failed:[/] {ex.Message}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|
|
|
|
static Task VerifyAsync(string path, bool verbose, CancellationToken cancellationToken)
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {path}");
|
|
Environment.ExitCode = 1;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var json = File.ReadAllText(path);
|
|
|
|
// Check if it's a DSSE envelope or a plain manifest
|
|
if (json.Contains("\"payloadType\"") && json.Contains("\"signatures\""))
|
|
{
|
|
AnsiConsole.MarkupLine("[blue]Verifying DSSE envelope...[/]");
|
|
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json);
|
|
if (envelope is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Invalid DSSE envelope");
|
|
Environment.ExitCode = 1;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Payload type:[/] {envelope.PayloadType}");
|
|
AnsiConsole.MarkupLine($"[green]Signatures:[/] {envelope.Signatures.Count}");
|
|
|
|
foreach (var sig in envelope.Signatures)
|
|
{
|
|
AnsiConsole.MarkupLine($" [dim]Key ID:[/] {sig.KeyId}");
|
|
AnsiConsole.MarkupLine($" [dim]Signature:[/] {sig.Sig[..Math.Min(32, sig.Sig.Length)]}...");
|
|
}
|
|
|
|
// Decode and parse payload
|
|
try
|
|
{
|
|
var payloadJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload));
|
|
var manifest = JsonSerializer.Deserialize<SymbolManifest>(payloadJson);
|
|
if (manifest is not null)
|
|
{
|
|
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
|
|
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
AnsiConsole.MarkupLine("[yellow]Warning:[/] Could not decode payload");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
AnsiConsole.MarkupLine("[blue]Verifying manifest...[/]");
|
|
var manifest = JsonSerializer.Deserialize<SymbolManifest>(json);
|
|
if (manifest is null)
|
|
{
|
|
AnsiConsole.MarkupLine("[red]Error:[/] Invalid manifest");
|
|
Environment.ExitCode = 1;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
AnsiConsole.MarkupLine($"[green]Manifest ID:[/] {manifest.ManifestId}");
|
|
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
|
|
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
|
|
AnsiConsole.MarkupLine($"[green]Format:[/] {manifest.Format}");
|
|
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}");
|
|
AnsiConsole.MarkupLine($"[green]Created:[/] {manifest.CreatedAt:O}");
|
|
}
|
|
|
|
AnsiConsole.MarkupLine("[bold green]Verification passed![/]");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
static async Task HealthCheckAsync(string serverUrl, CancellationToken cancellationToken)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddSymbolsClient(opts => opts.BaseUrl = serverUrl);
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
var client = provider.GetRequiredService<ISymbolsClient>();
|
|
|
|
AnsiConsole.MarkupLine($"[blue]Checking health:[/] {serverUrl}");
|
|
|
|
try
|
|
{
|
|
var health = await client.GetHealthAsync(cancellationToken).ConfigureAwait(false);
|
|
AnsiConsole.MarkupLine($"[green]Status:[/] {health.Status}");
|
|
AnsiConsole.MarkupLine($"[green]Version:[/] {health.Version}");
|
|
AnsiConsole.MarkupLine($"[green]Timestamp:[/] {health.Timestamp:O}");
|
|
if (health.TotalManifests.HasValue)
|
|
AnsiConsole.MarkupLine($"[green]Total manifests:[/] {health.TotalManifests}");
|
|
if (health.TotalSymbols.HasValue)
|
|
AnsiConsole.MarkupLine($"[green]Total symbols:[/] {health.TotalSymbols}");
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
AnsiConsole.MarkupLine($"[red]Health check failed:[/] {ex.Message}");
|
|
Environment.ExitCode = 1;
|
|
}
|
|
}
|