Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Plugins;
using StellaOps.Cli.Services.Models.AdvisoryAi;
@@ -5182,13 +5183,13 @@ internal static class CommandFactory
Description = "Image digests to test (can be specified multiple times).",
AllowMultipleArgumentsPerToken = true
};
imagesOption.IsRequired = true;
imagesOption.Required = true;
var scannerOption = new Option<string>("--scanner", "-s")
{
Description = "Scanner container image reference."
};
scannerOption.IsRequired = true;
scannerOption.Required = true;
var policyBundleOption = new Option<string?>("--policy-bundle")
{
@@ -5582,13 +5583,13 @@ internal static class CommandFactory
{
Description = "Start timestamp (ISO-8601). Required."
};
fromOption.IsRequired = true;
fromOption.Required = true;
var toOption = new Option<DateTimeOffset>("--to")
{
Description = "End timestamp (ISO-8601). Required."
};
toOption.IsRequired = true;
toOption.Required = true;
var logsTenantOption = new Option<string?>("--tenant", "-t")
{
@@ -6544,7 +6545,7 @@ internal static class CommandFactory
var secretsInjectRefOption = new Option<string>("--secret-ref")
{
Description = "Secret reference (provider-specific path).",
IsRequired = true
Required = true
};
var secretsInjectProviderOption = new Option<string>("--provider")
@@ -6844,19 +6845,18 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsListAsync(
services,
tenant,
vuln,
scopeType,
scopeValue,
statuses,
owner,
effect,
expiringDays,
expiringDays.HasValue ? DateTimeOffset.UtcNow.AddDays(expiringDays.Value) : null,
includeExpired,
pageSize,
pageToken,
tenant,
json,
csv,
json || csv,
verbose,
cancellationToken);
});
@@ -6977,7 +6977,8 @@ internal static class CommandFactory
var effect = parseResult.GetValue(createEffectOption) ?? string.Empty;
var justification = parseResult.GetValue(createJustificationOption) ?? string.Empty;
var owner = parseResult.GetValue(createOwnerOption) ?? string.Empty;
var expiration = parseResult.GetValue(createExpirationOption);
var expirationStr = parseResult.GetValue(createExpirationOption);
var expiration = !string.IsNullOrWhiteSpace(expirationStr) && DateTimeOffset.TryParse(expirationStr, out var exp) ? exp : (DateTimeOffset?)null;
var evidence = parseResult.GetValue(createEvidenceOption) ?? Array.Empty<string>();
var policy = parseResult.GetValue(createPolicyOption);
var stage = parseResult.GetValue(createStageOption);
@@ -6987,17 +6988,17 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsCreateAsync(
services,
tenant ?? string.Empty,
vuln,
scopeType,
scopeValue,
effect,
justification,
owner,
owner ?? string.Empty,
expiration,
evidence,
policy,
stage,
tenant,
json,
verbose,
cancellationToken);
@@ -7042,9 +7043,9 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsPromoteAsync(
services,
exceptionId,
target,
comment,
tenant,
target ?? "active",
comment,
json,
verbose,
cancellationToken);
@@ -7128,10 +7129,10 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsImportAsync(
services,
tenant ?? string.Empty,
file,
stage,
source,
tenant,
json,
verbose,
cancellationToken);
@@ -7184,11 +7185,13 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsExportAsync(
services,
output,
tenant,
statuses,
format,
output,
false, // includeManifest
signed,
tenant,
false, // json output
verbose,
cancellationToken);
});
@@ -7470,13 +7473,13 @@ internal static class CommandFactory
var backfillFromOption = new Option<DateTimeOffset>("--from")
{
Description = "Start date/time for backfill (ISO 8601 format).",
IsRequired = true
Required = true
};
var backfillToOption = new Option<DateTimeOffset>("--to")
{
Description = "End date/time for backfill (ISO 8601 format).",
IsRequired = true
Required = true
};
var backfillDryRunOption = new Option<bool>("--dry-run")
@@ -7732,19 +7735,19 @@ internal static class CommandFactory
var quotaSetTenantOption = new Option<string>("--tenant")
{
Description = "Tenant ID.",
IsRequired = true
Required = true
};
var quotaSetResourceTypeOption = new Option<string>("--resource-type")
{
Description = "Resource type (api_calls, data_ingested_bytes, items_processed, backfills, concurrent_jobs, storage_bytes).",
IsRequired = true
Required = true
};
var quotaSetLimitOption = new Option<long>("--limit")
{
Description = "Quota limit value.",
IsRequired = true
Required = true
};
var quotaSetPeriodOption = new Option<string>("--period")
@@ -7800,13 +7803,13 @@ internal static class CommandFactory
var quotaResetTenantOption = new Option<string>("--tenant")
{
Description = "Tenant ID.",
IsRequired = true
Required = true
};
var quotaResetResourceTypeOption = new Option<string>("--resource-type")
{
Description = "Resource type to reset.",
IsRequired = true
Required = true
};
var quotaResetReasonOption = new Option<string?>("--reason")
@@ -9547,7 +9550,7 @@ internal static class CommandFactory
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output path for the downloaded spec (file or directory).",
IsRequired = true
Required = true
};
var serviceOption = new Option<string?>("--service", "-s")

