up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,38 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
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.ConcelierJobsTrigger
|
||||
: 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}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{scope}";
|
||||
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
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.ConcelierJobsTrigger
|
||||
: 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}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{scope}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
if (scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -59,10 +59,10 @@ internal static class AuthorityTokenUtilities
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var bytes = Encoding.UTF8.GetBytes(trimmed);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var bytes = Encoding.UTF8.GetBytes(trimmed);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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");
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
|
||||
cliOptions.AdvisoryAiUrl = ResolveWithFallback(cliOptions.AdvisoryAiUrl, configuration, "STELLAOPS_ADVISORYAI_URL", "StellaOps:AdvisoryAiUrl", "AdvisoryAiUrl");
|
||||
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 = 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.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.AdvisoryAiUrl = cliOptions.AdvisoryAiUrl?.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.OperatorReason = ResolveWithFallback(
|
||||
authority.OperatorReason,
|
||||
configuration,
|
||||
"STELLAOPS_ORCH_REASON",
|
||||
"StellaOps:Authority:OperatorReason",
|
||||
"Authority:OperatorReason");
|
||||
|
||||
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.OperatorReason = ResolveWithFallback(
|
||||
authority.OperatorReason,
|
||||
configuration,
|
||||
"STELLAOPS_ORCH_REASON",
|
||||
"StellaOps:Authority:OperatorReason",
|
||||
"Authority:OperatorReason");
|
||||
|
||||
authority.OperatorTicket = ResolveWithFallback(
|
||||
authority.OperatorTicket,
|
||||
configuration,
|
||||
@@ -139,298 +139,298 @@ public static class CliBootstrapper
|
||||
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: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.ConcelierJobsTrigger : authority.Scope.Trim();
|
||||
authority.OperatorReason = authority.OperatorReason?.Trim() ?? string.Empty;
|
||||
authority.OperatorTicket = authority.OperatorTicket?.Trim() ?? string.Empty;
|
||||
authority.BackfillReason = authority.BackfillReason?.Trim() ?? string.Empty;
|
||||
authority.BackfillTicket = authority.BackfillTicket?.Trim() ?? string.Empty;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
cliOptions.Offline ??= new StellaOpsCliOfflineOptions();
|
||||
var offline = cliOptions.Offline;
|
||||
|
||||
var kitsDirectory = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_OFFLINE_KITS_DIRECTORY",
|
||||
"STELLAOPS_OFFLINE_KITS_DIR",
|
||||
"StellaOps:Offline:KitsDirectory",
|
||||
"StellaOps:Offline:KitDirectory",
|
||||
"Offline:KitsDirectory",
|
||||
"Offline:KitDirectory");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(kitsDirectory))
|
||||
{
|
||||
kitsDirectory = offline.KitsDirectory ?? "offline-kits";
|
||||
}
|
||||
|
||||
offline.KitsDirectory = Path.GetFullPath(kitsDirectory);
|
||||
if (!Directory.Exists(offline.KitsDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(offline.KitsDirectory);
|
||||
}
|
||||
|
||||
var mirror = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_OFFLINE_MIRROR_URL",
|
||||
"StellaOps:Offline:KitMirror",
|
||||
"Offline:KitMirror",
|
||||
"Offline:MirrorUrl");
|
||||
|
||||
offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim();
|
||||
|
||||
cliOptions.Plugins ??= new StellaOpsCliPluginOptions();
|
||||
var pluginOptions = cliOptions.Plugins;
|
||||
|
||||
pluginOptions.BaseDirectory = ResolveWithFallback(
|
||||
pluginOptions.BaseDirectory,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_BASE_DIRECTORY",
|
||||
"StellaOps:Plugins:BaseDirectory",
|
||||
"Plugins:BaseDirectory");
|
||||
|
||||
pluginOptions.BaseDirectory = (pluginOptions.BaseDirectory ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.BaseDirectory))
|
||||
{
|
||||
pluginOptions.BaseDirectory = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
pluginOptions.BaseDirectory = Path.GetFullPath(pluginOptions.BaseDirectory);
|
||||
|
||||
pluginOptions.Directory = ResolveWithFallback(
|
||||
pluginOptions.Directory,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_DIRECTORY",
|
||||
"StellaOps:Plugins:Directory",
|
||||
"Plugins:Directory");
|
||||
|
||||
pluginOptions.Directory = (pluginOptions.Directory ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.Directory))
|
||||
{
|
||||
pluginOptions.Directory = Path.Combine("plugins", "cli");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(pluginOptions.Directory))
|
||||
{
|
||||
pluginOptions.Directory = Path.GetFullPath(Path.Combine(pluginOptions.BaseDirectory, pluginOptions.Directory));
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.Directory = Path.GetFullPath(pluginOptions.Directory);
|
||||
}
|
||||
|
||||
pluginOptions.ManifestSearchPattern = ResolveWithFallback(
|
||||
pluginOptions.ManifestSearchPattern,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_MANIFEST_PATTERN",
|
||||
"StellaOps:Plugins:ManifestSearchPattern",
|
||||
"Plugins:ManifestSearchPattern");
|
||||
|
||||
pluginOptions.ManifestSearchPattern = (pluginOptions.ManifestSearchPattern ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern))
|
||||
{
|
||||
pluginOptions.ManifestSearchPattern = "*.manifest.json";
|
||||
}
|
||||
|
||||
if (pluginOptions.SearchPatterns is null || pluginOptions.SearchPatterns.Count == 0)
|
||||
{
|
||||
pluginOptions.SearchPatterns = new List<string> { "StellaOps.Cli.Plugin.*.dll" };
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.SearchPatterns = pluginOptions.SearchPatterns
|
||||
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
|
||||
.Select(pattern => pattern.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (pluginOptions.SearchPatterns.Count == 0)
|
||||
{
|
||||
pluginOptions.SearchPatterns.Add("StellaOps.Cli.Plugin.*.dll");
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginOptions.PluginOrder is null)
|
||||
{
|
||||
pluginOptions.PluginOrder = new List<string>();
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.PluginOrder = pluginOptions.PluginOrder
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
cliOptions.Offline ??= new StellaOpsCliOfflineOptions();
|
||||
var offline = cliOptions.Offline;
|
||||
|
||||
var kitsDirectory = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_OFFLINE_KITS_DIRECTORY",
|
||||
"STELLAOPS_OFFLINE_KITS_DIR",
|
||||
"StellaOps:Offline:KitsDirectory",
|
||||
"StellaOps:Offline:KitDirectory",
|
||||
"Offline:KitsDirectory",
|
||||
"Offline:KitDirectory");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(kitsDirectory))
|
||||
{
|
||||
kitsDirectory = offline.KitsDirectory ?? "offline-kits";
|
||||
}
|
||||
|
||||
offline.KitsDirectory = Path.GetFullPath(kitsDirectory);
|
||||
if (!Directory.Exists(offline.KitsDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(offline.KitsDirectory);
|
||||
}
|
||||
|
||||
var mirror = ResolveWithFallback(
|
||||
string.Empty,
|
||||
configuration,
|
||||
"STELLAOPS_OFFLINE_MIRROR_URL",
|
||||
"StellaOps:Offline:KitMirror",
|
||||
"Offline:KitMirror",
|
||||
"Offline:MirrorUrl");
|
||||
|
||||
offline.MirrorUrl = string.IsNullOrWhiteSpace(mirror) ? null : mirror.Trim();
|
||||
|
||||
cliOptions.Plugins ??= new StellaOpsCliPluginOptions();
|
||||
var pluginOptions = cliOptions.Plugins;
|
||||
|
||||
pluginOptions.BaseDirectory = ResolveWithFallback(
|
||||
pluginOptions.BaseDirectory,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_BASE_DIRECTORY",
|
||||
"StellaOps:Plugins:BaseDirectory",
|
||||
"Plugins:BaseDirectory");
|
||||
|
||||
pluginOptions.BaseDirectory = (pluginOptions.BaseDirectory ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.BaseDirectory))
|
||||
{
|
||||
pluginOptions.BaseDirectory = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
pluginOptions.BaseDirectory = Path.GetFullPath(pluginOptions.BaseDirectory);
|
||||
|
||||
pluginOptions.Directory = ResolveWithFallback(
|
||||
pluginOptions.Directory,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_DIRECTORY",
|
||||
"StellaOps:Plugins:Directory",
|
||||
"Plugins:Directory");
|
||||
|
||||
pluginOptions.Directory = (pluginOptions.Directory ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.Directory))
|
||||
{
|
||||
pluginOptions.Directory = Path.Combine("plugins", "cli");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(pluginOptions.Directory))
|
||||
{
|
||||
pluginOptions.Directory = Path.GetFullPath(Path.Combine(pluginOptions.BaseDirectory, pluginOptions.Directory));
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.Directory = Path.GetFullPath(pluginOptions.Directory);
|
||||
}
|
||||
|
||||
pluginOptions.ManifestSearchPattern = ResolveWithFallback(
|
||||
pluginOptions.ManifestSearchPattern,
|
||||
configuration,
|
||||
"STELLAOPS_CLI_PLUGIN_MANIFEST_PATTERN",
|
||||
"StellaOps:Plugins:ManifestSearchPattern",
|
||||
"Plugins:ManifestSearchPattern");
|
||||
|
||||
pluginOptions.ManifestSearchPattern = (pluginOptions.ManifestSearchPattern ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern))
|
||||
{
|
||||
pluginOptions.ManifestSearchPattern = "*.manifest.json";
|
||||
}
|
||||
|
||||
if (pluginOptions.SearchPatterns is null || pluginOptions.SearchPatterns.Count == 0)
|
||||
{
|
||||
pluginOptions.SearchPatterns = new List<string> { "StellaOps.Cli.Plugin.*.dll" };
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.SearchPatterns = pluginOptions.SearchPatterns
|
||||
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
|
||||
.Select(pattern => pattern.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (pluginOptions.SearchPatterns.Count == 0)
|
||||
{
|
||||
pluginOptions.SearchPatterns.Add("StellaOps.Cli.Plugin.*.dll");
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginOptions.PluginOrder is null)
|
||||
{
|
||||
pluginOptions.PluginOrder = new List<string>();
|
||||
}
|
||||
else
|
||||
{
|
||||
pluginOptions.PluginOrder = pluginOptions.PluginOrder
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
public string BackendUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ConcelierUrl { get; set; } = string.Empty;
|
||||
|
||||
public string AdvisoryAiUrl { 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 StellaOpsCliOfflineOptions Offline { 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
|
||||
{
|
||||
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.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 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; }
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliOfflineOptions
|
||||
{
|
||||
public string KitsDirectory { get; set; } = "offline-kits";
|
||||
|
||||
public string? MirrorUrl { get; set; }
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
public string BackendUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ConcelierUrl { get; set; } = string.Empty;
|
||||
|
||||
public string AdvisoryAiUrl { 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 StellaOpsCliOfflineOptions Offline { 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
|
||||
{
|
||||
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.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 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; }
|
||||
}
|
||||
|
||||
public sealed class StellaOpsCliOfflineOptions
|
||||
{
|
||||
public string KitsDirectory { get; set; } = "offline-kits";
|
||||
|
||||
public string? MirrorUrl { get; set; }
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -1,278 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class CliCommandModuleLoader
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<CliCommandModuleLoader> _logger;
|
||||
private readonly RestartOnlyCliPluginGuard _guard = new();
|
||||
|
||||
private IReadOnlyList<ICliCommandModule> _modules = Array.Empty<ICliCommandModule>();
|
||||
private bool _loaded;
|
||||
|
||||
public CliCommandModuleLoader(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<CliCommandModuleLoader> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ICliCommandModule> LoadModules()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions();
|
||||
|
||||
var baseDirectory = ResolveBaseDirectory(pluginOptions);
|
||||
var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory);
|
||||
var searchPatterns = ResolveSearchPatterns(pluginOptions);
|
||||
var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern)
|
||||
? "*.manifest.json"
|
||||
: pluginOptions.ManifestSearchPattern;
|
||||
|
||||
_logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory);
|
||||
|
||||
var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern);
|
||||
IReadOnlyList<CliPluginManifest> manifests;
|
||||
try
|
||||
{
|
||||
manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory);
|
||||
manifests = Array.Empty<CliPluginManifest>();
|
||||
}
|
||||
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory);
|
||||
_loaded = true;
|
||||
_guard.Seal();
|
||||
_modules = Array.Empty<ICliCommandModule>();
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var hostOptions = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = baseDirectory,
|
||||
PluginsDirectory = pluginsDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = true,
|
||||
PrimaryPrefix = "StellaOps.Cli"
|
||||
};
|
||||
|
||||
foreach (var pattern in searchPatterns)
|
||||
{
|
||||
hostOptions.SearchPatterns.Add(pattern);
|
||||
}
|
||||
|
||||
foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty<string>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ordered))
|
||||
{
|
||||
hostOptions.PluginOrder.Add(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
var loadResult = PluginHost.LoadPlugins(hostOptions, _logger);
|
||||
|
||||
var assemblies = loadResult.Plugins.ToDictionary(
|
||||
descriptor => Normalize(descriptor.AssemblyPath),
|
||||
descriptor => descriptor.Assembly,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var modules = new List<ICliCommandModule>(manifests.Count);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assemblyPath = ResolveAssemblyPath(manifest);
|
||||
_guard.EnsureRegistrationAllowed(assemblyPath);
|
||||
|
||||
if (!assemblies.TryGetValue(assemblyPath, out var assembly))
|
||||
{
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found.");
|
||||
}
|
||||
|
||||
assembly = Assembly.LoadFrom(assemblyPath);
|
||||
assemblies[assemblyPath] = assembly;
|
||||
}
|
||||
|
||||
var module = CreateModule(assembly, manifest);
|
||||
if (module is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
modules.Add(module);
|
||||
_logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_modules = modules;
|
||||
_loaded = true;
|
||||
_guard.Seal();
|
||||
return _modules;
|
||||
}
|
||||
|
||||
public void RegisterModules(RootCommand root, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
if (root is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(root));
|
||||
}
|
||||
if (verboseOption is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(verboseOption));
|
||||
}
|
||||
|
||||
var modules = LoadModules();
|
||||
if (modules.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (!module.IsAvailable(_services))
|
||||
{
|
||||
_logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken);
|
||||
_logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveAssemblyPath(CliPluginManifest manifest)
|
||||
{
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point.");
|
||||
}
|
||||
|
||||
var assemblyPath = manifest.EntryPoint.Assembly;
|
||||
if (string.IsNullOrWhiteSpace(assemblyPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(assemblyPath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifest.SourceDirectory))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata.");
|
||||
}
|
||||
|
||||
assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath);
|
||||
}
|
||||
|
||||
return Normalize(assemblyPath);
|
||||
}
|
||||
|
||||
private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest)
|
||||
{
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true);
|
||||
if (type is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'.");
|
||||
}
|
||||
|
||||
var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule;
|
||||
if (module is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}.");
|
||||
}
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options)
|
||||
{
|
||||
var baseDirectory = options.BaseDirectory;
|
||||
if (string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
baseDirectory = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(baseDirectory);
|
||||
}
|
||||
|
||||
private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory)
|
||||
{
|
||||
var directory = options.Directory;
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
directory = Path.Combine("plugins", "cli");
|
||||
}
|
||||
|
||||
directory = directory.Trim();
|
||||
|
||||
if (!Path.IsPathRooted(directory))
|
||||
{
|
||||
directory = Path.Combine(baseDirectory, directory);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveSearchPatterns(StellaOpsCliPluginOptions options)
|
||||
{
|
||||
if (options.SearchPatterns is null || options.SearchPatterns.Count == 0)
|
||||
{
|
||||
return new[] { "StellaOps.Cli.Plugin.*.dll" };
|
||||
}
|
||||
|
||||
return options.SearchPatterns
|
||||
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
|
||||
.Select(pattern => pattern.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class CliCommandModuleLoader
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<CliCommandModuleLoader> _logger;
|
||||
private readonly RestartOnlyCliPluginGuard _guard = new();
|
||||
|
||||
private IReadOnlyList<ICliCommandModule> _modules = Array.Empty<ICliCommandModule>();
|
||||
private bool _loaded;
|
||||
|
||||
public CliCommandModuleLoader(
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<CliCommandModuleLoader> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ICliCommandModule> LoadModules()
|
||||
{
|
||||
if (_loaded)
|
||||
{
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions();
|
||||
|
||||
var baseDirectory = ResolveBaseDirectory(pluginOptions);
|
||||
var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory);
|
||||
var searchPatterns = ResolveSearchPatterns(pluginOptions);
|
||||
var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern)
|
||||
? "*.manifest.json"
|
||||
: pluginOptions.ManifestSearchPattern;
|
||||
|
||||
_logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory);
|
||||
|
||||
var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern);
|
||||
IReadOnlyList<CliPluginManifest> manifests;
|
||||
try
|
||||
{
|
||||
manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory);
|
||||
manifests = Array.Empty<CliPluginManifest>();
|
||||
}
|
||||
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory);
|
||||
_loaded = true;
|
||||
_guard.Seal();
|
||||
_modules = Array.Empty<ICliCommandModule>();
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var hostOptions = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = baseDirectory,
|
||||
PluginsDirectory = pluginsDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = true,
|
||||
PrimaryPrefix = "StellaOps.Cli"
|
||||
};
|
||||
|
||||
foreach (var pattern in searchPatterns)
|
||||
{
|
||||
hostOptions.SearchPatterns.Add(pattern);
|
||||
}
|
||||
|
||||
foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty<string>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ordered))
|
||||
{
|
||||
hostOptions.PluginOrder.Add(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
var loadResult = PluginHost.LoadPlugins(hostOptions, _logger);
|
||||
|
||||
var assemblies = loadResult.Plugins.ToDictionary(
|
||||
descriptor => Normalize(descriptor.AssemblyPath),
|
||||
descriptor => descriptor.Assembly,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var modules = new List<ICliCommandModule>(manifests.Count);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assemblyPath = ResolveAssemblyPath(manifest);
|
||||
_guard.EnsureRegistrationAllowed(assemblyPath);
|
||||
|
||||
if (!assemblies.TryGetValue(assemblyPath, out var assembly))
|
||||
{
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found.");
|
||||
}
|
||||
|
||||
assembly = Assembly.LoadFrom(assemblyPath);
|
||||
assemblies[assemblyPath] = assembly;
|
||||
}
|
||||
|
||||
var module = CreateModule(assembly, manifest);
|
||||
if (module is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
modules.Add(module);
|
||||
_logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id);
|
||||
}
|
||||
}
|
||||
|
||||
_modules = modules;
|
||||
_loaded = true;
|
||||
_guard.Seal();
|
||||
return _modules;
|
||||
}
|
||||
|
||||
public void RegisterModules(RootCommand root, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
if (root is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(root));
|
||||
}
|
||||
if (verboseOption is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(verboseOption));
|
||||
}
|
||||
|
||||
var modules = LoadModules();
|
||||
if (modules.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (!module.IsAvailable(_services))
|
||||
{
|
||||
_logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken);
|
||||
_logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveAssemblyPath(CliPluginManifest manifest)
|
||||
{
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point.");
|
||||
}
|
||||
|
||||
var assemblyPath = manifest.EntryPoint.Assembly;
|
||||
if (string.IsNullOrWhiteSpace(assemblyPath))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(assemblyPath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifest.SourceDirectory))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata.");
|
||||
}
|
||||
|
||||
assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath);
|
||||
}
|
||||
|
||||
return Normalize(assemblyPath);
|
||||
}
|
||||
|
||||
private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest)
|
||||
{
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true);
|
||||
if (type is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'.");
|
||||
}
|
||||
|
||||
var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule;
|
||||
if (module is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}.");
|
||||
}
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options)
|
||||
{
|
||||
var baseDirectory = options.BaseDirectory;
|
||||
if (string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
baseDirectory = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(baseDirectory);
|
||||
}
|
||||
|
||||
private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory)
|
||||
{
|
||||
var directory = options.Directory;
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
directory = Path.Combine("plugins", "cli");
|
||||
}
|
||||
|
||||
directory = directory.Trim();
|
||||
|
||||
if (!Path.IsPathRooted(directory))
|
||||
{
|
||||
directory = Path.Combine(baseDirectory, directory);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveSearchPatterns(StellaOpsCliPluginOptions options)
|
||||
{
|
||||
if (options.SearchPatterns is null || options.SearchPatterns.Count == 0)
|
||||
{
|
||||
return new[] { "StellaOps.Cli.Plugin.*.dll" };
|
||||
}
|
||||
|
||||
return options.SearchPatterns
|
||||
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
|
||||
.Select(pattern => pattern.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public sealed record CliPluginManifest
|
||||
{
|
||||
public const string CurrentSchemaVersion = "1.0";
|
||||
|
||||
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = "0.0.0";
|
||||
|
||||
public bool RequiresRestart { get; init; } = true;
|
||||
|
||||
public CliPluginEntryPoint? EntryPoint { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
public string? SourceDirectory { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CliPluginEntryPoint
|
||||
{
|
||||
public string Type { get; init; } = "dotnet";
|
||||
|
||||
public string Assembly { get; init; } = string.Empty;
|
||||
|
||||
public string TypeName { get; init; } = string.Empty;
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public sealed record CliPluginManifest
|
||||
{
|
||||
public const string CurrentSchemaVersion = "1.0";
|
||||
|
||||
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = "0.0.0";
|
||||
|
||||
public bool RequiresRestart { get; init; } = true;
|
||||
|
||||
public CliPluginEntryPoint? EntryPoint { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
public string? SourceDirectory { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CliPluginEntryPoint
|
||||
{
|
||||
public string Type { get; init; } = "dotnet";
|
||||
|
||||
public string Assembly { get; init; } = string.Empty;
|
||||
|
||||
public string TypeName { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class CliPluginManifestLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly string _searchPattern;
|
||||
|
||||
public CliPluginManifestLoader(string directory, string searchPattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new ArgumentException("Plug-in manifest directory is required.", nameof(directory));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchPattern))
|
||||
{
|
||||
throw new ArgumentException("Manifest search pattern is required.", nameof(searchPattern));
|
||||
}
|
||||
|
||||
_directory = Path.GetFullPath(directory);
|
||||
_searchPattern = searchPattern;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CliPluginManifest>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(_directory))
|
||||
{
|
||||
return Array.Empty<CliPluginManifest>();
|
||||
}
|
||||
|
||||
var manifests = new List<CliPluginManifest>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(_directory, _searchPattern, SearchOption.AllDirectories))
|
||||
{
|
||||
if (IsHidden(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifest = await DeserializeAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
manifests.Add(manifest);
|
||||
}
|
||||
|
||||
return manifests
|
||||
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsHidden(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (name.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<CliPluginManifest> DeserializeAsync(string file, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
|
||||
CliPluginManifest? manifest;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = await JsonSerializer.DeserializeAsync<CliPluginManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse CLI plug-in manifest '{file}'.", ex);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"CLI plug-in manifest '{file}' is empty or invalid.");
|
||||
}
|
||||
|
||||
ValidateManifest(manifest, file);
|
||||
|
||||
var directory = Path.GetDirectoryName(file);
|
||||
return manifest with
|
||||
{
|
||||
SourcePath = file,
|
||||
SourceDirectory = directory
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateManifest(CliPluginManifest manifest, string file)
|
||||
{
|
||||
if (!string.Equals(manifest.SchemaVersion, CliPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{CliPluginManifest.CurrentSchemaVersion}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify a non-empty 'id'.");
|
||||
}
|
||||
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an 'entryPoint'.");
|
||||
}
|
||||
|
||||
if (!string.Equals(manifest.EntryPoint.Type, "dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' entry point type '{manifest.EntryPoint.Type}' is not supported. Expected 'dotnet'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Assembly))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point assembly.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.TypeName))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point type.");
|
||||
}
|
||||
|
||||
if (!manifest.RequiresRestart)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must set 'requiresRestart' to true.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class CliPluginManifestLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly string _searchPattern;
|
||||
|
||||
public CliPluginManifestLoader(string directory, string searchPattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new ArgumentException("Plug-in manifest directory is required.", nameof(directory));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchPattern))
|
||||
{
|
||||
throw new ArgumentException("Manifest search pattern is required.", nameof(searchPattern));
|
||||
}
|
||||
|
||||
_directory = Path.GetFullPath(directory);
|
||||
_searchPattern = searchPattern;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CliPluginManifest>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(_directory))
|
||||
{
|
||||
return Array.Empty<CliPluginManifest>();
|
||||
}
|
||||
|
||||
var manifests = new List<CliPluginManifest>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(_directory, _searchPattern, SearchOption.AllDirectories))
|
||||
{
|
||||
if (IsHidden(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifest = await DeserializeAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
manifests.Add(manifest);
|
||||
}
|
||||
|
||||
return manifests
|
||||
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsHidden(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (name.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<CliPluginManifest> DeserializeAsync(string file, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
|
||||
CliPluginManifest? manifest;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = await JsonSerializer.DeserializeAsync<CliPluginManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse CLI plug-in manifest '{file}'.", ex);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"CLI plug-in manifest '{file}' is empty or invalid.");
|
||||
}
|
||||
|
||||
ValidateManifest(manifest, file);
|
||||
|
||||
var directory = Path.GetDirectoryName(file);
|
||||
return manifest with
|
||||
{
|
||||
SourcePath = file,
|
||||
SourceDirectory = directory
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateManifest(CliPluginManifest manifest, string file)
|
||||
{
|
||||
if (!string.Equals(manifest.SchemaVersion, CliPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{CliPluginManifest.CurrentSchemaVersion}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify a non-empty 'id'.");
|
||||
}
|
||||
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an 'entryPoint'.");
|
||||
}
|
||||
|
||||
if (!string.Equals(manifest.EntryPoint.Type, "dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' entry point type '{manifest.EntryPoint.Type}' is not supported. Expected 'dotnet'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Assembly))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point assembly.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.TypeName))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point type.");
|
||||
}
|
||||
|
||||
if (!manifest.RequiresRestart)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must set 'requiresRestart' to true.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public interface ICliCommandModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
bool IsAvailable(IServiceProvider services);
|
||||
|
||||
void RegisterCommands(
|
||||
RootCommand root,
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public interface ICliCommandModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
bool IsAvailable(IServiceProvider services);
|
||||
|
||||
void RegisterCommands(
|
||||
RootCommand root,
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class RestartOnlyCliPluginGuard
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _plugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _sealed;
|
||||
|
||||
public IReadOnlyCollection<string> KnownPlugins => _plugins.Keys.ToArray();
|
||||
|
||||
public bool IsSealed => Volatile.Read(ref _sealed);
|
||||
|
||||
public void EnsureRegistrationAllowed(string pluginPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath);
|
||||
|
||||
var normalized = Normalize(pluginPath);
|
||||
if (IsSealed && !_plugins.ContainsKey(normalized))
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required.");
|
||||
}
|
||||
|
||||
_plugins.TryAdd(normalized, 0);
|
||||
}
|
||||
|
||||
public void Seal()
|
||||
{
|
||||
Volatile.Write(ref _sealed, true);
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class RestartOnlyCliPluginGuard
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _plugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _sealed;
|
||||
|
||||
public IReadOnlyCollection<string> KnownPlugins => _plugins.Keys.ToArray();
|
||||
|
||||
public bool IsSealed => Volatile.Read(ref _sealed);
|
||||
|
||||
public void EnsureRegistrationAllowed(string pluginPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath);
|
||||
|
||||
var normalized = Normalize(pluginPath);
|
||||
if (IsSealed && !_plugins.ContainsKey(normalized))
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required.");
|
||||
}
|
||||
|
||||
_plugins.TryAdd(normalized, 0);
|
||||
}
|
||||
|
||||
public void Seal()
|
||||
{
|
||||
Volatile.Write(ref _sealed, true);
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cli.Tests")]
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cli.Plugins.NonCore")]
|
||||
|
||||
@@ -1,223 +1,223 @@
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
|
||||
{
|
||||
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<AuthorityRevocationClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public AuthorityRevocationClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<AuthorityRevocationClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
httpClient.BaseAddress = authorityUri;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureAuthorityConfigured();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export");
|
||||
var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}";
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>(
|
||||
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
|
||||
SerializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority export response payload was empty.");
|
||||
}
|
||||
|
||||
var bundleBytes = Convert.FromBase64String(payload.Bundle.Data);
|
||||
var digest = payload.Digest?.Value ?? string.Empty;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}, provider {Provider}).",
|
||||
payload.Sequence,
|
||||
digest,
|
||||
payload.SigningKeyId ?? "<unspecified>",
|
||||
string.IsNullOrWhiteSpace(payload.Signature?.Provider) ? "default" : payload.Signature!.Provider);
|
||||
}
|
||||
|
||||
return new AuthorityRevocationExportResult
|
||||
{
|
||||
BundleBytes = bundleBytes,
|
||||
Signature = payload.Signature?.Value ?? string.Empty,
|
||||
Digest = digest,
|
||||
Sequence = payload.Sequence,
|
||||
IssuedAt = payload.IssuedAt,
|
||||
SigningKeyId = payload.SigningKeyId,
|
||||
SigningProvider = payload.Signature?.Provider
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var scope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Authority is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority credentials are not configured.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured or run 'auth login'.");
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestPasswordTokenAsync(
|
||||
options.Authority.Username,
|
||||
options.Authority.Password!,
|
||||
scope,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void EnsureAuthorityConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml.");
|
||||
}
|
||||
|
||||
if (httpClient.BaseAddress is null)
|
||||
{
|
||||
if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is invalid.");
|
||||
}
|
||||
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ExportResponseDto
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public long Sequence { get; set; }
|
||||
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("signingKeyId")]
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public ExportPayloadDto Bundle { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public ExportSignatureDto? Signature { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public ExportDigestDto? Digest { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportPayloadDto
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportSignatureDto
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportDigestDto
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
|
||||
{
|
||||
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<AuthorityRevocationClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public AuthorityRevocationClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<AuthorityRevocationClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
httpClient.BaseAddress = authorityUri;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureAuthorityConfigured();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export");
|
||||
var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}";
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>(
|
||||
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
|
||||
SerializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority export response payload was empty.");
|
||||
}
|
||||
|
||||
var bundleBytes = Convert.FromBase64String(payload.Bundle.Data);
|
||||
var digest = payload.Digest?.Value ?? string.Empty;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}, provider {Provider}).",
|
||||
payload.Sequence,
|
||||
digest,
|
||||
payload.SigningKeyId ?? "<unspecified>",
|
||||
string.IsNullOrWhiteSpace(payload.Signature?.Provider) ? "default" : payload.Signature!.Provider);
|
||||
}
|
||||
|
||||
return new AuthorityRevocationExportResult
|
||||
{
|
||||
BundleBytes = bundleBytes,
|
||||
Signature = payload.Signature?.Value ?? string.Empty,
|
||||
Digest = digest,
|
||||
Sequence = payload.Sequence,
|
||||
IssuedAt = payload.IssuedAt,
|
||||
SigningKeyId = payload.SigningKeyId,
|
||||
SigningProvider = payload.Signature?.Provider
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var scope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Authority is null)
|
||||
{
|
||||
throw new InvalidOperationException("Authority credentials are not configured.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured or run 'auth login'.");
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestPasswordTokenAsync(
|
||||
options.Authority.Username,
|
||||
options.Authority.Password!,
|
||||
scope,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void EnsureAuthorityConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml.");
|
||||
}
|
||||
|
||||
if (httpClient.BaseAddress is null)
|
||||
{
|
||||
if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Authority URL is invalid.");
|
||||
}
|
||||
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ExportResponseDto
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sequence")]
|
||||
public long Sequence { get; set; }
|
||||
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("signingKeyId")]
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("bundle")]
|
||||
public ExportPayloadDto Bundle { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public ExportSignatureDto? Signature { get; set; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public ExportDigestDto? Digest { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExportPayloadDto
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportSignatureDto
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("provider")]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class ExportDigestDto
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,392 +1,392 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
{
|
||||
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<ConcelierObservationsClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ConcelierObservationsClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ConcelierObservationsClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query observations (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryObservationsResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildLinksetRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query linkset (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryLinksetResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/concelier/observations/{Uri.EscapeDataString(observationId)}?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get observation (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetObservation>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildRequestUri(AdvisoryObservationsQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/observations?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLinksetRequestUri(AdvisoryLinksetQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/linkset?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
AppendValues(builder, "source", query.Sources);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Severity))
|
||||
{
|
||||
builder.Append("&severity=");
|
||||
builder.Append(Uri.EscapeDataString(query.Severity));
|
||||
}
|
||||
|
||||
if (query.KevOnly.HasValue)
|
||||
{
|
||||
builder.Append("&kevOnly=");
|
||||
builder.Append(query.KevOnly.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.HasFix.HasValue)
|
||||
{
|
||||
builder.Append("&hasFix=");
|
||||
builder.Append(query.HasFix.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append("&limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append("&cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
|
||||
}
|
||||
|
||||
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 (scope, cacheKey) = BuildScopeAndCacheKey(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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
|
||||
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
|
||||
return (finalScope, cacheKey);
|
||||
}
|
||||
|
||||
private static string EnsureScope(string scopes, string required)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopes))
|
||||
{
|
||||
return required;
|
||||
}
|
||||
|
||||
var parts = scopes
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static scope => scope.ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (!parts.Contains(required, StringComparer.Ordinal))
|
||||
{
|
||||
parts.Add(required);
|
||||
}
|
||||
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
{
|
||||
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<ConcelierObservationsClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ConcelierObservationsClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ConcelierObservationsClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query observations (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryObservationsResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildLinksetRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query linkset (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryLinksetResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/concelier/observations/{Uri.EscapeDataString(observationId)}?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get observation (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetObservation>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildRequestUri(AdvisoryObservationsQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/observations?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append('&');
|
||||
builder.Append("cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLinksetRequestUri(AdvisoryLinksetQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/linkset?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
AppendValues(builder, "source", query.Sources);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Severity))
|
||||
{
|
||||
builder.Append("&severity=");
|
||||
builder.Append(Uri.EscapeDataString(query.Severity));
|
||||
}
|
||||
|
||||
if (query.KevOnly.HasValue)
|
||||
{
|
||||
builder.Append("&kevOnly=");
|
||||
builder.Append(query.KevOnly.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.HasFix.HasValue)
|
||||
{
|
||||
builder.Append("&hasFix=");
|
||||
builder.Append(query.HasFix.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append("&limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append("&cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
|
||||
}
|
||||
|
||||
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 (scope, cacheKey) = BuildScopeAndCacheKey(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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = token.AccessToken;
|
||||
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
|
||||
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
|
||||
return (finalScope, cacheKey);
|
||||
}
|
||||
|
||||
private static string EnsureScope(string scopes, string required)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopes))
|
||||
{
|
||||
return required;
|
||||
}
|
||||
|
||||
var parts = scopes
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static scope => scope.ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (!parts.Contains(required, StringComparer.Ordinal))
|
||||
{
|
||||
parts.Add(required);
|
||||
}
|
||||
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Concelier advisory observations API.
|
||||
/// Per CLI-LNM-22-001, supports obs get, linkset show, and export operations.
|
||||
/// </summary>
|
||||
internal interface IConcelierObservationsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets advisory observations matching the query.
|
||||
/// </summary>
|
||||
Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001, includes conflict display.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Concelier advisory observations API.
|
||||
/// Per CLI-LNM-22-001, supports obs get, linkset show, and export operations.
|
||||
/// </summary>
|
||||
internal interface IConcelierObservationsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets advisory observations matching the query.
|
||||
/// </summary>
|
||||
Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001, includes conflict display.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerExecutor
|
||||
{
|
||||
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,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerInstaller
|
||||
{
|
||||
Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal interface IScannerInstaller
|
||||
{
|
||||
Task InstallAsync(string artifactPath, bool verbose, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record AdvisoryObservationsQuery(
|
||||
string Tenant,
|
||||
IReadOnlyList<string> ObservationIds,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> Purls,
|
||||
IReadOnlyList<string> Cpes,
|
||||
int? Limit,
|
||||
string? Cursor);
|
||||
|
||||
internal sealed class AdvisoryObservationsResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<AdvisoryObservationDocument> Observations { get; init; } =
|
||||
Array.Empty<AdvisoryObservationDocument>();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
|
||||
new();
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationDocument
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public AdvisoryObservationSource Source { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("upstream")]
|
||||
public AdvisoryObservationUpstream Upstream { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinkset Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationSource
|
||||
{
|
||||
[JsonPropertyName("vendor")]
|
||||
public string Vendor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("api")]
|
||||
public string Api { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("collectorVersion")]
|
||||
public string? CollectorVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationUpstream
|
||||
{
|
||||
[JsonPropertyName("upstreamId")]
|
||||
public string UpstreamId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("documentVersion")]
|
||||
public string? DocumentVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinkset
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationReference
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinksetAggregate
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record AdvisoryObservationsQuery(
|
||||
string Tenant,
|
||||
IReadOnlyList<string> ObservationIds,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> Purls,
|
||||
IReadOnlyList<string> Cpes,
|
||||
int? Limit,
|
||||
string? Cursor);
|
||||
|
||||
internal sealed class AdvisoryObservationsResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<AdvisoryObservationDocument> Observations { get; init; } =
|
||||
Array.Empty<AdvisoryObservationDocument>();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
|
||||
new();
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationDocument
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public AdvisoryObservationSource Source { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("upstream")]
|
||||
public AdvisoryObservationUpstream Upstream { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinkset Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationSource
|
||||
{
|
||||
[JsonPropertyName("vendor")]
|
||||
public string Vendor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("api")]
|
||||
public string Api { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("collectorVersion")]
|
||||
public string? CollectorVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationUpstream
|
||||
{
|
||||
[JsonPropertyName("upstreamId")]
|
||||
public string UpstreamId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("documentVersion")]
|
||||
public string? DocumentVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinkset
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationReference
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryObservationLinksetAggregate
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
}
|
||||
|
||||
@@ -1,93 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocIngestDryRunRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocument
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string ContentType { get; init; } = "application/json";
|
||||
|
||||
[JsonPropertyName("contentEncoding")]
|
||||
public string? ContentEncoding { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunResponse
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("guardVersion")]
|
||||
public string? GuardVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocumentResult Document { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocIngestDryRunViolation> Violations { get; init; } =
|
||||
Array.Empty<AocIngestDryRunViolation>();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocumentResult
|
||||
{
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public AocIngestDryRunProvenance Provenance { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunProvenance
|
||||
{
|
||||
[JsonPropertyName("signature")]
|
||||
public AocIngestDryRunSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunSignature
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("present")]
|
||||
public bool Present { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocIngestDryRunRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocument
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string ContentType { get; init; } = "application/json";
|
||||
|
||||
[JsonPropertyName("contentEncoding")]
|
||||
public string? ContentEncoding { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunResponse
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("guardVersion")]
|
||||
public string? GuardVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public AocIngestDryRunDocumentResult Document { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocIngestDryRunViolation> Violations { get; init; } =
|
||||
Array.Empty<AocIngestDryRunViolation>();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunDocumentResult
|
||||
{
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
[JsonPropertyName("provenance")]
|
||||
public AocIngestDryRunProvenance Provenance { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunProvenance
|
||||
{
|
||||
[JsonPropertyName("signature")]
|
||||
public AocIngestDryRunSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunSignature
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("present")]
|
||||
public bool Present { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocIngestDryRunViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public string? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("codes")]
|
||||
public IReadOnlyList<string>? Codes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyResponse
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("window")]
|
||||
public AocVerifyWindow Window { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("checked")]
|
||||
public AocVerifyChecked Checked { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocVerifyViolation> Violations { get; init; } =
|
||||
Array.Empty<AocVerifyViolation>();
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public AocVerifyMetrics Metrics { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("truncated")]
|
||||
public bool? Truncated { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyWindow
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset? From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset? To { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyChecked
|
||||
{
|
||||
[JsonPropertyName("advisories")]
|
||||
public int Advisories { get; init; }
|
||||
|
||||
[JsonPropertyName("vex")]
|
||||
public int Vex { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("examples")]
|
||||
public IReadOnlyList<AocVerifyViolationExample> Examples { get; init; } =
|
||||
Array.Empty<AocVerifyViolationExample>();
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolationExample
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("documentId")]
|
||||
public string? DocumentId { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyMetrics
|
||||
{
|
||||
[JsonPropertyName("ingestion_write_total")]
|
||||
public int? IngestionWriteTotal { get; init; }
|
||||
|
||||
[JsonPropertyName("aoc_violation_total")]
|
||||
public int? AocViolationTotal { get; init; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed class AocVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public string? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("codes")]
|
||||
public IReadOnlyList<string>? Codes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyResponse
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("window")]
|
||||
public AocVerifyWindow Window { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("checked")]
|
||||
public AocVerifyChecked Checked { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("violations")]
|
||||
public IReadOnlyList<AocVerifyViolation> Violations { get; init; } =
|
||||
Array.Empty<AocVerifyViolation>();
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public AocVerifyMetrics Metrics { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("truncated")]
|
||||
public bool? Truncated { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyWindow
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset? From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset? To { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyChecked
|
||||
{
|
||||
[JsonPropertyName("advisories")]
|
||||
public int Advisories { get; init; }
|
||||
|
||||
[JsonPropertyName("vex")]
|
||||
public int Vex { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolation
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("examples")]
|
||||
public IReadOnlyList<AocVerifyViolationExample> Examples { get; init; } =
|
||||
Array.Empty<AocVerifyViolationExample>();
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyViolationExample
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("documentId")]
|
||||
public string? DocumentId { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public string? ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AocVerifyMetrics
|
||||
{
|
||||
[JsonPropertyName("ingestion_write_total")]
|
||||
public int? IngestionWriteTotal { get; init; }
|
||||
|
||||
[JsonPropertyName("aoc_violation_total")]
|
||||
public int? AocViolationTotal { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorExportDownloadResult(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
bool FromCache);
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorExportDownloadResult(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
bool FromCache);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorOperationResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JsonElement? Payload);
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorOperationResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JsonElement? Payload);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorProviderSummary(
|
||||
string Id,
|
||||
string Kind,
|
||||
string DisplayName,
|
||||
string TrustTier,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastIngestedAt);
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ExcititorProviderSummary(
|
||||
string Id,
|
||||
string Kind,
|
||||
string DisplayName,
|
||||
string TrustTier,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastIngestedAt);
|
||||
|
||||
@@ -1,9 +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);
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record JobTriggerResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
string? Location,
|
||||
JobRunResponse? Run);
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record OfflineKitBundleDescriptor(
|
||||
string BundleId,
|
||||
string BundleName,
|
||||
string BundleSha256,
|
||||
long BundleSize,
|
||||
Uri BundleDownloadUri,
|
||||
string ManifestName,
|
||||
string ManifestSha256,
|
||||
Uri ManifestDownloadUri,
|
||||
DateTimeOffset CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? BundleSignatureName,
|
||||
Uri? BundleSignatureDownloadUri,
|
||||
string? ManifestSignatureName,
|
||||
Uri? ManifestSignatureDownloadUri,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitDownloadResult(
|
||||
OfflineKitBundleDescriptor Descriptor,
|
||||
string BundlePath,
|
||||
string ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string MetadataPath,
|
||||
bool FromCache);
|
||||
|
||||
internal sealed record OfflineKitImportRequest(
|
||||
string BundlePath,
|
||||
string? ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string? BundleId,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
DateTimeOffset? CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool? IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? ManifestSha256,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitImportResult(
|
||||
string? ImportId,
|
||||
string? Status,
|
||||
DateTimeOffset SubmittedAt,
|
||||
string? Message);
|
||||
|
||||
internal sealed record OfflineKitStatus(
|
||||
string? BundleId,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
DateTimeOffset? CapturedAt,
|
||||
DateTimeOffset? ImportedAt,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
IReadOnlyList<OfflineKitComponentStatus> Components);
|
||||
|
||||
internal sealed record OfflineKitComponentStatus(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Digest,
|
||||
DateTimeOffset? CapturedAt,
|
||||
long? SizeBytes);
|
||||
|
||||
internal sealed record OfflineKitMetadataDocument
|
||||
{
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
public string BundleName { get; init; } = string.Empty;
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long BundleSize { get; init; }
|
||||
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
public string? Channel { get; init; }
|
||||
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public bool IsDelta { get; init; }
|
||||
|
||||
public string? BaseBundleId { get; init; }
|
||||
|
||||
public string ManifestName { get; init; } = string.Empty;
|
||||
|
||||
public string ManifestSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long? ManifestSize { get; init; }
|
||||
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
public string? BundleSignaturePath { get; init; }
|
||||
|
||||
public string? ManifestSignaturePath { get; init; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record OfflineKitBundleDescriptor(
|
||||
string BundleId,
|
||||
string BundleName,
|
||||
string BundleSha256,
|
||||
long BundleSize,
|
||||
Uri BundleDownloadUri,
|
||||
string ManifestName,
|
||||
string ManifestSha256,
|
||||
Uri ManifestDownloadUri,
|
||||
DateTimeOffset CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? BundleSignatureName,
|
||||
Uri? BundleSignatureDownloadUri,
|
||||
string? ManifestSignatureName,
|
||||
Uri? ManifestSignatureDownloadUri,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitDownloadResult(
|
||||
OfflineKitBundleDescriptor Descriptor,
|
||||
string BundlePath,
|
||||
string ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string MetadataPath,
|
||||
bool FromCache);
|
||||
|
||||
internal sealed record OfflineKitImportRequest(
|
||||
string BundlePath,
|
||||
string? ManifestPath,
|
||||
string? BundleSignaturePath,
|
||||
string? ManifestSignaturePath,
|
||||
string? BundleId,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
DateTimeOffset? CapturedAt,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool? IsDelta,
|
||||
string? BaseBundleId,
|
||||
string? ManifestSha256,
|
||||
long? ManifestSize);
|
||||
|
||||
internal sealed record OfflineKitImportResult(
|
||||
string? ImportId,
|
||||
string? Status,
|
||||
DateTimeOffset SubmittedAt,
|
||||
string? Message);
|
||||
|
||||
internal sealed record OfflineKitStatus(
|
||||
string? BundleId,
|
||||
string? Channel,
|
||||
string? Kind,
|
||||
bool IsDelta,
|
||||
string? BaseBundleId,
|
||||
DateTimeOffset? CapturedAt,
|
||||
DateTimeOffset? ImportedAt,
|
||||
string? BundleSha256,
|
||||
long? BundleSize,
|
||||
IReadOnlyList<OfflineKitComponentStatus> Components);
|
||||
|
||||
internal sealed record OfflineKitComponentStatus(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? Digest,
|
||||
DateTimeOffset? CapturedAt,
|
||||
long? SizeBytes);
|
||||
|
||||
internal sealed record OfflineKitMetadataDocument
|
||||
{
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
public string BundleName { get; init; } = string.Empty;
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long BundleSize { get; init; }
|
||||
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
public string? Channel { get; init; }
|
||||
|
||||
public string? Kind { get; init; }
|
||||
|
||||
public bool IsDelta { get; init; }
|
||||
|
||||
public string? BaseBundleId { get; init; }
|
||||
|
||||
public string ManifestName { get; init; } = string.Empty;
|
||||
|
||||
public string ManifestSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long? ManifestSize { get; init; }
|
||||
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
public string? BundleSignaturePath { get; init; }
|
||||
|
||||
public string? ManifestSignaturePath { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyActivationRequest(
|
||||
bool RunNow,
|
||||
DateTimeOffset? ScheduledAt,
|
||||
string? Priority,
|
||||
bool Rollback,
|
||||
string? IncidentId,
|
||||
string? Comment);
|
||||
|
||||
internal sealed record PolicyActivationResult(
|
||||
string Status,
|
||||
PolicyActivationRevision Revision);
|
||||
|
||||
internal sealed record PolicyActivationRevision(
|
||||
string PolicyId,
|
||||
int Version,
|
||||
string Status,
|
||||
bool RequiresTwoPersonApproval,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
IReadOnlyList<PolicyActivationApproval> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApproval(
|
||||
string ActorId,
|
||||
DateTimeOffset ApprovedAt,
|
||||
string? Comment);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyActivationRequest(
|
||||
bool RunNow,
|
||||
DateTimeOffset? ScheduledAt,
|
||||
string? Priority,
|
||||
bool Rollback,
|
||||
string? IncidentId,
|
||||
string? Comment);
|
||||
|
||||
internal sealed record PolicyActivationResult(
|
||||
string Status,
|
||||
PolicyActivationRevision Revision);
|
||||
|
||||
internal sealed record PolicyActivationRevision(
|
||||
string PolicyId,
|
||||
int Version,
|
||||
string Status,
|
||||
bool RequiresTwoPersonApproval,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? ActivatedAt,
|
||||
IReadOnlyList<PolicyActivationApproval> Approvals);
|
||||
|
||||
internal sealed record PolicyActivationApproval(
|
||||
string ActorId,
|
||||
DateTimeOffset ApprovedAt,
|
||||
string? Comment);
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyFindingsQuery(
|
||||
string PolicyId,
|
||||
IReadOnlyList<string> SbomIds,
|
||||
IReadOnlyList<string> Statuses,
|
||||
IReadOnlyList<string> Severities,
|
||||
string? Cursor,
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
DateTimeOffset? Since);
|
||||
|
||||
internal sealed record PolicyFindingsPage(
|
||||
IReadOnlyList<PolicyFindingDocument> Items,
|
||||
string? NextCursor,
|
||||
int? TotalCount);
|
||||
|
||||
internal sealed record PolicyFindingDocument(
|
||||
string FindingId,
|
||||
string Status,
|
||||
PolicyFindingSeverity Severity,
|
||||
string SbomId,
|
||||
IReadOnlyList<string> AdvisoryIds,
|
||||
PolicyFindingVexMetadata? Vex,
|
||||
int PolicyVersion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? RunId);
|
||||
|
||||
internal sealed record PolicyFindingSeverity(string Normalized, double? Score);
|
||||
|
||||
internal sealed record PolicyFindingVexMetadata(string? WinningStatementId, string? Source, string? Status);
|
||||
|
||||
internal sealed record PolicyFindingExplainResult(
|
||||
string FindingId,
|
||||
int PolicyVersion,
|
||||
IReadOnlyList<PolicyFindingExplainStep> Steps,
|
||||
IReadOnlyList<PolicyFindingExplainHint> SealedHints);
|
||||
|
||||
internal sealed record PolicyFindingExplainStep(
|
||||
string Rule,
|
||||
string? Status,
|
||||
string? Action,
|
||||
double? Score,
|
||||
IReadOnlyDictionary<string, string> Inputs,
|
||||
IReadOnlyDictionary<string, string>? Evidence);
|
||||
|
||||
internal sealed record PolicyFindingExplainHint(string Message);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record PolicyFindingsQuery(
|
||||
string PolicyId,
|
||||
IReadOnlyList<string> SbomIds,
|
||||
IReadOnlyList<string> Statuses,
|
||||
IReadOnlyList<string> Severities,
|
||||
string? Cursor,
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
DateTimeOffset? Since);
|
||||
|
||||
internal sealed record PolicyFindingsPage(
|
||||
IReadOnlyList<PolicyFindingDocument> Items,
|
||||
string? NextCursor,
|
||||
int? TotalCount);
|
||||
|
||||
internal sealed record PolicyFindingDocument(
|
||||
string FindingId,
|
||||
string Status,
|
||||
PolicyFindingSeverity Severity,
|
||||
string SbomId,
|
||||
IReadOnlyList<string> AdvisoryIds,
|
||||
PolicyFindingVexMetadata? Vex,
|
||||
int PolicyVersion,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? RunId);
|
||||
|
||||
internal sealed record PolicyFindingSeverity(string Normalized, double? Score);
|
||||
|
||||
internal sealed record PolicyFindingVexMetadata(string? WinningStatementId, string? Source, string? Status);
|
||||
|
||||
internal sealed record PolicyFindingExplainResult(
|
||||
string FindingId,
|
||||
int PolicyVersion,
|
||||
IReadOnlyList<PolicyFindingExplainStep> Steps,
|
||||
IReadOnlyList<PolicyFindingExplainHint> SealedHints);
|
||||
|
||||
internal sealed record PolicyFindingExplainStep(
|
||||
string Rule,
|
||||
string? Status,
|
||||
string? Action,
|
||||
double? Score,
|
||||
IReadOnlyDictionary<string, string> Inputs,
|
||||
IReadOnlyDictionary<string, string>? Evidence);
|
||||
|
||||
internal sealed record PolicyFindingExplainHint(string Message);
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation modes
|
||||
internal enum PolicySimulationMode
|
||||
{
|
||||
Quick,
|
||||
Batch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for policy simulation.
|
||||
/// Per CLI-EXC-25-002, supports exception preview via WithExceptions/WithoutExceptions.
|
||||
/// Per CLI-POLICY-27-003, supports mode (quick/batch), SBOM selectors, heatmap, and manifest download.
|
||||
/// Per CLI-SIG-26-002, supports reachability overrides for vulnerability/package state and score.
|
||||
/// </summary>
|
||||
internal sealed record PolicySimulationInput(
|
||||
int? BaseVersion,
|
||||
int? CandidateVersion,
|
||||
IReadOnlyList<string> SbomSet,
|
||||
IReadOnlyDictionary<string, object?> Environment,
|
||||
bool Explain,
|
||||
IReadOnlyList<string>? WithExceptions = null,
|
||||
IReadOnlyList<string>? WithoutExceptions = null,
|
||||
PolicySimulationMode? Mode = null,
|
||||
IReadOnlyList<string>? SbomSelectors = null,
|
||||
bool IncludeHeatmap = false,
|
||||
bool IncludeManifest = false,
|
||||
IReadOnlyList<ReachabilityOverride>? ReachabilityOverrides = null);
|
||||
|
||||
internal sealed record PolicySimulationResult(
|
||||
PolicySimulationDiff Diff,
|
||||
string? ExplainUri,
|
||||
PolicySimulationHeatmap? Heatmap = null,
|
||||
string? ManifestDownloadUri = null,
|
||||
string? ManifestDigest = null);
|
||||
|
||||
internal sealed record PolicySimulationDiff(
|
||||
string? SchemaVersion,
|
||||
int Added,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
IReadOnlyDictionary<string, PolicySimulationSeverityDelta> BySeverity,
|
||||
IReadOnlyList<PolicySimulationRuleDelta> RuleHits);
|
||||
|
||||
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
|
||||
|
||||
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap summary for quick severity visualization
|
||||
internal sealed record PolicySimulationHeatmap(
|
||||
int Critical,
|
||||
int High,
|
||||
int Medium,
|
||||
int Low,
|
||||
int Info,
|
||||
IReadOnlyList<PolicySimulationHeatmapBucket> Buckets);
|
||||
|
||||
internal sealed record PolicySimulationHeatmapBucket(
|
||||
string Label,
|
||||
int Count,
|
||||
string? Color);
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation modes
|
||||
internal enum PolicySimulationMode
|
||||
{
|
||||
Quick,
|
||||
Batch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for policy simulation.
|
||||
/// Per CLI-EXC-25-002, supports exception preview via WithExceptions/WithoutExceptions.
|
||||
/// Per CLI-POLICY-27-003, supports mode (quick/batch), SBOM selectors, heatmap, and manifest download.
|
||||
/// Per CLI-SIG-26-002, supports reachability overrides for vulnerability/package state and score.
|
||||
/// </summary>
|
||||
internal sealed record PolicySimulationInput(
|
||||
int? BaseVersion,
|
||||
int? CandidateVersion,
|
||||
IReadOnlyList<string> SbomSet,
|
||||
IReadOnlyDictionary<string, object?> Environment,
|
||||
bool Explain,
|
||||
IReadOnlyList<string>? WithExceptions = null,
|
||||
IReadOnlyList<string>? WithoutExceptions = null,
|
||||
PolicySimulationMode? Mode = null,
|
||||
IReadOnlyList<string>? SbomSelectors = null,
|
||||
bool IncludeHeatmap = false,
|
||||
bool IncludeManifest = false,
|
||||
IReadOnlyList<ReachabilityOverride>? ReachabilityOverrides = null);
|
||||
|
||||
internal sealed record PolicySimulationResult(
|
||||
PolicySimulationDiff Diff,
|
||||
string? ExplainUri,
|
||||
PolicySimulationHeatmap? Heatmap = null,
|
||||
string? ManifestDownloadUri = null,
|
||||
string? ManifestDigest = null);
|
||||
|
||||
internal sealed record PolicySimulationDiff(
|
||||
string? SchemaVersion,
|
||||
int Added,
|
||||
int Removed,
|
||||
int Unchanged,
|
||||
IReadOnlyDictionary<string, PolicySimulationSeverityDelta> BySeverity,
|
||||
IReadOnlyList<PolicySimulationRuleDelta> RuleHits);
|
||||
|
||||
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
|
||||
|
||||
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap summary for quick severity visualization
|
||||
internal sealed record PolicySimulationHeatmap(
|
||||
int Critical,
|
||||
int High,
|
||||
int Medium,
|
||||
int Low,
|
||||
int Info,
|
||||
IReadOnlyList<PolicySimulationHeatmapBucket> Buckets);
|
||||
|
||||
internal sealed record PolicySimulationHeatmapBucket(
|
||||
string Label,
|
||||
int Count,
|
||||
string? Color);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Decisions);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset? ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Decisions);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
string PolicyVerdict,
|
||||
bool? Signed,
|
||||
bool? HasSbomReferrers,
|
||||
IReadOnlyList<string> Reasons,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
IReadOnlyDictionary<string, object?> AdditionalProperties);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record ScannerArtifactResult(string Path, long SizeBytes, bool FromCache);
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record TaskRunnerSimulationRequest(string Manifest, JsonObject? Inputs);
|
||||
|
||||
internal sealed record TaskRunnerSimulationResult(
|
||||
string PlanHash,
|
||||
TaskRunnerSimulationFailurePolicy FailurePolicy,
|
||||
IReadOnlyList<TaskRunnerSimulationStep> Steps,
|
||||
IReadOnlyList<TaskRunnerSimulationOutput> Outputs,
|
||||
bool HasPendingApprovals);
|
||||
|
||||
internal sealed record TaskRunnerSimulationFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
|
||||
|
||||
internal sealed record TaskRunnerSimulationStep(
|
||||
string Id,
|
||||
string TemplateId,
|
||||
string Kind,
|
||||
bool Enabled,
|
||||
string Status,
|
||||
string? StatusReason,
|
||||
string? Uses,
|
||||
string? ApprovalId,
|
||||
string? GateMessage,
|
||||
int? MaxParallel,
|
||||
bool ContinueOnError,
|
||||
IReadOnlyList<TaskRunnerSimulationStep> Children);
|
||||
|
||||
internal sealed record TaskRunnerSimulationOutput(
|
||||
string Name,
|
||||
string Type,
|
||||
bool RequiresRuntimeValue,
|
||||
string? PathExpression,
|
||||
string? ValueExpression);
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
internal sealed record TaskRunnerSimulationRequest(string Manifest, JsonObject? Inputs);
|
||||
|
||||
internal sealed record TaskRunnerSimulationResult(
|
||||
string PlanHash,
|
||||
TaskRunnerSimulationFailurePolicy FailurePolicy,
|
||||
IReadOnlyList<TaskRunnerSimulationStep> Steps,
|
||||
IReadOnlyList<TaskRunnerSimulationOutput> Outputs,
|
||||
bool HasPendingApprovals);
|
||||
|
||||
internal sealed record TaskRunnerSimulationFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
|
||||
|
||||
internal sealed record TaskRunnerSimulationStep(
|
||||
string Id,
|
||||
string TemplateId,
|
||||
string Kind,
|
||||
bool Enabled,
|
||||
string Status,
|
||||
string? StatusReason,
|
||||
string? Uses,
|
||||
string? ApprovalId,
|
||||
string? GateMessage,
|
||||
int? MaxParallel,
|
||||
bool ContinueOnError,
|
||||
IReadOnlyList<TaskRunnerSimulationStep> Children);
|
||||
|
||||
internal sealed record TaskRunnerSimulationOutput(
|
||||
string Name,
|
||||
string Type,
|
||||
bool RequiresRuntimeValue,
|
||||
string? PathExpression,
|
||||
string? ValueExpression);
|
||||
|
||||
@@ -1,27 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,10 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class OfflineKitBundleDescriptorTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? BundleName { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long BundleSize { get; set; }
|
||||
|
||||
public string? BundleUrl { get; set; }
|
||||
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
public string? BundleSignatureName { get; set; }
|
||||
|
||||
public string? BundleSignatureUrl { get; set; }
|
||||
|
||||
public string? BundleSignaturePath { get; set; }
|
||||
|
||||
public string? ManifestName { get; set; }
|
||||
|
||||
public string? ManifestSha256 { get; set; }
|
||||
|
||||
public long? ManifestSize { get; set; }
|
||||
|
||||
public string? ManifestUrl { get; set; }
|
||||
|
||||
public string? ManifestPath { get; set; }
|
||||
|
||||
public string? ManifestSignatureName { get; set; }
|
||||
|
||||
public string? ManifestSignatureUrl { get; set; }
|
||||
|
||||
public string? ManifestSignaturePath { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusBundleTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long? BundleSize { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ImportedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusTransport
|
||||
{
|
||||
public OfflineKitStatusBundleTransport? Current { get; set; }
|
||||
|
||||
public List<OfflineKitComponentStatusTransport>? Components { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitComponentStatusTransport
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Digest { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public long? SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitImportResponseTransport
|
||||
{
|
||||
public string? ImportId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class OfflineKitBundleDescriptorTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? BundleName { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long BundleSize { get; set; }
|
||||
|
||||
public string? BundleUrl { get; set; }
|
||||
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
public string? BundleSignatureName { get; set; }
|
||||
|
||||
public string? BundleSignatureUrl { get; set; }
|
||||
|
||||
public string? BundleSignaturePath { get; set; }
|
||||
|
||||
public string? ManifestName { get; set; }
|
||||
|
||||
public string? ManifestSha256 { get; set; }
|
||||
|
||||
public long? ManifestSize { get; set; }
|
||||
|
||||
public string? ManifestUrl { get; set; }
|
||||
|
||||
public string? ManifestPath { get; set; }
|
||||
|
||||
public string? ManifestSignatureName { get; set; }
|
||||
|
||||
public string? ManifestSignatureUrl { get; set; }
|
||||
|
||||
public string? ManifestSignaturePath { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusBundleTransport
|
||||
{
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
public string? Channel { get; set; }
|
||||
|
||||
public string? Kind { get; set; }
|
||||
|
||||
public bool? IsDelta { get; set; }
|
||||
|
||||
public string? BaseBundleId { get; set; }
|
||||
|
||||
public string? BundleSha256 { get; set; }
|
||||
|
||||
public long? BundleSize { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ImportedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitStatusTransport
|
||||
{
|
||||
public OfflineKitStatusBundleTransport? Current { get; set; }
|
||||
|
||||
public List<OfflineKitComponentStatusTransport>? Components { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitComponentStatusTransport
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
|
||||
public string? Digest { get; set; }
|
||||
|
||||
public DateTimeOffset? CapturedAt { get; set; }
|
||||
|
||||
public long? SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class OfflineKitImportResponseTransport
|
||||
{
|
||||
public string? ImportId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyActivationRequestDocument
|
||||
{
|
||||
public string? Comment { get; set; }
|
||||
|
||||
public bool? RunNow { get; set; }
|
||||
|
||||
public DateTimeOffset? ScheduledAt { get; set; }
|
||||
|
||||
public string? Priority { get; set; }
|
||||
|
||||
public bool? Rollback { get; set; }
|
||||
|
||||
public string? IncidentId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationResponseDocument
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyActivationRevisionDocument? Revision { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationRevisionDocument
|
||||
{
|
||||
public string? PackId { get; set; }
|
||||
|
||||
public int? Version { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public bool? RequiresTwoPersonApproval { get; set; }
|
||||
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
public List<PolicyActivationApprovalDocument>? Approvals { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationApprovalDocument
|
||||
{
|
||||
public string? ActorId { get; set; }
|
||||
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyActivationRequestDocument
|
||||
{
|
||||
public string? Comment { get; set; }
|
||||
|
||||
public bool? RunNow { get; set; }
|
||||
|
||||
public DateTimeOffset? ScheduledAt { get; set; }
|
||||
|
||||
public string? Priority { get; set; }
|
||||
|
||||
public bool? Rollback { get; set; }
|
||||
|
||||
public string? IncidentId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationResponseDocument
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyActivationRevisionDocument? Revision { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationRevisionDocument
|
||||
{
|
||||
public string? PackId { get; set; }
|
||||
|
||||
public int? Version { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public bool? RequiresTwoPersonApproval { get; set; }
|
||||
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
public List<PolicyActivationApprovalDocument>? Approvals { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyActivationApprovalDocument
|
||||
{
|
||||
public string? ActorId { get; set; }
|
||||
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyFindingsResponseDocument
|
||||
{
|
||||
public List<PolicyFindingDocumentDocument>? Items { get; set; }
|
||||
|
||||
public string? NextCursor { get; set; }
|
||||
|
||||
public int? TotalCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingDocumentDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyFindingSeverityDocument? Severity { get; set; }
|
||||
|
||||
public string? SbomId { get; set; }
|
||||
|
||||
public List<string>? AdvisoryIds { get; set; }
|
||||
|
||||
public PolicyFindingVexDocument? Vex { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public string? RunId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingSeverityDocument
|
||||
{
|
||||
public string? Normalized { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingVexDocument
|
||||
{
|
||||
public string? WinningStatementId { get; set; }
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainResponseDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainStepDocument>? Steps { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainHintDocument>? SealedHints { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainStepDocument
|
||||
{
|
||||
public string? Rule { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public string? Action { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Inputs { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Evidence { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainHintDocument
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicyFindingsResponseDocument
|
||||
{
|
||||
public List<PolicyFindingDocumentDocument>? Items { get; set; }
|
||||
|
||||
public string? NextCursor { get; set; }
|
||||
|
||||
public int? TotalCount { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingDocumentDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public PolicyFindingSeverityDocument? Severity { get; set; }
|
||||
|
||||
public string? SbomId { get; set; }
|
||||
|
||||
public List<string>? AdvisoryIds { get; set; }
|
||||
|
||||
public PolicyFindingVexDocument? Vex { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public string? RunId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingSeverityDocument
|
||||
{
|
||||
public string? Normalized { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingVexDocument
|
||||
{
|
||||
public string? WinningStatementId { get; set; }
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainResponseDocument
|
||||
{
|
||||
public string? FindingId { get; set; }
|
||||
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainStepDocument>? Steps { get; set; }
|
||||
|
||||
public List<PolicyFindingExplainHintDocument>? SealedHints { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainStepDocument
|
||||
{
|
||||
public string? Rule { get; set; }
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
public string? Action { get; set; }
|
||||
|
||||
public double? Score { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Inputs { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Evidence { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicyFindingExplainHintDocument
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicySimulationRequestDocument
|
||||
{
|
||||
public int? BaseVersion { get; set; }
|
||||
|
||||
public int? CandidateVersion { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSet { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Env { get; set; }
|
||||
|
||||
public bool? Explain { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation options
|
||||
public string? Mode { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSelectors { get; set; }
|
||||
|
||||
public bool? IncludeHeatmap { get; set; }
|
||||
|
||||
public bool? IncludeManifest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationResponseDocument
|
||||
{
|
||||
public PolicySimulationDiffDocument? Diff { get; set; }
|
||||
|
||||
public string? ExplainUri { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced response fields
|
||||
public PolicySimulationHeatmapDocument? Heatmap { get; set; }
|
||||
|
||||
public string? ManifestDownloadUri { get; set; }
|
||||
|
||||
public string? ManifestDigest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationDiffDocument
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
public int? Added { get; set; }
|
||||
|
||||
public int? Removed { get; set; }
|
||||
|
||||
public int? Unchanged { get; set; }
|
||||
|
||||
public Dictionary<string, PolicySimulationSeverityDeltaDocument>? BySeverity { get; set; }
|
||||
|
||||
public List<PolicySimulationRuleDeltaDocument>? RuleHits { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationSeverityDeltaDocument
|
||||
{
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationRuleDeltaDocument
|
||||
{
|
||||
public string? RuleId { get; set; }
|
||||
|
||||
public string? RuleName { get; set; }
|
||||
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap response documents
|
||||
internal sealed class PolicySimulationHeatmapDocument
|
||||
{
|
||||
public int? Critical { get; set; }
|
||||
|
||||
public int? High { get; set; }
|
||||
|
||||
public int? Medium { get; set; }
|
||||
|
||||
public int? Low { get; set; }
|
||||
|
||||
public int? Info { get; set; }
|
||||
|
||||
public List<PolicySimulationHeatmapBucketDocument>? Buckets { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationHeatmapBucketDocument
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
|
||||
public int? Count { get; set; }
|
||||
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class PolicySimulationRequestDocument
|
||||
{
|
||||
public int? BaseVersion { get; set; }
|
||||
|
||||
public int? CandidateVersion { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSet { get; set; }
|
||||
|
||||
public Dictionary<string, JsonElement>? Env { get; set; }
|
||||
|
||||
public bool? Explain { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation options
|
||||
public string? Mode { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSelectors { get; set; }
|
||||
|
||||
public bool? IncludeHeatmap { get; set; }
|
||||
|
||||
public bool? IncludeManifest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationResponseDocument
|
||||
{
|
||||
public PolicySimulationDiffDocument? Diff { get; set; }
|
||||
|
||||
public string? ExplainUri { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced response fields
|
||||
public PolicySimulationHeatmapDocument? Heatmap { get; set; }
|
||||
|
||||
public string? ManifestDownloadUri { get; set; }
|
||||
|
||||
public string? ManifestDigest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationDiffDocument
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
|
||||
public int? Added { get; set; }
|
||||
|
||||
public int? Removed { get; set; }
|
||||
|
||||
public int? Unchanged { get; set; }
|
||||
|
||||
public Dictionary<string, PolicySimulationSeverityDeltaDocument>? BySeverity { get; set; }
|
||||
|
||||
public List<PolicySimulationRuleDeltaDocument>? RuleHits { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationSeverityDeltaDocument
|
||||
{
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationRuleDeltaDocument
|
||||
{
|
||||
public string? RuleId { get; set; }
|
||||
|
||||
public string? RuleName { get; set; }
|
||||
|
||||
public int? Up { get; set; }
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap response documents
|
||||
internal sealed class PolicySimulationHeatmapDocument
|
||||
{
|
||||
public int? Critical { get; set; }
|
||||
|
||||
public int? High { get; set; }
|
||||
|
||||
public int? Medium { get; set; }
|
||||
|
||||
public int? Low { get; set; }
|
||||
|
||||
public int? Info { get; set; }
|
||||
|
||||
public List<PolicySimulationHeatmapBucketDocument>? Buckets { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationHeatmapBucketDocument
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
|
||||
public int? Count { get; set; }
|
||||
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,184 +1,184 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 7807 Problem Details response.
|
||||
/// </summary>
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public int? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("instance")]
|
||||
public string? Instance { get; set; }
|
||||
|
||||
[JsonPropertyName("extensions")]
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standardized API error envelope with error.code and trace_id.
|
||||
/// CLI-SDK-62-002: Supports surfacing structured error information.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public ApiErrorDetail? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trace_id")]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("request_id")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error detail within the standardized envelope.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable error code (e.g., "ERR_AUTH_INVALID_SCOPE").
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error (field name, resource identifier).
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors for nested error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inner_errors")]
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("help_url")]
|
||||
public string? HelpUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds (for rate limiting).
|
||||
/// </summary>
|
||||
[JsonPropertyName("retry_after")]
|
||||
public int? RetryAfter { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed API error result combining multiple error formats.
|
||||
/// </summary>
|
||||
internal sealed class ParsedApiError
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code (from envelope, problem, or HTTP status).
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error description.
|
||||
/// </summary>
|
||||
public string? Detail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for distributed tracing.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request ID.
|
||||
/// </summary>
|
||||
public string? RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code.
|
||||
/// </summary>
|
||||
public int HttpStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error.
|
||||
/// </summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
public string? HelpUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds.
|
||||
/// </summary>
|
||||
public int? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original problem document if parsed.
|
||||
/// </summary>
|
||||
public ProblemDocument? ProblemDocument { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original error envelope if parsed.
|
||||
/// </summary>
|
||||
public ApiErrorEnvelope? ErrorEnvelope { get; init; }
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 7807 Problem Details response.
|
||||
/// </summary>
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public int? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("instance")]
|
||||
public string? Instance { get; set; }
|
||||
|
||||
[JsonPropertyName("extensions")]
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standardized API error envelope with error.code and trace_id.
|
||||
/// CLI-SDK-62-002: Supports surfacing structured error information.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public ApiErrorDetail? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trace_id")]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("request_id")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error detail within the standardized envelope.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable error code (e.g., "ERR_AUTH_INVALID_SCOPE").
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error (field name, resource identifier).
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors for nested error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inner_errors")]
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("help_url")]
|
||||
public string? HelpUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds (for rate limiting).
|
||||
/// </summary>
|
||||
[JsonPropertyName("retry_after")]
|
||||
public int? RetryAfter { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed API error result combining multiple error formats.
|
||||
/// </summary>
|
||||
internal sealed class ParsedApiError
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code (from envelope, problem, or HTTP status).
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error description.
|
||||
/// </summary>
|
||||
public string? Detail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for distributed tracing.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request ID.
|
||||
/// </summary>
|
||||
public string? RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code.
|
||||
/// </summary>
|
||||
public int HttpStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error.
|
||||
/// </summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
public string? HelpUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds.
|
||||
/// </summary>
|
||||
public int? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original problem document if parsed.
|
||||
/// </summary>
|
||||
public ProblemDocument? ProblemDocument { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original error envelope if parsed.
|
||||
/// </summary>
|
||||
public ApiErrorEnvelope? ErrorEnvelope { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationRequestDocument
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Namespace { get; set; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<string> Images { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationResponseDocument
|
||||
{
|
||||
[JsonPropertyName("ttlSeconds")]
|
||||
public int? TtlSeconds { get; set; }
|
||||
|
||||
[JsonPropertyName("expiresAtUtc")]
|
||||
public DateTimeOffset? ExpiresAtUtc { get; set; }
|
||||
|
||||
[JsonPropertyName("policyRevision")]
|
||||
public string? PolicyRevision { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public Dictionary<string, RuntimePolicyEvaluationImageDocument>? Results { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
{
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public string? PolicyVerdict { get; set; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool? HasSbomReferrers { get; set; }
|
||||
|
||||
// Legacy field kept for pre-contract-sync services.
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbomLegacy { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public RuntimePolicyRekorDocument? Rekor { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyRekorDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationRequestDocument
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Namespace { get; set; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<string> Images { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationResponseDocument
|
||||
{
|
||||
[JsonPropertyName("ttlSeconds")]
|
||||
public int? TtlSeconds { get; set; }
|
||||
|
||||
[JsonPropertyName("expiresAtUtc")]
|
||||
public DateTimeOffset? ExpiresAtUtc { get; set; }
|
||||
|
||||
[JsonPropertyName("policyRevision")]
|
||||
public string? PolicyRevision { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public Dictionary<string, RuntimePolicyEvaluationImageDocument>? Results { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyEvaluationImageDocument
|
||||
{
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public string? PolicyVerdict { get; set; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool? Signed { get; set; }
|
||||
|
||||
[JsonPropertyName("hasSbomReferrers")]
|
||||
public bool? HasSbomReferrers { get; set; }
|
||||
|
||||
// Legacy field kept for pre-contract-sync services.
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool? HasSbomLegacy { get; set; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public List<string>? Reasons { get; set; }
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public RuntimePolicyRekorDocument? Rekor { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyRekorDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class TaskRunnerSimulationRequestDocument
|
||||
{
|
||||
public string Manifest { get; set; } = string.Empty;
|
||||
|
||||
public JsonObject? Inputs { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationResponseDocument
|
||||
{
|
||||
public string PlanHash { get; set; } = string.Empty;
|
||||
|
||||
public TaskRunnerSimulationFailurePolicyDocument? FailurePolicy { get; set; }
|
||||
|
||||
public List<TaskRunnerSimulationStepDocument>? Steps { get; set; }
|
||||
|
||||
public List<TaskRunnerSimulationOutputDocument>? Outputs { get; set; }
|
||||
|
||||
public bool HasPendingApprovals { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationFailurePolicyDocument
|
||||
{
|
||||
public int MaxAttempts { get; set; }
|
||||
|
||||
public int BackoffSeconds { get; set; }
|
||||
|
||||
public bool ContinueOnError { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationStepDocument
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public string? StatusReason { get; set; }
|
||||
|
||||
public string? Uses { get; set; }
|
||||
|
||||
public string? ApprovalId { get; set; }
|
||||
|
||||
public string? GateMessage { get; set; }
|
||||
|
||||
public int? MaxParallel { get; set; }
|
||||
|
||||
public bool ContinueOnError { get; set; }
|
||||
|
||||
public List<TaskRunnerSimulationStepDocument>? Children { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationOutputDocument
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public bool RequiresRuntimeValue { get; set; }
|
||||
|
||||
public string? PathExpression { get; set; }
|
||||
|
||||
public string? ValueExpression { get; set; }
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
internal sealed class TaskRunnerSimulationRequestDocument
|
||||
{
|
||||
public string Manifest { get; set; } = string.Empty;
|
||||
|
||||
public JsonObject? Inputs { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationResponseDocument
|
||||
{
|
||||
public string PlanHash { get; set; } = string.Empty;
|
||||
|
||||
public TaskRunnerSimulationFailurePolicyDocument? FailurePolicy { get; set; }
|
||||
|
||||
public List<TaskRunnerSimulationStepDocument>? Steps { get; set; }
|
||||
|
||||
public List<TaskRunnerSimulationOutputDocument>? Outputs { get; set; }
|
||||
|
||||
public bool HasPendingApprovals { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationFailurePolicyDocument
|
||||
{
|
||||
public int MaxAttempts { get; set; }
|
||||
|
||||
public int BackoffSeconds { get; set; }
|
||||
|
||||
public bool ContinueOnError { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationStepDocument
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string TemplateId { get; set; } = string.Empty;
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
public string? StatusReason { get; set; }
|
||||
|
||||
public string? Uses { get; set; }
|
||||
|
||||
public string? ApprovalId { get; set; }
|
||||
|
||||
public string? GateMessage { get; set; }
|
||||
|
||||
public int? MaxParallel { get; set; }
|
||||
|
||||
public bool ContinueOnError { get; set; }
|
||||
|
||||
public List<TaskRunnerSimulationStepDocument>? Children { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TaskRunnerSimulationOutputDocument
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public bool RequiresRuntimeValue { get; set; }
|
||||
|
||||
public string? PathExpression { get; set; }
|
||||
|
||||
public string? ValueExpression { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class PolicyApiException : Exception
|
||||
{
|
||||
public PolicyApiException(string message, HttpStatusCode statusCode, string? errorCode, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public string? ErrorCode { get; }
|
||||
}
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed class PolicyApiException : Exception
|
||||
{
|
||||
public PolicyApiException(string message, HttpStatusCode statusCode, string? errorCode, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public string? ErrorCode { get; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
internal sealed record ScannerExecutionResult(int ExitCode, string ResultsPath, string RunMetadataPath);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -11,86 +11,86 @@ 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);
|
||||
{
|
||||
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();
|
||||
|
||||
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;
|
||||
|
||||
@@ -98,11 +98,11 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
{
|
||||
_logger.LogInformation("Scanner completed successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Scanner exited with code {Code}.", process.ExitCode);
|
||||
}
|
||||
|
||||
var resultsPath = ResolveResultsPath(resultsDirectory, executionTimestamp, baseline);
|
||||
if (string.IsNullOrWhiteSpace(resultsPath))
|
||||
{
|
||||
@@ -124,161 +124,161 @@ internal sealed class ScannerExecutor : IScannerExecutor
|
||||
|
||||
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 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";
|
||||
|
||||
@@ -1,79 +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.");
|
||||
}
|
||||
}
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal static class CliActivitySource
|
||||
{
|
||||
public static readonly ActivitySource Instance = new("StellaOps.Cli");
|
||||
}
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal static class CliActivitySource
|
||||
{
|
||||
public static readonly ActivitySource Instance = new("StellaOps.Cli");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal sealed class VerbosityState
|
||||
{
|
||||
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
|
||||
}
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
internal sealed class VerbosityState
|
||||
{
|
||||
public LogLevel MinimumLevel { get; set; } = LogLevel.Information;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user