Initial commit (history squashed)
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user