View File

@@ -48,43 +48,72 @@ namespace StellaOps.Cli.Commands;
internal static class CommandHandlers
{
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
var shaPath = path + ".sha256";
if (!File.Exists(shaPath))
{
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
Environment.ExitCode = 21;
throw new InvalidOperationException("Checksum file missing");
}
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
Environment.ExitCode = 22;
throw new InvalidOperationException("Checksum verification failed");
}
logger.LogInformation("Checksum verified for {Bundle}", path);
}
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
/// <summary>
/// Standard JSON serializer options for CLI output.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// JSON serializer options for output (alias for JsonOptions).
/// </summary>
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
/// <summary>
/// Sets the verbosity level for logging.
/// </summary>
private static void SetVerbosity(IServiceProvider services, bool verbose)
{
// Configure logging level based on verbose flag
var loggerFactory = services.GetService<ILoggerFactory>();
if (loggerFactory is not null && verbose)
{
// Enable debug logging when verbose is true
var logger = loggerFactory.CreateLogger("StellaOps.Cli.Commands.CommandHandlers");
logger.LogDebug("Verbose logging enabled");
}
}
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
var shaPath = path + ".sha256";
if (!File.Exists(shaPath))
{
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
Environment.ExitCode = 21;
throw new InvalidOperationException("Checksum file missing");
}
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
Environment.ExitCode = 22;
throw new InvalidOperationException("Checksum verification failed");
}
logger.LogInformation("Checksum verified for {Bundle}", path);
}
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
bool verbose,
CancellationToken cancellationToken)
{
@@ -114,29 +143,29 @@ internal static class CommandHandlers
CliMetrics.RecordScannerDownload(channel, result.FromCache);
if (install)
{
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordScannerInstall(channel);
}
if (install)
{
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
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.");
if (Environment.ExitCode == 0)
{
Environment.ExitCode = 1;
}
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download scanner bundle.");
if (Environment.ExitCode == 0)
{
Environment.ExitCode = 1;
}
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleTaskRunnerSimulateAsync(
@@ -264,15 +293,15 @@ internal static class CommandHandlers
{
var console = AnsiConsole.Console;
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
console.MarkupLine($"Image: {result.ImageDigest}");
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
if (result.BestPlan is not null)
{
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
}
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
console.MarkupLine($"Image: {result.ImageDigest}");
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
if (result.BestPlan is not null)
{
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
}
var planTable = new Table()
.AddColumn("Terminal")
@@ -284,15 +313,15 @@ internal static class CommandHandlers
foreach (var plan in result.Graph.Plans.OrderByDescending(p => p.Confidence))
{
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
planTable.AddRow(
plan.TerminalPath,
plan.Runtime ?? "-",
plan.Type.ToString(),
confidence,
plan.User,
plan.WorkingDirectory);
}
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
planTable.AddRow(
plan.TerminalPath,
plan.Runtime ?? "-",
plan.Type.ToString(),
confidence,
plan.User,
plan.WorkingDirectory);
}
if (planTable.Rows.Count > 0)
{
@@ -6860,7 +6889,7 @@ internal static class CommandHandlers
}
AnsiConsole.Write(violationTable);
}
}
private static int DetermineVerifyExitCode(AocVerifyResponse response)
{
@@ -10895,13 +10924,10 @@ stella policy test {policyName}.stella
Code = diag.Code,
Message = diag.Message,
Severity = diag.Severity.ToString().ToLowerInvariant(),
Line = diag.Line,
Column = diag.Column,
Span = diag.Span,
Suggestion = diag.Suggestion
Path = diag.Path
};
if (diag.Severity == PolicyDsl.DiagnosticSeverity.Error)
if (diag.Severity == PolicyIssueSeverity.Error)
{
errors.Add(diagnostic);
}
@@ -10939,7 +10965,7 @@ stella policy test {policyName}.stella
InputPath = fullPath,
IrPath = irPath,
Digest = digest,
SyntaxVersion = compileResult.Document?.SyntaxVersion,
SyntaxVersion = compileResult.Document?.Syntax,
PolicyName = compileResult.Document?.Name,
RuleCount = compileResult.Document?.Rules.Length ?? 0,
ProfileCount = compileResult.Document?.Profiles.Length ?? 0,
@@ -10985,24 +11011,14 @@ stella policy test {policyName}.stella
foreach (var err in errors)
{
var location = err.Line.HasValue ? $":{err.Line}" : "";
if (err.Column.HasValue) location += $":{err.Column}";
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{location}: {Markup.Escape(err.Message)}[/]");
if (!string.IsNullOrWhiteSpace(err.Suggestion))
{
AnsiConsole.MarkupLine($" [cyan]suggestion: {Markup.Escape(err.Suggestion)}[/]");
}
var location = !string.IsNullOrWhiteSpace(err.Path) ? $" at {err.Path}" : "";
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{Markup.Escape(location)}: {Markup.Escape(err.Message)}[/]");
}
foreach (var warn in warnings)
{
var location = warn.Line.HasValue ? $":{warn.Line}" : "";
if (warn.Column.HasValue) location += $":{warn.Column}";
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{location}: {Markup.Escape(warn.Message)}[/]");
if (!string.IsNullOrWhiteSpace(warn.Suggestion))
{
AnsiConsole.MarkupLine($" [cyan]suggestion: {Markup.Escape(warn.Suggestion)}[/]");
}
var location = !string.IsNullOrWhiteSpace(warn.Path) ? $" at {warn.Path}" : "";
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{Markup.Escape(location)}: {Markup.Escape(warn.Message)}[/]");
}
}
@@ -13248,18 +13264,6 @@ stella policy test {policyName}.stella
}
}
private static string GetVexStatusMarkup(string status)
{
return status?.ToLowerInvariant() switch
{
"affected" => "[red]affected[/]",
"not_affected" => "[green]not_affected[/]",
"fixed" => "[blue]fixed[/]",
"under_investigation" => "[yellow]under_investigation[/]",
_ => Markup.Escape(status ?? "(unknown)")
};
}
#endregion
#region Vulnerability Explorer (CLI-VULN-29-001)
@@ -14543,13 +14547,13 @@ stella policy test {policyName}.stella
var fixText = obs.Fix?.Available == true ? "[green]available[/]" : "[grey]none[/]";
table.AddRow(
Markup.Escape(obs.ObservationId),
Markup.Escape(sourceVendor),
Markup.Escape(aliasesText),
Markup.Escape(severityText),
new Markup(Markup.Escape(obs.ObservationId)),
new Markup(Markup.Escape(sourceVendor)),
new Markup(Markup.Escape(aliasesText)),
new Markup(Markup.Escape(severityText)),
new Markup(kevText),
new Markup(fixText),
obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
new Markup(Markup.Escape(obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
}
AnsiConsole.Write(table);
@@ -15386,12 +15390,12 @@ stella policy test {policyName}.stella
var size = FormatSize(snapshot.SizeBytes);
table.AddRow(
Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId),
Markup.Escape(snapshot.CaseId),
new Markup(Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId)),
new Markup(Markup.Escape(snapshot.CaseId)),
new Markup(statusMarkup),
artifactCount,
size,
snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
new Markup(Markup.Escape(artifactCount)),
new Markup(Markup.Escape(size)),
new Markup(Markup.Escape(snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
}
AnsiConsole.Write(table);

View File

@@ -12,7 +12,7 @@ namespace StellaOps.Cli.Configuration;
/// CLI profile for storing named configurations.
/// Per CLI-CORE-41-001, supports profiles/contexts for multi-environment workflows.
/// </summary>
public sealed class CliProfile
public sealed record CliProfile
{
/// <summary>
/// Profile name (e.g., "prod", "staging", "dev").

View File

@@ -54,87 +54,45 @@ public sealed class GlobalOptions
/// </summary>
public static IEnumerable<Option> CreateGlobalOptions()
{
yield return new Option<string?>(
aliases: ["--profile", "-p"],
description: "Profile name to use for this invocation");
yield return new Option<string?>("--profile", "-p")
{
Description = "Profile name to use for this invocation"
};
yield return new Option<OutputFormat>(
aliases: ["--output", "-o"],
getDefaultValue: () => OutputFormat.Table,
description: "Output format (table, json, yaml)");
yield return new Option<OutputFormat>("--output", "-o")
{
Description = "Output format (table, json, yaml)",
DefaultValueFactory = _ => OutputFormat.Table
};
yield return new Option<bool>(
aliases: ["--verbose", "-v"],
description: "Enable verbose output");
yield return new Option<bool>("--verbose", "-v")
{
Description = "Enable verbose output"
};
yield return new Option<bool>(
aliases: ["--quiet", "-q"],
description: "Quiet mode - suppress non-error output");
yield return new Option<bool>("--quiet", "-q")
{
Description = "Quiet mode - suppress non-error output"
};
yield return new Option<bool>(
name: "--no-color",
description: "Disable colored output");
yield return new Option<bool>("--no-color")
{
Description = "Disable colored output"
};
yield return new Option<string?>(
name: "--backend-url",
description: "Override backend URL for this invocation");
yield return new Option<string?>("--backend-url")
{
Description = "Override backend URL for this invocation"
};
yield return new Option<string?>(
name: "--tenant-id",
description: "Override tenant ID for this invocation");
yield return new Option<string?>("--tenant-id")
{
Description = "Override tenant ID for this invocation"
};
yield return new Option<bool>(
name: "--dry-run",
description: "Show what would happen without executing");
}
/// <summary>
/// Parses global options from invocation context.
/// </summary>
public static GlobalOptions FromInvocationContext(System.CommandLine.Invocation.InvocationContext context)
{
var options = new GlobalOptions();
var profileOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--profile"));
if (profileOption is not null)
options.Profile = context.ParseResult.GetValueForOption(profileOption) as string;
var outputOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--output"));
if (outputOption is not null && context.ParseResult.GetValueForOption(outputOption) is OutputFormat format)
options.OutputFormat = format;
var verboseOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--verbose"));
if (verboseOption is not null && context.ParseResult.GetValueForOption(verboseOption) is bool verbose)
options.Verbose = verbose;
var quietOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--quiet"));
if (quietOption is not null && context.ParseResult.GetValueForOption(quietOption) is bool quiet)
options.Quiet = quiet;
var noColorOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--no-color"));
if (noColorOption is not null && context.ParseResult.GetValueForOption(noColorOption) is bool noColor)
options.NoColor = noColor;
var backendOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--backend-url"));
if (backendOption is not null)
options.BackendUrl = context.ParseResult.GetValueForOption(backendOption) as string;
var tenantOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--tenant-id"));
if (tenantOption is not null)
options.TenantId = context.ParseResult.GetValueForOption(tenantOption) as string;
var dryRunOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--dry-run"));
if (dryRunOption is not null && context.ParseResult.GetValueForOption(dryRunOption) is bool dryRun)
options.DryRun = dryRun;
return options;
yield return new Option<bool>("--dry-run")
{
Description = "Show what would happen without executing"
};
}
}

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
using System;
using System.Collections.Generic;
using System.IO;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
namespace StellaOps.Cli.Configuration;
@@ -12,9 +12,9 @@ public sealed class StellaOpsCliOptions
public string BackendUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string AdvisoryAiUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string AdvisoryAiUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";
@@ -32,11 +32,20 @@ public sealed class StellaOpsCliOptions
public StellaOpsCliOfflineOptions Offline { get; set; } = new();
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
public StellaOpsCryptoOptions Crypto { get; set; } = new();
}
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
public StellaOpsCryptoOptions Crypto { get; set; } = new();
/// <summary>
/// Indicates if CLI is running in offline mode.
/// </summary>
public bool IsOffline { get; set; }
/// <summary>
/// Directory containing offline kits when in offline mode.
/// </summary>
public string? OfflineKitDirectory { get; set; }
}
public sealed class StellaOpsCliAuthorityOptions
{
@@ -52,15 +61,15 @@ public sealed class StellaOpsCliAuthorityOptions
public string Scope { get; set; } = StellaOpsScopes.ConcelierJobsTrigger;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string BackfillReason { get; set; } = string.Empty;
public string BackfillTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string BackfillReason { get; set; } = string.Empty;
public string BackfillTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();
}
@@ -83,15 +92,15 @@ public sealed class StellaOpsCliOfflineOptions
public string? MirrorUrl { get; set; }
}
public sealed class StellaOpsCliPluginOptions
{
public sealed class StellaOpsCliPluginOptions
{
public string BaseDirectory { get; set; } = string.Empty;
public string Directory { get; set; } = "plugins/cli";
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> PluginOrder { get; set; } = new List<string>();
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
}
public IList<string> PluginOrder { get; set; } = new List<string>();
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
}

