Initial commit (history squashed)
This commit is contained in:
27
src/StellaOps.Cli/AGENTS.md
Normal file
27
src/StellaOps.Cli/AGENTS.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# StellaOps.Cli — Agent Brief
|
||||
|
||||
## Mission
|
||||
- Deliver an offline-capable command-line interface that drives StellaOps back-end operations: scanner distribution, scan execution, result uploads, and Feedser database lifecycle calls (init/resume/export).
|
||||
- Honour StellaOps principles of determinism, observability, and offline-first behaviour while providing a polished operator experience.
|
||||
|
||||
## Role Charter
|
||||
| Role | Mandate | Collaboration |
|
||||
| --- | --- | --- |
|
||||
| **DevEx/CLI** | Own CLI UX, command routing, and configuration model. Ensure commands work with empty/default config and document overrides. | Coordinate with Backend/WebService for API contracts and with Docs for operator workflows. |
|
||||
| **Ops Integrator** | Maintain integration paths for shell/dotnet/docker tooling. Validate that air-gapped runners can bootstrap required binaries. | Work with Feedser/Agent teams to mirror packaging and signing requirements. |
|
||||
| **QA** | Provide command-level fixtures, golden outputs, and regression coverage (unit & smoke). Ensure commands respect cancellation and deterministic logging. | Partner with QA guild for shared harnesses and test data. |
|
||||
|
||||
## Working Agreements
|
||||
- Configuration is centralised in `StellaOps.Configuration`; always consume the bootstrapper instead of hand rolling builders. Env vars (`API_KEY`, `STELLAOPS_BACKEND_URL`, `StellaOps:*`) override JSON/YAML and default to empty values.
|
||||
- Command verbs (`scanner`, `scan`, `db`, `config`) are wired through System.CommandLine 2.0; keep handlers composable, cancellation-aware, and unit-testable.
|
||||
- `scanner download` must verify digests/signatures, install containers locally (docker load), and log artefact metadata.
|
||||
- `scan run` must execute the container against a directory, materialise artefacts in `ResultsDirectory`, and auto-upload them on success; `scan upload` is the manual retry path.
|
||||
- Emit structured console logs (single line, UTC timestamps) and honour offline-first expectations—no hidden network calls.
|
||||
- Mirror repository guidance: stay within `src/StellaOps.Cli` unless collaborating via documented handshakes.
|
||||
- Update `TASKS.md` as states change (TODO → DOING → DONE/BLOCKED) and record added tests/fixtures alongside implementation notes.
|
||||
|
||||
## Reference Materials
|
||||
- `docs/ARCHITECTURE_FEEDSER.md` for database operations surface area.
|
||||
- Backend OpenAPI/contract docs (once available) for job triggers and scanner endpoints.
|
||||
- Existing module AGENTS/TASKS files for style and coordination cues.
|
||||
- `docs/09_API_CLI_REFERENCE.md` (section 3) for the user-facing synopsis of the CLI verbs and flags.
|
||||
324
src/StellaOps.Cli/Commands/CommandFactory.cs
Normal file
324
src/StellaOps.Cli/Commands/CommandFactory.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class CommandFactory
|
||||
{
|
||||
public static RootCommand Create(IServiceProvider services, StellaOpsCliOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var verboseOption = new Option<bool>("--verbose", new[] { "-v" })
|
||||
{
|
||||
Description = "Enable verbose logging output."
|
||||
};
|
||||
|
||||
var root = new RootCommand("StellaOps command-line interface")
|
||||
{
|
||||
TreatUnmatchedTokensAsErrors = true
|
||||
};
|
||||
root.Add(verboseOption);
|
||||
|
||||
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildConfigCommand(options));
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static Command BuildScannerCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var scanner = new Command("scanner", "Manage scanner artifacts and lifecycle.");
|
||||
|
||||
var download = new Command("download", "Download the latest scanner bundle.");
|
||||
var channelOption = new Option<string>("--channel", new[] { "-c" })
|
||||
{
|
||||
Description = "Scanner channel (stable, beta, nightly)."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string?>("--output")
|
||||
{
|
||||
Description = "Optional output path for the downloaded bundle."
|
||||
};
|
||||
|
||||
var overwriteOption = new Option<bool>("--overwrite")
|
||||
{
|
||||
Description = "Overwrite existing bundle if present."
|
||||
};
|
||||
|
||||
var noInstallOption = new Option<bool>("--no-install")
|
||||
{
|
||||
Description = "Skip installing the scanner container after download."
|
||||
};
|
||||
|
||||
download.Add(channelOption);
|
||||
download.Add(outputOption);
|
||||
download.Add(overwriteOption);
|
||||
download.Add(noInstallOption);
|
||||
|
||||
download.SetAction((parseResult, _) =>
|
||||
{
|
||||
var channel = parseResult.GetValue(channelOption) ?? "stable";
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var overwrite = parseResult.GetValue(overwriteOption);
|
||||
var install = !parseResult.GetValue(noInstallOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleScannerDownloadAsync(services, channel, output, overwrite, install, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scanner.Add(download);
|
||||
return scanner;
|
||||
}
|
||||
|
||||
private static Command BuildScanCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var scan = new Command("scan", "Execute scanners and manage scan outputs.");
|
||||
|
||||
var run = new Command("run", "Execute a scanner bundle with the configured runner.");
|
||||
var runnerOption = new Option<string>("--runner")
|
||||
{
|
||||
Description = "Execution runtime (dotnet, self, docker)."
|
||||
};
|
||||
var entryOption = new Option<string>("--entry")
|
||||
{
|
||||
Description = "Path to the scanner entrypoint or Docker image.",
|
||||
Required = true
|
||||
};
|
||||
var targetOption = new Option<string>("--target")
|
||||
{
|
||||
Description = "Directory to scan.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var argsArgument = new Argument<string[]>("scanner-args")
|
||||
{
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
|
||||
run.Add(runnerOption);
|
||||
run.Add(entryOption);
|
||||
run.Add(targetOption);
|
||||
run.Add(argsArgument);
|
||||
|
||||
run.SetAction((parseResult, _) =>
|
||||
{
|
||||
var runner = parseResult.GetValue(runnerOption) ?? options.DefaultRunner;
|
||||
var entry = parseResult.GetValue(entryOption) ?? string.Empty;
|
||||
var target = parseResult.GetValue(targetOption) ?? string.Empty;
|
||||
var forwardedArgs = parseResult.GetValue(argsArgument) ?? Array.Empty<string>();
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleScannerRunAsync(services, runner, entry, target, forwardedArgs, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var upload = new Command("upload", "Upload completed scan results to the backend.");
|
||||
var fileOption = new Option<string>("--file")
|
||||
{
|
||||
Description = "Path to the scan result artifact.",
|
||||
Required = true
|
||||
};
|
||||
upload.Add(fileOption);
|
||||
upload.SetAction((parseResult, _) =>
|
||||
{
|
||||
var file = parseResult.GetValue(fileOption) ?? string.Empty;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleScanUploadAsync(services, file, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
scan.Add(run);
|
||||
scan.Add(upload);
|
||||
return scan;
|
||||
}
|
||||
|
||||
private static Command BuildDatabaseCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var db = new Command("db", "Trigger Feedser database operations via backend jobs.");
|
||||
|
||||
var fetch = new Command("fetch", "Trigger connector fetch/parse/map stages.");
|
||||
var sourceOption = new Option<string>("--source")
|
||||
{
|
||||
Description = "Connector source identifier (e.g. redhat, osv, vmware).",
|
||||
Required = true
|
||||
};
|
||||
var stageOption = new Option<string>("--stage")
|
||||
{
|
||||
Description = "Stage to trigger: fetch, parse, or map."
|
||||
};
|
||||
var modeOption = new Option<string?>("--mode")
|
||||
{
|
||||
Description = "Optional connector-specific mode (init, resume, cursor)."
|
||||
};
|
||||
|
||||
fetch.Add(sourceOption);
|
||||
fetch.Add(stageOption);
|
||||
fetch.Add(modeOption);
|
||||
fetch.SetAction((parseResult, _) =>
|
||||
{
|
||||
var source = parseResult.GetValue(sourceOption) ?? string.Empty;
|
||||
var stage = parseResult.GetValue(stageOption) ?? "fetch";
|
||||
var mode = parseResult.GetValue(modeOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleConnectorJobAsync(services, source, stage, mode, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var merge = new Command("merge", "Run canonical merge reconciliation.");
|
||||
merge.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleMergeJobAsync(services, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var export = new Command("export", "Run Feedser export jobs.");
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Export format: json or trivy-db."
|
||||
};
|
||||
var deltaOption = new Option<bool>("--delta")
|
||||
{
|
||||
Description = "Request a delta export when supported."
|
||||
};
|
||||
var publishFullOption = new Option<bool?>("--publish-full")
|
||||
{
|
||||
Description = "Override whether full exports push to ORAS (true/false)."
|
||||
};
|
||||
var publishDeltaOption = new Option<bool?>("--publish-delta")
|
||||
{
|
||||
Description = "Override whether delta exports push to ORAS (true/false)."
|
||||
};
|
||||
var includeFullOption = new Option<bool?>("--bundle-full")
|
||||
{
|
||||
Description = "Override whether offline bundles include full exports (true/false)."
|
||||
};
|
||||
var includeDeltaOption = new Option<bool?>("--bundle-delta")
|
||||
{
|
||||
Description = "Override whether offline bundles include delta exports (true/false)."
|
||||
};
|
||||
|
||||
export.Add(formatOption);
|
||||
export.Add(deltaOption);
|
||||
export.Add(publishFullOption);
|
||||
export.Add(publishDeltaOption);
|
||||
export.Add(includeFullOption);
|
||||
export.Add(includeDeltaOption);
|
||||
export.SetAction((parseResult, _) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "json";
|
||||
var delta = parseResult.GetValue(deltaOption);
|
||||
var publishFull = parseResult.GetValue(publishFullOption);
|
||||
var publishDelta = parseResult.GetValue(publishDeltaOption);
|
||||
var includeFull = parseResult.GetValue(includeFullOption);
|
||||
var includeDelta = parseResult.GetValue(includeDeltaOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExportJobAsync(services, format, delta, publishFull, publishDelta, includeFull, includeDelta, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
db.Add(fetch);
|
||||
db.Add(merge);
|
||||
db.Add(export);
|
||||
return db;
|
||||
}
|
||||
|
||||
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
|
||||
|
||||
var login = new Command("login", "Acquire and cache access tokens using the configured credentials.");
|
||||
var forceOption = new Option<bool>("--force")
|
||||
{
|
||||
Description = "Ignore existing cached tokens and force re-authentication."
|
||||
};
|
||||
login.Add(forceOption);
|
||||
login.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
return CommandHandlers.HandleAuthLoginAsync(services, options, verbose, force, cancellationToken);
|
||||
});
|
||||
|
||||
var logout = new Command("logout", "Remove cached tokens for the current credentials.");
|
||||
logout.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthLogoutAsync(services, options, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var status = new Command("status", "Display cached token status.");
|
||||
status.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthStatusAsync(services, options, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
var whoami = new Command("whoami", "Display cached token claims (subject, scopes, expiry).");
|
||||
whoami.SetAction((parseResult, _) =>
|
||||
{
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleAuthWhoAmIAsync(services, options, verbose, cancellationToken);
|
||||
});
|
||||
|
||||
auth.Add(login);
|
||||
auth.Add(logout);
|
||||
auth.Add(status);
|
||||
auth.Add(whoami);
|
||||
return auth;
|
||||
}
|
||||
|
||||
private static Command BuildConfigCommand(StellaOpsCliOptions options)
|
||||
{
|
||||
var config = new Command("config", "Inspect CLI configuration state.");
|
||||
var show = new Command("show", "Display resolved configuration values.");
|
||||
|
||||
show.SetAction((_, _) =>
|
||||
{
|
||||
var authority = options.Authority ?? new StellaOpsCliAuthorityOptions();
|
||||
var lines = new[]
|
||||
{
|
||||
$"Backend URL: {MaskIfEmpty(options.BackendUrl)}",
|
||||
$"API Key: {DescribeSecret(options.ApiKey)}",
|
||||
$"Scanner Cache: {options.ScannerCacheDirectory}",
|
||||
$"Results Directory: {options.ResultsDirectory}",
|
||||
$"Default Runner: {options.DefaultRunner}",
|
||||
$"Authority URL: {MaskIfEmpty(authority.Url)}",
|
||||
$"Authority Client ID: {MaskIfEmpty(authority.ClientId)}",
|
||||
$"Authority Client Secret: {DescribeSecret(authority.ClientSecret ?? string.Empty)}",
|
||||
$"Authority Username: {MaskIfEmpty(authority.Username)}",
|
||||
$"Authority Password: {DescribeSecret(authority.Password ?? string.Empty)}",
|
||||
$"Authority Scope: {MaskIfEmpty(authority.Scope)}",
|
||||
$"Authority Token Cache: {MaskIfEmpty(authority.TokenCacheDirectory ?? string.Empty)}"
|
||||
};
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
config.Add(show);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static string MaskIfEmpty(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? "<not configured>" : value;
|
||||
|
||||
private static string DescribeSecret(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "<not configured>";
|
||||
}
|
||||
|
||||
return value.Length switch
|
||||
{
|
||||
<= 4 => "****",
|
||||
_ => $"{value[..2]}***{value[^2..]}"
|
||||
};
|
||||
}
|
||||
}
|
||||
840
src/StellaOps.Cli/Commands/CommandHandlers.cs
Normal file
840
src/StellaOps.Cli/Commands/CommandHandlers.cs
Normal file
@@ -0,0 +1,840 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Prompts;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class CommandHandlers
|
||||
{
|
||||
public static async Task HandleScannerDownloadAsync(
|
||||
IServiceProvider services,
|
||||
string channel,
|
||||
string? output,
|
||||
bool overwrite,
|
||||
bool install,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-download");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scanner.download", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scanner download");
|
||||
activity?.SetTag("stellaops.cli.channel", channel);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scanner download");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await client.DownloadScannerAsync(channel, output ?? string.Empty, overwrite, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.FromCache)
|
||||
{
|
||||
logger.LogInformation("Using cached scanner at {Path}.", result.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", result.Path, result.SizeBytes);
|
||||
}
|
||||
|
||||
CliMetrics.RecordScannerDownload(channel, result.FromCache);
|
||||
|
||||
if (install)
|
||||
{
|
||||
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
|
||||
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
|
||||
CliMetrics.RecordScannerInstall(channel);
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to download scanner bundle.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScannerRunAsync(
|
||||
IServiceProvider services,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var executor = scope.ServiceProvider.GetRequiredService<IScannerExecutor>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-run");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.run", ActivityKind.Internal);
|
||||
activity?.SetTag("stellaops.cli.command", "scan run");
|
||||
activity?.SetTag("stellaops.cli.runner", runner);
|
||||
activity?.SetTag("stellaops.cli.entry", entry);
|
||||
activity?.SetTag("stellaops.cli.target", targetDirectory);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan run");
|
||||
|
||||
try
|
||||
{
|
||||
var options = scope.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
|
||||
var resultsDirectory = options.ResultsDirectory;
|
||||
|
||||
var executionResult = await executor.RunAsync(
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsDirectory,
|
||||
arguments,
|
||||
verbose,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Environment.ExitCode = executionResult.ExitCode;
|
||||
CliMetrics.RecordScanRun(runner, executionResult.ExitCode);
|
||||
|
||||
if (executionResult.ExitCode == 0)
|
||||
{
|
||||
var backend = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
logger.LogInformation("Uploading scan artefact {Path}...", executionResult.ResultsPath);
|
||||
await backend.UploadScanResultsAsync(executionResult.ResultsPath, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Scan artefact uploaded.");
|
||||
activity?.SetTag("stellaops.cli.results", executionResult.ResultsPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Skipping automatic upload because scan exited with code {Code}.", executionResult.ExitCode);
|
||||
}
|
||||
|
||||
logger.LogInformation("Run metadata written to {Path}.", executionResult.RunMetadataPath);
|
||||
activity?.SetTag("stellaops.cli.run_metadata", executionResult.RunMetadataPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Scanner execution failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleScanUploadAsync(
|
||||
IServiceProvider services,
|
||||
string file,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("scanner-upload");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.scan.upload", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "scan upload");
|
||||
activity?.SetTag("stellaops.cli.file", file);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("scan upload");
|
||||
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(file);
|
||||
await client.UploadScanResultsAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Scan results uploaded successfully.");
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to upload scan results.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleConnectorJobAsync(
|
||||
IServiceProvider services,
|
||||
string source,
|
||||
string stage,
|
||||
string? mode,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-connector");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.fetch", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db fetch");
|
||||
activity?.SetTag("stellaops.cli.source", source);
|
||||
activity?.SetTag("stellaops.cli.stage", stage);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.mode", mode);
|
||||
}
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db fetch");
|
||||
|
||||
try
|
||||
{
|
||||
var jobKind = $"source:{source}:{stage}";
|
||||
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
parameters["mode"] = mode;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Connector job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleMergeJobAsync(
|
||||
IServiceProvider services,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-merge");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.merge", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db merge");
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db merge");
|
||||
|
||||
try
|
||||
{
|
||||
await TriggerJobAsync(client, logger, "merge:reconcile", new Dictionary<string, object?>(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Merge job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleExportJobAsync(
|
||||
IServiceProvider services,
|
||||
string format,
|
||||
bool delta,
|
||||
bool? publishFull,
|
||||
bool? publishDelta,
|
||||
bool? includeFull,
|
||||
bool? includeDelta,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("db-export");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.db.export", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "db export");
|
||||
activity?.SetTag("stellaops.cli.format", format);
|
||||
activity?.SetTag("stellaops.cli.delta", delta);
|
||||
using var duration = CliMetrics.MeasureCommandDuration("db export");
|
||||
activity?.SetTag("stellaops.cli.publish_full", publishFull);
|
||||
activity?.SetTag("stellaops.cli.publish_delta", publishDelta);
|
||||
activity?.SetTag("stellaops.cli.include_full", includeFull);
|
||||
activity?.SetTag("stellaops.cli.include_delta", includeDelta);
|
||||
|
||||
try
|
||||
{
|
||||
var jobKind = format switch
|
||||
{
|
||||
"trivy-db" or "trivy" => "export:trivy-db",
|
||||
_ => "export:json"
|
||||
};
|
||||
|
||||
var isTrivy = jobKind == "export:trivy-db";
|
||||
if (isTrivy
|
||||
&& !publishFull.HasValue
|
||||
&& !publishDelta.HasValue
|
||||
&& !includeFull.HasValue
|
||||
&& !includeDelta.HasValue
|
||||
&& AnsiConsole.Profile.Capabilities.Interactive)
|
||||
{
|
||||
var overrides = TrivyDbExportPrompt.PromptOverrides();
|
||||
publishFull = overrides.publishFull;
|
||||
publishDelta = overrides.publishDelta;
|
||||
includeFull = overrides.includeFull;
|
||||
includeDelta = overrides.includeDelta;
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["delta"] = delta
|
||||
};
|
||||
if (publishFull.HasValue)
|
||||
{
|
||||
parameters["publishFull"] = publishFull.Value;
|
||||
}
|
||||
if (publishDelta.HasValue)
|
||||
{
|
||||
parameters["publishDelta"] = publishDelta.Value;
|
||||
}
|
||||
if (includeFull.HasValue)
|
||||
{
|
||||
parameters["includeFull"] = includeFull.Value;
|
||||
}
|
||||
if (includeDelta.HasValue)
|
||||
{
|
||||
parameters["includeDelta"] = includeDelta.Value;
|
||||
}
|
||||
|
||||
await TriggerJobAsync(client, logger, jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Export job failed.");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleAuthLoginAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
bool verbose,
|
||||
bool force,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-login");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
logger.LogError("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update your configuration.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
||||
if (tokenClient is null)
|
||||
{
|
||||
logger.LogError("Authority client is not available. Ensure AddStellaOpsAuthClient is registered in Program.cs.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
logger.LogError("Authority configuration is incomplete; unable to determine cache key.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (force)
|
||||
{
|
||||
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var scopeName = AuthorityTokenUtilities.ResolveScope(options);
|
||||
StellaOpsTokenResult token;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
||||
{
|
||||
logger.LogError("Authority password must be provided when username is configured.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
token = await tokenClient.RequestPasswordTokenAsync(
|
||||
options.Authority.Username,
|
||||
options.Authority.Password!,
|
||||
scopeName,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await tokenClient.RequestClientCredentialsTokenAsync(scopeName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation("Authenticated with {Authority} (scopes: {Scopes}).", options.Authority.Url, string.Join(", ", token.Scopes));
|
||||
}
|
||||
|
||||
logger.LogInformation("Login successful. Access token expires at {Expires}.", token.ExpiresAtUtc.ToString("u"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Authentication failed: {Message}", ex.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleAuthLogoutAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-logout");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
||||
if (tokenClient is null)
|
||||
{
|
||||
logger.LogInformation("No authority client registered; nothing to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
logger.LogInformation("Authority configuration missing; no cached tokens to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation("Cleared cached token for {Authority}.", options.Authority?.Url ?? "authority");
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleAuthStatusAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-status");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
||||
if (tokenClient is null)
|
||||
{
|
||||
logger.LogInformation("Authority client not registered; no cached tokens available.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Cached token for {Authority} expires at {Expires}.", options.Authority.Url, entry.ExpiresAtUtc.ToString("u"));
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleAuthWhoAmIAsync(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-whoami");
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>();
|
||||
if (tokenClient is null)
|
||||
{
|
||||
logger.LogInformation("Authority client not registered; no cached tokens available.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options);
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
logger.LogInformation("Authority configuration incomplete; no cached tokens available.");
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url);
|
||||
Environment.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var remaining = entry.ExpiresAtUtc - now;
|
||||
if (remaining < TimeSpan.Zero)
|
||||
{
|
||||
remaining = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
logger.LogInformation("Authority: {Authority}", options.Authority.Url);
|
||||
logger.LogInformation("Grant type: {GrantType}", grantType);
|
||||
logger.LogInformation("Token type: {TokenType}", entry.TokenType);
|
||||
logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining));
|
||||
|
||||
if (entry.Scopes.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
|
||||
}
|
||||
|
||||
if (TryExtractJwtClaims(entry.AccessToken, out var claims, out var issuedAt, out var notBefore))
|
||||
{
|
||||
if (claims.TryGetValue("sub", out var subject) && !string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
logger.LogInformation("Subject: {Subject}", subject);
|
||||
}
|
||||
|
||||
if (claims.TryGetValue("client_id", out var clientId) && !string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
logger.LogInformation("Client ID (token): {ClientId}", clientId);
|
||||
}
|
||||
|
||||
if (claims.TryGetValue("aud", out var audience) && !string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
logger.LogInformation("Audience: {Audience}", audience);
|
||||
}
|
||||
|
||||
if (claims.TryGetValue("iss", out var issuer) && !string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
logger.LogInformation("Issuer: {Issuer}", issuer);
|
||||
}
|
||||
|
||||
if (issuedAt is not null)
|
||||
{
|
||||
logger.LogInformation("Issued at: {IssuedAt}", issuedAt.Value.ToString("u"));
|
||||
}
|
||||
|
||||
if (notBefore is not null)
|
||||
{
|
||||
logger.LogInformation("Not before: {NotBefore}", notBefore.Value.ToString("u"));
|
||||
}
|
||||
|
||||
var extraClaims = CollectAdditionalClaims(claims);
|
||||
if (extraClaims.Count > 0 && verbose)
|
||||
{
|
||||
logger.LogInformation("Additional claims: {Claims}", string.Join(", ", extraClaims));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Access token appears opaque; claims are unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return "expired";
|
||||
}
|
||||
|
||||
if (duration.TotalDays >= 1)
|
||||
{
|
||||
var days = (int)duration.TotalDays;
|
||||
var hours = duration.Hours;
|
||||
return hours > 0
|
||||
? FormattableString.Invariant($"{days}d {hours}h")
|
||||
: FormattableString.Invariant($"{days}d");
|
||||
}
|
||||
|
||||
if (duration.TotalHours >= 1)
|
||||
{
|
||||
return FormattableString.Invariant($"{(int)duration.TotalHours}h {duration.Minutes}m");
|
||||
}
|
||||
|
||||
if (duration.TotalMinutes >= 1)
|
||||
{
|
||||
return FormattableString.Invariant($"{(int)duration.TotalMinutes}m {duration.Seconds}s");
|
||||
}
|
||||
|
||||
return FormattableString.Invariant($"{duration.Seconds}s");
|
||||
}
|
||||
|
||||
private static bool TryExtractJwtClaims(
|
||||
string accessToken,
|
||||
out Dictionary<string, string> claims,
|
||||
out DateTimeOffset? issuedAt,
|
||||
out DateTimeOffset? notBefore)
|
||||
{
|
||||
claims = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
issuedAt = null;
|
||||
notBefore = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = accessToken.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64Url(parts[1], out var payloadBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(payloadBytes);
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
var value = FormatJsonValue(property.Value);
|
||||
claims[property.Name] = value;
|
||||
|
||||
if (issuedAt is null && property.NameEquals("iat") && TryParseUnixSeconds(property.Value, out var parsedIat))
|
||||
{
|
||||
issuedAt = parsedIat;
|
||||
}
|
||||
|
||||
if (notBefore is null && property.NameEquals("nbf") && TryParseUnixSeconds(property.Value, out var parsedNbf))
|
||||
{
|
||||
notBefore = parsedNbf;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
claims.Clear();
|
||||
issuedAt = null;
|
||||
notBefore = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64Url(string value, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
var padding = normalized.Length % 4;
|
||||
if (padding is 2 or 3)
|
||||
{
|
||||
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
|
||||
}
|
||||
else if (padding == 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(normalized);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatJsonValue(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString() ?? string.Empty,
|
||||
JsonValueKind.Number => element.TryGetInt64(out var longValue)
|
||||
? longValue.ToString(CultureInfo.InvariantCulture)
|
||||
: element.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
JsonValueKind.Array => FormatArray(element),
|
||||
JsonValueKind.Object => element.GetRawText(),
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatArray(JsonElement array)
|
||||
{
|
||||
var values = new List<string>();
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
values.Add(FormatJsonValue(item));
|
||||
}
|
||||
|
||||
return string.Join(", ", values);
|
||||
}
|
||||
|
||||
private static bool TryParseUnixSeconds(JsonElement element, out DateTimeOffset value)
|
||||
{
|
||||
value = default;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
if (element.TryGetInt64(out var seconds))
|
||||
{
|
||||
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.TryGetDouble(out var doubleValue))
|
||||
{
|
||||
value = DateTimeOffset.FromUnixTimeSeconds((long)doubleValue);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = element.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds))
|
||||
{
|
||||
value = DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<string> CollectAdditionalClaims(Dictionary<string, string> claims)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var pair in claims)
|
||||
{
|
||||
if (CommonClaimNames.Contains(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(FormattableString.Invariant($"{pair.Key}={pair.Value}"));
|
||||
}
|
||||
|
||||
result.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> CommonClaimNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"aud",
|
||||
"client_id",
|
||||
"exp",
|
||||
"iat",
|
||||
"iss",
|
||||
"nbf",
|
||||
"scope",
|
||||
"scopes",
|
||||
"sub",
|
||||
"token_type",
|
||||
"jti"
|
||||
};
|
||||
|
||||
private static async Task TriggerJobAsync(
|
||||
IBackendOperationsClient client,
|
||||
ILogger logger,
|
||||
string jobKind,
|
||||
IDictionary<string, object?> parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
JobTriggerResult result = await client.TriggerJobAsync(jobKind, parameters, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.Location))
|
||||
{
|
||||
logger.LogInformation("Job accepted. Track status at {Location}.", result.Location);
|
||||
}
|
||||
else if (result.Run is not null)
|
||||
{
|
||||
logger.LogInformation("Job accepted. RunId: {RunId} Status: {Status}", result.Run.RunId, result.Run.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Job accepted.");
|
||||
}
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs
Normal file
34
src/StellaOps.Cli/Configuration/AuthorityTokenUtilities.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
internal static class AuthorityTokenUtilities
|
||||
{
|
||||
public static string ResolveScope(StellaOpsCliOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var scope = options.Authority?.Scope;
|
||||
return string.IsNullOrWhiteSpace(scope)
|
||||
? StellaOpsScopes.FeedserJobsTrigger
|
||||
: scope.Trim();
|
||||
}
|
||||
|
||||
public static string BuildCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.Authority is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var scope = ResolveScope(options);
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
return $"{options.Authority.Url}|{credential}|{scope}";
|
||||
}
|
||||
}
|
||||
278
src/StellaOps.Cli/Configuration/CliBootstrapper.cs
Normal file
278
src/StellaOps.Cli/Configuration/CliBootstrapper.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
public static class CliBootstrapper
|
||||
{
|
||||
public static (StellaOpsCliOptions Options, IConfigurationRoot Configuration) Build(string[] args)
|
||||
{
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<StellaOpsCliOptions>(options =>
|
||||
{
|
||||
options.BindingSection = "StellaOps";
|
||||
options.ConfigureBuilder = builder =>
|
||||
{
|
||||
if (args.Length > 0)
|
||||
{
|
||||
builder.AddCommandLine(args);
|
||||
}
|
||||
};
|
||||
options.PostBind = (cliOptions, configuration) =>
|
||||
{
|
||||
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
|
||||
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
|
||||
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
|
||||
|
||||
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
|
||||
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
|
||||
|
||||
var attemptsRaw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"SCANNER_DOWNLOAD_ATTEMPTS",
|
||||
"STELLAOPS_SCANNER_DOWNLOAD_ATTEMPTS",
|
||||
"StellaOps:ScannerDownloadAttempts",
|
||||
"ScannerDownloadAttempts");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attemptsRaw))
|
||||
{
|
||||
attemptsRaw = cliOptions.ScannerDownloadAttempts.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (int.TryParse(attemptsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempts) && parsedAttempts > 0)
|
||||
{
|
||||
cliOptions.ScannerDownloadAttempts = parsedAttempts;
|
||||
}
|
||||
|
||||
if (cliOptions.ScannerDownloadAttempts <= 0)
|
||||
{
|
||||
cliOptions.ScannerDownloadAttempts = 3;
|
||||
}
|
||||
|
||||
cliOptions.Authority ??= new StellaOpsCliAuthorityOptions();
|
||||
var authority = cliOptions.Authority;
|
||||
|
||||
authority.Url = ResolveWithFallback(
|
||||
authority.Url,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_URL",
|
||||
"StellaOps:Authority:Url",
|
||||
"Authority:Url",
|
||||
"Authority:Issuer");
|
||||
|
||||
authority.ClientId = ResolveWithFallback(
|
||||
authority.ClientId,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_CLIENT_ID",
|
||||
"StellaOps:Authority:ClientId",
|
||||
"Authority:ClientId");
|
||||
|
||||
authority.ClientSecret = ResolveWithFallback(
|
||||
authority.ClientSecret ?? string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_CLIENT_SECRET",
|
||||
"StellaOps:Authority:ClientSecret",
|
||||
"Authority:ClientSecret");
|
||||
|
||||
authority.Username = ResolveWithFallback(
|
||||
authority.Username,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_USERNAME",
|
||||
"StellaOps:Authority:Username",
|
||||
"Authority:Username");
|
||||
|
||||
authority.Password = ResolveWithFallback(
|
||||
authority.Password ?? string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_PASSWORD",
|
||||
"StellaOps:Authority:Password",
|
||||
"Authority:Password");
|
||||
|
||||
authority.Scope = ResolveWithFallback(
|
||||
authority.Scope,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_SCOPE",
|
||||
"StellaOps:Authority:Scope",
|
||||
"Authority:Scope");
|
||||
|
||||
authority.TokenCacheDirectory = ResolveWithFallback(
|
||||
authority.TokenCacheDirectory,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_TOKEN_CACHE_DIR",
|
||||
"StellaOps:Authority:TokenCacheDirectory",
|
||||
"Authority:TokenCacheDirectory");
|
||||
|
||||
authority.Url = authority.Url?.Trim() ?? string.Empty;
|
||||
authority.ClientId = authority.ClientId?.Trim() ?? string.Empty;
|
||||
authority.ClientSecret = string.IsNullOrWhiteSpace(authority.ClientSecret) ? null : authority.ClientSecret.Trim();
|
||||
authority.Username = authority.Username?.Trim() ?? string.Empty;
|
||||
authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim();
|
||||
authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.FeedserJobsTrigger : authority.Scope.Trim();
|
||||
|
||||
authority.Resilience ??= new StellaOpsCliAuthorityResilienceOptions();
|
||||
authority.Resilience.RetryDelays ??= new List<TimeSpan>();
|
||||
var resilience = authority.Resilience;
|
||||
|
||||
if (!resilience.EnableRetries.HasValue)
|
||||
{
|
||||
var raw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_ENABLE_RETRIES",
|
||||
"StellaOps:Authority:Resilience:EnableRetries",
|
||||
"StellaOps:Authority:EnableRetries",
|
||||
"Authority:Resilience:EnableRetries",
|
||||
"Authority:EnableRetries");
|
||||
|
||||
if (TryParseBoolean(raw, out var parsed))
|
||||
{
|
||||
resilience.EnableRetries = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
var retryDelaysRaw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_RETRY_DELAYS",
|
||||
"StellaOps:Authority:Resilience:RetryDelays",
|
||||
"StellaOps:Authority:RetryDelays",
|
||||
"Authority:Resilience:RetryDelays",
|
||||
"Authority:RetryDelays");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(retryDelaysRaw))
|
||||
{
|
||||
resilience.RetryDelays.Clear();
|
||||
foreach (var delay in ParseRetryDelays(retryDelaysRaw))
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
resilience.RetryDelays.Add(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resilience.AllowOfflineCacheFallback.HasValue)
|
||||
{
|
||||
var raw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK",
|
||||
"StellaOps:Authority:Resilience:AllowOfflineCacheFallback",
|
||||
"StellaOps:Authority:AllowOfflineCacheFallback",
|
||||
"Authority:Resilience:AllowOfflineCacheFallback",
|
||||
"Authority:AllowOfflineCacheFallback");
|
||||
|
||||
if (TryParseBoolean(raw, out var parsed))
|
||||
{
|
||||
resilience.AllowOfflineCacheFallback = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resilience.OfflineCacheTolerance.HasValue)
|
||||
{
|
||||
var raw = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE",
|
||||
"StellaOps:Authority:Resilience:OfflineCacheTolerance",
|
||||
"StellaOps:Authority:OfflineCacheTolerance",
|
||||
"Authority:Resilience:OfflineCacheTolerance",
|
||||
"Authority:OfflineCacheTolerance");
|
||||
|
||||
if (TimeSpan.TryParse(raw, CultureInfo.InvariantCulture, out var tolerance) && tolerance >= TimeSpan.Zero)
|
||||
{
|
||||
resilience.OfflineCacheTolerance = tolerance;
|
||||
}
|
||||
}
|
||||
|
||||
var defaultTokenCache = GetDefaultTokenCacheDirectory();
|
||||
if (string.IsNullOrWhiteSpace(authority.TokenCacheDirectory))
|
||||
{
|
||||
authority.TokenCacheDirectory = defaultTokenCache;
|
||||
}
|
||||
else
|
||||
{
|
||||
authority.TokenCacheDirectory = Path.GetFullPath(authority.TokenCacheDirectory);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (bootstrap.Options, bootstrap.Configuration);
|
||||
}
|
||||
|
||||
private static string ResolveWithFallback(string currentValue, IConfiguration configuration, params string[] keys)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(currentValue))
|
||||
{
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = configuration[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static bool TryParseBoolean(string value, out bool parsed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
parsed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
|
||||
{
|
||||
parsed = numeric != 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
parsed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<TimeSpan> ParseRetryDelays(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var separators = new[] { ',', ';', ' ' };
|
||||
foreach (var token in raw.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (TimeSpan.TryParse(token, CultureInfo.InvariantCulture, out var delay) && delay > TimeSpan.Zero)
|
||||
{
|
||||
yield return delay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultTokenCacheDirectory()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
{
|
||||
home = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(home, ".stellaops", "tokens"));
|
||||
}
|
||||
}
|
||||
56
src/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs
Normal file
56
src/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
public string BackendUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ScannerCacheDirectory { get; set; } = "scanners";
|
||||
|
||||
public string ResultsDirectory { get; set; } = "results";
|
||||
|
||||
public string DefaultRunner { get; set; } = "docker";
|
||||
|
||||
public string ScannerSignaturePublicKeyPath { get; set; } = string.Empty;
|
||||
|
||||
public int ScannerDownloadAttempts { get; set; } = 3;
|
||||
|
||||
public int ScanUploadAttempts { get; set; } = 3;
|
||||
|
||||
public StellaOpsCliAuthorityOptions Authority { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliAuthorityOptions
|
||||
{
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string? Password { get; set; }
|
||||
|
||||
public string Scope { get; set; } = StellaOpsScopes.FeedserJobsTrigger;
|
||||
|
||||
public string TokenCacheDirectory { get; set; } = string.Empty;
|
||||
|
||||
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliAuthorityResilienceOptions
|
||||
{
|
||||
public bool? EnableRetries { get; set; }
|
||||
|
||||
public IList<TimeSpan> RetryDelays { get; set; } = new List<TimeSpan>();
|
||||
|
||||
public bool? AllowOfflineCacheFallback { get; set; }
|
||||
|
||||
public TimeSpan? OfflineCacheTolerance { get; set; }
|
||||
}
|
||||
119
src/StellaOps.Cli/Program.cs
Normal file
119
src/StellaOps.Cli/Program.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
|
||||
namespace StellaOps.Cli;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
internal static async Task<int> Main(string[] args)
|
||||
{
|
||||
var (options, configuration) = CliBootstrapper.Build(args);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(configuration);
|
||||
services.AddSingleton(options);
|
||||
|
||||
var verbosityState = new VerbosityState();
|
||||
services.AddSingleton(verbosityState);
|
||||
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddSimpleConsole(logOptions =>
|
||||
{
|
||||
logOptions.TimestampFormat = "HH:mm:ss ";
|
||||
logOptions.SingleLine = true;
|
||||
});
|
||||
builder.AddFilter((category, level) => level >= verbosityState.MinimumLevel);
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Url))
|
||||
{
|
||||
services.AddStellaOpsAuthClient(clientOptions =>
|
||||
{
|
||||
clientOptions.Authority = options.Authority.Url;
|
||||
clientOptions.ClientId = options.Authority.ClientId ?? string.Empty;
|
||||
clientOptions.ClientSecret = options.Authority.ClientSecret;
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
clientOptions.DefaultScopes.Add(string.IsNullOrWhiteSpace(options.Authority.Scope)
|
||||
? StellaOps.Auth.Abstractions.StellaOpsScopes.FeedserJobsTrigger
|
||||
: options.Authority.Scope);
|
||||
|
||||
var resilience = options.Authority.Resilience ?? new StellaOpsCliAuthorityResilienceOptions();
|
||||
clientOptions.EnableRetries = resilience.EnableRetries ?? true;
|
||||
|
||||
if (resilience.RetryDelays is { Count: > 0 })
|
||||
{
|
||||
clientOptions.RetryDelays.Clear();
|
||||
foreach (var delay in resilience.RetryDelays)
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
clientOptions.RetryDelays.Add(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resilience.AllowOfflineCacheFallback.HasValue)
|
||||
{
|
||||
clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value;
|
||||
}
|
||||
|
||||
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value >= TimeSpan.Zero)
|
||||
{
|
||||
clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value;
|
||||
}
|
||||
});
|
||||
|
||||
var cacheDirectory = options.Authority.TokenCacheDirectory;
|
||||
if (!string.IsNullOrWhiteSpace(cacheDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
services.AddStellaOpsFileTokenCache(cacheDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
services.AddHttpClient<IBackendOperationsClient, BackendOperationsClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
|
||||
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
var rootCommand = CommandFactory.Create(serviceProvider, options, cts.Token);
|
||||
var commandConfiguration = new CommandLineConfiguration(rootCommand);
|
||||
var commandExit = await commandConfiguration.InvokeAsync(args, cts.Token).ConfigureAwait(false);
|
||||
|
||||
var finalExit = Environment.ExitCode != 0 ? Environment.ExitCode : commandExit;
|
||||
if (cts.IsCancellationRequested && finalExit == 0)
|
||||
{
|
||||
finalExit = 130; // Typical POSIX cancellation exit code
|
||||
}
|
||||
|
||||
return finalExit;
|
||||
}
|
||||
}
|
||||
52
src/StellaOps.Cli/Prompts/TrivyDbExportPrompt.cs
Normal file
52
src/StellaOps.Cli/Prompts/TrivyDbExportPrompt.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Prompts;
|
||||
|
||||
internal static class TrivyDbExportPrompt
|
||||
{
|
||||
public static (bool? publishFull, bool? publishDelta, bool? includeFull, bool? includeDelta) PromptOverrides()
|
||||
{
|
||||
if (!AnsiConsole.Profile.Capabilities.Interactive)
|
||||
{
|
||||
return (null, null, null, null);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(
|
||||
new Panel("[bold]Trivy DB Export Overrides[/]")
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Header("Trivy DB")
|
||||
.Collapse());
|
||||
|
||||
var shouldOverride = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Adjust publishing or offline bundle behaviour?")
|
||||
.AddChoices("Leave defaults", "Override"));
|
||||
|
||||
if (shouldOverride == "Leave defaults")
|
||||
{
|
||||
return (null, null, null, null);
|
||||
}
|
||||
|
||||
var publishFull = PromptBoolean("Push full exports to ORAS?");
|
||||
var publishDelta = PromptBoolean("Push delta exports to ORAS?");
|
||||
var includeFull = PromptBoolean("Include full exports in offline bundle?");
|
||||
var includeDelta = PromptBoolean("Include delta exports in offline bundle?");
|
||||
|
||||
return (publishFull, publishDelta, includeFull, includeDelta);
|
||||
}
|
||||
|
||||
private static bool? PromptBoolean(string question)
|
||||
{
|
||||
var choice = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title($"{question} [grey](select override or keep default)[/]")
|
||||
.AddChoices("Keep default", "Yes", "No"));
|
||||
|
||||
return choice switch
|
||||
{
|
||||
"Yes" => true,
|
||||
"No" => false,
|
||||
_ => (bool?)null,
|
||||
};
|
||||
}
|
||||
}
|
||||
3
src/StellaOps.Cli/Properties/AssemblyInfo.cs
Normal file
3
src/StellaOps.Cli/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]
|
||||
535
src/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
535
src/StellaOps.Cli/Services/BackendOperationsClient.cs
Normal file
@@ -0,0 +1,535 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<BackendOperationsClient> _logger;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly object _tokenSync = new();
|
||||
private string? _cachedAccessToken;
|
||||
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public BackendOperationsClient(HttpClient httpClient, StellaOpsCliOptions options, ILogger<BackendOperationsClient> logger, IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim();
|
||||
outputPath = ResolveArtifactPath(outputPath, channel);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
|
||||
if (!overwrite && File.Exists(outputPath))
|
||||
{
|
||||
var existing = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length);
|
||||
return new ScannerArtifactResult(outputPath, existing.Length, true);
|
||||
}
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var backoffSeconds = Math.Pow(2, attempt);
|
||||
_logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ScannerArtifactResult> ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
var tempFile = outputPath + ".tmp";
|
||||
await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var fileStream = File.Create(tempFile))
|
||||
{
|
||||
await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest");
|
||||
var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature");
|
||||
|
||||
var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false);
|
||||
await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated";
|
||||
_logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote);
|
||||
}
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
File.Move(tempFile, outputPath);
|
||||
|
||||
PersistMetadata(outputPath, channel, digestHex, signatureHeader, response);
|
||||
|
||||
var downloaded = new FileInfo(outputPath);
|
||||
_logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length);
|
||||
|
||||
return new ScannerArtifactResult(outputPath, downloaded.Length, false);
|
||||
}
|
||||
|
||||
public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("Scan result file not found.", filePath);
|
||||
}
|
||||
|
||||
var maxAttempts = Math.Max(1, _options.ScanUploadAttempts);
|
||||
var attempt = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
await using var fileStream = File.OpenRead(filePath);
|
||||
var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
content.Add(streamContent, "file", Path.GetFileName(filePath));
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Post, "api/scanner/results");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
request.Content = content;
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Scan results uploaded from {Path}.", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
if (attempt >= maxAttempts)
|
||||
{
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var delay = GetRetryDelay(response, attempt);
|
||||
_logger.LogWarning(
|
||||
"Scan upload attempt {Attempt}/{MaxAttempts} failed ({Reason}). Retrying in {Delay:F1}s...",
|
||||
attempt,
|
||||
maxAttempts,
|
||||
failure,
|
||||
delay.TotalSeconds);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (attempt < maxAttempts)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Scan upload attempt {Attempt}/{MaxAttempts} threw an exception. Retrying in {Delay:F1}s...",
|
||||
attempt,
|
||||
maxAttempts,
|
||||
delay.TotalSeconds);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobKind))
|
||||
{
|
||||
throw new ArgumentException("Job kind must be provided.", nameof(jobKind));
|
||||
}
|
||||
|
||||
var requestBody = new JobTriggerRequest
|
||||
{
|
||||
Trigger = "cli",
|
||||
Parameters = parameters is null ? new Dictionary<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(parameters, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
JobRunResponse? run = null;
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
run = await response.Content.ReadFromJsonAsync<JobRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind);
|
||||
}
|
||||
}
|
||||
|
||||
var location = response.Headers.Location?.ToString();
|
||||
return new JobTriggerResult(true, "Accepted", location, run);
|
||||
}
|
||||
|
||||
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return new JobTriggerResult(false, failureMessage, null, null);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||
{
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid request URI '{relativeUri}'.");
|
||||
}
|
||||
|
||||
if (requestUri.IsAbsoluteUri)
|
||||
{
|
||||
// Nothing to normalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative);
|
||||
}
|
||||
|
||||
return new HttpRequestMessage(method, requestUri);
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
return _options.ApiKey;
|
||||
}
|
||||
|
||||
if (_tokenClient is null || string.IsNullOrWhiteSpace(_options.Authority.Url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_cachedAccessToken) && now < _cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var cacheKey = AuthorityTokenUtilities.BuildCacheKey(_options);
|
||||
var cachedEntry = await _tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
|
||||
{
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = cachedEntry.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var scope = AuthorityTokenUtilities.ResolveScope(_options);
|
||||
|
||||
StellaOpsTokenResult token;
|
||||
if (!string.IsNullOrWhiteSpace(_options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured when username is provided.");
|
||||
}
|
||||
|
||||
token = await _tokenClient.RequestPasswordTokenAsync(
|
||||
_options.Authority.Username,
|
||||
_options.Authority.Password!,
|
||||
scope,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = token.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureBackendConfigured()
|
||||
{
|
||||
if (_httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveArtifactPath(string outputPath, string channel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return Path.GetFullPath(outputPath);
|
||||
}
|
||||
|
||||
var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(_options.ScannerCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
var fileName = $"stellaops-scanner-{channel}.tar.gz";
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Backend request failed with status ");
|
||||
builder.Append(statusCode);
|
||||
builder.Append(' ');
|
||||
builder.Append(response.ReasonPhrase ?? "Unknown");
|
||||
|
||||
if (response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (problem is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(problem.Title))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Title);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(problem.Detail))
|
||||
{
|
||||
builder.AppendLine().Append(problem.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
builder.AppendLine().Append(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
|
||||
{
|
||||
if (headers.TryGetValues(name, out var values))
|
||||
{
|
||||
return values.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
string digestHex;
|
||||
await using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(expectedDigest))
|
||||
{
|
||||
var normalized = NormalizeDigest(expectedDigest);
|
||||
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only.");
|
||||
}
|
||||
|
||||
return digestHex;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return digest[7..];
|
||||
}
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogDebug("Signature header present but no public key configured; skipping validation.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature missing while a public key is configured.");
|
||||
}
|
||||
|
||||
var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath);
|
||||
if (!File.Exists(publicKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath);
|
||||
}
|
||||
|
||||
var signatureBytes = Convert.FromBase64String(signatureHeader);
|
||||
var digestBytes = Convert.FromHexString(digestHex);
|
||||
|
||||
var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false);
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(pem);
|
||||
|
||||
var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
if (!valid)
|
||||
{
|
||||
throw new InvalidOperationException("Scanner signature validation failed.");
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response)
|
||||
{
|
||||
var metadata = new
|
||||
{
|
||||
channel,
|
||||
digest = $"sha256:{digestHex}",
|
||||
signature = signatureHeader,
|
||||
downloadedAt = DateTimeOffset.UtcNow,
|
||||
source = response.RequestMessage?.RequestUri?.ToString(),
|
||||
sizeBytes = new FileInfo(outputPath).Length,
|
||||
headers = new
|
||||
{
|
||||
etag = response.Headers.ETag?.Tag,
|
||||
lastModified = response.Content.Headers.LastModified,
|
||||
contentType = response.Content.Headers.ContentType?.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var metadataPath = outputPath + ".metadata.json";
|
||||
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
File.WriteAllText(metadataPath, json);
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
|
||||
{
|
||||
if (response.Headers.TryGetValues("Retry-After", out var retryValues))
|
||||
{
|
||||
var value = retryValues.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0)
|
||||
{
|
||||
return TimeSpan.FromSeconds(Math.Min(seconds, 300));
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var when))
|
||||
{
|
||||
var delta = when - DateTimeOffset.UtcNow;
|
||||
if (delta > TimeSpan.Zero)
|
||||
{
|
||||
return delta < TimeSpan.FromMinutes(5) ? delta : TimeSpan.FromMinutes(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackSeconds = Math.Min(60, Math.Pow(2, attempt));
|
||||
return TimeSpan.FromSeconds(fallbackSeconds);
|
||||
}
|
||||
}
|
||||
16
src/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
16
src/StellaOps.Cli/Services/IBackendOperationsClient.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IBackendOperationsClient
|
||||
{
|
||||
Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken);
|
||||
|
||||
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
|
||||
|
||||
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
|
||||
}
|
||||
17
src/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
17
src/StellaOps.Cli/Services/IScannerExecutor.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerExecutor
|
||||
{
|
||||
Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
9
src/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
9
src/StellaOps.Cli/Services/IScannerInstaller.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerInstaller
|
||||
{
|
||||
Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
9
src/StellaOps.Cli/Services/Models/JobTriggerResult.cs
Normal file
9
src/StellaOps.Cli/Services/Models/JobTriggerResult.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record JobTriggerResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JobRunResponse? Run);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobRunResponse
|
||||
{
|
||||
public Guid RunId { get; set; }
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public string Trigger { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
|
||||
public TimeSpan? Duration { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class JobTriggerRequest
|
||||
{
|
||||
public string Trigger { get; set; } = "cli";
|
||||
|
||||
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Detail { get; set; }
|
||||
|
||||
public int? Status { get; set; }
|
||||
|
||||
public string? Instance { get; set; }
|
||||
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
3
src/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
3
src/StellaOps.Cli/Services/ScannerExecutionResult.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed record ScannerExecutionResult(int ExitCode, string ResultsPath, string RunMetadataPath);
|
||||
329
src/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
329
src/StellaOps.Cli/Services/ScannerExecutor.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ILogger<ScannerExecutor> _logger;
|
||||
|
||||
public ScannerExecutor(ILogger<ScannerExecutor> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ScannerExecutionResult> RunAsync(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetDirectory))
|
||||
{
|
||||
throw new ArgumentException("Target directory must be provided.", nameof(targetDirectory));
|
||||
}
|
||||
|
||||
runner = string.IsNullOrWhiteSpace(runner) ? "docker" : runner.Trim().ToLowerInvariant();
|
||||
entry = entry?.Trim() ?? string.Empty;
|
||||
|
||||
var normalizedTarget = Path.GetFullPath(targetDirectory);
|
||||
if (!Directory.Exists(normalizedTarget))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Scan target directory '{normalizedTarget}' does not exist.");
|
||||
}
|
||||
|
||||
resultsDirectory = string.IsNullOrWhiteSpace(resultsDirectory)
|
||||
? Path.Combine(Directory.GetCurrentDirectory(), "scan-results")
|
||||
: Path.GetFullPath(resultsDirectory);
|
||||
|
||||
Directory.CreateDirectory(resultsDirectory);
|
||||
var executionTimestamp = DateTimeOffset.UtcNow;
|
||||
var baselineFiles = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
var baseline = new HashSet<string>(baselineFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var startInfo = BuildProcessStartInfo(runner, entry, normalizedTarget, resultsDirectory, arguments);
|
||||
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
|
||||
var stdout = new List<string>();
|
||||
var stderr = new List<string>();
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stdout.Add(args.Data);
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[scan] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
stderr.Add(args.Data);
|
||||
_logger.LogError("[scan] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Launching scanner via {Runner} (entry: {Entry})...", runner, entry);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start scanner process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var completionTimestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Scanner completed successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
|
||||
}
|
||||
|
||||
var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline);
|
||||
if (string.IsNullOrWhiteSpace(resultsPath))
|
||||
{
|
||||
resultsPath = CreatePlaceholderResult(resultsDirectory);
|
||||
}
|
||||
|
||||
var metadataPath = WriteRunMetadata(
|
||||
resultsDirectory,
|
||||
executionTimestamp,
|
||||
completionTimestamp,
|
||||
runner,
|
||||
entry,
|
||||
normalizedTarget,
|
||||
resultsPath,
|
||||
arguments,
|
||||
process.ExitCode,
|
||||
stdout,
|
||||
stderr);
|
||||
|
||||
return new ScannerExecutionResult(process.ExitCode, resultsPath, metadataPath);
|
||||
}
|
||||
|
||||
private ProcessStartInfo BuildProcessStartInfo(
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsDirectory,
|
||||
IReadOnlyList<string> args)
|
||||
{
|
||||
return runner switch
|
||||
{
|
||||
"self" or "native" => BuildNativeStartInfo(entry, args),
|
||||
"dotnet" => BuildDotNetStartInfo(entry, args),
|
||||
"docker" => BuildDockerStartInfo(entry, targetDirectory, resultsDirectory, args),
|
||||
_ => BuildCustomRunnerStartInfo(runner, entry, args)
|
||||
};
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildNativeStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(binaryPath) || !File.Exists(binaryPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner entrypoint not found.", binaryPath);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binaryPath,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDotNetStartInfo(string binaryPath, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(binaryPath);
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildDockerStartInfo(string image, string targetDirectory, string resultsDirectory, IReadOnlyList<string> args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
throw new ArgumentException("Docker image must be provided when runner is 'docker'.", nameof(image));
|
||||
}
|
||||
|
||||
var cwd = Directory.GetCurrentDirectory();
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
WorkingDirectory = cwd
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("run");
|
||||
startInfo.ArgumentList.Add("--rm");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{cwd}:{cwd}");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{targetDirectory}:/scan-target:ro");
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add($"{resultsDirectory}:/scan-results");
|
||||
startInfo.ArgumentList.Add("-w");
|
||||
startInfo.ArgumentList.Add(cwd);
|
||||
startInfo.ArgumentList.Add(image);
|
||||
startInfo.ArgumentList.Add("--target");
|
||||
startInfo.ArgumentList.Add("/scan-target");
|
||||
startInfo.ArgumentList.Add("--output");
|
||||
startInfo.ArgumentList.Add("/scan-results/scan.json");
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo BuildCustomRunnerStartInfo(string runner, string entry, IReadOnlyList<string> args)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = runner,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
startInfo.ArgumentList.Add(entry);
|
||||
}
|
||||
|
||||
foreach (var argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
startInfo.RedirectStandardError = true;
|
||||
startInfo.RedirectStandardOutput = true;
|
||||
startInfo.UseShellExecute = false;
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static string ResolveResultsPath(string resultsDirectory, DateTimeOffset startTimestamp, HashSet<string> baseline)
|
||||
{
|
||||
var candidates = Directory.GetFiles(resultsDirectory, "*", SearchOption.AllDirectories);
|
||||
string? newest = null;
|
||||
DateTimeOffset newestTimestamp = startTimestamp;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (baseline.Contains(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(candidate);
|
||||
if (info.LastWriteTimeUtc >= newestTimestamp)
|
||||
{
|
||||
newestTimestamp = info.LastWriteTimeUtc;
|
||||
newest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return newest ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string CreatePlaceholderResult(string resultsDirectory)
|
||||
{
|
||||
var fileName = $"scan-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
File.WriteAllText(path, "{\"status\":\"placeholder\"}");
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string WriteRunMetadata(
|
||||
string resultsDirectory,
|
||||
DateTimeOffset startedAt,
|
||||
DateTimeOffset completedAt,
|
||||
string runner,
|
||||
string entry,
|
||||
string targetDirectory,
|
||||
string resultsPath,
|
||||
IReadOnlyList<string> arguments,
|
||||
int exitCode,
|
||||
IReadOnlyList<string> stdout,
|
||||
IReadOnlyList<string> stderr)
|
||||
{
|
||||
var duration = completedAt - startedAt;
|
||||
var payload = new
|
||||
{
|
||||
runner,
|
||||
entry,
|
||||
targetDirectory,
|
||||
resultsPath,
|
||||
arguments,
|
||||
exitCode,
|
||||
startedAt = startedAt,
|
||||
completedAt = completedAt,
|
||||
durationSeconds = Math.Round(duration.TotalSeconds, 3, MidpointRounding.AwayFromZero),
|
||||
stdout,
|
||||
stderr
|
||||
};
|
||||
|
||||
var fileName = $"scan-run-{startedAt:yyyyMMddHHmmssfff}.json";
|
||||
var path = Path.Combine(resultsDirectory, fileName);
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload, options);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
79
src/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
79
src/StellaOps.Cli/Services/ScannerInstaller.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ScannerInstaller : IScannerInstaller
|
||||
{
|
||||
private readonly ILogger<ScannerInstaller> _logger;
|
||||
|
||||
public ScannerInstaller(ILogger<ScannerInstaller> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactPath) || !File.Exists(artifactPath))
|
||||
{
|
||||
throw new FileNotFoundException("Scanner artifact not found for installation.", artifactPath);
|
||||
}
|
||||
|
||||
// Current implementation assumes docker-based scanner bundle.
|
||||
var processInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
ArgumentList = { "load", "-i", artifactPath },
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = processInfo, EnableRaisingEvents = true };
|
||||
|
||||
process.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
_logger.LogInformation("[install] {Line}", args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogError("[install] {Line}", args.Data);
|
||||
};
|
||||
|
||||
_logger.LogInformation("Installing scanner container from {Path}...", artifactPath);
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start container installation process.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Container installation failed with exit code {process.ExitCode}.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scanner container installed successfully.");
|
||||
}
|
||||
}
|
||||
44
src/StellaOps.Cli/StellaOps.Cli.csproj
Normal file
44
src/StellaOps.Cli/StellaOps.Cli.csproj
Normal file
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
|
||||
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.48.0" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.yaml" Condition="Exists('appsettings.yaml')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="appsettings.local.yaml" Condition="Exists('appsettings.local.yaml')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
14
src/StellaOps.Cli/TASKS.md
Normal file
14
src/StellaOps.Cli/TASKS.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|Bootstrap configuration fallback (env → appsettings{{.json/.yaml}})|DevEx/CLI|Core|**DONE** – CLI loads `API_KEY`/`STELLAOPS_BACKEND_URL` from environment or local settings, defaulting to empty strings when unset.|
|
||||
|Introduce command host & routing skeleton|DevEx/CLI|Configuration|**DONE** – System.CommandLine (v2.0.0-beta5) router stitched with `scanner`, `scan`, `db`, and `config` verbs.|
|
||||
|Scanner artifact download/install commands|Ops Integrator|Backend contracts|**DONE** – `scanner download` caches bundles, validates SHA-256 (plus optional RSA signature), installs via `docker load`, persists metadata, and retries with exponential backoff.|
|
||||
|Scan execution & result upload workflow|Ops Integrator, QA|Scanner cmd|**DONE** – `scan run` drives container scans against directories, emits artefacts in `ResultsDirectory`, auto-uploads on success, and `scan upload` covers manual retries.|
|
||||
|Feedser DB operations passthrough|DevEx/CLI|Backend, Feedser APIs|**DONE** – `db fetch|merge|export` trigger `/jobs/*` endpoints with parameter binding and consistent exit codes.|
|
||||
|CLI observability & tests|QA|Command host|**DONE** – Added console logging defaults & configuration bootstrap tests; future metrics hooks tracked separately.|
|
||||
|Authority auth commands|DevEx/CLI|Auth libraries|**DONE** – `auth login/logout/status` wrap the shared auth client, manage token cache, and surface status messages.|
|
||||
|Document authority workflow in CLI help & quickstart|Docs/CLI|Authority auth commands|**DONE (2025-10-10)** – CLI help now surfaces Authority config fields and docs/09 + docs/10 describe env vars, auth login/status flow, and cache location.|
|
||||
|Authority whoami command|DevEx/CLI|Authority auth commands|**DONE (2025-10-10)** – Added `auth whoami` verb that displays subject/audience/expiry from cached tokens and handles opaque tokens gracefully.|
|
||||
|Expose auth client resilience settings|DevEx/CLI|Auth libraries LIB5|**DONE (2025-10-10)** – CLI options now bind resilience knobs, `AddStellaOpsAuthClient` honours them, and tests cover env overrides.|
|
||||
|Document advanced Authority tuning|Docs/CLI|Expose auth client resilience settings|**DONE (2025-10-10)** – docs/09 and docs/10 describe retry/offline settings with env examples and point to the integration guide.|
|
||||
8
src/StellaOps.Cli/Telemetry/CliActivitySource.cs
Normal file
8
src/StellaOps.Cli/Telemetry/CliActivitySource.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal static class CliActivitySource
|
||||
{
|
||||
public static readonly ActivitySource Instance = new("StellaOps.Cli");
|
||||
}
|
||||
62
src/StellaOps.Cli/Telemetry/CliMetrics.cs
Normal file
62
src/StellaOps.Cli/Telemetry/CliMetrics.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal static class CliMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.Cli", "1.0.0");
|
||||
|
||||
private static readonly Counter<long> ScannerDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.download.count");
|
||||
private static readonly Counter<long> ScannerInstallCounter = Meter.CreateCounter<long>("stellaops.cli.scanner.install.count");
|
||||
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
|
||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||
|
||||
public static void RecordScannerDownload(string channel, bool fromCache)
|
||||
=> ScannerDownloadCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("channel", channel),
|
||||
new("cache", fromCache ? "hit" : "miss")
|
||||
});
|
||||
|
||||
public static void RecordScannerInstall(string channel)
|
||||
=> ScannerInstallCounter.Add(1, new KeyValuePair<string, object?>[] { new("channel", channel) });
|
||||
|
||||
public static void RecordScanRun(string runner, int exitCode)
|
||||
=> ScanRunCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("runner", runner),
|
||||
new("exit_code", exitCode)
|
||||
});
|
||||
|
||||
public static IDisposable MeasureCommandDuration(string command)
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
return new DurationScope(command, start);
|
||||
}
|
||||
|
||||
private sealed class DurationScope : IDisposable
|
||||
{
|
||||
private readonly string _command;
|
||||
private readonly DateTime _start;
|
||||
private bool _disposed;
|
||||
|
||||
public DurationScope(string command, DateTime start)
|
||||
{
|
||||
_command = command;
|
||||
_start = start;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
var elapsed = (DateTime.UtcNow - _start).TotalMilliseconds;
|
||||
CommandDurationHistogram.Record(elapsed, new KeyValuePair<string, object?>[] { new("command", _command) });
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/StellaOps.Cli/Telemetry/VerbosityState.cs
Normal file
8
src/StellaOps.Cli/Telemetry/VerbosityState.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal sealed class VerbosityState
|
||||
{
|
||||
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
|
||||
}
|
||||
11
src/StellaOps.Cli/appsettings.json
Normal file
11
src/StellaOps.Cli/appsettings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"StellaOps": {
|
||||
"ApiKey": "",
|
||||
"BackendUrl": "",
|
||||
"ScannerCacheDirectory": "scanners",
|
||||
"ResultsDirectory": "results",
|
||||
"DefaultRunner": "dotnet",
|
||||
"ScannerSignaturePublicKeyPath": "",
|
||||
"ScannerDownloadAttempts": 3
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user