Initial commit (history squashed)

This commit is contained in:
master
2025-10-07 10:14:21 +03:00
commit 016c5a3fe7
1132 changed files with 117842 additions and 0 deletions

View 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.

View 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..]}"
};
}
}

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Cli.Services.Models;
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Cli.Services;
internal sealed record ScannerExecutionResult(int ExitCode, string ResultsPath, string RunMetadataPath);

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

View 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.");
}
}

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

View 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.|

View File

@@ -0,0 +1,8 @@
using System.Diagnostics;
namespace StellaOps.Cli.Telemetry;
internal static class CliActivitySource
{
public static readonly ActivitySource Instance = new("StellaOps.Cli");
}

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

View File

@@ -0,0 +1,8 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Telemetry;
internal sealed class VerbosityState
{
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
}

View File

@@ -0,0 +1,11 @@
{
"StellaOps": {
"ApiKey": "",
"BackendUrl": "",
"ScannerCacheDirectory": "scanners",
"ResultsDirectory": "results",
"DefaultRunner": "dotnet",
"ScannerSignaturePublicKeyPath": "",
"ScannerDownloadAttempts": 3
}
}