View File

@@ -0,0 +1,32 @@
using System.CommandLine;
namespace StellaOps.Cli.Extensions;
/// <summary>
/// Compatibility extensions for System.CommandLine 2.0.0-beta5+ API changes.
/// These restore the older extension method patterns that were used in earlier versions.
/// See: https://learn.microsoft.com/en-us/dotnet/standard/commandline/migration-guide-2.0.0-beta5
/// </summary>
public static class CommandLineExtensions
{
/// <summary>
/// Sets the default value for an option (compatibility shim for older API).
/// In beta5+, this maps to DefaultValueFactory.
/// </summary>
public static Option<T> SetDefaultValue<T>(this Option<T> option, T defaultValue)
{
option.DefaultValueFactory = _ => defaultValue;
return option;
}
/// <summary>
/// Restricts the option to accept only the specified values (compatibility shim).
/// Works for both Option&lt;string&gt; and Option&lt;string?&gt;.
/// </summary>
public static Option<T> FromAmong<T>(this Option<T> option, params string[] allowedValues)
where T : class?
{
option.AcceptOnlyFromAmong(allowedValues);
return option;
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Auth.Client;
namespace StellaOps.Cli.Extensions;
/// <summary>
/// Extension methods for IStellaOpsTokenClient providing compatibility with older CLI patterns.
/// These bridge the gap between the old API (GetTokenAsync, GetAccessTokenAsync) and the
/// new API (RequestClientCredentialsTokenAsync, GetCachedTokenAsync).
/// </summary>
public static class StellaOpsTokenClientExtensions
{
/// <summary>
/// Requests an access token using client credentials flow with the specified scopes.
/// This is a compatibility shim for the old GetAccessTokenAsync pattern.
/// </summary>
public static async Task<StellaOpsTokenResult> GetAccessTokenAsync(
this IStellaOpsTokenClient client,
IEnumerable<string> scopes,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
var scope = scopes is not null ? string.Join(" ", scopes.Where(s => !string.IsNullOrWhiteSpace(s))) : null;
return await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Requests an access token using client credentials flow with a single scope.
/// </summary>
public static async Task<StellaOpsTokenResult> GetAccessTokenAsync(
this IStellaOpsTokenClient client,
string scope,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
return await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets a cached access token or requests a new one if not cached or expired.
/// This is a compatibility shim for the old GetCachedAccessTokenAsync pattern.
/// </summary>
public static async Task<StellaOpsTokenCacheEntry> GetCachedAccessTokenAsync(
this IStellaOpsTokenClient client,
IEnumerable<string> scopes,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
var scopeList = scopes?.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToArray() ?? [];
var scope = string.Join(" ", scopeList);
var cacheKey = $"cc:{scope}";
// Check cache first
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cached is not null && !cached.IsExpired(TimeProvider.System, TimeSpan.FromMinutes(1)))
{
return cached;
}
// Request new token
var result = await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
var entry = result.ToCacheEntry();
// Cache the result
await client.CacheTokenAsync(cacheKey, entry, cancellationToken).ConfigureAwait(false);
return entry;
}
/// <summary>
/// Gets a cached access token or requests a new one if not cached or expired.
/// Single scope version.
/// </summary>
public static async Task<StellaOpsTokenCacheEntry> GetCachedAccessTokenAsync(
this IStellaOpsTokenClient client,
string scope,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
var cacheKey = $"cc:{scope ?? "default"}";
// Check cache first
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cached is not null && !cached.IsExpired(TimeProvider.System, TimeSpan.FromMinutes(1)))
{
return cached;
}
// Request new token
var result = await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
var entry = result.ToCacheEntry();
// Cache the result
await client.CacheTokenAsync(cacheKey, entry, cancellationToken).ConfigureAwait(false);
return entry;
}
/// <summary>
/// Requests a token using client credentials. Parameterless version for simple cases.
/// </summary>
public static async Task<StellaOpsTokenResult> GetTokenAsync(
this IStellaOpsTokenClient client,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
return await client.RequestClientCredentialsTokenAsync(null, null, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -121,6 +121,19 @@ public sealed record CliError(
return 1;
}
/// <summary>
/// Error code for offline mode violations.
/// </summary>
public const string OfflineMode = "ERR_OFFLINE_MODE";
/// <summary>
/// Creates an error from an error code.
/// </summary>
public static CliError FromCode(string code, string? message = null)
{
return new CliError(code, message ?? $"Error: {code}");
}
/// <summary>
/// Creates an error from an exception.
/// </summary>
@@ -167,7 +180,7 @@ public sealed record CliError(
/// Creates an error from a parsed API error.
/// CLI-SDK-62-002: Surfaces standardized API error envelope fields.
/// </summary>
public static CliError FromParsedApiError(ParsedApiError error)
internal static CliError FromParsedApiError(ParsedApiError error)
{
ArgumentNullException.ThrowIfNull(error);
@@ -195,7 +208,7 @@ public sealed record CliError(
/// Creates an error from an API error envelope.
/// CLI-SDK-62-002: Direct conversion from envelope format.
/// </summary>
public static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
internal static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
{
ArgumentNullException.ThrowIfNull(envelope);

View File

@@ -28,6 +28,7 @@ namespace StellaOps.Cli.Services;
internal sealed class BackendOperationsClient : IBackendOperationsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly JsonSerializerOptions JsonOptions = SerializerOptions;
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
@@ -523,8 +524,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -639,8 +639,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -758,8 +757,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -807,8 +805,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -858,8 +855,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -909,11 +905,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
throw new InvalidOperationException(failure);
}
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
return result;
}
@@ -4512,11 +4508,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
// CLI-SDK-64-001: SDK update operations
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("sdk update");
var queryParams = new List<string>();
@@ -4554,9 +4550,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
};
}
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
}
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
}
public async Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken)
{

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -576,12 +577,10 @@ internal sealed class ExceptionClient : IExceptionClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -589,8 +588,10 @@ internal sealed class ExceptionClient : IExceptionClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Reflection;
namespace StellaOps.Cli.Services;
/// <summary>
/// Defines a PostgreSQL module with its migration metadata.
/// </summary>
public sealed record MigrationModuleInfo(
string Name,
string SchemaName,
Assembly MigrationsAssembly,
string? ResourcePrefix = null);
/// <summary>
/// Registry of all PostgreSQL modules and their migration assemblies.
/// Stub implementation - actual module assemblies will be wired in Wave 3-8.
/// </summary>
public static class MigrationModuleRegistry
{
// TODO: Wire actual module assemblies when Storage.Postgres projects are implemented
// Modules will be registered as:
// - Authority (auth schema) - StellaOps.Authority.Storage.Postgres.AuthorityDataSource
// - Scheduler (scheduler schema) - StellaOps.Scheduler.Storage.Postgres.SchedulerDataSource
// - Concelier (vuln schema) - StellaOps.Concelier.Storage.Postgres.ConcelierDataSource
// - Policy (policy schema) - StellaOps.Policy.Storage.Postgres.PolicyDataSource
// - Notify (notify schema) - StellaOps.Notify.Storage.Postgres.NotifyDataSource
// - Excititor (vex schema) - StellaOps.Excititor.Storage.Postgres.ExcititorDataSource
private static readonly List<MigrationModuleInfo> _modules = [];
/// <summary>
/// Gets all registered modules.
/// </summary>
public static IReadOnlyList<MigrationModuleInfo> Modules => _modules;
/// <summary>
/// Gets module names for CLI completion.
/// </summary>
public static IEnumerable<string> ModuleNames => _modules.Select(m => m.Name);
/// <summary>
/// Finds a module by name (case-insensitive).
/// </summary>
public static MigrationModuleInfo? FindModule(string name) =>
_modules.FirstOrDefault(m =>
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Gets modules matching the filter, or all if filter is null/empty.
/// </summary>
public static IEnumerable<MigrationModuleInfo> GetModules(string? moduleFilter)
{
if (string.IsNullOrWhiteSpace(moduleFilter) || moduleFilter.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return _modules;
}
var module = FindModule(moduleFilter);
return module != null ? [module] : [];
}
}

View File

@@ -111,7 +111,7 @@ internal sealed class AttestationSubjectInfo
/// <summary>
/// Signature information for display.
/// </summary>
internal sealed class AttestationSignatureInfo
internal sealed record AttestationSignatureInfo
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
@@ -162,7 +162,7 @@ internal sealed class AttestationSignerInfo
/// <summary>
/// Summary of the predicate for display.
/// </summary>
internal sealed class AttestationPredicateSummary
internal sealed record AttestationPredicateSummary
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;

View File

@@ -519,17 +519,8 @@ internal sealed class PolicyDiagnostic
[JsonPropertyName("severity")]
public string Severity { get; init; } = "error";
[JsonPropertyName("line")]
public int? Line { get; init; }
[JsonPropertyName("column")]
public int? Column { get; init; }
[JsonPropertyName("span")]
public string? Span { get; init; }
[JsonPropertyName("suggestion")]
public string? Suggestion { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
}
// CLI-POLICY-27-002: Policy submission/review workflow models

View File

@@ -236,7 +236,7 @@ internal sealed class ReachabilityFunction
/// <summary>
/// Reachability override for policy simulation.
/// </summary>
internal sealed class ReachabilityOverride
internal sealed record ReachabilityOverride
{
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -634,12 +635,10 @@ internal sealed class NotifyClient : INotifyClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -647,8 +646,10 @@ internal sealed class NotifyClient : INotifyClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -170,12 +171,10 @@ internal sealed class ObservabilityClient : IObservabilityClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = ["obs:read"] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(StellaOpsScopes.ObservabilityRead, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -183,9 +182,11 @@ internal sealed class ObservabilityClient : IObservabilityClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
// CLI-OBS-52-001: Trace retrieval

View File

@@ -0,0 +1,41 @@
using System;
namespace StellaOps.Cli.Services;
/// <summary>
/// Guard for operations that require network connectivity.
/// Stub implementation - will be wired to actual offline mode detection.
/// </summary>
internal static class OfflineModeGuard
{
/// <summary>
/// Gets whether the CLI is currently in offline mode.
/// </summary>
public static bool IsOffline { get; set; }
/// <summary>
/// Gets whether network operations are allowed.
/// </summary>
public static bool IsNetworkAllowed() => !IsOffline;
/// <summary>
/// Gets whether network operations are allowed, checking options and logging operation.
/// </summary>
/// <param name="options">CLI options (used to check offline mode setting).</param>
/// <param name="operationName">Name of the operation being checked.</param>
public static bool IsNetworkAllowed(object? options, string operationName) => !IsOffline;
/// <summary>
/// Throws if the CLI is in offline mode and the operation requires network.
/// </summary>
/// <param name="operationName">Name of the operation being guarded.</param>
/// <exception cref="InvalidOperationException">Thrown when offline and network required.</exception>
public static void ThrowIfOffline(string operationName)
{
if (IsOffline)
{
throw new InvalidOperationException(
$"Operation '{operationName}' requires network connectivity but CLI is in offline mode.");
}
}
}

View File

@@ -8,9 +8,10 @@ using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.Client.Scopes;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -386,7 +387,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
private async Task ConfigureAuthAsync(CancellationToken cancellationToken)
{
var token = await _tokenClient.GetCachedAccessTokenAsync(
new[] { StellaOpsScope.OrchRead },
new[] { StellaOpsScopes.OrchRead },
cancellationToken);
_httpClient.DefaultRequestHeaders.Authorization =

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -997,12 +998,10 @@ internal sealed class PackClient : IPackClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -1010,8 +1009,10 @@ internal sealed class PackClient : IPackClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -463,12 +464,10 @@ internal sealed class SbomClient : ISbomClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -476,8 +475,10 @@ internal sealed class SbomClient : ISbomClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Client;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -194,11 +195,18 @@ internal sealed class SbomerClient : ISbomerClient
if (_tokenClient == null)
return;
var token = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
try
{
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var tokenResult = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(tokenResult.AccessToken))
{
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to acquire token for Sbomer API access.");
}
}

View File

@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -20,7 +21,7 @@ namespace StellaOps.Cli.Services;
internal sealed class VexObservationsClient : IVexObservationsClient
{
private readonly HttpClient _httpClient;
private readonly ITokenClient? _tokenClient;
private readonly IStellaOpsTokenClient? _tokenClient;
private readonly ILogger<VexObservationsClient> _logger;
private string? _cachedToken;
private DateTimeOffset _tokenExpiry;
@@ -33,7 +34,7 @@ internal sealed class VexObservationsClient : IVexObservationsClient
public VexObservationsClient(
HttpClient httpClient,
ILogger<VexObservationsClient> logger,
ITokenClient? tokenClient = null)
IStellaOpsTokenClient? tokenClient = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -138,20 +139,23 @@ internal sealed class VexObservationsClient : IVexObservationsClient
return;
}
var tokenResult = await _tokenClient.GetAccessTokenAsync(
new[] { StellaOpsScopes.VexRead },
cancellationToken).ConfigureAwait(false);
try
{
var tokenResult = await _tokenClient.GetAccessTokenAsync(
StellaOpsScopes.VexRead,
cancellationToken).ConfigureAwait(false);
if (tokenResult.IsSuccess && !string.IsNullOrWhiteSpace(tokenResult.AccessToken))
{
_cachedToken = tokenResult.AccessToken;
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _cachedToken);
if (!string.IsNullOrWhiteSpace(tokenResult.AccessToken))
{
_cachedToken = tokenResult.AccessToken;
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _cachedToken);
}
}
else
catch (Exception ex)
{
_logger.LogWarning("Failed to acquire token for VEX API access.");
_logger.LogWarning(ex, "Failed to acquire token for VEX API access.");
}
}

View File

@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Telemetry;