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
Policy Lint & Smoke / policy-lint (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
Policy Lint & Smoke / policy-lint (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
444
src/Cli/StellaOps.Cli/Configuration/CliProfile.cs
Normal file
444
src/Cli/StellaOps.Cli/Configuration/CliProfile.cs
Normal file
@@ -0,0 +1,444 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// CLI profile for storing named configurations.
|
||||
/// Per CLI-CORE-41-001, supports profiles/contexts for multi-environment workflows.
|
||||
/// </summary>
|
||||
public sealed class CliProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "prod", "staging", "dev").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backend URL for this profile.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Concelier URL for this profile.
|
||||
/// </summary>
|
||||
public string? ConcelierUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority URL for this profile.
|
||||
/// </summary>
|
||||
public string? AuthorityUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Client ID for this profile.
|
||||
/// </summary>
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default scope for this profile.
|
||||
/// </summary>
|
||||
public string? DefaultScope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy Studio scopes for this profile.
|
||||
/// CLI-POLICY-27-006: Supports the Policy Studio scope family.
|
||||
/// </summary>
|
||||
public PolicyStudioScopes? PolicyScopes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for this profile.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an air-gapped/offline profile.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offline kit directory for this profile.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default output format for this profile.
|
||||
/// </summary>
|
||||
public string? DefaultOutputFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional profile-specific settings.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Settings { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when profile was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when profile was last modified.
|
||||
/// </summary>
|
||||
public DateTimeOffset ModifiedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Profile store configuration.
|
||||
/// </summary>
|
||||
public sealed class CliProfileStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Current active profile name.
|
||||
/// </summary>
|
||||
public string? CurrentProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All stored profiles.
|
||||
/// </summary>
|
||||
public Dictionary<string, CliProfile> Profiles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Default telemetry opt-in status.
|
||||
/// </summary>
|
||||
public bool? TelemetryEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages CLI profiles persistence.
|
||||
/// </summary>
|
||||
public sealed class CliProfileManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly string _profilesFilePath;
|
||||
private CliProfileStore? _store;
|
||||
|
||||
public CliProfileManager(string? profilesDirectory = null)
|
||||
{
|
||||
var directory = profilesDirectory ?? GetDefaultProfilesDirectory();
|
||||
_profilesFilePath = Path.Combine(directory, "profiles.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current profile store.
|
||||
/// </summary>
|
||||
public async Task<CliProfileStore> GetStoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store is not null)
|
||||
return _store;
|
||||
|
||||
_store = await LoadStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return _store;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active profile.
|
||||
/// </summary>
|
||||
public async Task<CliProfile?> GetCurrentProfileAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(store.CurrentProfile))
|
||||
return null;
|
||||
|
||||
store.Profiles.TryGetValue(store.CurrentProfile, out var profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by name.
|
||||
/// </summary>
|
||||
public async Task<CliProfile?> GetProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.Profiles.TryGetValue(name, out var profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all profile names.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> ListProfilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return store.Profiles.Keys.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a profile as the current active profile.
|
||||
/// </summary>
|
||||
public async Task SetCurrentProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!store.Profiles.ContainsKey(name))
|
||||
throw new InvalidOperationException($"Profile '{name}' does not exist.");
|
||||
|
||||
store.CurrentProfile = name;
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves or updates a profile.
|
||||
/// </summary>
|
||||
public async Task SaveProfileAsync(CliProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.Profiles[profile.Name] = profile with { ModifiedAt = DateTimeOffset.UtcNow };
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile.
|
||||
/// </summary>
|
||||
public async Task<bool> RemoveProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!store.Profiles.Remove(name))
|
||||
return false;
|
||||
|
||||
if (string.Equals(store.CurrentProfile, name, StringComparison.OrdinalIgnoreCase))
|
||||
store.CurrentProfile = null;
|
||||
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets or clears the telemetry opt-in status.
|
||||
/// </summary>
|
||||
public async Task SetTelemetryEnabledAsync(bool? enabled, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.TelemetryEnabled = enabled;
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the telemetry opt-in status.
|
||||
/// </summary>
|
||||
public async Task<bool?> GetTelemetryEnabledAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return store.TelemetryEnabled;
|
||||
}
|
||||
|
||||
private async Task<CliProfileStore> LoadStoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(_profilesFilePath))
|
||||
return new CliProfileStore();
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(_profilesFilePath);
|
||||
var store = await JsonSerializer.DeserializeAsync<CliProfileStore>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return store ?? new CliProfileStore();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new CliProfileStore();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveStoreAsync(CliProfileStore store, CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_profilesFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
await using var stream = File.Create(_profilesFilePath);
|
||||
await JsonSerializer.SerializeAsync(stream, store, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
_store = store;
|
||||
}
|
||||
|
||||
private static string GetDefaultProfilesDirectory()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
home = AppContext.BaseDirectory;
|
||||
|
||||
return Path.Combine(home, ".stellaops");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy Studio scope configuration.
|
||||
/// CLI-POLICY-27-006: Defines the Policy Studio scope family for CLI operations.
|
||||
/// </summary>
|
||||
public sealed class PolicyStudioScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Base scope for Policy Studio operations.
|
||||
/// Required for all policy commands.
|
||||
/// </summary>
|
||||
public const string PolicyRead = "stella.policy:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy write operations (create, update, delete).
|
||||
/// </summary>
|
||||
public const string PolicyWrite = "stella.policy:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy simulation operations.
|
||||
/// </summary>
|
||||
public const string PolicySimulate = "stella.policy:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy workflow operations (submit, review, approve, reject).
|
||||
/// </summary>
|
||||
public const string PolicyWorkflow = "stella.policy:workflow";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy publish/promote operations.
|
||||
/// </summary>
|
||||
public const string PolicyPublish = "stella.policy:publish";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy signing operations.
|
||||
/// </summary>
|
||||
public const string PolicySign = "stella.policy:sign";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for risk profile operations.
|
||||
/// </summary>
|
||||
public const string RiskRead = "stella.risk:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for risk simulation operations.
|
||||
/// </summary>
|
||||
public const string RiskSimulate = "stella.risk:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for reachability operations.
|
||||
/// </summary>
|
||||
public const string ReachabilityRead = "stella.reachability:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for reachability upload operations.
|
||||
/// </summary>
|
||||
public const string ReachabilityUpload = "stella.reachability:upload";
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy read operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyRead { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy write operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyWrite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy simulation is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicySimulate { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy workflow operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyWorkflow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy publish operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyPublish { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy signing is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicySign { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether risk operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnableRisk { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether reachability operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnableReachability { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the scope string for token requests.
|
||||
/// </summary>
|
||||
public string BuildScopeString()
|
||||
{
|
||||
var scopes = new List<string>();
|
||||
|
||||
if (EnablePolicyRead)
|
||||
scopes.Add(PolicyRead);
|
||||
if (EnablePolicyWrite)
|
||||
scopes.Add(PolicyWrite);
|
||||
if (EnablePolicySimulate)
|
||||
scopes.Add(PolicySimulate);
|
||||
if (EnablePolicyWorkflow)
|
||||
scopes.Add(PolicyWorkflow);
|
||||
if (EnablePolicyPublish)
|
||||
scopes.Add(PolicyPublish);
|
||||
if (EnablePolicySign)
|
||||
scopes.Add(PolicySign);
|
||||
if (EnableRisk)
|
||||
{
|
||||
scopes.Add(RiskRead);
|
||||
scopes.Add(RiskSimulate);
|
||||
}
|
||||
if (EnableReachability)
|
||||
{
|
||||
scopes.Add(ReachabilityRead);
|
||||
scopes.Add(ReachabilityUpload);
|
||||
}
|
||||
|
||||
return string.Join(" ", scopes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets guidance message for invalid_scope errors.
|
||||
/// </summary>
|
||||
public static string GetInvalidScopeGuidance(string? requiredScope)
|
||||
{
|
||||
var guidance = new System.Text.StringBuilder();
|
||||
guidance.AppendLine("The requested operation requires additional OAuth scopes.");
|
||||
guidance.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requiredScope))
|
||||
{
|
||||
guidance.AppendLine($"Required scope: {requiredScope}");
|
||||
guidance.AppendLine();
|
||||
}
|
||||
|
||||
guidance.AppendLine("To resolve this issue:");
|
||||
guidance.AppendLine(" 1. Check your CLI profile configuration: stella profile show");
|
||||
guidance.AppendLine(" 2. Update your profile with required scopes: stella profile edit <name>");
|
||||
guidance.AppendLine(" 3. Request additional scopes from your administrator");
|
||||
guidance.AppendLine(" 4. Re-authenticate with the updated scopes: stella auth login");
|
||||
guidance.AppendLine();
|
||||
guidance.AppendLine("Available Policy Studio scopes:");
|
||||
guidance.AppendLine($" - {PolicyRead} (read policies)");
|
||||
guidance.AppendLine($" - {PolicyWrite} (create/update policies)");
|
||||
guidance.AppendLine($" - {PolicySimulate} (simulate policy changes)");
|
||||
guidance.AppendLine($" - {PolicyWorkflow} (submit/review/approve policies)");
|
||||
guidance.AppendLine($" - {PolicyPublish} (publish/promote policies)");
|
||||
guidance.AppendLine($" - {PolicySign} (sign policies)");
|
||||
guidance.AppendLine($" - {RiskRead} (read risk profiles)");
|
||||
guidance.AppendLine($" - {RiskSimulate} (simulate risk scoring)");
|
||||
guidance.AppendLine($" - {ReachabilityRead} (read reachability analyses)");
|
||||
guidance.AppendLine($" - {ReachabilityUpload} (upload call graphs)");
|
||||
|
||||
return guidance.ToString();
|
||||
}
|
||||
}
|
||||
140
src/Cli/StellaOps.Cli/Configuration/GlobalOptions.cs
Normal file
140
src/Cli/StellaOps.Cli/Configuration/GlobalOptions.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Output;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Global command-line options available to all commands.
|
||||
/// Per CLI-CORE-41-001, provides global flags for profile, output, verbosity, and telemetry.
|
||||
/// </summary>
|
||||
public sealed class GlobalOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile name to use for this invocation.
|
||||
/// </summary>
|
||||
public string? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format (json, yaml, table).
|
||||
/// </summary>
|
||||
public OutputFormat OutputFormat { get; set; } = OutputFormat.Table;
|
||||
|
||||
/// <summary>
|
||||
/// Verbose output (debug level logging).
|
||||
/// </summary>
|
||||
public bool Verbose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quiet mode (errors only).
|
||||
/// </summary>
|
||||
public bool Quiet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Disable colored output.
|
||||
/// </summary>
|
||||
public bool NoColor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override backend URL for this invocation.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override tenant ID for this invocation.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dry run mode - show what would happen without executing.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates System.CommandLine options for global flags.
|
||||
/// </summary>
|
||||
public static IEnumerable<Option> CreateGlobalOptions()
|
||||
{
|
||||
yield return new Option<string?>(
|
||||
aliases: ["--profile", "-p"],
|
||||
description: "Profile name to use for this invocation");
|
||||
|
||||
yield return new Option<OutputFormat>(
|
||||
aliases: ["--output", "-o"],
|
||||
getDefaultValue: () => OutputFormat.Table,
|
||||
description: "Output format (table, json, yaml)");
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--verbose", "-v"],
|
||||
description: "Enable verbose output");
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--quiet", "-q"],
|
||||
description: "Quiet mode - suppress non-error output");
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--no-color",
|
||||
description: "Disable colored output");
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--backend-url",
|
||||
description: "Override backend URL for this invocation");
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--tenant-id",
|
||||
description: "Override tenant ID for this invocation");
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--dry-run",
|
||||
description: "Show what would happen without executing");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses global options from invocation context.
|
||||
/// </summary>
|
||||
public static GlobalOptions FromInvocationContext(System.CommandLine.Invocation.InvocationContext context)
|
||||
{
|
||||
var options = new GlobalOptions();
|
||||
|
||||
var profileOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--profile"));
|
||||
if (profileOption is not null)
|
||||
options.Profile = context.ParseResult.GetValueForOption(profileOption) as string;
|
||||
|
||||
var outputOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--output"));
|
||||
if (outputOption is not null && context.ParseResult.GetValueForOption(outputOption) is OutputFormat format)
|
||||
options.OutputFormat = format;
|
||||
|
||||
var verboseOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--verbose"));
|
||||
if (verboseOption is not null && context.ParseResult.GetValueForOption(verboseOption) is bool verbose)
|
||||
options.Verbose = verbose;
|
||||
|
||||
var quietOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--quiet"));
|
||||
if (quietOption is not null && context.ParseResult.GetValueForOption(quietOption) is bool quiet)
|
||||
options.Quiet = quiet;
|
||||
|
||||
var noColorOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--no-color"));
|
||||
if (noColorOption is not null && context.ParseResult.GetValueForOption(noColorOption) is bool noColor)
|
||||
options.NoColor = noColor;
|
||||
|
||||
var backendOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--backend-url"));
|
||||
if (backendOption is not null)
|
||||
options.BackendUrl = context.ParseResult.GetValueForOption(backendOption) as string;
|
||||
|
||||
var tenantOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--tenant-id"));
|
||||
if (tenantOption is not null)
|
||||
options.TenantId = context.ParseResult.GetValueForOption(tenantOption) as string;
|
||||
|
||||
var dryRunOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--dry-run"));
|
||||
if (dryRunOption is not null && context.ParseResult.GetValueForOption(dryRunOption) is bool dryRun)
|
||||
options.DryRun = dryRun;
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal file
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Structured CLI error with code, message, and optional details.
|
||||
/// Per CLI-CORE-41-001, provides error mapping for standardized API error envelopes.
|
||||
/// CLI-SDK-62-002: Enhanced to surface error.code and trace_id from API responses.
|
||||
/// </summary>
|
||||
public sealed record CliError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? TraceId = null,
|
||||
string? Detail = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null,
|
||||
string? RequestId = null,
|
||||
string? HelpUrl = null,
|
||||
int? RetryAfter = null,
|
||||
string? Target = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Exit code to use when this error occurs.
|
||||
/// </summary>
|
||||
public int ExitCode => GetExitCode(Code);
|
||||
|
||||
/// <summary>
|
||||
/// Maps error code prefixes to exit codes.
|
||||
/// </summary>
|
||||
private static int GetExitCode(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return 1;
|
||||
|
||||
// Authentication/authorization errors
|
||||
if (code.StartsWith("AUTH_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
|
||||
return 2;
|
||||
|
||||
// Invalid scope errors
|
||||
if (code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
|
||||
return 3;
|
||||
|
||||
// Not found errors
|
||||
if (code.StartsWith("NOT_FOUND", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_NOT_FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
return 4;
|
||||
|
||||
// Validation errors
|
||||
if (code.StartsWith("VALIDATION_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_VALIDATION_", StringComparison.OrdinalIgnoreCase))
|
||||
return 5;
|
||||
|
||||
// Rate limit errors
|
||||
if (code.StartsWith("RATE_LIMIT", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return 6;
|
||||
|
||||
// Air-gap errors
|
||||
if (code.StartsWith("AIRGAP_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_AIRGAP_", StringComparison.OrdinalIgnoreCase))
|
||||
return 7;
|
||||
|
||||
// AOC errors
|
||||
if (code.StartsWith("ERR_AOC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 8;
|
||||
|
||||
// Aggregation errors
|
||||
if (code.StartsWith("ERR_AGG_", StringComparison.OrdinalIgnoreCase))
|
||||
return 9;
|
||||
|
||||
// Forensic verification errors
|
||||
if (code.StartsWith("ERR_FORENSIC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 12;
|
||||
|
||||
// Determinism errors
|
||||
if (code.StartsWith("ERR_DETER_", StringComparison.OrdinalIgnoreCase))
|
||||
return 13;
|
||||
|
||||
// Observability errors
|
||||
if (code.StartsWith("ERR_OBS_", StringComparison.OrdinalIgnoreCase))
|
||||
return 14;
|
||||
|
||||
// Pack errors
|
||||
if (code.StartsWith("ERR_PACK_", StringComparison.OrdinalIgnoreCase))
|
||||
return 15;
|
||||
|
||||
// Exception governance errors
|
||||
if (code.StartsWith("ERR_EXC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 16;
|
||||
|
||||
// Orchestrator errors
|
||||
if (code.StartsWith("ERR_ORCH_", StringComparison.OrdinalIgnoreCase))
|
||||
return 17;
|
||||
|
||||
// SBOM errors
|
||||
if (code.StartsWith("ERR_SBOM_", StringComparison.OrdinalIgnoreCase))
|
||||
return 18;
|
||||
|
||||
// Notify errors
|
||||
if (code.StartsWith("ERR_NOTIFY_", StringComparison.OrdinalIgnoreCase))
|
||||
return 19;
|
||||
|
||||
// Sbomer errors
|
||||
if (code.StartsWith("ERR_SBOMER_", StringComparison.OrdinalIgnoreCase))
|
||||
return 20;
|
||||
|
||||
// Network/connectivity errors
|
||||
if (code.StartsWith("NETWORK_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_NETWORK_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("CONNECTION_", StringComparison.OrdinalIgnoreCase))
|
||||
return 10;
|
||||
|
||||
// Timeout errors
|
||||
if (code.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase))
|
||||
return 11;
|
||||
|
||||
// Generic errors
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an exception.
|
||||
/// </summary>
|
||||
public static CliError FromException(Exception ex, string? traceId = null)
|
||||
{
|
||||
var code = ex switch
|
||||
{
|
||||
UnauthorizedAccessException => "ERR_AUTH_UNAUTHORIZED",
|
||||
TimeoutException => "ERR_TIMEOUT",
|
||||
OperationCanceledException => "ERR_CANCELLED",
|
||||
InvalidOperationException => "ERR_INVALID_OPERATION",
|
||||
ArgumentException => "ERR_VALIDATION_ARGUMENT",
|
||||
_ => "ERR_UNKNOWN"
|
||||
};
|
||||
|
||||
return new CliError(code, ex.Message, traceId, ex.InnerException?.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an HTTP status code.
|
||||
/// </summary>
|
||||
public static CliError FromHttpStatus(int statusCode, string? message = null, string? traceId = null)
|
||||
{
|
||||
var (code, defaultMessage) = statusCode switch
|
||||
{
|
||||
400 => ("ERR_VALIDATION_BAD_REQUEST", "Bad request"),
|
||||
401 => ("ERR_AUTH_UNAUTHORIZED", "Unauthorized"),
|
||||
403 => ("ERR_AUTH_FORBIDDEN", "Forbidden"),
|
||||
404 => ("ERR_NOT_FOUND", "Resource not found"),
|
||||
409 => ("ERR_CONFLICT", "Resource conflict"),
|
||||
422 => ("ERR_VALIDATION_UNPROCESSABLE", "Unprocessable entity"),
|
||||
429 => ("ERR_RATE_LIMIT", "Rate limit exceeded"),
|
||||
500 => ("ERR_SERVER_INTERNAL", "Internal server error"),
|
||||
502 => ("ERR_SERVER_BAD_GATEWAY", "Bad gateway"),
|
||||
503 => ("ERR_SERVER_UNAVAILABLE", "Service unavailable"),
|
||||
504 => ("ERR_SERVER_TIMEOUT", "Gateway timeout"),
|
||||
_ => ($"ERR_HTTP_{statusCode}", $"HTTP error {statusCode}")
|
||||
};
|
||||
|
||||
return new CliError(code, message ?? defaultMessage, traceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from a parsed API error.
|
||||
/// CLI-SDK-62-002: Surfaces standardized API error envelope fields.
|
||||
/// </summary>
|
||||
public static CliError FromParsedApiError(ParsedApiError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
|
||||
Dictionary<string, string>? metadata = null;
|
||||
if (error.Metadata is not null && error.Metadata.Count > 0)
|
||||
{
|
||||
metadata = error.Metadata
|
||||
.Where(kvp => kvp.Value is not null)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return new CliError(
|
||||
Code: error.Code,
|
||||
Message: error.Message,
|
||||
TraceId: error.TraceId,
|
||||
Detail: error.Detail,
|
||||
Metadata: metadata,
|
||||
RequestId: error.RequestId,
|
||||
HelpUrl: error.HelpUrl,
|
||||
RetryAfter: error.RetryAfter,
|
||||
Target: error.Target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an API error envelope.
|
||||
/// CLI-SDK-62-002: Direct conversion from envelope format.
|
||||
/// </summary>
|
||||
public static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var errorDetail = envelope.Error;
|
||||
var code = errorDetail?.Code ?? $"ERR_HTTP_{httpStatus}";
|
||||
var message = errorDetail?.Message ?? $"HTTP error {httpStatus}";
|
||||
|
||||
Dictionary<string, string>? metadata = null;
|
||||
if (errorDetail?.Metadata is not null && errorDetail.Metadata.Count > 0)
|
||||
{
|
||||
metadata = errorDetail.Metadata
|
||||
.Where(kvp => kvp.Value is not null)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return new CliError(
|
||||
Code: code,
|
||||
Message: message,
|
||||
TraceId: envelope.TraceId,
|
||||
Detail: errorDetail?.Detail,
|
||||
Metadata: metadata,
|
||||
RequestId: envelope.RequestId,
|
||||
HelpUrl: errorDetail?.HelpUrl,
|
||||
RetryAfter: errorDetail?.RetryAfter,
|
||||
Target: errorDetail?.Target);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known CLI error codes.
|
||||
/// </summary>
|
||||
public static class CliErrorCodes
|
||||
{
|
||||
public const string Unauthorized = "ERR_AUTH_UNAUTHORIZED";
|
||||
public const string Forbidden = "ERR_AUTH_FORBIDDEN";
|
||||
public const string InvalidScope = "ERR_AUTH_INVALID_SCOPE";
|
||||
public const string NotFound = "ERR_NOT_FOUND";
|
||||
public const string ValidationFailed = "ERR_VALIDATION_FAILED";
|
||||
public const string RateLimited = "ERR_RATE_LIMIT";
|
||||
public const string AirGapBlocked = "ERR_AIRGAP_EGRESS_BLOCKED";
|
||||
public const string AocViolation = "ERR_AOC_001";
|
||||
public const string NetworkError = "ERR_NETWORK_FAILED";
|
||||
public const string Timeout = "ERR_TIMEOUT";
|
||||
public const string Cancelled = "ERR_CANCELLED";
|
||||
public const string ConfigurationMissing = "ERR_CONFIG_MISSING";
|
||||
public const string ProfileNotFound = "ERR_PROFILE_NOT_FOUND";
|
||||
|
||||
// CLI-LNM-22-001: Aggregation error codes (exit code 9)
|
||||
public const string AggNoObservations = "ERR_AGG_NO_OBSERVATIONS";
|
||||
public const string AggConflictDetected = "ERR_AGG_CONFLICT_DETECTED";
|
||||
public const string AggLinksetEmpty = "ERR_AGG_LINKSET_EMPTY";
|
||||
public const string AggSourceMissing = "ERR_AGG_SOURCE_MISSING";
|
||||
public const string AggExportFailed = "ERR_AGG_EXPORT_FAILED";
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic verification error codes (exit code 12)
|
||||
public const string ForensicBundleNotFound = "ERR_FORENSIC_BUNDLE_NOT_FOUND";
|
||||
public const string ForensicBundleInvalid = "ERR_FORENSIC_BUNDLE_INVALID";
|
||||
public const string ForensicChecksumMismatch = "ERR_FORENSIC_CHECKSUM_MISMATCH";
|
||||
public const string ForensicSignatureInvalid = "ERR_FORENSIC_SIGNATURE_INVALID";
|
||||
public const string ForensicSignatureUntrusted = "ERR_FORENSIC_SIGNATURE_UNTRUSTED";
|
||||
public const string ForensicChainOfCustodyBroken = "ERR_FORENSIC_CHAIN_BROKEN";
|
||||
public const string ForensicTimelineInvalid = "ERR_FORENSIC_TIMELINE_INVALID";
|
||||
public const string ForensicTrustRootMissing = "ERR_FORENSIC_TRUST_ROOT_MISSING";
|
||||
|
||||
// CLI-DETER-70-003: Determinism error codes (exit code 13)
|
||||
public const string DeterminismDockerUnavailable = "ERR_DETER_DOCKER_UNAVAILABLE";
|
||||
public const string DeterminismNoImages = "ERR_DETER_NO_IMAGES";
|
||||
public const string DeterminismScannerMissing = "ERR_DETER_SCANNER_MISSING";
|
||||
public const string DeterminismThresholdFailed = "ERR_DETER_THRESHOLD_FAILED";
|
||||
public const string DeterminismRunFailed = "ERR_DETER_RUN_FAILED";
|
||||
public const string DeterminismManifestInvalid = "ERR_DETER_MANIFEST_INVALID";
|
||||
|
||||
// CLI-OBS-51-001: Observability error codes (exit code 14)
|
||||
public const string ObsConnectionFailed = "ERR_OBS_CONNECTION_FAILED";
|
||||
public const string ObsServiceUnavailable = "ERR_OBS_SERVICE_UNAVAILABLE";
|
||||
public const string ObsNoData = "ERR_OBS_NO_DATA";
|
||||
public const string ObsInvalidFilter = "ERR_OBS_INVALID_FILTER";
|
||||
public const string ObsOfflineViolation = "ERR_OBS_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-PACKS-42-001: Pack error codes (exit code 15)
|
||||
public const string PackNotFound = "ERR_PACK_NOT_FOUND";
|
||||
public const string PackValidationFailed = "ERR_PACK_VALIDATION_FAILED";
|
||||
public const string PackPlanFailed = "ERR_PACK_PLAN_FAILED";
|
||||
public const string PackRunFailed = "ERR_PACK_RUN_FAILED";
|
||||
public const string PackPushFailed = "ERR_PACK_PUSH_FAILED";
|
||||
public const string PackPullFailed = "ERR_PACK_PULL_FAILED";
|
||||
public const string PackVerifyFailed = "ERR_PACK_VERIFY_FAILED";
|
||||
public const string PackSignatureInvalid = "ERR_PACK_SIGNATURE_INVALID";
|
||||
public const string PackApprovalRequired = "ERR_PACK_APPROVAL_REQUIRED";
|
||||
public const string PackOfflineViolation = "ERR_PACK_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-EXC-25-001: Exception governance error codes (exit code 16)
|
||||
public const string ExcNotFound = "ERR_EXC_NOT_FOUND";
|
||||
public const string ExcValidationFailed = "ERR_EXC_VALIDATION_FAILED";
|
||||
public const string ExcCreateFailed = "ERR_EXC_CREATE_FAILED";
|
||||
public const string ExcPromoteFailed = "ERR_EXC_PROMOTE_FAILED";
|
||||
public const string ExcRevokeFailed = "ERR_EXC_REVOKE_FAILED";
|
||||
public const string ExcImportFailed = "ERR_EXC_IMPORT_FAILED";
|
||||
public const string ExcExportFailed = "ERR_EXC_EXPORT_FAILED";
|
||||
public const string ExcApprovalRequired = "ERR_EXC_APPROVAL_REQUIRED";
|
||||
public const string ExcExpired = "ERR_EXC_EXPIRED";
|
||||
public const string ExcConflict = "ERR_EXC_CONFLICT";
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator error codes (exit code 17)
|
||||
public const string OrchSourceNotFound = "ERR_ORCH_SOURCE_NOT_FOUND";
|
||||
public const string OrchSourcePaused = "ERR_ORCH_SOURCE_PAUSED";
|
||||
public const string OrchSourceThrottled = "ERR_ORCH_SOURCE_THROTTLED";
|
||||
public const string OrchTestFailed = "ERR_ORCH_TEST_FAILED";
|
||||
public const string OrchQuotaExceeded = "ERR_ORCH_QUOTA_EXCEEDED";
|
||||
public const string OrchConnectionFailed = "ERR_ORCH_CONNECTION_FAILED";
|
||||
|
||||
// CLI-PARITY-41-001: SBOM error codes (exit code 18)
|
||||
public const string SbomNotFound = "ERR_SBOM_NOT_FOUND";
|
||||
public const string SbomConnectionFailed = "ERR_SBOM_CONNECTION_FAILED";
|
||||
public const string SbomExportFailed = "ERR_SBOM_EXPORT_FAILED";
|
||||
public const string SbomCompareFailed = "ERR_SBOM_COMPARE_FAILED";
|
||||
public const string SbomInvalidFormat = "ERR_SBOM_INVALID_FORMAT";
|
||||
public const string SbomOfflineViolation = "ERR_SBOM_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-PARITY-41-002: Notify error codes (exit code 19)
|
||||
public const string NotifyChannelNotFound = "ERR_NOTIFY_CHANNEL_NOT_FOUND";
|
||||
public const string NotifyDeliveryNotFound = "ERR_NOTIFY_DELIVERY_NOT_FOUND";
|
||||
public const string NotifyConnectionFailed = "ERR_NOTIFY_CONNECTION_FAILED";
|
||||
public const string NotifySendFailed = "ERR_NOTIFY_SEND_FAILED";
|
||||
public const string NotifyTestFailed = "ERR_NOTIFY_TEST_FAILED";
|
||||
public const string NotifyRetryFailed = "ERR_NOTIFY_RETRY_FAILED";
|
||||
public const string NotifyOfflineViolation = "ERR_NOTIFY_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer error codes (exit code 20)
|
||||
public const string SbomerLayerNotFound = "ERR_SBOMER_LAYER_NOT_FOUND";
|
||||
public const string SbomerCompositionNotFound = "ERR_SBOMER_COMPOSITION_NOT_FOUND";
|
||||
public const string SbomerDsseInvalid = "ERR_SBOMER_DSSE_INVALID";
|
||||
public const string SbomerContentHashMismatch = "ERR_SBOMER_CONTENT_HASH_MISMATCH";
|
||||
public const string SbomerMerkleProofInvalid = "ERR_SBOMER_MERKLE_PROOF_INVALID";
|
||||
public const string SbomerComposeFailed = "ERR_SBOMER_COMPOSE_FAILED";
|
||||
public const string SbomerVerifyFailed = "ERR_SBOMER_VERIFY_FAILED";
|
||||
public const string SbomerNonDeterministic = "ERR_SBOMER_NON_DETERMINISTIC";
|
||||
public const string SbomerOfflineViolation = "ERR_SBOMER_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-POLICY-27-006: Policy Studio scope error codes (exit code 3)
|
||||
public const string PolicyStudioScopeRequired = "ERR_SCOPE_POLICY_STUDIO_REQUIRED";
|
||||
public const string PolicyStudioScopeInvalid = "ERR_SCOPE_POLICY_STUDIO_INVALID";
|
||||
public const string PolicyStudioScopeWorkflowRequired = "ERR_SCOPE_POLICY_WORKFLOW_REQUIRED";
|
||||
public const string PolicyStudioScopePublishRequired = "ERR_SCOPE_POLICY_PUBLISH_REQUIRED";
|
||||
public const string PolicyStudioScopeSignRequired = "ERR_SCOPE_POLICY_SIGN_REQUIRED";
|
||||
|
||||
// CLI-RISK-66-001: Risk scope error codes (exit code 3)
|
||||
public const string RiskScopeRequired = "ERR_SCOPE_RISK_REQUIRED";
|
||||
public const string RiskScopeProfileRequired = "ERR_SCOPE_RISK_PROFILE_REQUIRED";
|
||||
public const string RiskScopeSimulateRequired = "ERR_SCOPE_RISK_SIMULATE_REQUIRED";
|
||||
|
||||
// CLI-SIG-26-001: Reachability scope error codes (exit code 3)
|
||||
public const string ReachabilityScopeRequired = "ERR_SCOPE_REACHABILITY_REQUIRED";
|
||||
public const string ReachabilityScopeUploadRequired = "ERR_SCOPE_REACHABILITY_UPLOAD_REQUIRED";
|
||||
}
|
||||
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal file
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for rendering CLI errors consistently.
|
||||
/// CLI-SDK-62-002: Provides standardized error output with error.code and trace_id.
|
||||
/// </summary>
|
||||
internal static class CliErrorRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error to the console.
|
||||
/// </summary>
|
||||
public static void Render(CliError error, bool verbose = false, bool asJson = false)
|
||||
{
|
||||
if (asJson)
|
||||
{
|
||||
RenderJson(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderConsole(error, verbose);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error as JSON.
|
||||
/// </summary>
|
||||
public static void RenderJson(CliError error)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = error.Code,
|
||||
message = error.Message,
|
||||
detail = error.Detail,
|
||||
target = error.Target,
|
||||
help_url = error.HelpUrl,
|
||||
retry_after = error.RetryAfter,
|
||||
metadata = error.Metadata
|
||||
},
|
||||
trace_id = error.TraceId,
|
||||
request_id = error.RequestId,
|
||||
exit_code = error.ExitCode
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(output, JsonOptions);
|
||||
AnsiConsole.WriteLine(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error to the console with formatting.
|
||||
/// </summary>
|
||||
public static void RenderConsole(CliError error, bool verbose = false)
|
||||
{
|
||||
// Main error message
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
||||
|
||||
// Error code
|
||||
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(error.Code)}");
|
||||
|
||||
// Detail (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.Detail))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Detail:[/] {Markup.Escape(error.Detail)}");
|
||||
}
|
||||
|
||||
// Target (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.Target))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Target:[/] {Markup.Escape(error.Target)}");
|
||||
}
|
||||
|
||||
// Help URL (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.HelpUrl))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Help:[/] [link]{Markup.Escape(error.HelpUrl)}[/]");
|
||||
}
|
||||
|
||||
// Retry-after (if present)
|
||||
if (error.RetryAfter.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Retry after:[/] {error.RetryAfter} seconds");
|
||||
}
|
||||
|
||||
// Trace/Request IDs (shown in verbose mode or always for debugging)
|
||||
if (verbose || !string.IsNullOrWhiteSpace(error.TraceId))
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
if (!string.IsNullOrWhiteSpace(error.TraceId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Trace ID:[/] {Markup.Escape(error.TraceId)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(error.RequestId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Request ID:[/] {Markup.Escape(error.RequestId)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata (shown in verbose mode)
|
||||
if (verbose && error.Metadata is not null && error.Metadata.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[grey]Metadata:[/]");
|
||||
foreach (var (key, value) in error.Metadata)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(key)}:[/] {Markup.Escape(value)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a simple error message.
|
||||
/// </summary>
|
||||
public static void RenderSimple(string message, string? code = null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
|
||||
if (!string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(code)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a warning message.
|
||||
/// </summary>
|
||||
public static void RenderWarning(string message)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(message)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders scope guidance for invalid_scope errors.
|
||||
/// </summary>
|
||||
public static void RenderScopeGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[yellow]The requested operation requires additional OAuth scopes.[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("To resolve this issue:");
|
||||
AnsiConsole.MarkupLine(" 1. Check your CLI profile configuration: [cyan]stella profile show[/]");
|
||||
AnsiConsole.MarkupLine(" 2. Update your profile with required scopes: [cyan]stella profile edit <name>[/]");
|
||||
AnsiConsole.MarkupLine(" 3. Request additional scopes from your administrator");
|
||||
AnsiConsole.MarkupLine(" 4. Re-authenticate with the updated scopes: [cyan]stella auth login[/]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders rate limit guidance.
|
||||
/// </summary>
|
||||
public static void RenderRateLimitGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.Contains("RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[yellow]Rate limit exceeded.[/]");
|
||||
|
||||
if (error.RetryAfter.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"Please wait [cyan]{error.RetryAfter}[/] seconds before retrying.");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("Please wait before retrying your request.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders authentication guidance.
|
||||
/// </summary>
|
||||
public static void RenderAuthGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (error.Code == CliErrorCodes.Unauthorized)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Authentication required.[/]");
|
||||
AnsiConsole.MarkupLine("Please authenticate using: [cyan]stella auth login[/]");
|
||||
}
|
||||
else if (error.Code == CliErrorCodes.Forbidden)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Access denied.[/]");
|
||||
AnsiConsole.MarkupLine("You do not have permission to perform this operation.");
|
||||
AnsiConsole.MarkupLine("Contact your administrator to request access.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders contextual guidance based on error code.
|
||||
/// </summary>
|
||||
public static void RenderGuidance(CliError error)
|
||||
{
|
||||
RenderScopeGuidance(error);
|
||||
RenderRateLimitGuidance(error);
|
||||
RenderAuthGuidance(error);
|
||||
}
|
||||
}
|
||||
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal file
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for rendering CLI output in multiple formats.
|
||||
/// Per CLI-CORE-41-001, supports json/yaml/table rendering.
|
||||
/// </summary>
|
||||
public interface IOutputRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current output format.
|
||||
/// </summary>
|
||||
OutputFormat Format { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Renders a single object to the output stream.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the object to render.</typeparam>
|
||||
/// <param name="value">The value to render.</param>
|
||||
/// <param name="writer">The text writer to output to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a collection as a table or list.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of items in the collection.</typeparam>
|
||||
/// <param name="items">The items to render.</param>
|
||||
/// <param name="writer">The text writer to output to.</param>
|
||||
/// <param name="columns">Optional column definitions for table format.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RenderTableAsync<T>(
|
||||
IEnumerable<T> items,
|
||||
TextWriter writer,
|
||||
IReadOnlyList<ColumnDefinition<T>>? columns = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a success message.
|
||||
/// </summary>
|
||||
Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error message.
|
||||
/// </summary>
|
||||
Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a warning message.
|
||||
/// </summary>
|
||||
Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Column definition for table rendering.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the row item.</typeparam>
|
||||
public sealed class ColumnDefinition<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Column header text.
|
||||
/// </summary>
|
||||
public required string Header { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function to extract the column value from an item.
|
||||
/// </summary>
|
||||
public required Func<T, string?> ValueSelector { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional minimum width for the column.
|
||||
/// </summary>
|
||||
public int? MinWidth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum width for the column (truncates with ellipsis).
|
||||
/// </summary>
|
||||
public int? MaxWidth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alignment for the column content.
|
||||
/// </summary>
|
||||
public ColumnAlignment Alignment { get; init; } = ColumnAlignment.Left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Column alignment for table rendering.
|
||||
/// </summary>
|
||||
public enum ColumnAlignment
|
||||
{
|
||||
Left,
|
||||
Right,
|
||||
Center
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal file
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Output format for CLI commands.
|
||||
/// Per CLI-CORE-41-001, supports json/yaml/table formats.
|
||||
/// </summary>
|
||||
public enum OutputFormat
|
||||
{
|
||||
/// <summary>Human-readable table format (default).</summary>
|
||||
Table,
|
||||
|
||||
/// <summary>JSON format for automation/scripting.</summary>
|
||||
Json,
|
||||
|
||||
/// <summary>YAML format for configuration/scripting.</summary>
|
||||
Yaml
|
||||
}
|
||||
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IOutputRenderer"/>.
|
||||
/// Per CLI-CORE-41-001, renders output in json/yaml/table formats.
|
||||
/// </summary>
|
||||
public sealed class OutputRenderer : IOutputRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
public OutputFormat Format { get; }
|
||||
|
||||
public OutputRenderer(OutputFormat format = OutputFormat.Table)
|
||||
{
|
||||
Format = format;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var output = Format switch
|
||||
{
|
||||
OutputFormat.Json => RenderJson(value),
|
||||
OutputFormat.Yaml => RenderYaml(value),
|
||||
OutputFormat.Table => RenderObject(value),
|
||||
_ => RenderObject(value)
|
||||
};
|
||||
|
||||
await writer.WriteLineAsync(output.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderTableAsync<T>(
|
||||
IEnumerable<T> items,
|
||||
TextWriter writer,
|
||||
IReadOnlyList<ColumnDefinition<T>>? columns = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var itemList = items.ToList();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var json = RenderJson(itemList);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
var yaml = RenderYaml(itemList);
|
||||
await writer.WriteLineAsync(yaml.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Table format
|
||||
if (itemList.Count == 0)
|
||||
{
|
||||
await writer.WriteLineAsync("(no results)".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveColumns = columns ?? InferColumns<T>();
|
||||
var table = BuildTable(itemList, effectiveColumns);
|
||||
await writer.WriteLineAsync(table.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "success", message };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
await writer.WriteLineAsync($"status: success\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync($"✓ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "error", error_code = errorCode, message, trace_id = traceId };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("status: error");
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
sb.AppendLine($"error_code: {errorCode}");
|
||||
sb.AppendLine($"message: {EscapeYamlString(message)}");
|
||||
if (!string.IsNullOrWhiteSpace(traceId))
|
||||
sb.AppendLine($"trace_id: {traceId}");
|
||||
await writer.WriteLineAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.Append("✗ ");
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
output.Append($"[{errorCode}] ");
|
||||
output.Append(message);
|
||||
if (!string.IsNullOrWhiteSpace(traceId))
|
||||
output.Append($" (trace: {traceId})");
|
||||
|
||||
await writer.WriteLineAsync(output.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "warning", message };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
await writer.WriteLineAsync($"status: warning\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync($"⚠ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string RenderJson<T>(T value)
|
||||
{
|
||||
return JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
private static string RenderYaml<T>(T value)
|
||||
{
|
||||
// Simple YAML rendering via JSON conversion for now
|
||||
// A full YAML library would be used in production
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return ConvertJsonElementToYaml(doc.RootElement, 0);
|
||||
}
|
||||
|
||||
private static string ConvertJsonElementToYaml(JsonElement element, int indent)
|
||||
{
|
||||
var indentStr = new string(' ', indent * 2);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine($"{indentStr}{prop.Name}:");
|
||||
sb.Append(ConvertJsonElementToYaml(prop.Value, indent + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{indentStr}{prop.Name}: {FormatYamlValue(prop.Value)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Object || item.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine($"{indentStr}-");
|
||||
sb.Append(ConvertJsonElementToYaml(item, indent + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{indentStr}- {FormatYamlValue(item)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
sb.AppendLine($"{indentStr}{FormatYamlValue(element)}");
|
||||
break;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatYamlValue(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => EscapeYamlString(element.GetString() ?? ""),
|
||||
JsonValueKind.Number => element.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static string EscapeYamlString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return "\"\"";
|
||||
|
||||
if (value.Contains('\n') || value.Contains(':') || value.Contains('#') ||
|
||||
value.StartsWith(' ') || value.EndsWith(' ') ||
|
||||
value.StartsWith('"') || value.StartsWith('\''))
|
||||
{
|
||||
return $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string RenderObject<T>(T value)
|
||||
{
|
||||
if (value is null)
|
||||
return "(null)";
|
||||
|
||||
var type = typeof(T);
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.ToList();
|
||||
|
||||
if (properties.Count == 0)
|
||||
return value.ToString() ?? "(empty)";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var maxNameLength = properties.Max(p => p.Name.Length);
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var propValue = prop.GetValue(value);
|
||||
var displayValue = propValue?.ToString() ?? "(null)";
|
||||
sb.AppendLine($"{prop.Name.PadRight(maxNameLength)} : {displayValue}");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ColumnDefinition<T>> InferColumns<T>()
|
||||
{
|
||||
var properties = typeof(T).GetProperties()
|
||||
.Where(p => p.CanRead && IsSimpleType(p.PropertyType))
|
||||
.Take(8) // Limit to 8 columns for readability
|
||||
.ToList();
|
||||
|
||||
return properties.Select(p => new ColumnDefinition<T>
|
||||
{
|
||||
Header = ToHeaderCase(p.Name),
|
||||
ValueSelector = item => p.GetValue(item)?.ToString()
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static bool IsSimpleType(Type type)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return underlying.IsPrimitive ||
|
||||
underlying == typeof(string) ||
|
||||
underlying == typeof(DateTime) ||
|
||||
underlying == typeof(DateTimeOffset) ||
|
||||
underlying == typeof(Guid) ||
|
||||
underlying == typeof(decimal) ||
|
||||
underlying.IsEnum;
|
||||
}
|
||||
|
||||
private static string ToHeaderCase(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return name;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
var c = name[i];
|
||||
if (i > 0 && char.IsUpper(c))
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
sb.Append(i == 0 ? char.ToUpperInvariant(c) : c);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildTable<T>(IReadOnlyList<T> items, IReadOnlyList<ColumnDefinition<T>> columns)
|
||||
{
|
||||
if (columns.Count == 0 || items.Count == 0)
|
||||
return "(no data)";
|
||||
|
||||
// Calculate column widths
|
||||
var widths = new int[columns.Count];
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
widths[i] = columns[i].Header.Length;
|
||||
if (columns[i].MinWidth.HasValue)
|
||||
widths[i] = Math.Max(widths[i], columns[i].MinWidth.Value);
|
||||
}
|
||||
|
||||
// Get all values and update widths
|
||||
var rows = new List<string[]>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var row = new string[columns.Count];
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
var value = columns[i].ValueSelector(item) ?? "";
|
||||
if (columns[i].MaxWidth.HasValue && value.Length > columns[i].MaxWidth.Value)
|
||||
{
|
||||
value = value[..(columns[i].MaxWidth.Value - 3)] + "...";
|
||||
}
|
||||
row[i] = value;
|
||||
widths[i] = Math.Max(widths[i], value.Length);
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
// Build output
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(PadColumn(columns[i].Header.ToUpperInvariant(), widths[i], columns[i].Alignment));
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Separator
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(new string('-', widths[i]));
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Rows
|
||||
foreach (var row in rows)
|
||||
{
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(PadColumn(row[i], widths[i], columns[i].Alignment));
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string PadColumn(string value, int width, ColumnAlignment alignment)
|
||||
{
|
||||
return alignment switch
|
||||
{
|
||||
ColumnAlignment.Right => value.PadLeft(width),
|
||||
ColumnAlignment.Center => value.PadLeft((width + value.Length) / 2).PadRight(width),
|
||||
_ => value.PadRight(width)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,85 @@ internal static class Program
|
||||
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
|
||||
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
|
||||
|
||||
// CLI-FORENSICS-53-001: Forensic snapshot client
|
||||
services.AddHttpClient<IForensicSnapshotClient, ForensicSnapshotClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "forensic-api");
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic verifier (local only, no HTTP)
|
||||
services.AddSingleton<IForensicVerifier, ForensicVerifier>();
|
||||
|
||||
// CLI-FORENSICS-54-002: Attestation reader (local only, no HTTP)
|
||||
services.AddSingleton<IAttestationReader, AttestationReader>();
|
||||
|
||||
// CLI-LNM-22-002: VEX observations client
|
||||
services.AddHttpClient<IVexObservationsClient, VexObservationsClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(2);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "vex-api");
|
||||
|
||||
// CLI-PROMO-70-001: Promotion assembler (local, may call crane/cosign)
|
||||
services.AddHttpClient<IPromotionAssembler, PromotionAssembler>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
|
||||
// CLI-DETER-70-003: Determinism harness (local only, executes docker)
|
||||
services.AddSingleton<IDeterminismHarness, DeterminismHarness>();
|
||||
|
||||
// CLI-OBS-51-001: Observability client for health metrics
|
||||
services.AddHttpClient<IObservabilityClient, ObservabilityClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "observability-api");
|
||||
|
||||
// CLI-PACKS-42-001: Pack client for Task Pack operations
|
||||
services.AddHttpClient<IPackClient, PackClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(10); // Pack operations may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "packs-api");
|
||||
|
||||
// CLI-EXC-25-001: Exception client for exception governance operations
|
||||
services.AddHttpClient<IExceptionClient, ExceptionClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "exceptions-api");
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator client for source/job management
|
||||
services.AddHttpClient<IOrchestratorClient, OrchestratorClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "orchestrator-api");
|
||||
|
||||
// CLI-PARITY-41-001: SBOM client for SBOM explorer
|
||||
services.AddHttpClient<ISbomClient, SbomClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbom-api");
|
||||
|
||||
// CLI-PARITY-41-002: Notify client for notification management
|
||||
services.AddHttpClient<INotifyClient, NotifyClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "notify-api");
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer client for layer/compose operations
|
||||
services.AddHttpClient<ISbomerClient, SbomerClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Composition may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbomer-api");
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
|
||||
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reader for attestation files (DSSE envelopes with in-toto statements).
|
||||
/// Per CLI-FORENSICS-54-002.
|
||||
/// </summary>
|
||||
internal sealed class AttestationReader : IAttestationReader
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
private const string InTotoStatementType = "https://in-toto.io/Statement/v0.1";
|
||||
private const string InTotoStatementV1Type = "https://in-toto.io/Statement/v1";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<AttestationReader> _logger;
|
||||
private readonly IForensicVerifier _verifier;
|
||||
|
||||
public AttestationReader(ILogger<AttestationReader> logger, IForensicVerifier verifier)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
|
||||
}
|
||||
|
||||
public async Task<AttestationShowResult> ReadAttestationAsync(
|
||||
string filePath,
|
||||
AttestationShowOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_logger.LogDebug("Reading attestation from {FilePath}", filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Attestation file not found: {filePath}", filePath);
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AttestationEnvelope envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<AttestationEnvelope>(json, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid attestation JSON");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse attestation envelope from {FilePath}", filePath);
|
||||
throw new InvalidDataException($"Failed to parse attestation envelope: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid base64 payload: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
InTotoStatement statement;
|
||||
try
|
||||
{
|
||||
statement = JsonSerializer.Deserialize<InTotoStatement>(payloadJson, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid in-toto statement JSON");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse in-toto statement from payload");
|
||||
throw new InvalidDataException($"Failed to parse in-toto statement: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Extract subjects
|
||||
var subjects = statement.Subject
|
||||
.Select(s => new AttestationSubjectInfo
|
||||
{
|
||||
Name = s.Name,
|
||||
DigestAlgorithm = s.Digest.Keys.FirstOrDefault() ?? "unknown",
|
||||
DigestValue = s.Digest.Values.FirstOrDefault() ?? string.Empty
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Extract signatures
|
||||
var signatures = new List<AttestationSignatureInfo>();
|
||||
var trustRoots = options.TrustRoots.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
|
||||
{
|
||||
var loadedRoots = await _verifier.LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
trustRoots.AddRange(loadedRoots);
|
||||
}
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
var sigInfo = new AttestationSignatureInfo
|
||||
{
|
||||
KeyId = sig.KeyId ?? "(no key id)",
|
||||
Algorithm = "unknown" // Would need certificate parsing for actual algorithm
|
||||
};
|
||||
|
||||
if (options.VerifySignatures && trustRoots.Count > 0)
|
||||
{
|
||||
var matchingRoot = trustRoots.FirstOrDefault(tr =>
|
||||
string.Equals(tr.KeyId, sig.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingRoot is not null)
|
||||
{
|
||||
var isValid = VerifySignature(envelope, sig, matchingRoot);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
sigInfo = sigInfo with
|
||||
{
|
||||
Algorithm = matchingRoot.Algorithm,
|
||||
IsValid = isValid,
|
||||
IsTrusted = isValid && timeValid,
|
||||
SignerInfo = new AttestationSignerInfo
|
||||
{
|
||||
Fingerprint = matchingRoot.Fingerprint,
|
||||
NotBefore = matchingRoot.NotBefore,
|
||||
NotAfter = matchingRoot.NotAfter
|
||||
},
|
||||
Reason = !isValid ? "Signature verification failed" :
|
||||
!timeValid ? "Key outside validity period" : null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
sigInfo = sigInfo with
|
||||
{
|
||||
IsValid = null,
|
||||
IsTrusted = false,
|
||||
Reason = "No matching trust root found"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
signatures.Add(sigInfo);
|
||||
}
|
||||
|
||||
// Extract predicate summary
|
||||
var predicateSummary = ExtractPredicateSummary(statement);
|
||||
|
||||
// Build verification result
|
||||
AttestationVerificationResult? verificationResult = null;
|
||||
if (options.VerifySignatures)
|
||||
{
|
||||
var validCount = signatures.Count(s => s.IsValid == true);
|
||||
var trustedCount = signatures.Count(s => s.IsTrusted == true);
|
||||
var errors = signatures
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Reason))
|
||||
.Select(s => $"{s.KeyId}: {s.Reason}")
|
||||
.ToList();
|
||||
|
||||
verificationResult = new AttestationVerificationResult
|
||||
{
|
||||
IsValid = validCount > 0,
|
||||
SignatureCount = signatures.Count,
|
||||
ValidSignatures = validCount,
|
||||
TrustedSignatures = trustedCount,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
return new AttestationShowResult
|
||||
{
|
||||
FilePath = filePath,
|
||||
PayloadType = envelope.PayloadType,
|
||||
StatementType = statement.Type,
|
||||
PredicateType = statement.PredicateType,
|
||||
Subjects = subjects,
|
||||
Signatures = signatures,
|
||||
PredicateSummary = predicateSummary,
|
||||
VerificationResult = verificationResult
|
||||
};
|
||||
}
|
||||
|
||||
private static bool VerifySignature(
|
||||
AttestationEnvelope envelope,
|
||||
AttestationSignature sig,
|
||||
ForensicTrustRoot trustRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
var pae = BuildPreAuthEncoding(envelope.PayloadType, payloadBytes);
|
||||
var signatureBytes = Convert.FromBase64String(sig.Signature);
|
||||
var publicKeyBytes = Convert.FromBase64String(trustRoot.PublicKey);
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
|
||||
|
||||
return rsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
// DSSE PAE format: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
// Write "DSSEv1" prefix length and value
|
||||
writer.Write((long)PaePrefix.Length);
|
||||
writer.Write(Encoding.UTF8.GetBytes(PaePrefix));
|
||||
|
||||
// Write payload type length and value
|
||||
writer.Write((long)payloadTypeBytes.Length);
|
||||
writer.Write(payloadTypeBytes);
|
||||
|
||||
// Write payload length and value
|
||||
writer.Write((long)payload.Length);
|
||||
writer.Write(payload);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static AttestationPredicateSummary? ExtractPredicateSummary(InTotoStatement statement)
|
||||
{
|
||||
if (statement.Predicate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = new AttestationPredicateSummary
|
||||
{
|
||||
Type = statement.PredicateType
|
||||
};
|
||||
|
||||
// Try to extract common fields from predicate
|
||||
if (statement.Predicate is JsonElement element)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
var materials = new List<AttestationMaterial>();
|
||||
|
||||
// Extract buildType (SLSA)
|
||||
if (element.TryGetProperty("buildType", out var buildTypeProp) &&
|
||||
buildTypeProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { BuildType = buildTypeProp.GetString() };
|
||||
}
|
||||
|
||||
// Extract builder (SLSA)
|
||||
if (element.TryGetProperty("builder", out var builderProp))
|
||||
{
|
||||
if (builderProp.TryGetProperty("id", out var builderIdProp) &&
|
||||
builderIdProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { Builder = builderIdProp.GetString() };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract invocation ID (SLSA)
|
||||
if (element.TryGetProperty("invocation", out var invocationProp))
|
||||
{
|
||||
if (invocationProp.TryGetProperty("configSource", out var configSourceProp) &&
|
||||
configSourceProp.TryGetProperty("digest", out var digestProp))
|
||||
{
|
||||
foreach (var d in digestProp.EnumerateObject())
|
||||
{
|
||||
metadata[$"invocation.digest.{d.Name}"] = d.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract materials
|
||||
if (element.TryGetProperty("materials", out var materialsProp) &&
|
||||
materialsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var material in materialsProp.EnumerateArray())
|
||||
{
|
||||
var uri = string.Empty;
|
||||
var digest = new Dictionary<string, string>();
|
||||
|
||||
if (material.TryGetProperty("uri", out var uriProp) &&
|
||||
uriProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
uri = uriProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (material.TryGetProperty("digest", out var matDigestProp))
|
||||
{
|
||||
foreach (var d in matDigestProp.EnumerateObject())
|
||||
{
|
||||
digest[d.Name] = d.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
materials.Add(new AttestationMaterial { Uri = uri, Digest = digest });
|
||||
}
|
||||
|
||||
summary = summary with { Materials = materials };
|
||||
}
|
||||
|
||||
// Extract timestamp
|
||||
if (element.TryGetProperty("metadata", out var metaProp))
|
||||
{
|
||||
if (metaProp.TryGetProperty("buildStartedOn", out var startedProp) &&
|
||||
startedProp.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(startedProp.GetString(), out var started))
|
||||
{
|
||||
summary = summary with { Timestamp = started };
|
||||
}
|
||||
else if (metaProp.TryGetProperty("buildFinishedOn", out var finishedProp) &&
|
||||
finishedProp.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(finishedProp.GetString(), out var finished))
|
||||
{
|
||||
summary = summary with { Timestamp = finished };
|
||||
}
|
||||
|
||||
if (metaProp.TryGetProperty("invocationId", out var invIdProp) &&
|
||||
invIdProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { InvocationId = invIdProp.GetString() };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract VEX-specific fields
|
||||
if (statement.PredicateType.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (element.TryGetProperty("author", out var authorProp) &&
|
||||
authorProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
metadata["author"] = authorProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("timestamp", out var tsProp) &&
|
||||
tsProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(tsProp.GetString(), out var ts))
|
||||
{
|
||||
summary = summary with { Timestamp = ts };
|
||||
}
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("version", out var versionProp))
|
||||
{
|
||||
if (versionProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
metadata["version"] = versionProp.GetString() ?? string.Empty;
|
||||
}
|
||||
else if (versionProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
metadata["version"] = versionProp.GetInt32().ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.Count > 0)
|
||||
{
|
||||
summary = summary with { Metadata = metadata };
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,83 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
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=");
|
||||
@@ -130,6 +207,71 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
850
src/Cli/StellaOps.Cli/Services/DeterminismHarness.cs
Normal file
850
src/Cli/StellaOps.Cli/Services/DeterminismHarness.cs
Normal file
@@ -0,0 +1,850 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism harness for running scanner with frozen conditions.
|
||||
/// Per CLI-DETER-70-003.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismHarness : IDeterminismHarness
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly string[] ArtifactPatterns = new[]
|
||||
{
|
||||
"sbom.json", "sbom.spdx.json", "sbom.cdx.json",
|
||||
"vex.json", "findings.json", "scan.json",
|
||||
"layers.json", "metadata.json"
|
||||
};
|
||||
|
||||
private readonly ILogger<DeterminismHarness> _logger;
|
||||
|
||||
public DeterminismHarness(ILogger<DeterminismHarness> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DeterminismRunResult> RunAsync(
|
||||
DeterminismRunRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
var imageResults = new List<DeterminismImageResult>();
|
||||
var failedImages = new List<string>();
|
||||
|
||||
_logger.LogDebug("Starting determinism harness with {ImageCount} images, {Runs} runs each",
|
||||
request.Images.Count, request.Runs);
|
||||
|
||||
// Validate prerequisites
|
||||
if (!await IsDockerAvailableAsync(cancellationToken))
|
||||
{
|
||||
errors.Add("Docker is not available or not running");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
if (request.Images.Count == 0)
|
||||
{
|
||||
errors.Add("No images specified for determinism testing");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Scanner))
|
||||
{
|
||||
errors.Add("Scanner image reference is required");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
var outputDir = request.OutputDir ?? Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"stella-detscore-{DateTime.UtcNow:yyyyMMdd-HHmmss}");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
// Resolve SHAs
|
||||
var scannerSha = await ResolveImageDigestAsync(request.Scanner, cancellationToken) ?? "unknown";
|
||||
var policySha = await ComputeBundleShaAsync(request.PolicyBundle, cancellationToken) ?? "none";
|
||||
var feedsSha = await ComputeBundleShaAsync(request.FeedsBundle, cancellationToken) ?? "none";
|
||||
|
||||
var fixedClock = request.FixedClock ?? DateTimeOffset.UtcNow;
|
||||
|
||||
// Run determinism tests for each image
|
||||
foreach (var imageRef in request.Images)
|
||||
{
|
||||
_logger.LogInformation("Testing determinism for image: {Image}", imageRef);
|
||||
|
||||
try
|
||||
{
|
||||
var imageResult = await RunImageDeterminismAsync(
|
||||
imageRef,
|
||||
request,
|
||||
fixedClock,
|
||||
outputDir,
|
||||
cancellationToken);
|
||||
|
||||
imageResults.Add(imageResult);
|
||||
|
||||
if (imageResult.Score < request.ImageThreshold)
|
||||
{
|
||||
failedImages.Add(imageRef);
|
||||
warnings.Add($"Image {imageRef} score {imageResult.Score:P0} below threshold {request.ImageThreshold:P0}");
|
||||
}
|
||||
|
||||
if (imageResult.NonDeterministic.Count > 0)
|
||||
{
|
||||
warnings.Add($"Image {imageRef} has non-deterministic artifacts: {string.Join(", ", imageResult.NonDeterministic)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test determinism for image {Image}", imageRef);
|
||||
errors.Add($"Failed to test image {imageRef}: {ex.Message}");
|
||||
imageResults.Add(new DeterminismImageResult
|
||||
{
|
||||
Digest = imageRef,
|
||||
Runs = 0,
|
||||
Identical = 0,
|
||||
Score = 0,
|
||||
Notes = $"Error: {ex.Message}"
|
||||
});
|
||||
failedImages.Add(imageRef);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall score
|
||||
var overallScore = imageResults.Count > 0
|
||||
? imageResults.Average(r => r.Score)
|
||||
: 0;
|
||||
|
||||
var passedThreshold = overallScore >= request.OverallThreshold &&
|
||||
failedImages.Count == 0;
|
||||
|
||||
if (overallScore < request.OverallThreshold)
|
||||
{
|
||||
warnings.Add($"Overall score {overallScore:P0} below threshold {request.OverallThreshold:P0}");
|
||||
}
|
||||
|
||||
// Build manifest
|
||||
var manifest = new DeterminismManifest
|
||||
{
|
||||
Version = "1",
|
||||
Release = request.Release ?? $"local-{DateTime.UtcNow:yyyyMMdd}",
|
||||
Platform = request.Platform,
|
||||
PolicySha = policySha,
|
||||
FeedsSha = feedsSha,
|
||||
ScannerSha = scannerSha,
|
||||
Images = imageResults,
|
||||
OverallScore = overallScore,
|
||||
Thresholds = new DeterminismThresholds
|
||||
{
|
||||
ImageMin = request.ImageThreshold,
|
||||
OverallMin = request.OverallThreshold
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Execution = new DeterminismExecutionInfo
|
||||
{
|
||||
FixedClock = fixedClock,
|
||||
RngSeed = request.RngSeed,
|
||||
MaxConcurrency = request.MaxConcurrency,
|
||||
MemoryLimit = request.MemoryLimit,
|
||||
CpuSet = request.CpuSet,
|
||||
NetworkMode = "none"
|
||||
}
|
||||
};
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = Path.Combine(outputDir, "determinism.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
|
||||
_logger.LogInformation("Wrote determinism manifest to {Path}", manifestPath);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
Manifest = manifest,
|
||||
OutputPath = manifestPath,
|
||||
PassedThreshold = passedThreshold,
|
||||
FailedImages = failedImages,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
public DeterminismVerificationResult VerifyManifest(
|
||||
DeterminismManifest manifest,
|
||||
double imageThreshold,
|
||||
double overallThreshold)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var failedImages = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var image in manifest.Images)
|
||||
{
|
||||
if (image.Score < imageThreshold)
|
||||
{
|
||||
failedImages.Add(image.Digest);
|
||||
}
|
||||
|
||||
if (image.NonDeterministic.Count > 0)
|
||||
{
|
||||
warnings.Add($"Image {image.Digest} has non-deterministic artifacts: {string.Join(", ", image.NonDeterministic)}");
|
||||
}
|
||||
}
|
||||
|
||||
var passed = manifest.OverallScore >= overallThreshold && failedImages.Count == 0;
|
||||
|
||||
if (manifest.OverallScore < overallThreshold)
|
||||
{
|
||||
warnings.Add($"Overall score {manifest.OverallScore:P0} below threshold {overallThreshold:P0}");
|
||||
}
|
||||
|
||||
return new DeterminismVerificationResult
|
||||
{
|
||||
Passed = passed,
|
||||
OverallScore = manifest.OverallScore,
|
||||
FailedImages = failedImages.ToArray(),
|
||||
Warnings = warnings.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<DeterminismImageResult> RunImageDeterminismAsync(
|
||||
string imageRef,
|
||||
DeterminismRunRequest request,
|
||||
DateTimeOffset fixedClock,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageDigest = await ResolveImageDigestAsync(imageRef, cancellationToken) ?? imageRef;
|
||||
var imageOutputDir = Path.Combine(outputDir, SanitizeForPath(imageDigest));
|
||||
Directory.CreateDirectory(imageOutputDir);
|
||||
|
||||
var runDetails = new List<DeterminismRunDetail>();
|
||||
Dictionary<string, string>? baselineHashes = null;
|
||||
var identicalCount = 0;
|
||||
var nonDeterministic = new HashSet<string>();
|
||||
|
||||
for (var runNum = 1; runNum <= request.Runs; runNum++)
|
||||
{
|
||||
_logger.LogDebug("Running determinism test {Run}/{Total} for {Image}",
|
||||
runNum, request.Runs, imageRef);
|
||||
|
||||
var runDir = Path.Combine(imageOutputDir, $"run_{runNum}");
|
||||
Directory.CreateDirectory(runDir);
|
||||
|
||||
var runSw = Stopwatch.StartNew();
|
||||
var exitCode = await RunScannerAsync(
|
||||
imageRef,
|
||||
request.Scanner,
|
||||
request.PolicyBundle,
|
||||
request.FeedsBundle,
|
||||
fixedClock,
|
||||
request.RngSeed,
|
||||
request.MaxConcurrency,
|
||||
request.MemoryLimit,
|
||||
request.CpuSet,
|
||||
runDir,
|
||||
cancellationToken);
|
||||
runSw.Stop();
|
||||
|
||||
// Compute hashes for artifacts
|
||||
var artifactHashes = await ComputeArtifactHashesAsync(runDir, cancellationToken);
|
||||
|
||||
// Compare with baseline
|
||||
var isIdentical = true;
|
||||
if (baselineHashes == null)
|
||||
{
|
||||
baselineHashes = artifactHashes;
|
||||
isIdentical = true; // First run is always "identical" (it's the baseline)
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (artifact, hash) in artifactHashes)
|
||||
{
|
||||
if (baselineHashes.TryGetValue(artifact, out var baselineHash))
|
||||
{
|
||||
if (hash != baselineHash)
|
||||
{
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
_logger.LogWarning("Non-deterministic artifact {Artifact} in run {Run}: {Hash} != {Baseline}",
|
||||
artifact, runNum, hash[..16], baselineHash[..16]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New artifact not in baseline
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing artifacts
|
||||
foreach (var artifact in baselineHashes.Keys)
|
||||
{
|
||||
if (!artifactHashes.ContainsKey(artifact))
|
||||
{
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isIdentical)
|
||||
{
|
||||
identicalCount++;
|
||||
}
|
||||
|
||||
runDetails.Add(new DeterminismRunDetail
|
||||
{
|
||||
RunNumber = runNum,
|
||||
Identical = isIdentical,
|
||||
ArtifactHashes = artifactHashes,
|
||||
DurationMs = runSw.ElapsedMilliseconds,
|
||||
ExitCode = exitCode
|
||||
});
|
||||
}
|
||||
|
||||
var score = request.Runs > 0 ? (double)identicalCount / request.Runs : 0;
|
||||
|
||||
return new DeterminismImageResult
|
||||
{
|
||||
Digest = imageDigest,
|
||||
Runs = request.Runs,
|
||||
Identical = identicalCount,
|
||||
Score = score,
|
||||
ArtifactHashes = baselineHashes ?? new Dictionary<string, string>(),
|
||||
NonDeterministic = nonDeterministic.ToArray(),
|
||||
RunDetails = runDetails
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<int> RunScannerAsync(
|
||||
string imageRef,
|
||||
string scannerImage,
|
||||
string? policyBundle,
|
||||
string? feedsBundle,
|
||||
DateTimeOffset fixedClock,
|
||||
int rngSeed,
|
||||
int maxConcurrency,
|
||||
string memoryLimit,
|
||||
string cpuSet,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Build docker run command with determinism constraints
|
||||
var args = new StringBuilder();
|
||||
args.Append("run --rm ");
|
||||
args.Append($"--network=none ");
|
||||
args.Append($"--cpuset-cpus={cpuSet} ");
|
||||
args.Append($"--memory={memoryLimit} ");
|
||||
args.Append($"-e RNG_SEED={rngSeed} ");
|
||||
args.Append($"-e SCANNER_MAX_CONCURRENCY={maxConcurrency} ");
|
||||
args.Append($"-v \"{outputDir}:/output\" ");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyBundle) && File.Exists(policyBundle))
|
||||
{
|
||||
args.Append($"-v \"{Path.GetFullPath(policyBundle)}:/policy:ro\" ");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(feedsBundle) && File.Exists(feedsBundle))
|
||||
{
|
||||
args.Append($"-v \"{Path.GetFullPath(feedsBundle)}:/feeds:ro\" ");
|
||||
}
|
||||
|
||||
args.Append($"{scannerImage} ");
|
||||
args.Append($"scan --image {imageRef} ");
|
||||
args.Append($"--fixed-clock {fixedClock:yyyy-MM-ddTHH:mm:ssZ} ");
|
||||
args.Append("--output /output ");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = args.ToString(),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogDebug("Executing: docker {Args}", args);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Read output asynchronously
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
_logger.LogDebug("Scanner stderr: {Stderr}", stderr);
|
||||
}
|
||||
|
||||
// Save stdout/stderr for diagnostics
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "stdout.log"),
|
||||
stdout,
|
||||
cancellationToken);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "stderr.log"),
|
||||
stderr,
|
||||
cancellationToken);
|
||||
|
||||
return process.ExitCode;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ComputeArtifactHashesAsync(
|
||||
string directory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hashes = new Dictionary<string, string>();
|
||||
|
||||
foreach (var pattern in ArtifactPatterns)
|
||||
{
|
||||
var filePath = Path.Combine(directory, pattern);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(filePath, cancellationToken);
|
||||
hashes[pattern] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for any .json files in output
|
||||
foreach (var file in Directory.GetFiles(directory, "*.json"))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
if (!hashes.ContainsKey(fileName))
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(file, cancellationToken);
|
||||
hashes[fileName] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task<string?> ComputeBundleShaAsync(string? bundlePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
return null;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
return null;
|
||||
|
||||
return await ComputeFileHashAsync(bundlePath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveImageDigestAsync(string imageRef, CancellationToken cancellationToken)
|
||||
{
|
||||
// If already contains digest, extract it
|
||||
if (imageRef.Contains("@sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var atIndex = imageRef.IndexOf('@');
|
||||
if (atIndex >= 0)
|
||||
{
|
||||
return imageRef[(atIndex + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
// Try using crane/docker to resolve
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = $"inspect --format='{{{{.RepoDigests}}}}' {imageRef}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode == 0 && stdout.Contains("sha256:"))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(stdout, @"sha256:[a-f0-9]{64}");
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors, return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<bool> IsDockerAvailableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = "version --format '{{.Server.Version}}'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeForPath(string input)
|
||||
{
|
||||
// Replace invalid path characters
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var result = new StringBuilder(input);
|
||||
foreach (var c in invalid)
|
||||
{
|
||||
result.Replace(c, '_');
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
// CLI-DETER-70-004: Generate report implementation
|
||||
public async Task<DeterminismReportResult> GenerateReportAsync(
|
||||
DeterminismReportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
var manifests = new List<(string path, DeterminismManifest manifest)>();
|
||||
|
||||
_logger.LogDebug("Generating determinism report from {Count} manifests", request.ManifestPaths.Count);
|
||||
|
||||
if (request.ManifestPaths.Count == 0)
|
||||
{
|
||||
errors.Add("No manifest paths provided");
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
// Load all manifests
|
||||
foreach (var path in request.ManifestPaths)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
warnings.Add($"Manifest not found: {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<DeterminismManifest>(json, SerializerOptions);
|
||||
if (manifest != null)
|
||||
{
|
||||
manifests.Add((path, manifest));
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add($"Failed to parse manifest: {path}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"Error reading manifest {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
errors.Add("No valid manifests found");
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
// Build release entries
|
||||
var releases = manifests.Select(m => new DeterminismReleaseEntry
|
||||
{
|
||||
Release = m.manifest.Release,
|
||||
Platform = m.manifest.Platform,
|
||||
OverallScore = m.manifest.OverallScore,
|
||||
Passed = m.manifest.OverallScore >= m.manifest.Thresholds.OverallMin,
|
||||
ImageCount = m.manifest.Images.Count,
|
||||
GeneratedAt = m.manifest.GeneratedAt,
|
||||
ScannerSha = m.manifest.ScannerSha,
|
||||
ManifestPath = m.path
|
||||
}).OrderByDescending(r => r.GeneratedAt).ToList();
|
||||
|
||||
// Build image matrix
|
||||
var imageScores = new Dictionary<string, Dictionary<string, double>>();
|
||||
var imageNonDet = new Dictionary<string, HashSet<string>>();
|
||||
|
||||
foreach (var (path, manifest) in manifests)
|
||||
{
|
||||
foreach (var img in manifest.Images)
|
||||
{
|
||||
if (!imageScores.ContainsKey(img.Digest))
|
||||
{
|
||||
imageScores[img.Digest] = new Dictionary<string, double>();
|
||||
imageNonDet[img.Digest] = new HashSet<string>();
|
||||
}
|
||||
|
||||
imageScores[img.Digest][manifest.Release] = img.Score;
|
||||
|
||||
foreach (var nd in img.NonDeterministic)
|
||||
{
|
||||
imageNonDet[img.Digest].Add(nd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var imageMatrix = imageScores.Select(kvp => new DeterminismImageMatrixEntry
|
||||
{
|
||||
ImageDigest = kvp.Key,
|
||||
Scores = kvp.Value,
|
||||
AverageScore = kvp.Value.Values.Average(),
|
||||
NonDeterministicArtifacts = imageNonDet[kvp.Key].ToArray()
|
||||
}).OrderBy(e => e.AverageScore).ToList();
|
||||
|
||||
// Compute summary
|
||||
var allScores = manifests.Select(m => m.manifest.OverallScore).ToList();
|
||||
var allNonDet = manifests
|
||||
.SelectMany(m => m.manifest.Images)
|
||||
.SelectMany(i => i.NonDeterministic)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var summary = new DeterminismReportSummary
|
||||
{
|
||||
TotalReleases = manifests.Count,
|
||||
TotalImages = imageMatrix.Count,
|
||||
AverageScore = allScores.Count > 0 ? allScores.Average() : 0,
|
||||
MinScore = allScores.Count > 0 ? allScores.Min() : 0,
|
||||
MaxScore = allScores.Count > 0 ? allScores.Max() : 0,
|
||||
PassedCount = releases.Count(r => r.Passed),
|
||||
FailedCount = releases.Count(r => !r.Passed),
|
||||
NonDeterministicArtifacts = allNonDet
|
||||
};
|
||||
|
||||
var report = new DeterminismReport
|
||||
{
|
||||
Title = request.Title ?? "Determinism Score Report",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = summary,
|
||||
Releases = releases,
|
||||
ImageMatrix = imageMatrix
|
||||
};
|
||||
|
||||
// Write output
|
||||
string? outputPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.OutputPath))
|
||||
{
|
||||
outputPath = request.OutputPath;
|
||||
var content = request.Format.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => JsonSerializer.Serialize(report, SerializerOptions),
|
||||
"csv" => GenerateCsvReport(report),
|
||||
_ => GenerateMarkdownReport(report, request.IncludeDetails)
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Wrote determinism report to {Path}", outputPath);
|
||||
}
|
||||
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = true,
|
||||
Report = report,
|
||||
OutputPath = outputPath,
|
||||
Format = request.Format,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateMarkdownReport(DeterminismReport report, bool includeDetails)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine($"# {report.Title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Generated: {report.GeneratedAt:u}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary
|
||||
sb.AppendLine("## Summary");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"| Metric | Value |");
|
||||
sb.AppendLine($"|--------|-------|");
|
||||
sb.AppendLine($"| Total Releases | {report.Summary.TotalReleases} |");
|
||||
sb.AppendLine($"| Total Images | {report.Summary.TotalImages} |");
|
||||
sb.AppendLine($"| Average Score | {report.Summary.AverageScore:P1} |");
|
||||
sb.AppendLine($"| Min Score | {report.Summary.MinScore:P1} |");
|
||||
sb.AppendLine($"| Max Score | {report.Summary.MaxScore:P1} |");
|
||||
sb.AppendLine($"| Passed | {report.Summary.PassedCount} |");
|
||||
sb.AppendLine($"| Failed | {report.Summary.FailedCount} |");
|
||||
sb.AppendLine();
|
||||
|
||||
if (report.Summary.NonDeterministicArtifacts.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Non-Deterministic Artifacts");
|
||||
sb.AppendLine();
|
||||
foreach (var artifact in report.Summary.NonDeterministicArtifacts)
|
||||
{
|
||||
sb.AppendLine($"- `{artifact}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Releases table
|
||||
sb.AppendLine("## Releases");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Release | Platform | Score | Status | Images | Generated |");
|
||||
sb.AppendLine("|---------|----------|-------|--------|--------|-----------|");
|
||||
|
||||
foreach (var release in report.Releases)
|
||||
{
|
||||
var status = release.Passed ? "✅ PASS" : "❌ FAIL";
|
||||
sb.AppendLine($"| {release.Release} | {release.Platform} | {release.OverallScore:P1} | {status} | {release.ImageCount} | {release.GeneratedAt:yyyy-MM-dd} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Image matrix
|
||||
if (includeDetails && report.ImageMatrix.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Per-Image Matrix");
|
||||
sb.AppendLine();
|
||||
|
||||
var releaseNames = report.Releases.Select(r => r.Release).ToList();
|
||||
|
||||
// Header
|
||||
sb.Append("| Image |");
|
||||
foreach (var rel in releaseNames)
|
||||
{
|
||||
sb.Append($" {rel} |");
|
||||
}
|
||||
sb.AppendLine(" Avg |");
|
||||
|
||||
// Separator
|
||||
sb.Append("|-------|");
|
||||
foreach (var _ in releaseNames)
|
||||
{
|
||||
sb.Append("------|");
|
||||
}
|
||||
sb.AppendLine("-----|");
|
||||
|
||||
// Rows
|
||||
foreach (var img in report.ImageMatrix)
|
||||
{
|
||||
var digest = img.ImageDigest.Length > 16 ? img.ImageDigest[..16] + "..." : img.ImageDigest;
|
||||
sb.Append($"| `{digest}` |");
|
||||
|
||||
foreach (var rel in releaseNames)
|
||||
{
|
||||
if (img.Scores.TryGetValue(rel, out var score))
|
||||
{
|
||||
sb.Append($" {score:P0} |");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" - |");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($" {img.AverageScore:P0} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateCsvReport(DeterminismReport report)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("Release,Platform,Score,Passed,ImageCount,GeneratedAt,ScannerSha");
|
||||
|
||||
// Rows
|
||||
foreach (var release in report.Releases)
|
||||
{
|
||||
sb.AppendLine($"{release.Release},{release.Platform},{release.OverallScore:F4},{release.Passed},{release.ImageCount},{release.GeneratedAt:o},{release.ScannerSha}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
596
src/Cli/StellaOps.Cli/Services/ExceptionClient.cs
Normal file
596
src/Cli/StellaOps.Cli/Services/ExceptionClient.cs
Normal file
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for exception governance API operations.
|
||||
/// Per CLI-EXC-25-001.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionClient : IExceptionClient
|
||||
{
|
||||
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<ExceptionClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ExceptionClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ExceptionClient> 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.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionListResponse> ListAsync(
|
||||
ExceptionListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing exceptions");
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing exceptions");
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionInstance?> GetAsync(
|
||||
string exceptionId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/exceptions/{Uri.EscapeDataString(exceptionId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, 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 exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<ExceptionInstance>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting exception");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting exception");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> CreateAsync(
|
||||
ExceptionCreateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to create exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while creating exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while creating exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> PromoteAsync(
|
||||
ExceptionPromoteRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/promote")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.approve", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to promote exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while promoting exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while promoting exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> RevokeAsync(
|
||||
ExceptionRevokeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/revoke")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to revoke exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while revoking exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while revoking exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionImportResult> ImportAsync(
|
||||
ExceptionImportRequest request,
|
||||
Stream ndjsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(ndjsonStream);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var streamContent = new StreamContent(ndjsonStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson");
|
||||
content.Add(streamContent, "file", "exceptions.ndjson");
|
||||
content.Add(new StringContent(request.Tenant), "tenant");
|
||||
content.Add(new StringContent(request.Stage.ToString().ToLowerInvariant()), "stage");
|
||||
if (!string.IsNullOrWhiteSpace(request.Source))
|
||||
{
|
||||
content.Add(new StringContent(request.Source), "source");
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions/import")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to import exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}" }]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionImportResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = "Empty response" }] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while importing exceptions");
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = $"Connection error: {ex.Message}" }]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while importing exceptions");
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = "Request timed out" }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
|
||||
ExceptionExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.Statuses is { Count: > 0 })
|
||||
{
|
||||
foreach (var status in request.Statuses)
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
||||
}
|
||||
}
|
||||
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
|
||||
queryParams.Add($"includeManifest={request.IncludeManifest.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/exceptions/export{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to export exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
|
||||
// Parse manifest from header if present
|
||||
ExceptionExportManifest? manifest = null;
|
||||
if (response.Headers.TryGetValues("X-Export-Manifest", out var manifestValues))
|
||||
{
|
||||
var manifestJson = string.Join("", manifestValues);
|
||||
if (!string.IsNullOrWhiteSpace(manifestJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<ExceptionExportManifest>(manifestJson, SerializerOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore parse errors for optional header
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (contentStream, manifest);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while exporting exceptions");
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while exporting exceptions");
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildListUri(ExceptionListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Vuln))
|
||||
{
|
||||
queryParams.Add($"vuln={Uri.EscapeDataString(request.Vuln)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ScopeType))
|
||||
{
|
||||
queryParams.Add($"scopeType={Uri.EscapeDataString(request.ScopeType)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ScopeValue))
|
||||
{
|
||||
queryParams.Add($"scopeValue={Uri.EscapeDataString(request.ScopeValue)}");
|
||||
}
|
||||
if (request.Statuses is { Count: > 0 })
|
||||
{
|
||||
foreach (var status in request.Statuses)
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Owner))
|
||||
{
|
||||
queryParams.Add($"owner={Uri.EscapeDataString(request.Owner)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EffectType))
|
||||
{
|
||||
queryParams.Add($"effectType={Uri.EscapeDataString(request.EffectType)}");
|
||||
}
|
||||
if (request.ExpiringBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.IncludeExpired)
|
||||
{
|
||||
queryParams.Add("includeExpired=true");
|
||||
}
|
||||
queryParams.Add($"pageSize={request.PageSize}");
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
{
|
||||
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/exceptions{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
372
src/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs
Normal file
372
src/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for forensic snapshot and evidence locker APIs.
|
||||
/// Per CLI-FORENSICS-53-001.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotClient : IForensicSnapshotClient
|
||||
{
|
||||
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<ForensicSnapshotClient> _logger;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly object _tokenSync = new();
|
||||
|
||||
private string? _cachedAccessToken;
|
||||
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ForensicSnapshotClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ForensicSnapshotClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotDocument> CreateSnapshotAsync(
|
||||
string tenant,
|
||||
ForensicSnapshotCreateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to create forensic snapshot (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<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Invalid response from forensic API.");
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
|
||||
ForensicSnapshotListQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildListRequestUri(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 list forensic snapshots (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<ForensicSnapshotListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ForensicSnapshotListResponse();
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotDocument?> GetSnapshotAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}?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 forensic snapshot (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<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}/manifest?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 forensic snapshot manifest (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<ForensicSnapshotManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildListRequestUri(ForensicSnapshotListQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/forensic/snapshots?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.CaseId))
|
||||
{
|
||||
builder.Append("&caseId=");
|
||||
builder.Append(Uri.EscapeDataString(query.CaseId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
builder.Append("&status=");
|
||||
builder.Append(Uri.EscapeDataString(query.Status));
|
||||
}
|
||||
|
||||
if (query.Tags is { Count: > 0 })
|
||||
{
|
||||
foreach (var tag in query.Tags)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
builder.Append("&tag=");
|
||||
builder.Append(Uri.EscapeDataString(tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
builder.Append("&createdAfter=");
|
||||
builder.Append(Uri.EscapeDataString(query.CreatedAfter.Value.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
builder.Append("&createdBefore=");
|
||||
builder.Append(Uri.EscapeDataString(query.CreatedBefore.Value.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append("&limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (query.Offset.HasValue && query.Offset.Value > 0)
|
||||
{
|
||||
builder.Append("&offset=");
|
||||
builder.Append(query.Offset.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"BackendUrl is not configured. Set StellaOps:BackendUrl or STELLAOPS_BACKEND_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.EvidenceRead);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
592
src/Cli/StellaOps.Cli/Services/ForensicVerifier.cs
Normal file
592
src/Cli/StellaOps.Cli/Services/ForensicVerifier.cs
Normal file
@@ -0,0 +1,592 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier for forensic bundles including checksums, DSSE signatures, and chain-of-custody.
|
||||
/// Per CLI-FORENSICS-54-001.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerifier : IForensicVerifier
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<ForensicVerifier> _logger;
|
||||
|
||||
public ForensicVerifier(ILogger<ForensicVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ForensicVerificationResult> VerifyBundleAsync(
|
||||
string bundlePath,
|
||||
ForensicVerificationOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var errors = new List<ForensicVerificationError>();
|
||||
var warnings = new List<string>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogDebug("Verifying forensic bundle at {BundlePath}", bundlePath);
|
||||
|
||||
// Check bundle exists
|
||||
if (!File.Exists(bundlePath) && !Directory.Exists(bundlePath))
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleNotFound,
|
||||
Message = "Bundle path not found",
|
||||
Detail = bundlePath
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = ResolveManifestPath(bundlePath);
|
||||
if (manifestPath is null || !File.Exists(manifestPath))
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleInvalid,
|
||||
Message = "Manifest not found in bundle",
|
||||
Detail = "Expected manifest.json in bundle root"
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
ForensicSnapshotManifest manifest;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<ForensicSnapshotManifest>(manifestJson, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid manifest JSON");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse manifest at {ManifestPath}", manifestPath);
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleInvalid,
|
||||
Message = "Failed to parse manifest",
|
||||
Detail = ex.Message
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
var bundleDir = Path.GetDirectoryName(manifestPath) ?? bundlePath;
|
||||
|
||||
// Verify manifest
|
||||
var manifestVerification = await VerifyManifestAsync(manifest, manifestPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!manifestVerification.IsValid)
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicChecksumMismatch,
|
||||
Message = "Manifest digest verification failed",
|
||||
Detail = $"Expected: {manifestVerification.Digest}, Computed: {manifestVerification.ComputedDigest}"
|
||||
});
|
||||
}
|
||||
|
||||
// Verify checksums
|
||||
ForensicChecksumVerification? checksumVerification = null;
|
||||
if (options.VerifyChecksums)
|
||||
{
|
||||
checksumVerification = await VerifyChecksumsAsync(manifest, bundleDir, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var failure in checksumVerification.FailedArtifacts)
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicChecksumMismatch,
|
||||
Message = $"Checksum mismatch for artifact {failure.ArtifactId}",
|
||||
Detail = failure.Reason,
|
||||
ArtifactId = failure.ArtifactId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signatures
|
||||
ForensicSignatureVerification? signatureVerification = null;
|
||||
if (options.VerifySignatures && manifest.Signature is not null)
|
||||
{
|
||||
var trustRoots = options.TrustRoots.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
|
||||
{
|
||||
var loadedRoots = await LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
trustRoots.AddRange(loadedRoots);
|
||||
}
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
warnings.Add("No trust roots configured; signature verification skipped");
|
||||
}
|
||||
else
|
||||
{
|
||||
signatureVerification = VerifySignature(manifest, trustRoots);
|
||||
|
||||
if (!signatureVerification.IsValid)
|
||||
{
|
||||
var untrusted = signatureVerification.Signatures
|
||||
.Where(s => !s.IsTrusted)
|
||||
.Select(s => s.KeyId);
|
||||
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = signatureVerification.VerifiedSignatures == 0
|
||||
? CliErrorCodes.ForensicSignatureInvalid
|
||||
: CliErrorCodes.ForensicSignatureUntrusted,
|
||||
Message = "Signature verification failed",
|
||||
Detail = string.Join(", ", signatureVerification.Signatures.Select(s => s.Reason).Where(r => r is not null))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify chain of custody
|
||||
ForensicChainOfCustodyVerification? chainVerification = null;
|
||||
if (options.VerifyChainOfCustody && manifest.Metadata?.ChainOfCustody is { Count: > 0 })
|
||||
{
|
||||
chainVerification = VerifyChainOfCustody(manifest.Metadata.ChainOfCustody, options.StrictTimeline);
|
||||
|
||||
if (!chainVerification.IsValid)
|
||||
{
|
||||
var errorCode = !chainVerification.TimelineValid
|
||||
? CliErrorCodes.ForensicTimelineInvalid
|
||||
: CliErrorCodes.ForensicChainOfCustodyBroken;
|
||||
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = errorCode,
|
||||
Message = "Chain of custody verification failed",
|
||||
Detail = chainVerification.Gaps.Count > 0
|
||||
? $"Found {chainVerification.Gaps.Count} timeline gap(s)"
|
||||
: "Invalid entry signatures"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var isValid = errors.Count == 0 &&
|
||||
manifestVerification.IsValid &&
|
||||
(checksumVerification?.IsValid ?? true) &&
|
||||
(signatureVerification?.IsValid ?? true) &&
|
||||
(chainVerification?.IsValid ?? true);
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = isValid,
|
||||
VerifiedAt = verifiedAt,
|
||||
ManifestVerification = manifestVerification,
|
||||
ChecksumVerification = checksumVerification,
|
||||
SignatureVerification = signatureVerification,
|
||||
ChainOfCustodyVerification = chainVerification,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
|
||||
string trustRootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(trustRootPath);
|
||||
|
||||
if (!File.Exists(trustRootPath))
|
||||
{
|
||||
_logger.LogWarning("Trust root file not found: {Path}", trustRootPath);
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(trustRootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Try array format first
|
||||
var roots = JsonSerializer.Deserialize<List<ForensicTrustRoot>>(json, SerializerOptions);
|
||||
if (roots is not null)
|
||||
{
|
||||
return roots;
|
||||
}
|
||||
|
||||
// Try single object
|
||||
var singleRoot = JsonSerializer.Deserialize<ForensicTrustRoot>(json, SerializerOptions);
|
||||
if (singleRoot is not null)
|
||||
{
|
||||
return new[] { singleRoot };
|
||||
}
|
||||
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse trust roots from {Path}", trustRootPath);
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveManifestPath(string bundlePath)
|
||||
{
|
||||
if (File.Exists(bundlePath))
|
||||
{
|
||||
// If bundlePath is a file, check if it's the manifest
|
||||
var fileName = Path.GetFileName(bundlePath);
|
||||
if (fileName.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
// Otherwise look for manifest in same directory
|
||||
var dir = Path.GetDirectoryName(bundlePath);
|
||||
if (dir is not null)
|
||||
{
|
||||
var manifestInDir = Path.Combine(dir, "manifest.json");
|
||||
if (File.Exists(manifestInDir))
|
||||
{
|
||||
return manifestInDir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Directory.Exists(bundlePath))
|
||||
{
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
return File.Exists(manifestPath) ? manifestPath : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<ForensicManifestVerification> VerifyManifestAsync(
|
||||
ForensicSnapshotManifest manifest,
|
||||
string manifestPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
var computedDigest = ComputeDigest(manifestBytes, manifest.DigestAlgorithm);
|
||||
|
||||
var isValid = string.Equals(manifest.Digest, computedDigest, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.IsNullOrEmpty(manifest.Digest); // Allow empty digest for unsigned manifests
|
||||
|
||||
return new ForensicManifestVerification
|
||||
{
|
||||
IsValid = isValid,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Version = manifest.Version,
|
||||
Digest = manifest.Digest,
|
||||
DigestAlgorithm = manifest.DigestAlgorithm,
|
||||
ComputedDigest = computedDigest,
|
||||
ArtifactCount = manifest.Artifacts.Count
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ForensicChecksumVerification> VerifyChecksumsAsync(
|
||||
ForensicSnapshotManifest manifest,
|
||||
string bundleDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<ForensicArtifactChecksumFailure>();
|
||||
var verified = 0;
|
||||
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
var artifactPath = Path.Combine(bundleDir, artifact.Path);
|
||||
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = string.Empty,
|
||||
Reason = "Artifact file not found"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileBytes = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
|
||||
var actualDigest = ComputeDigest(fileBytes, artifact.DigestAlgorithm);
|
||||
|
||||
if (!string.Equals(artifact.Digest, actualDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = actualDigest,
|
||||
Reason = "Digest mismatch"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
verified++;
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = string.Empty,
|
||||
Reason = $"IO error: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ForensicChecksumVerification
|
||||
{
|
||||
IsValid = failures.Count == 0,
|
||||
TotalArtifacts = manifest.Artifacts.Count,
|
||||
VerifiedArtifacts = verified,
|
||||
FailedArtifacts = failures
|
||||
};
|
||||
}
|
||||
|
||||
private ForensicSignatureVerification VerifySignature(
|
||||
ForensicSnapshotManifest manifest,
|
||||
IReadOnlyList<ForensicTrustRoot> trustRoots)
|
||||
{
|
||||
if (manifest.Signature is null)
|
||||
{
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = false,
|
||||
SignatureCount = 0,
|
||||
VerifiedSignatures = 0,
|
||||
Signatures = Array.Empty<ForensicSignatureDetail>()
|
||||
};
|
||||
}
|
||||
|
||||
var signatures = new List<ForensicSignatureDetail>();
|
||||
var verifiedCount = 0;
|
||||
|
||||
// Find matching trust root
|
||||
var matchingRoot = trustRoots.FirstOrDefault(tr =>
|
||||
string.Equals(tr.KeyId, manifest.Signature.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingRoot is null)
|
||||
{
|
||||
signatures.Add(new ForensicSignatureDetail
|
||||
{
|
||||
KeyId = manifest.Signature.KeyId ?? "unknown",
|
||||
Algorithm = manifest.Signature.Algorithm,
|
||||
IsValid = false,
|
||||
IsTrusted = false,
|
||||
SignedAt = manifest.Signature.SignedAt,
|
||||
Reason = "No matching trust root found"
|
||||
});
|
||||
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = false,
|
||||
SignatureCount = 1,
|
||||
VerifiedSignatures = 0,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
var isValid = VerifyRsaPssSignature(
|
||||
manifest.Digest,
|
||||
manifest.Signature.Value,
|
||||
matchingRoot.PublicKey);
|
||||
|
||||
// Check time validity
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
if (isValid && timeValid)
|
||||
{
|
||||
verifiedCount++;
|
||||
}
|
||||
|
||||
signatures.Add(new ForensicSignatureDetail
|
||||
{
|
||||
KeyId = manifest.Signature.KeyId ?? "unknown",
|
||||
Algorithm = manifest.Signature.Algorithm,
|
||||
IsValid = isValid,
|
||||
IsTrusted = isValid && timeValid,
|
||||
SignedAt = manifest.Signature.SignedAt,
|
||||
Fingerprint = matchingRoot.Fingerprint,
|
||||
Reason = !isValid ? "Signature verification failed" :
|
||||
!timeValid ? "Key outside validity period" : null
|
||||
});
|
||||
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = verifiedCount > 0,
|
||||
SignatureCount = 1,
|
||||
VerifiedSignatures = verifiedCount,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private ForensicChainOfCustodyVerification VerifyChainOfCustody(
|
||||
IReadOnlyList<ForensicChainOfCustodyEntry> entries,
|
||||
bool strictTimeline)
|
||||
{
|
||||
var entryVerifications = new List<ForensicChainOfCustodyEntryVerification>();
|
||||
var gaps = new List<ForensicTimelineGap>();
|
||||
var timelineValid = true;
|
||||
var signaturesValid = true;
|
||||
|
||||
DateTimeOffset? lastTimestamp = null;
|
||||
var index = 0;
|
||||
|
||||
foreach (var entry in entries.OrderBy(e => e.Timestamp))
|
||||
{
|
||||
// Check timeline progression
|
||||
if (lastTimestamp.HasValue && entry.Timestamp < lastTimestamp.Value)
|
||||
{
|
||||
timelineValid = false;
|
||||
gaps.Add(new ForensicTimelineGap
|
||||
{
|
||||
FromIndex = index - 1,
|
||||
ToIndex = index,
|
||||
FromTimestamp = lastTimestamp.Value,
|
||||
ToTimestamp = entry.Timestamp,
|
||||
GapDuration = lastTimestamp.Value - entry.Timestamp,
|
||||
Description = "Timestamp out of order"
|
||||
});
|
||||
}
|
||||
else if (strictTimeline && lastTimestamp.HasValue)
|
||||
{
|
||||
var gap = entry.Timestamp - lastTimestamp.Value;
|
||||
if (gap > TimeSpan.FromDays(1))
|
||||
{
|
||||
gaps.Add(new ForensicTimelineGap
|
||||
{
|
||||
FromIndex = index - 1,
|
||||
ToIndex = index,
|
||||
FromTimestamp = lastTimestamp.Value,
|
||||
ToTimestamp = entry.Timestamp,
|
||||
GapDuration = gap,
|
||||
Description = $"Large gap of {gap.TotalHours:F1} hours"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature verification (if present)
|
||||
bool? signatureValid = null;
|
||||
if (!string.IsNullOrWhiteSpace(entry.Signature))
|
||||
{
|
||||
// For now, just check signature is present
|
||||
// Full verification would require the signing key
|
||||
signatureValid = true;
|
||||
}
|
||||
|
||||
entryVerifications.Add(new ForensicChainOfCustodyEntryVerification
|
||||
{
|
||||
Index = index,
|
||||
Action = entry.Action,
|
||||
Actor = entry.Actor,
|
||||
Timestamp = entry.Timestamp,
|
||||
SignatureValid = signatureValid,
|
||||
Notes = entry.Notes
|
||||
});
|
||||
|
||||
lastTimestamp = entry.Timestamp;
|
||||
index++;
|
||||
}
|
||||
|
||||
return new ForensicChainOfCustodyVerification
|
||||
{
|
||||
IsValid = timelineValid && signaturesValid && (gaps.Count == 0 || !strictTimeline),
|
||||
EntryCount = entries.Count,
|
||||
TimelineValid = timelineValid,
|
||||
SignaturesValid = signaturesValid,
|
||||
Entries = entryVerifications,
|
||||
Gaps = gaps
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] data, string algorithm)
|
||||
{
|
||||
byte[] hash;
|
||||
switch (algorithm.ToLowerInvariant())
|
||||
{
|
||||
case "sha256":
|
||||
hash = SHA256.HashData(data);
|
||||
break;
|
||||
case "sha384":
|
||||
hash = SHA384.HashData(data);
|
||||
break;
|
||||
case "sha512":
|
||||
hash = SHA512.HashData(data);
|
||||
break;
|
||||
default:
|
||||
hash = SHA256.HashData(data);
|
||||
break;
|
||||
}
|
||||
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool VerifyRsaPssSignature(string digest, string signatureBase64, string publicKeyBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publicKeyBytes = Convert.FromBase64String(publicKeyBase64);
|
||||
var signatureBytes = Convert.FromBase64String(signatureBase64);
|
||||
var digestBytes = Convert.FromHexString(digest);
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
|
||||
|
||||
return rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Cli/StellaOps.Cli/Services/IAttestationReader.cs
Normal file
20
src/Cli/StellaOps.Cli/Services/IAttestationReader.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reader for attestation files.
|
||||
/// Per CLI-FORENSICS-54-002.
|
||||
/// </summary>
|
||||
internal interface IAttestationReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads and parses an attestation file.
|
||||
/// </summary>
|
||||
Task<AttestationShowResult> ReadAttestationAsync(
|
||||
string filePath,
|
||||
AttestationShowOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -83,4 +83,48 @@ internal interface IBackendOperationsClient
|
||||
// CLI-VULN-29-005: Vulnerability export
|
||||
Task<VulnExportResponse> ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-23-006: Policy history and explain
|
||||
Task<PolicyHistoryResponse> GetPolicyHistoryAsync(PolicyHistoryRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyExplainResult> GetPolicyExplainAsync(PolicyExplainRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-27-002: Policy submission/review workflow
|
||||
Task<PolicyVersionBumpResult> BumpPolicyVersionAsync(PolicyVersionBumpRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicySubmitResult> SubmitPolicyForReviewAsync(PolicySubmitRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyReviewCommentResult> AddPolicyReviewCommentAsync(PolicyReviewCommentRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyApproveResult> ApprovePolicyReviewAsync(PolicyApproveRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyRejectResult> RejectPolicyReviewAsync(PolicyRejectRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyReviewSummary?> GetPolicyReviewStatusAsync(PolicyReviewStatusRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-27-004: Policy lifecycle (publish/promote/rollback/sign)
|
||||
Task<PolicyPublishResult> PublishPolicyAsync(PolicyPublishRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyPromoteResult> PromotePolicyAsync(PolicyPromoteRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyRollbackResult> RollbackPolicyAsync(PolicyRollbackRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicySignResult> SignPolicyAsync(PolicySignRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyVerifySignatureResult> VerifyPolicySignatureAsync(PolicyVerifySignatureRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-66-001: Risk profile list
|
||||
Task<RiskProfileListResponse> ListRiskProfilesAsync(RiskProfileListRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-66-002: Risk simulate
|
||||
Task<RiskSimulateResult> SimulateRiskAsync(RiskSimulateRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-67-001: Risk results
|
||||
Task<RiskResultsResponse> GetRiskResultsAsync(RiskResultsRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-68-001: Risk bundle verify
|
||||
Task<RiskBundleVerifyResult> VerifyRiskBundleAsync(RiskBundleVerifyRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SIG-26-001: Reachability operations
|
||||
Task<ReachabilityUploadCallGraphResult> UploadCallGraphAsync(ReachabilityUploadCallGraphRequest request, Stream callGraphStream, CancellationToken cancellationToken);
|
||||
Task<ReachabilityListResponse> ListReachabilityAnalysesAsync(ReachabilityListRequest request, CancellationToken cancellationToken);
|
||||
Task<ReachabilityExplainResult> ExplainReachabilityAsync(ReachabilityExplainRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SDK-63-001: API spec download
|
||||
Task<ApiSpecListResponse> ListApiSpecsAsync(string? tenant, CancellationToken cancellationToken);
|
||||
Task<ApiSpecDownloadResult> DownloadApiSpecAsync(ApiSpecDownloadRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SDK-64-001: SDK update
|
||||
Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken);
|
||||
Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,32 @@ 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);
|
||||
}
|
||||
|
||||
46
src/Cli/StellaOps.Cli/Services/IDeterminismHarness.cs
Normal file
46
src/Cli/StellaOps.Cli/Services/IDeterminismHarness.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism harness for running scanner with frozen conditions.
|
||||
/// Per CLI-DETER-70-003/004.
|
||||
/// </summary>
|
||||
internal interface IDeterminismHarness
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the determinism harness with the specified configuration.
|
||||
/// </summary>
|
||||
Task<DeterminismRunResult> RunAsync(
|
||||
DeterminismRunRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a determinism manifest against thresholds.
|
||||
/// </summary>
|
||||
DeterminismVerificationResult VerifyManifest(
|
||||
DeterminismManifest manifest,
|
||||
double imageThreshold,
|
||||
double overallThreshold);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a determinism report from multiple manifest files.
|
||||
/// Per CLI-DETER-70-004.
|
||||
/// </summary>
|
||||
Task<DeterminismReportResult> GenerateReportAsync(
|
||||
DeterminismReportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a determinism manifest.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismVerificationResult
|
||||
{
|
||||
public bool Passed { get; init; }
|
||||
public double OverallScore { get; init; }
|
||||
public string[] FailedImages { get; init; } = System.Array.Empty<string>();
|
||||
public string[] Warnings { get; init; } = System.Array.Empty<string>();
|
||||
}
|
||||
64
src/Cli/StellaOps.Cli/Services/IExceptionClient.cs
Normal file
64
src/Cli/StellaOps.Cli/Services/IExceptionClient.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for exception governance API operations.
|
||||
/// Per CLI-EXC-25-001.
|
||||
/// </summary>
|
||||
internal interface IExceptionClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists exceptions matching the query.
|
||||
/// </summary>
|
||||
Task<ExceptionListResponse> ListAsync(
|
||||
ExceptionListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an exception by ID.
|
||||
/// </summary>
|
||||
Task<ExceptionInstance?> GetAsync(
|
||||
string exceptionId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new exception.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> CreateAsync(
|
||||
ExceptionCreateRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Promotes an exception to the next lifecycle stage.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> PromoteAsync(
|
||||
ExceptionPromoteRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an exception.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> RevokeAsync(
|
||||
ExceptionRevokeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Imports exceptions from NDJSON stream.
|
||||
/// </summary>
|
||||
Task<ExceptionImportResult> ImportAsync(
|
||||
ExceptionImportRequest request,
|
||||
Stream ndjsonStream,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Exports exceptions to a stream.
|
||||
/// </summary>
|
||||
Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
|
||||
ExceptionExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
43
src/Cli/StellaOps.Cli/Services/IForensicSnapshotClient.cs
Normal file
43
src/Cli/StellaOps.Cli/Services/IForensicSnapshotClient.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for forensic snapshot and evidence locker APIs.
|
||||
/// Per CLI-FORENSICS-53-001.
|
||||
/// </summary>
|
||||
internal interface IForensicSnapshotClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new forensic snapshot.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotDocument> CreateSnapshotAsync(
|
||||
string tenant,
|
||||
ForensicSnapshotCreateRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists forensic snapshots matching the query.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
|
||||
ForensicSnapshotListQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a forensic snapshot by ID.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotDocument?> GetSnapshotAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the manifest for a forensic snapshot.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
27
src/Cli/StellaOps.Cli/Services/IForensicVerifier.cs
Normal file
27
src/Cli/StellaOps.Cli/Services/IForensicVerifier.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier for forensic bundles.
|
||||
/// Per CLI-FORENSICS-54-001.
|
||||
/// </summary>
|
||||
internal interface IForensicVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a forensic bundle at the specified path.
|
||||
/// </summary>
|
||||
Task<ForensicVerificationResult> VerifyBundleAsync(
|
||||
string bundlePath,
|
||||
ForensicVerificationOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Loads trust roots from a file path.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
|
||||
string trustRootPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
70
src/Cli/StellaOps.Cli/Services/INotifyClient.cs
Normal file
70
src/Cli/StellaOps.Cli/Services/INotifyClient.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Notify API operations.
|
||||
/// Per CLI-PARITY-41-002.
|
||||
/// </summary>
|
||||
internal interface INotifyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists notification channels.
|
||||
/// </summary>
|
||||
Task<NotifyChannelListResponse> ListChannelsAsync(
|
||||
NotifyChannelListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a notification channel by ID.
|
||||
/// </summary>
|
||||
Task<NotifyChannelDetail?> GetChannelAsync(
|
||||
string channelId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Tests a notification channel.
|
||||
/// </summary>
|
||||
Task<NotifyChannelTestResult> TestChannelAsync(
|
||||
NotifyChannelTestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists notification rules.
|
||||
/// </summary>
|
||||
Task<NotifyRuleListResponse> ListRulesAsync(
|
||||
NotifyRuleListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists notification deliveries.
|
||||
/// </summary>
|
||||
Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
|
||||
NotifyDeliveryListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a delivery by ID.
|
||||
/// </summary>
|
||||
Task<NotifyDeliveryDetail?> GetDeliveryAsync(
|
||||
string deliveryId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retries a failed delivery.
|
||||
/// </summary>
|
||||
Task<NotifyRetryResult> RetryDeliveryAsync(
|
||||
NotifyRetryRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification.
|
||||
/// </summary>
|
||||
Task<NotifySendResult> SendAsync(
|
||||
NotifySendRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
59
src/Cli/StellaOps.Cli/Services/IObservabilityClient.cs
Normal file
59
src/Cli/StellaOps.Cli/Services/IObservabilityClient.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for observability API operations.
|
||||
/// Per CLI-OBS-51-001/52-001.
|
||||
/// </summary>
|
||||
internal interface IObservabilityClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets platform health summary for obs top command.
|
||||
/// </summary>
|
||||
Task<ObsTopResult> GetHealthSummaryAsync(
|
||||
ObsTopRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a distributed trace by ID.
|
||||
/// Per CLI-OBS-52-001.
|
||||
/// </summary>
|
||||
Task<ObsTraceResult> GetTraceAsync(
|
||||
ObsTraceRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets logs within a time window.
|
||||
/// Per CLI-OBS-52-001.
|
||||
/// </summary>
|
||||
Task<ObsLogsResult> GetLogsAsync(
|
||||
ObsLogsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current incident mode status.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> GetIncidentModeStatusAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Enables incident mode.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> EnableIncidentModeAsync(
|
||||
IncidentModeEnableRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Disables incident mode.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> DisableIncidentModeAsync(
|
||||
IncidentModeDisableRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
102
src/Cli/StellaOps.Cli/Services/IOrchestratorClient.cs
Normal file
102
src/Cli/StellaOps.Cli/Services/IOrchestratorClient.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for orchestrator API operations.
|
||||
/// Per CLI-ORCH-32-001.
|
||||
/// </summary>
|
||||
internal interface IOrchestratorClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists sources matching the query.
|
||||
/// </summary>
|
||||
Task<SourceListResponse> ListSourcesAsync(
|
||||
SourceListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a source by ID.
|
||||
/// </summary>
|
||||
Task<OrchestratorSource?> GetSourceAsync(
|
||||
string sourceId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a source.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> PauseSourceAsync(
|
||||
SourcePauseRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused source.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> ResumeSourceAsync(
|
||||
SourceResumeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Tests a source connection.
|
||||
/// </summary>
|
||||
Task<SourceTestResult> TestSourceAsync(
|
||||
SourceTestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-ORCH-34-001: Backfill operations
|
||||
|
||||
/// <summary>
|
||||
/// Starts a backfill operation for a source.
|
||||
/// </summary>
|
||||
Task<BackfillResult> StartBackfillAsync(
|
||||
BackfillRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a backfill operation.
|
||||
/// </summary>
|
||||
Task<BackfillResult?> GetBackfillAsync(
|
||||
string backfillId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists backfill operations.
|
||||
/// </summary>
|
||||
Task<BackfillListResponse> ListBackfillsAsync(
|
||||
BackfillListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running backfill operation.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> CancelBackfillAsync(
|
||||
BackfillCancelRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-ORCH-34-001: Quota management
|
||||
|
||||
/// <summary>
|
||||
/// Gets quotas for a tenant/source.
|
||||
/// </summary>
|
||||
Task<QuotaGetResponse> GetQuotasAsync(
|
||||
QuotaGetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a quota limit.
|
||||
/// </summary>
|
||||
Task<QuotaOperationResult> SetQuotaAsync(
|
||||
QuotaSetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resets a quota's usage counter.
|
||||
/// </summary>
|
||||
Task<QuotaOperationResult> ResetQuotaAsync(
|
||||
QuotaResetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
124
src/Cli/StellaOps.Cli/Services/IPackClient.cs
Normal file
124
src/Cli/StellaOps.Cli/Services/IPackClient.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Task Pack registry and runner API operations.
|
||||
/// Per CLI-PACKS-42-001.
|
||||
/// </summary>
|
||||
internal interface IPackClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Plans a pack execution without running it.
|
||||
/// Returns the execution graph, validation errors, and approval requirements.
|
||||
/// </summary>
|
||||
Task<PackPlanResult> PlanAsync(
|
||||
PackPlanRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a pack with the specified inputs.
|
||||
/// Can optionally wait for completion.
|
||||
/// </summary>
|
||||
Task<PackRunResult> RunAsync(
|
||||
PackRunRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a pack run.
|
||||
/// </summary>
|
||||
Task<PackRunStatus?> GetRunStatusAsync(
|
||||
string runId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a pack to the registry.
|
||||
/// </summary>
|
||||
Task<PackPushResult> PushAsync(
|
||||
PackPushRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pulls a pack from the registry.
|
||||
/// </summary>
|
||||
Task<PackPullResult> PullAsync(
|
||||
PackPullRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a pack's signature, digest, and schema.
|
||||
/// </summary>
|
||||
Task<PackVerifyResult> VerifyAsync(
|
||||
PackVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets pack info from the registry.
|
||||
/// </summary>
|
||||
Task<TaskPackInfo?> GetPackInfoAsync(
|
||||
string packId,
|
||||
string? version,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-PACKS-43-001: Advanced pack features
|
||||
|
||||
/// <summary>
|
||||
/// Lists pack runs with optional filters.
|
||||
/// </summary>
|
||||
Task<PackRunListResponse> ListRunsAsync(
|
||||
PackRunListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running pack.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> CancelRunAsync(
|
||||
PackCancelRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a pack run waiting for approval.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> PauseForApprovalAsync(
|
||||
PackApprovalPauseRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused pack run with approval decision.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> ResumeWithApprovalAsync(
|
||||
PackApprovalResumeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Injects a secret into a pack run.
|
||||
/// </summary>
|
||||
Task<PackSecretInjectResult> InjectSecretAsync(
|
||||
PackSecretInjectRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets logs for a pack run.
|
||||
/// </summary>
|
||||
Task<PackLogsResult> GetLogsAsync(
|
||||
PackLogsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an artifact from a pack run.
|
||||
/// </summary>
|
||||
Task<PackArtifactDownloadResult> DownloadArtifactAsync(
|
||||
PackArtifactDownloadRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Manages the offline pack cache.
|
||||
/// </summary>
|
||||
Task<PackCacheResult> ManageCacheAsync(
|
||||
PackCacheRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
42
src/Cli/StellaOps.Cli/Services/IPromotionAssembler.cs
Normal file
42
src/Cli/StellaOps.Cli/Services/IPromotionAssembler.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Assembler for promotion attestations.
|
||||
/// Per CLI-PROMO-70-001/002.
|
||||
/// </summary>
|
||||
internal interface IPromotionAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembles a promotion attestation from the provided request.
|
||||
/// </summary>
|
||||
Task<PromotionAssembleResult> AssembleAsync(
|
||||
PromotionAssembleRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves image digest from registry.
|
||||
/// </summary>
|
||||
Task<string?> ResolveImageDigestAsync(
|
||||
string imageRef,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Signs a promotion predicate and produces a DSSE bundle.
|
||||
/// Per CLI-PROMO-70-002.
|
||||
/// </summary>
|
||||
Task<PromotionAttestResult> AttestAsync(
|
||||
PromotionAttestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a promotion attestation bundle offline.
|
||||
/// Per CLI-PROMO-70-002.
|
||||
/// </summary>
|
||||
Task<PromotionVerifyResult> VerifyAsync(
|
||||
PromotionVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
53
src/Cli/StellaOps.Cli/Services/ISbomClient.cs
Normal file
53
src/Cli/StellaOps.Cli/Services/ISbomClient.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for SBOM API operations.
|
||||
/// Per CLI-PARITY-41-001.
|
||||
/// </summary>
|
||||
internal interface ISbomClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists SBOMs matching the query.
|
||||
/// </summary>
|
||||
Task<SbomListResponse> ListAsync(
|
||||
SbomListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an SBOM by ID with optional explain information.
|
||||
/// </summary>
|
||||
Task<SbomDetailResponse?> GetAsync(
|
||||
string sbomId,
|
||||
string? tenant,
|
||||
bool includeComponents,
|
||||
bool includeVulnerabilities,
|
||||
bool includeLicenses,
|
||||
bool explain,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two SBOMs.
|
||||
/// </summary>
|
||||
Task<SbomCompareResponse?> CompareAsync(
|
||||
SbomCompareRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an SBOM in the specified format.
|
||||
/// </summary>
|
||||
Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
|
||||
SbomExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parity matrix showing CLI command coverage.
|
||||
/// </summary>
|
||||
Task<ParityMatrixResponse> GetParityMatrixAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
78
src/Cli/StellaOps.Cli/Services/ISbomerClient.cs
Normal file
78
src/Cli/StellaOps.Cli/Services/ISbomerClient.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Sbomer API operations (layer fragments and composition).
|
||||
/// Per CLI-SBOM-60-001.
|
||||
/// </summary>
|
||||
internal interface ISbomerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists layer fragments for a scan.
|
||||
/// </summary>
|
||||
Task<SbomerLayerListResponse> ListLayersAsync(
|
||||
SbomerLayerListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets layer fragment details.
|
||||
/// </summary>
|
||||
Task<SbomerLayerDetail?> GetLayerAsync(
|
||||
SbomerLayerShowRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a layer fragment DSSE signature.
|
||||
/// </summary>
|
||||
Task<SbomerLayerVerifyResult> VerifyLayerAsync(
|
||||
SbomerLayerVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the composition manifest for a scan.
|
||||
/// </summary>
|
||||
Task<CompositionManifest?> GetCompositionManifestAsync(
|
||||
SbomerCompositionShowRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Composes SBOM from layer fragments.
|
||||
/// </summary>
|
||||
Task<SbomerComposeResult> ComposeAsync(
|
||||
SbomerComposeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies composition against manifest and fragments.
|
||||
/// </summary>
|
||||
Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
|
||||
SbomerCompositionVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets Merkle diagnostics for a composition.
|
||||
/// </summary>
|
||||
Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
|
||||
string scanId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection methods
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes drift between current SBOM and baseline.
|
||||
/// </summary>
|
||||
Task<SbomerDriftResult> AnalyzeDriftAsync(
|
||||
SbomerDriftRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies SBOM with local recomposition and drift detection.
|
||||
/// </summary>
|
||||
Task<SbomerDriftVerifyResult> VerifyDriftAsync(
|
||||
SbomerDriftVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
34
src/Cli/StellaOps.Cli/Services/IVexObservationsClient.cs
Normal file
34
src/Cli/StellaOps.Cli/Services/IVexObservationsClient.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for VEX observation queries.
|
||||
/// Per CLI-LNM-22-002.
|
||||
/// </summary>
|
||||
internal interface IVexObservationsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets VEX observations matching the query.
|
||||
/// </summary>
|
||||
Task<VexObservationResponse> GetObservationsAsync(
|
||||
VexObservationQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a VEX linkset for a vulnerability ID.
|
||||
/// </summary>
|
||||
Task<VexLinksetResponse> GetLinksetAsync(
|
||||
VexLinksetQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single VEX observation by ID.
|
||||
/// </summary>
|
||||
Task<VexObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
503
src/Cli/StellaOps.Cli/Services/Models/AdvisoryLinksetModels.cs
Normal file
503
src/Cli/StellaOps.Cli/Services/Models/AdvisoryLinksetModels.cs
Normal file
@@ -0,0 +1,503 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-LNM-22-001: Advisory linkset models for obs get/linkset show/export commands
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory linkset query with additional filters for CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
internal sealed record AdvisoryLinksetQuery(
|
||||
string Tenant,
|
||||
IReadOnlyList<string> ObservationIds,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> Purls,
|
||||
IReadOnlyList<string> Cpes,
|
||||
IReadOnlyList<string> Sources,
|
||||
string? Severity,
|
||||
bool? KevOnly,
|
||||
bool? HasFix,
|
||||
int? Limit,
|
||||
string? Cursor)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates from a basic AdvisoryObservationsQuery.
|
||||
/// </summary>
|
||||
public static AdvisoryLinksetQuery FromBasicQuery(AdvisoryObservationsQuery query)
|
||||
{
|
||||
return new AdvisoryLinksetQuery(
|
||||
query.Tenant,
|
||||
query.ObservationIds,
|
||||
query.Aliases,
|
||||
query.Purls,
|
||||
query.Cpes,
|
||||
Sources: Array.Empty<string>(),
|
||||
Severity: null,
|
||||
KevOnly: null,
|
||||
HasFix: null,
|
||||
query.Limit,
|
||||
query.Cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory linkset response with conflict information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<AdvisoryLinksetObservation> Observations { get; init; } =
|
||||
Array.Empty<AdvisoryLinksetObservation>();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryLinksetAggregate Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<AdvisoryLinksetConflict> Conflicts { get; init; } =
|
||||
Array.Empty<AdvisoryLinksetConflict>();
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public int? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory observation with severity, KEV, and fix information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetObservation
|
||||
{
|
||||
[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("severity")]
|
||||
public AdvisoryLinksetSeverity? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("kev")]
|
||||
public AdvisoryLinksetKev? Kev { get; init; }
|
||||
|
||||
[JsonPropertyName("fix")]
|
||||
public AdvisoryLinksetFix? Fix { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory severity information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetSeverity
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cvssV3")]
|
||||
public AdvisoryLinksetCvss? CvssV3 { get; init; }
|
||||
|
||||
[JsonPropertyName("cvssV2")]
|
||||
public AdvisoryLinksetCvss? CvssV2 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score details.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetCvss
|
||||
{
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("vector")]
|
||||
public string? Vector { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KEV (Known Exploited Vulnerabilities) information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetKev
|
||||
{
|
||||
[JsonPropertyName("listed")]
|
||||
public bool Listed { get; init; }
|
||||
|
||||
[JsonPropertyName("addedDate")]
|
||||
public DateTimeOffset? AddedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("dueDate")]
|
||||
public DateTimeOffset? DueDate { get; init; }
|
||||
|
||||
[JsonPropertyName("knownRansomwareCampaignUse")]
|
||||
public bool? KnownRansomwareCampaignUse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix availability information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetFix
|
||||
{
|
||||
[JsonPropertyName("available")]
|
||||
public bool Available { get; init; }
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("advisoryLinks")]
|
||||
public IReadOnlyList<string> AdvisoryLinks { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated linkset with conflict summary.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetAggregate
|
||||
{
|
||||
[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>();
|
||||
|
||||
[JsonPropertyName("sourceCoverage")]
|
||||
public AdvisorySourceCoverageSummary? SourceCoverage { get; init; }
|
||||
|
||||
[JsonPropertyName("conflictSummary")]
|
||||
public AdvisoryConflictSummary? ConflictSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source coverage summary across observations.
|
||||
/// </summary>
|
||||
internal sealed class AdvisorySourceCoverageSummary
|
||||
{
|
||||
[JsonPropertyName("totalSources")]
|
||||
public int TotalSources { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("coveragePercent")]
|
||||
public double CoveragePercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict summary for the linkset.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryConflictSummary
|
||||
{
|
||||
[JsonPropertyName("hasConflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("totalConflicts")]
|
||||
public int TotalConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("severityConflicts")]
|
||||
public int SeverityConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("kevConflicts")]
|
||||
public int KevConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("fixConflicts")]
|
||||
public int FixConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed conflict information between observations.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetConflict
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string Field { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<AdvisoryConflictSource> Sources { get; init; } =
|
||||
Array.Empty<AdvisoryConflictSource>();
|
||||
|
||||
[JsonPropertyName("resolution")]
|
||||
public string? Resolution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source contribution to a conflict.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryConflictSource
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV (Open Source Vulnerability) format output.
|
||||
/// Per CLI-LNM-22-001, supports JSON/OSV output format.
|
||||
/// </summary>
|
||||
internal sealed class OsvVulnerability
|
||||
{
|
||||
[JsonPropertyName("schema_version")]
|
||||
public string SchemaVersion { get; init; } = "1.6.0";
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("modified")]
|
||||
public string Modified { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("published")]
|
||||
public string? Published { get; init; }
|
||||
|
||||
[JsonPropertyName("withdrawn")]
|
||||
public string? Withdrawn { get; init; }
|
||||
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("related")]
|
||||
public IReadOnlyList<string> Related { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
|
||||
|
||||
[JsonPropertyName("affected")]
|
||||
public IReadOnlyList<OsvAffected> Affected { get; init; } = Array.Empty<OsvAffected>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<OsvReference> References { get; init; } = Array.Empty<OsvReference>();
|
||||
|
||||
[JsonPropertyName("credits")]
|
||||
public IReadOnlyList<OsvCredit> Credits { get; init; } = Array.Empty<OsvCredit>();
|
||||
|
||||
[JsonPropertyName("database_specific")]
|
||||
public OsvDatabaseSpecific? DatabaseSpecific { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV severity entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvSeverity
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public string Score { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV affected package entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvAffected
|
||||
{
|
||||
[JsonPropertyName("package")]
|
||||
public OsvPackage Package { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
|
||||
|
||||
[JsonPropertyName("ranges")]
|
||||
public IReadOnlyList<OsvRange> Ranges { get; init; } = Array.Empty<OsvRange>();
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("ecosystem_specific")]
|
||||
public Dictionary<string, object>? EcosystemSpecific { get; init; }
|
||||
|
||||
[JsonPropertyName("database_specific")]
|
||||
public Dictionary<string, object>? DatabaseSpecific { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV package identifier.
|
||||
/// </summary>
|
||||
internal sealed class OsvPackage
|
||||
{
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public string Ecosystem { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV version range.
|
||||
/// </summary>
|
||||
internal sealed class OsvRange
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("repo")]
|
||||
public string? Repo { get; init; }
|
||||
|
||||
[JsonPropertyName("events")]
|
||||
public IReadOnlyList<OsvEvent> Events { get; init; } = Array.Empty<OsvEvent>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV range event.
|
||||
/// </summary>
|
||||
internal sealed class OsvEvent
|
||||
{
|
||||
[JsonPropertyName("introduced")]
|
||||
public string? Introduced { get; init; }
|
||||
|
||||
[JsonPropertyName("fixed")]
|
||||
public string? Fixed { get; init; }
|
||||
|
||||
[JsonPropertyName("last_affected")]
|
||||
public string? LastAffected { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public string? Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV reference entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvReference
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV credit entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvCredit
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("contact")]
|
||||
public IReadOnlyList<string> Contact { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV database-specific metadata.
|
||||
/// </summary>
|
||||
internal sealed class OsvDatabaseSpecific
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("kev")]
|
||||
public OsvKevInfo? Kev { get; init; }
|
||||
|
||||
[JsonPropertyName("stellaops")]
|
||||
public OsvStellaOpsInfo? StellaOps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV KEV information.
|
||||
/// </summary>
|
||||
internal sealed class OsvKevInfo
|
||||
{
|
||||
[JsonPropertyName("listed")]
|
||||
public bool Listed { get; init; }
|
||||
|
||||
[JsonPropertyName("added_date")]
|
||||
public string? AddedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("due_date")]
|
||||
public string? DueDate { get; init; }
|
||||
|
||||
[JsonPropertyName("ransomware")]
|
||||
public bool? Ransomware { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps-specific OSV metadata.
|
||||
/// </summary>
|
||||
internal sealed class OsvStellaOpsInfo
|
||||
{
|
||||
[JsonPropertyName("observation_ids")]
|
||||
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("has_conflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export format enumeration for advisory exports.
|
||||
/// </summary>
|
||||
internal enum AdvisoryExportFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON format (native StellaOps).
|
||||
/// </summary>
|
||||
Json,
|
||||
|
||||
/// <summary>
|
||||
/// OSV (Open Source Vulnerability) format.
|
||||
/// </summary>
|
||||
Osv,
|
||||
|
||||
/// <summary>
|
||||
/// NDJSON (newline-delimited JSON) format.
|
||||
/// </summary>
|
||||
Ndjson,
|
||||
|
||||
/// <summary>
|
||||
/// CSV format for spreadsheet imports.
|
||||
/// </summary>
|
||||
Csv
|
||||
}
|
||||
223
src/Cli/StellaOps.Cli/Services/Models/ApiSpecModels.cs
Normal file
223
src/Cli/StellaOps.Cli/Services/Models/ApiSpecModels.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request for downloading API specification.
|
||||
/// CLI-SDK-63-001: Exposes stella api spec download command.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecDownloadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant context for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public required string OutputPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Spec format to download (openapi-json, openapi-yaml).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "openapi-json";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to overwrite existing files.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool Overwrite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional service filter (e.g., "concelier", "scanner", "policy").
|
||||
/// When null, downloads the aggregate/combined spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service")]
|
||||
public string? Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected ETag for conditional download (If-None-Match).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? ExpectedETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected checksum for verification after download.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? ExpectedChecksum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checksum algorithm (sha256, sha384, sha512).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ChecksumAlgorithm { get; init; } = "sha256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of API spec download operation.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecDownloadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path where the spec was downloaded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the downloaded spec in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the result was served from cache (304 Not Modified).
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromCache")]
|
||||
public bool FromCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ETag of the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("etag")]
|
||||
public string? ETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed checksum of the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksum")]
|
||||
public string? Checksum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checksum algorithm used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksumAlgorithm")]
|
||||
public string? ChecksumAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether checksum verification passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksumVerified")]
|
||||
public bool? ChecksumVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// API version extracted from the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("apiVersion")]
|
||||
public string? ApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of when the spec was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset? GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errorCode")]
|
||||
public string? ErrorCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about available API specifications.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Service name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service")]
|
||||
public required string Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI spec version (e.g., "3.1.0").
|
||||
/// </summary>
|
||||
[JsonPropertyName("openApiVersion")]
|
||||
public string? OpenApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available formats.
|
||||
/// </summary>
|
||||
[JsonPropertyName("formats")]
|
||||
public IReadOnlyList<string> Formats { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// ETag for the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("etag")]
|
||||
public string? ETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 checksum of the JSON format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last modified timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastModified")]
|
||||
public DateTimeOffset? LastModified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing available API specifications.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available API specifications.
|
||||
/// </summary>
|
||||
[JsonPropertyName("specs")]
|
||||
public IReadOnlyList<ApiSpecInfo> Specs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate spec info (combined all services).
|
||||
/// </summary>
|
||||
[JsonPropertyName("aggregate")]
|
||||
public ApiSpecInfo? Aggregate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
230
src/Cli/StellaOps.Cli/Services/Models/AttestationModels.cs
Normal file
230
src/Cli/StellaOps.Cli/Services/Models/AttestationModels.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-54-002: Attestation models for forensic attest show command
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope from attestation file.
|
||||
/// </summary>
|
||||
internal sealed class AttestationEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<AttestationSignature> Signatures { get; init; } = Array.Empty<AttestationSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature in attestation envelope.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement from attestation payload.
|
||||
/// </summary>
|
||||
internal sealed class InTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public IReadOnlyList<InTotoSubject> Subject { get; init; } = Array.Empty<InTotoSubject>();
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public object? Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in in-toto statement.
|
||||
/// </summary>
|
||||
internal sealed class InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation show operation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationShowResult
|
||||
{
|
||||
[JsonPropertyName("filePath")]
|
||||
public string FilePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("statementType")]
|
||||
public string StatementType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subjects")]
|
||||
public IReadOnlyList<AttestationSubjectInfo> Subjects { get; init; } = Array.Empty<AttestationSubjectInfo>();
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<AttestationSignatureInfo> Signatures { get; init; } = Array.Empty<AttestationSignatureInfo>();
|
||||
|
||||
[JsonPropertyName("predicateSummary")]
|
||||
public AttestationPredicateSummary? PredicateSummary { get; init; }
|
||||
|
||||
[JsonPropertyName("verificationResult")]
|
||||
public AttestationVerificationResult? VerificationResult { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject information for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSubjectInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestValue")]
|
||||
public string DigestValue { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool? IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("isTrusted")]
|
||||
public bool? IsTrusted { get; init; }
|
||||
|
||||
[JsonPropertyName("signerInfo")]
|
||||
public AttestationSignerInfo? SignerInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signer information extracted from signature or certificate.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignerInfo
|
||||
{
|
||||
[JsonPropertyName("commonName")]
|
||||
public string? CommonName { get; init; }
|
||||
|
||||
[JsonPropertyName("organization")]
|
||||
public string? Organization { get; init; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the predicate for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationPredicateSummary
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("buildType")]
|
||||
public string? BuildType { get; init; }
|
||||
|
||||
[JsonPropertyName("builder")]
|
||||
public string? Builder { get; init; }
|
||||
|
||||
[JsonPropertyName("invocationId")]
|
||||
public string? InvocationId { get; init; }
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<AttestationMaterial>? Materials { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material in attestation predicate.
|
||||
/// </summary>
|
||||
internal sealed class AttestationMaterial
|
||||
{
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string>? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification result for attestation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationVerificationResult
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureCount")]
|
||||
public int SignatureCount { get; init; }
|
||||
|
||||
[JsonPropertyName("validSignatures")]
|
||||
public int ValidSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("trustedSignatures")]
|
||||
public int TrustedSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation show operation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationShowOptions
|
||||
{
|
||||
public bool VerifySignatures { get; init; } = true;
|
||||
public string? TrustRootPath { get; init; }
|
||||
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
420
src/Cli/StellaOps.Cli/Services/Models/DeterminismModels.cs
Normal file
420
src/Cli/StellaOps.Cli/Services/Models/DeterminismModels.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-DETER-70-003: Determinism score models (docs/modules/scanner/determinism-score.md)
|
||||
|
||||
/// <summary>
|
||||
/// Request for running determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Image digests to test.
|
||||
/// </summary>
|
||||
[JsonPropertyName("images")]
|
||||
public IReadOnlyList<string> Images { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Scanner container image reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanner")]
|
||||
public string Scanner { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle path or SHA.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyBundle")]
|
||||
public string? PolicyBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feeds bundle path or SHA.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedsBundle")]
|
||||
public string? FeedsBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of runs per image (default 10).
|
||||
/// </summary>
|
||||
[JsonPropertyName("runs")]
|
||||
public int Runs { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed clock timestamp for deterministic execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixedClock")]
|
||||
public DateTimeOffset? FixedClock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// RNG seed for deterministic execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rngSeed")]
|
||||
public int RngSeed { get; init; } = 1337;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrency (default 1 for determinism).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxConcurrency")]
|
||||
public int MaxConcurrency { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Memory limit for container (default 2G).
|
||||
/// </summary>
|
||||
[JsonPropertyName("memoryLimit")]
|
||||
public string MemoryLimit { get; init; } = "2G";
|
||||
|
||||
/// <summary>
|
||||
/// CPU set for container (default 0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("cpuSet")]
|
||||
public string CpuSet { get; init; } = "0";
|
||||
|
||||
/// <summary>
|
||||
/// Platform (default linux/amd64).
|
||||
/// </summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = "linux/amd64";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum threshold for individual image scores.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageThreshold")]
|
||||
public double ImageThreshold { get; init; } = 0.90;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum threshold for overall score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overallThreshold")]
|
||||
public double OverallThreshold { get; init; } = 0.95;
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for determinism.json and run artifacts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputDir")]
|
||||
public string? OutputDir { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release version string for the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("release")]
|
||||
public string? Release { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinism score manifest (determinism.json schema per SCAN-DETER-186-010).
|
||||
/// </summary>
|
||||
internal sealed class DeterminismManifest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1";
|
||||
|
||||
[JsonPropertyName("release")]
|
||||
public string Release { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = "linux/amd64";
|
||||
|
||||
[JsonPropertyName("policy_sha")]
|
||||
public string PolicySha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("feeds_sha")]
|
||||
public string FeedsSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanner_sha")]
|
||||
public string ScannerSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public IReadOnlyList<DeterminismImageResult> Images { get; init; } = Array.Empty<DeterminismImageResult>();
|
||||
|
||||
[JsonPropertyName("overall_score")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("thresholds")]
|
||||
public DeterminismThresholds Thresholds { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("generated_at")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("execution")]
|
||||
public DeterminismExecutionInfo? Execution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-image determinism result.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismImageResult
|
||||
{
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("runs")]
|
||||
public int Runs { get; init; }
|
||||
|
||||
[JsonPropertyName("identical")]
|
||||
public int Identical { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_hashes")]
|
||||
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("non_deterministic")]
|
||||
public IReadOnlyList<string> NonDeterministic { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("run_details")]
|
||||
public IReadOnlyList<DeterminismRunDetail>? RunDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a single determinism run.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunDetail
|
||||
{
|
||||
[JsonPropertyName("run_number")]
|
||||
public int RunNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("identical")]
|
||||
public bool Identical { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_hashes")]
|
||||
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("exit_code")]
|
||||
public int ExitCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thresholds for determinism scoring.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismThresholds
|
||||
{
|
||||
[JsonPropertyName("image_min")]
|
||||
public double ImageMin { get; init; } = 0.90;
|
||||
|
||||
[JsonPropertyName("overall_min")]
|
||||
public double OverallMin { get; init; } = 0.95;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution information for determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismExecutionInfo
|
||||
{
|
||||
[JsonPropertyName("fixed_clock")]
|
||||
public DateTimeOffset? FixedClock { get; init; }
|
||||
|
||||
[JsonPropertyName("rng_seed")]
|
||||
public int RngSeed { get; init; }
|
||||
|
||||
[JsonPropertyName("max_concurrency")]
|
||||
public int MaxConcurrency { get; init; }
|
||||
|
||||
[JsonPropertyName("memory_limit")]
|
||||
public string MemoryLimit { get; init; } = "2G";
|
||||
|
||||
[JsonPropertyName("cpu_set")]
|
||||
public string CpuSet { get; init; } = "0";
|
||||
|
||||
[JsonPropertyName("network_mode")]
|
||||
public string NetworkMode { get; init; } = "none";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of running the determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public DeterminismManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("passedThreshold")]
|
||||
public bool PassedThreshold { get; init; }
|
||||
|
||||
[JsonPropertyName("failedImages")]
|
||||
public IReadOnlyList<string> FailedImages { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
// CLI-DETER-70-004: Determinism report models
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a determinism report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Paths to determinism.json files to include in report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("manifestPaths")]
|
||||
public IReadOnlyList<string> ManifestPaths { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Output format (markdown, json, csv).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "markdown";
|
||||
|
||||
/// <summary>
|
||||
/// Output path for the report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include detailed per-run information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeDetails")]
|
||||
public bool IncludeDetails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Title for the report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated determinism report across multiple manifests.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReport
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "Determinism Score Report";
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public DeterminismReportSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("releases")]
|
||||
public IReadOnlyList<DeterminismReleaseEntry> Releases { get; init; } = Array.Empty<DeterminismReleaseEntry>();
|
||||
|
||||
[JsonPropertyName("imageMatrix")]
|
||||
public IReadOnlyList<DeterminismImageMatrixEntry> ImageMatrix { get; init; } = Array.Empty<DeterminismImageMatrixEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportSummary
|
||||
{
|
||||
[JsonPropertyName("totalReleases")]
|
||||
public int TotalReleases { get; init; }
|
||||
|
||||
[JsonPropertyName("totalImages")]
|
||||
public int TotalImages { get; init; }
|
||||
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public double MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("passedCount")]
|
||||
public int PassedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("failedCount")]
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("nonDeterministicArtifacts")]
|
||||
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a single release in the report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReleaseEntry
|
||||
{
|
||||
[JsonPropertyName("release")]
|
||||
public string Release { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("overallScore")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("passed")]
|
||||
public bool Passed { get; init; }
|
||||
|
||||
[JsonPropertyName("imageCount")]
|
||||
public int ImageCount { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("scannerSha")]
|
||||
public string ScannerSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifestPath")]
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-image matrix entry showing scores across releases.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismImageMatrixEntry
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scores")]
|
||||
public IReadOnlyDictionary<string, double> Scores { get; init; } = new Dictionary<string, double>();
|
||||
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("nonDeterministicArtifacts")]
|
||||
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating a determinism report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("report")]
|
||||
public DeterminismReport? Report { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "markdown";
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
422
src/Cli/StellaOps.Cli/Services/Models/ExceptionModels.cs
Normal file
422
src/Cli/StellaOps.Cli/Services/Models/ExceptionModels.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-EXC-25-001: Exception governance models for stella exceptions commands
|
||||
|
||||
/// <summary>
|
||||
/// Exception scope types.
|
||||
/// </summary>
|
||||
internal static class ExceptionScopeTypes
|
||||
{
|
||||
public const string Purl = "purl";
|
||||
public const string Image = "image";
|
||||
public const string Component = "component";
|
||||
public const string TenantWide = "tenant";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception status values following lifecycle: draft -> staged -> active -> expired.
|
||||
/// </summary>
|
||||
internal static class ExceptionStatuses
|
||||
{
|
||||
public const string Draft = "draft";
|
||||
public const string Staged = "staged";
|
||||
public const string Active = "active";
|
||||
public const string Expired = "expired";
|
||||
public const string Revoked = "revoked";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception effect types.
|
||||
/// </summary>
|
||||
internal static class ExceptionEffectTypes
|
||||
{
|
||||
public const string Suppress = "suppress";
|
||||
public const string Defer = "defer";
|
||||
public const string Downgrade = "downgrade";
|
||||
public const string RequireControl = "requireControl";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception scope definition.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionScope
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = ExceptionScopeTypes.Purl;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ruleNames")]
|
||||
public IReadOnlyList<string>? RuleNames { get; init; }
|
||||
|
||||
[JsonPropertyName("severities")]
|
||||
public IReadOnlyList<string>? Severities { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence reference for an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionEvidenceRef
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // ticket, vex_claim, scan_report, attestation
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception effect definition.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionEffect
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("effectType")]
|
||||
public string EffectType { get; init; } = ExceptionEffectTypes.Suppress;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("downgradeSeverity")]
|
||||
public string? DowngradeSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("requiredControlId")]
|
||||
public string? RequiredControlId { get; init; }
|
||||
|
||||
[JsonPropertyName("routingTemplate")]
|
||||
public string? RoutingTemplate { get; init; }
|
||||
|
||||
[JsonPropertyName("maxDurationDays")]
|
||||
public int? MaxDurationDays { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception instance representing a governed waiver.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionInstance
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string Vuln { get; init; } = string.Empty; // CVE ID or alias
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ExceptionScope Scope { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("effectId")]
|
||||
public string EffectId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("effect")]
|
||||
public ExceptionEffect? Effect { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string Justification { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = ExceptionStatuses.Draft;
|
||||
|
||||
[JsonPropertyName("expiration")]
|
||||
public DateTimeOffset? Expiration { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("approvedBy")]
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("approvedAt")]
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<ExceptionEvidenceRef> EvidenceRefs { get; init; } = Array.Empty<ExceptionEvidenceRef>();
|
||||
|
||||
[JsonPropertyName("policyBinding")]
|
||||
public string? PolicyBinding { get; init; }
|
||||
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string? Vuln { get; init; }
|
||||
|
||||
[JsonPropertyName("scopeType")]
|
||||
public string? ScopeType { get; init; }
|
||||
|
||||
[JsonPropertyName("scopeValue")]
|
||||
public string? ScopeValue { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string? Owner { get; init; }
|
||||
|
||||
[JsonPropertyName("effectType")]
|
||||
public string? EffectType { get; init; }
|
||||
|
||||
[JsonPropertyName("expiringBefore")]
|
||||
public DateTimeOffset? ExpiringBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("includeExpired")]
|
||||
public bool IncludeExpired { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionListResponse
|
||||
{
|
||||
[JsonPropertyName("exceptions")]
|
||||
public IReadOnlyList<ExceptionInstance> Exceptions { get; init; } = Array.Empty<ExceptionInstance>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionCreateRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string Vuln { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ExceptionScope Scope { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("effectId")]
|
||||
public string EffectId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string Justification { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expiration")]
|
||||
public DateTimeOffset? Expiration { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<ExceptionEvidenceRef>? EvidenceRefs { get; init; }
|
||||
|
||||
[JsonPropertyName("policyBinding")]
|
||||
public string? PolicyBinding { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public bool Stage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to promote an exception (draft -> staged -> active).
|
||||
/// </summary>
|
||||
internal sealed class ExceptionPromoteRequest
|
||||
{
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public string ExceptionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("targetStatus")]
|
||||
public string TargetStatus { get; init; } = ExceptionStatuses.Active;
|
||||
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionRevokeRequest
|
||||
{
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public string ExceptionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to import exceptions from NDJSON.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public bool Stage { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception import.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("imported")]
|
||||
public int Imported { get; init; }
|
||||
|
||||
[JsonPropertyName("skipped")]
|
||||
public int Skipped { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<ExceptionImportError> Errors { get; init; } = Array.Empty<ExceptionImportError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import error detail.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportError
|
||||
{
|
||||
[JsonPropertyName("line")]
|
||||
public int Line { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string? Field { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to export exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionExportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "ndjson"; // ndjson, json
|
||||
|
||||
[JsonPropertyName("includeManifest")]
|
||||
public bool IncludeManifest { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export manifest for exception bundle.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionExportManifest
|
||||
{
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("aocEnforced")]
|
||||
public bool AocEnforced { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureUri")]
|
||||
public string? SignatureUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception operation.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("exception")]
|
||||
public ExceptionInstance? Exception { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
279
src/Cli/StellaOps.Cli/Services/Models/ForensicSnapshotModels.cs
Normal file
279
src/Cli/StellaOps.Cli/Services/Models/ForensicSnapshotModels.cs
Normal file
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-53-001: Forensic snapshot models for evidence locker integration
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotCreateRequest(
|
||||
[property: JsonPropertyName("caseId")] string CaseId,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null,
|
||||
[property: JsonPropertyName("scope")] ForensicSnapshotScope? Scope = null,
|
||||
[property: JsonPropertyName("retentionDays")] int? RetentionDays = null);
|
||||
|
||||
/// <summary>
|
||||
/// Scope configuration for forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotScope(
|
||||
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
|
||||
[property: JsonPropertyName("scanIds")] IReadOnlyList<string>? ScanIds = null,
|
||||
[property: JsonPropertyName("policyIds")] IReadOnlyList<string>? PolicyIds = null,
|
||||
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
|
||||
[property: JsonPropertyName("timeRange")] ForensicTimeRange? TimeRange = null);
|
||||
|
||||
/// <summary>
|
||||
/// Time range for forensic snapshot scope.
|
||||
/// </summary>
|
||||
internal sealed record ForensicTimeRange(
|
||||
[property: JsonPropertyName("from")] DateTimeOffset? From = null,
|
||||
[property: JsonPropertyName("to")] DateTimeOffset? To = null);
|
||||
|
||||
/// <summary>
|
||||
/// Forensic snapshot document from the evidence locker.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotDocument
|
||||
{
|
||||
[JsonPropertyName("snapshotId")]
|
||||
public string SnapshotId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("caseId")]
|
||||
public string CaseId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public ForensicSnapshotManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ForensicSnapshotScope? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactCount")]
|
||||
public int? ArtifactCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotManifest
|
||||
{
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string ManifestId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public ForensicManifestSignature? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<ForensicSnapshotArtifact> Artifacts { get; init; } =
|
||||
Array.Empty<ForensicSnapshotArtifact>();
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public ForensicManifestMetadata? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for the manifest.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestSignature
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("certificate")]
|
||||
public string? Certificate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual artifact in a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotArtifact
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for the forensic manifest.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestMetadata
|
||||
{
|
||||
[JsonPropertyName("capturedAt")]
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("capturedBy")]
|
||||
public string? CapturedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("toolVersion")]
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("stellaOpsVersion")]
|
||||
public string? StellaOpsVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("chainOfCustody")]
|
||||
public IReadOnlyList<ForensicChainOfCustodyEntry> ChainOfCustody { get; init; } =
|
||||
Array.Empty<ForensicChainOfCustodyEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain of custody entry.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyEntry
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotListResponse
|
||||
{
|
||||
[JsonPropertyName("snapshots")]
|
||||
public IReadOnlyList<ForensicSnapshotDocument> Snapshots { get; init; } =
|
||||
Array.Empty<ForensicSnapshotDocument>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotListQuery(
|
||||
string Tenant,
|
||||
string? CaseId = null,
|
||||
string? Status = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
int? Limit = null,
|
||||
int? Offset = null);
|
||||
|
||||
/// <summary>
|
||||
/// Local cache metadata for forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotCacheEntry
|
||||
{
|
||||
[JsonPropertyName("snapshotId")]
|
||||
public string SnapshotId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("caseId")]
|
||||
public string CaseId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("localPath")]
|
||||
public string LocalPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifestDigest")]
|
||||
public string ManifestDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("downloadedAt")]
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot status enumeration.
|
||||
/// </summary>
|
||||
internal static class ForensicSnapshotStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Creating = "creating";
|
||||
public const string Ready = "ready";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
public const string Archived = "archived";
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic bundle verification models
|
||||
|
||||
/// <summary>
|
||||
/// Represents a forensic bundle for local verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicBundle
|
||||
{
|
||||
[JsonPropertyName("manifestPath")]
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public ForensicSnapshotManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<ForensicBundleArtifact> Artifacts { get; init; } = Array.Empty<ForensicBundleArtifact>();
|
||||
|
||||
[JsonPropertyName("dsseEnvelopes")]
|
||||
public IReadOnlyList<ForensicDsseEnvelope> DsseEnvelopes { get; init; } = Array.Empty<ForensicDsseEnvelope>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact in a forensic bundle with local file reference.
|
||||
/// </summary>
|
||||
internal sealed class ForensicBundleArtifact
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("localPath")]
|
||||
public string LocalPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for signature verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<ForensicDsseSignature> Signatures { get; init; } = Array.Empty<ForensicDsseSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature entry.
|
||||
/// </summary>
|
||||
internal sealed class ForensicDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of forensic bundle verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationResult
|
||||
{
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("manifestVerification")]
|
||||
public ForensicManifestVerification? ManifestVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("checksumVerification")]
|
||||
public ForensicChecksumVerification? ChecksumVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureVerification")]
|
||||
public ForensicSignatureVerification? SignatureVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("chainOfCustodyVerification")]
|
||||
public ForensicChainOfCustodyVerification? ChainOfCustodyVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<ForensicVerificationError> Errors { get; init; } = Array.Empty<ForensicVerificationError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string ManifestId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("computedDigest")]
|
||||
public string ComputedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactCount")]
|
||||
public int ArtifactCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checksum verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChecksumVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("totalArtifacts")]
|
||||
public int TotalArtifacts { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedArtifacts")]
|
||||
public int VerifiedArtifacts { get; init; }
|
||||
|
||||
[JsonPropertyName("failedArtifacts")]
|
||||
public IReadOnlyList<ForensicArtifactChecksumFailure> FailedArtifacts { get; init; } =
|
||||
Array.Empty<ForensicArtifactChecksumFailure>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual artifact checksum failure.
|
||||
/// </summary>
|
||||
internal sealed class ForensicArtifactChecksumFailure
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actualDigest")]
|
||||
public string ActualDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSignatureVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureCount")]
|
||||
public int SignatureCount { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedSignatures")]
|
||||
public int VerifiedSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<ForensicSignatureDetail> Signatures { get; init; } =
|
||||
Array.Empty<ForensicSignatureDetail>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual signature verification detail.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSignatureDetail
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("isTrusted")]
|
||||
public bool IsTrusted { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain of custody verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("entryCount")]
|
||||
public int EntryCount { get; init; }
|
||||
|
||||
[JsonPropertyName("timelineValid")]
|
||||
public bool TimelineValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signaturesValid")]
|
||||
public bool SignaturesValid { get; init; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<ForensicChainOfCustodyEntryVerification> Entries { get; init; } =
|
||||
Array.Empty<ForensicChainOfCustodyEntryVerification>();
|
||||
|
||||
[JsonPropertyName("gaps")]
|
||||
public IReadOnlyList<ForensicTimelineGap> Gaps { get; init; } = Array.Empty<ForensicTimelineGap>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual chain of custody entry verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyEntryVerification
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline gap in chain of custody.
|
||||
/// </summary>
|
||||
internal sealed class ForensicTimelineGap
|
||||
{
|
||||
[JsonPropertyName("fromIndex")]
|
||||
public int FromIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("toIndex")]
|
||||
public int ToIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("fromTimestamp")]
|
||||
public DateTimeOffset FromTimestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("toTimestamp")]
|
||||
public DateTimeOffset ToTimestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("gapDuration")]
|
||||
public TimeSpan GapDuration { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification error detail.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationError
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string? ArtifactId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust root configuration for forensic verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicTrustRoot
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string Fingerprint { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("publicKey")]
|
||||
public string PublicKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = "rsa-pss-sha256";
|
||||
|
||||
[JsonPropertyName("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification options for forensic bundle.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationOptions
|
||||
{
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
public bool VerifySignatures { get; init; } = true;
|
||||
public bool VerifyChainOfCustody { get; init; } = true;
|
||||
public bool StrictTimeline { get; init; } = false;
|
||||
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
|
||||
public string? TrustRootPath { get; init; }
|
||||
}
|
||||
612
src/Cli/StellaOps.Cli/Services/Models/NotifyModels.cs
Normal file
612
src/Cli/StellaOps.Cli/Services/Models/NotifyModels.cs
Normal file
@@ -0,0 +1,612 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PARITY-41-002: Notify command models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
internal enum NotifyChannelType
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
InApp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
internal enum NotifyDeliveryStatus
|
||||
{
|
||||
Pending,
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
Digested,
|
||||
Dropped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyChannelSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel summary for list view.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelSummary
|
||||
{
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryCount")]
|
||||
public int DeliveryCount { get; init; }
|
||||
|
||||
[JsonPropertyName("failureRate")]
|
||||
public double? FailureRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed notify channel response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelDetail
|
||||
{
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public NotifyChannelConfigInfo? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedBy")]
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("stats")]
|
||||
public NotifyChannelStats? Stats { get; init; }
|
||||
|
||||
[JsonPropertyName("health")]
|
||||
public NotifyChannelHealth? Health { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel configuration info (redacted secrets).
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelConfigInfo
|
||||
{
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; init; }
|
||||
|
||||
[JsonPropertyName("endpoint")]
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public IReadOnlyDictionary<string, string>? Properties { get; init; }
|
||||
|
||||
[JsonPropertyName("limits")]
|
||||
public NotifyChannelLimitsInfo? Limits { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel limits.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelLimitsInfo
|
||||
{
|
||||
[JsonPropertyName("concurrency")]
|
||||
public int? Concurrency { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerMinute")]
|
||||
public int? RequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int? TimeoutSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("maxBatchSize")]
|
||||
public int? MaxBatchSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel statistics.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelStats
|
||||
{
|
||||
[JsonPropertyName("totalDeliveries")]
|
||||
public long TotalDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("successfulDeliveries")]
|
||||
public long SuccessfulDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("failedDeliveries")]
|
||||
public long FailedDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("throttledDeliveries")]
|
||||
public long ThrottledDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("lastDeliveryAt")]
|
||||
public DateTimeOffset? LastDeliveryAt { get; init; }
|
||||
|
||||
[JsonPropertyName("avgLatencyMs")]
|
||||
public double? AvgLatencyMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel health status.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelHealth
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("lastCheckAt")]
|
||||
public DateTimeOffset? LastCheckAt { get; init; }
|
||||
|
||||
[JsonPropertyName("consecutiveFailures")]
|
||||
public int ConsecutiveFailures { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel test request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelTestRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel test result.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelTestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("responseCode")]
|
||||
public int? ResponseCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string? EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyRuleSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule summary.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleSummary
|
||||
{
|
||||
[JsonPropertyName("ruleId")]
|
||||
public string RuleId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("eventTypes")]
|
||||
public IReadOnlyList<string> EventTypes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("channelIds")]
|
||||
public IReadOnlyList<string> ChannelIds { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("matchCount")]
|
||||
public long MatchCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string? EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("until")]
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyDeliverySummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery summary.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliverySummary
|
||||
{
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelName")]
|
||||
public string? ChannelName { get; init; }
|
||||
|
||||
[JsonPropertyName("channelType")]
|
||||
public string ChannelType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attemptCount")]
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sentAt")]
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery detail.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryDetail
|
||||
{
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelName")]
|
||||
public string? ChannelName { get; init; }
|
||||
|
||||
[JsonPropertyName("channelType")]
|
||||
public string ChannelType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ruleId")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public string? EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("attemptCount")]
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
[JsonPropertyName("attempts")]
|
||||
public IReadOnlyList<NotifyDeliveryAttempt>? Attempts { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sentAt")]
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
|
||||
[JsonPropertyName("failedAt")]
|
||||
public DateTimeOffset? FailedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery attempt.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryAttempt
|
||||
{
|
||||
[JsonPropertyName("attemptNumber")]
|
||||
public int AttemptNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attemptedAt")]
|
||||
public DateTimeOffset AttemptedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("responseCode")]
|
||||
public int? ResponseCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry delivery request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRetryRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry delivery result.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRetryResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("newStatus")]
|
||||
public string? NewStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send notification request.
|
||||
/// </summary>
|
||||
internal sealed class NotifySendRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send notification result.
|
||||
/// </summary>
|
||||
internal sealed class NotifySendResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public string? EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryIds")]
|
||||
public IReadOnlyList<string>? DeliveryIds { get; init; }
|
||||
|
||||
[JsonPropertyName("channelsMatched")]
|
||||
public int ChannelsMatched { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
542
src/Cli/StellaOps.Cli/Services/Models/ObservabilityModels.cs
Normal file
542
src/Cli/StellaOps.Cli/Services/Models/ObservabilityModels.cs
Normal file
@@ -0,0 +1,542 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-OBS-51-001: Observability models for stella obs commands
|
||||
|
||||
/// <summary>
|
||||
/// Service health status from the platform.
|
||||
/// </summary>
|
||||
internal sealed class ServiceHealthStatus
|
||||
{
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "unknown"; // healthy, degraded, unhealthy, unknown
|
||||
|
||||
[JsonPropertyName("availability")]
|
||||
public double Availability { get; init; }
|
||||
|
||||
[JsonPropertyName("sloTarget")]
|
||||
public double SloTarget { get; init; } = 0.999;
|
||||
|
||||
[JsonPropertyName("errorBudgetRemaining")]
|
||||
public double ErrorBudgetRemaining { get; init; }
|
||||
|
||||
[JsonPropertyName("burnRate")]
|
||||
public BurnRateInfo? BurnRate { get; init; }
|
||||
|
||||
[JsonPropertyName("latency")]
|
||||
public LatencyInfo? Latency { get; init; }
|
||||
|
||||
[JsonPropertyName("traffic")]
|
||||
public TrafficInfo? Traffic { get; init; }
|
||||
|
||||
[JsonPropertyName("queues")]
|
||||
public IReadOnlyList<QueueHealth> Queues { get; init; } = Array.Empty<QueueHealth>();
|
||||
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTimeOffset LastUpdated { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Burn rate alert information.
|
||||
/// </summary>
|
||||
internal sealed class BurnRateInfo
|
||||
{
|
||||
[JsonPropertyName("current")]
|
||||
public double Current { get; init; }
|
||||
|
||||
[JsonPropertyName("shortWindow")]
|
||||
public double ShortWindow { get; init; } // 5m or 1h window
|
||||
|
||||
[JsonPropertyName("longWindow")]
|
||||
public double LongWindow { get; init; } // 6h or 3d window
|
||||
|
||||
[JsonPropertyName("alertLevel")]
|
||||
public string AlertLevel { get; init; } = "none"; // none, warning, critical
|
||||
|
||||
[JsonPropertyName("threshold2x")]
|
||||
public double Threshold2x { get; init; } = 2.0;
|
||||
|
||||
[JsonPropertyName("threshold14x")]
|
||||
public double Threshold14x { get; init; } = 14.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Latency percentile information.
|
||||
/// </summary>
|
||||
internal sealed class LatencyInfo
|
||||
{
|
||||
[JsonPropertyName("p50")]
|
||||
public double P50Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p95")]
|
||||
public double P95Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p99")]
|
||||
public double P99Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p95Target")]
|
||||
public double P95TargetMs { get; init; } = 300;
|
||||
|
||||
[JsonPropertyName("breaching")]
|
||||
public bool Breaching { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traffic/throughput information.
|
||||
/// </summary>
|
||||
internal sealed class TrafficInfo
|
||||
{
|
||||
[JsonPropertyName("requestsPerSecond")]
|
||||
public double RequestsPerSecond { get; init; }
|
||||
|
||||
[JsonPropertyName("successRate")]
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
[JsonPropertyName("errorRate")]
|
||||
public double ErrorRate { get; init; }
|
||||
|
||||
[JsonPropertyName("totalRequests")]
|
||||
public long TotalRequests { get; init; }
|
||||
|
||||
[JsonPropertyName("totalErrors")]
|
||||
public long TotalErrors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue health information.
|
||||
/// </summary>
|
||||
internal sealed class QueueHealth
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("depth")]
|
||||
public long Depth { get; init; }
|
||||
|
||||
[JsonPropertyName("depthThreshold")]
|
||||
public long DepthThreshold { get; init; } = 1000;
|
||||
|
||||
[JsonPropertyName("oldestMessageAge")]
|
||||
public TimeSpan OldestMessageAge { get; init; }
|
||||
|
||||
[JsonPropertyName("throughput")]
|
||||
public double Throughput { get; init; }
|
||||
|
||||
[JsonPropertyName("successRate")]
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
[JsonPropertyName("alerting")]
|
||||
public bool Alerting { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide health summary.
|
||||
/// </summary>
|
||||
internal sealed class PlatformHealthSummary
|
||||
{
|
||||
[JsonPropertyName("overallStatus")]
|
||||
public string OverallStatus { get; init; } = "unknown";
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<ServiceHealthStatus> Services { get; init; } = Array.Empty<ServiceHealthStatus>();
|
||||
|
||||
[JsonPropertyName("activeAlerts")]
|
||||
public IReadOnlyList<ActiveAlert> ActiveAlerts { get; init; } = Array.Empty<ActiveAlert>();
|
||||
|
||||
[JsonPropertyName("globalErrorBudget")]
|
||||
public double GlobalErrorBudget { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active alert information.
|
||||
/// </summary>
|
||||
internal sealed class ActiveAlert
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // burn_rate, latency, error_rate, queue_depth
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "warning"; // warning, critical
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public double Value { get; init; }
|
||||
|
||||
[JsonPropertyName("threshold")]
|
||||
public double Threshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for obs top command.
|
||||
/// </summary>
|
||||
internal sealed class ObsTopRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by service names.
|
||||
/// </summary>
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tenant.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include queue details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeQueues")]
|
||||
public bool IncludeQueues { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh interval in seconds for streaming mode (0 = single fetch).
|
||||
/// </summary>
|
||||
[JsonPropertyName("refreshInterval")]
|
||||
public int RefreshInterval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum alerts to return.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxAlerts")]
|
||||
public int MaxAlerts { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of obs top command.
|
||||
/// </summary>
|
||||
internal sealed class ObsTopResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public PlatformHealthSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Trace and logs models
|
||||
|
||||
/// <summary>
|
||||
/// Request for fetching a trace by ID.
|
||||
/// </summary>
|
||||
internal sealed class ObsTraceRequest
|
||||
{
|
||||
[JsonPropertyName("traceId")]
|
||||
public string TraceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("includeEvidence")]
|
||||
public bool IncludeEvidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace with spans.
|
||||
/// </summary>
|
||||
internal sealed class DistributedTrace
|
||||
{
|
||||
[JsonPropertyName("traceId")]
|
||||
public string TraceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootSpan")]
|
||||
public TraceSpan? RootSpan { get; init; }
|
||||
|
||||
[JsonPropertyName("spans")]
|
||||
public IReadOnlyList<TraceSpan> Spans { get; init; } = Array.Empty<TraceSpan>();
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("startTime")]
|
||||
public DateTimeOffset StartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("endTime")]
|
||||
public DateTimeOffset EndTime { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "ok"; // ok, error
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual span within a trace.
|
||||
/// </summary>
|
||||
internal sealed class TraceSpan
|
||||
{
|
||||
[JsonPropertyName("spanId")]
|
||||
public string SpanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("parentSpanId")]
|
||||
public string? ParentSpanId { get; init; }
|
||||
|
||||
[JsonPropertyName("operationName")]
|
||||
public string OperationName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("serviceName")]
|
||||
public string ServiceName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startTime")]
|
||||
public DateTimeOffset StartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "ok"; // ok, error
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyDictionary<string, string> Tags { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<SpanLog> Logs { get; init; } = Array.Empty<SpanLog>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log entry within a span.
|
||||
/// </summary>
|
||||
internal sealed class SpanLog
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info"; // debug, info, warn, error
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link to evidence artifact (SBOM, VEX, attestation, etc.).
|
||||
/// </summary>
|
||||
internal sealed class EvidenceLink
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // sbom, vex, attestation, scan_result
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching a trace.
|
||||
/// </summary>
|
||||
internal sealed class ObsTraceResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("trace")]
|
||||
public DistributedTrace? Trace { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for fetching logs.
|
||||
/// </summary>
|
||||
internal sealed class ObsLogsRequest
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("levels")]
|
||||
public IReadOnlyList<string> Levels { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public string? Query { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 100;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log entry from the platform.
|
||||
/// </summary>
|
||||
internal sealed class LogEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info";
|
||||
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
[JsonPropertyName("spanId")]
|
||||
public string? SpanId { get; init; }
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching logs.
|
||||
/// </summary>
|
||||
internal sealed class ObsLogsResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<LogEntry> Logs { get; init; } = Array.Empty<LogEntry>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-OBS-55-001: Incident mode models
|
||||
|
||||
/// <summary>
|
||||
/// Incident mode state.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeState
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("setAt")]
|
||||
public DateTimeOffset? SetAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("retentionExtensionDays")]
|
||||
public int RetentionExtensionDays { get; init; } = 60;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = "cli"; // cli, config, api
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to enable incident mode.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeEnableRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("ttlMinutes")]
|
||||
public int TtlMinutes { get; init; } = 30;
|
||||
|
||||
[JsonPropertyName("retentionExtensionDays")]
|
||||
public int RetentionExtensionDays { get; init; } = 60;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to disable incident mode.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeDisableRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of incident mode operation.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public IncidentModeState? State { get; init; }
|
||||
|
||||
[JsonPropertyName("previousState")]
|
||||
public IncidentModeState? PreviousState { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
671
src/Cli/StellaOps.Cli/Services/Models/OrchestratorModels.cs
Normal file
671
src/Cli/StellaOps.Cli/Services/Models/OrchestratorModels.cs
Normal file
@@ -0,0 +1,671 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator source and job models for stella orch commands
|
||||
|
||||
/// <summary>
|
||||
/// Source status values.
|
||||
/// </summary>
|
||||
internal static class SourceStatuses
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Paused = "paused";
|
||||
public const string Disabled = "disabled";
|
||||
public const string Throttled = "throttled";
|
||||
public const string Error = "error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source type values representing data feed categories.
|
||||
/// </summary>
|
||||
internal static class SourceTypes
|
||||
{
|
||||
public const string Advisory = "advisory";
|
||||
public const string Vex = "vex";
|
||||
public const string Sbom = "sbom";
|
||||
public const string Package = "package";
|
||||
public const string Registry = "registry";
|
||||
public const string Custom = "custom";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator source definition representing a data feed.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorSource
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = SourceTypes.Advisory;
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = SourceStatuses.Active;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("schedule")]
|
||||
public SourceSchedule? Schedule { get; init; }
|
||||
|
||||
[JsonPropertyName("rateLimit")]
|
||||
public SourceRateLimit? RateLimit { get; init; }
|
||||
|
||||
[JsonPropertyName("lastRun")]
|
||||
public SourceLastRun? LastRun { get; init; }
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public SourceMetrics? Metrics { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("pausedAt")]
|
||||
public DateTimeOffset? PausedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("pausedBy")]
|
||||
public string? PausedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("pauseReason")]
|
||||
public string? PauseReason { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source schedule configuration.
|
||||
/// </summary>
|
||||
internal sealed class SourceSchedule
|
||||
{
|
||||
[JsonPropertyName("cron")]
|
||||
public string? Cron { get; init; }
|
||||
|
||||
[JsonPropertyName("intervalMinutes")]
|
||||
public int? IntervalMinutes { get; init; }
|
||||
|
||||
[JsonPropertyName("nextRunAt")]
|
||||
public DateTimeOffset? NextRunAt { get; init; }
|
||||
|
||||
[JsonPropertyName("timezone")]
|
||||
public string Timezone { get; init; } = "UTC";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source rate limit configuration.
|
||||
/// </summary>
|
||||
internal sealed class SourceRateLimit
|
||||
{
|
||||
[JsonPropertyName("maxRequestsPerMinute")]
|
||||
public int MaxRequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("maxRequestsPerHour")]
|
||||
public int? MaxRequestsPerHour { get; init; }
|
||||
|
||||
[JsonPropertyName("burstSize")]
|
||||
public int BurstSize { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("currentTokens")]
|
||||
public double? CurrentTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("refillRatePerSecond")]
|
||||
public double? RefillRatePerSecond { get; init; }
|
||||
|
||||
[JsonPropertyName("throttledUntil")]
|
||||
public DateTimeOffset? ThrottledUntil { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source last run information.
|
||||
/// </summary>
|
||||
internal sealed class SourceLastRun
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string? RunId { get; init; }
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("itemsProcessed")]
|
||||
public long? ItemsProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("itemsFailed")]
|
||||
public long? ItemsFailed { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long? DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source metrics summary.
|
||||
/// </summary>
|
||||
internal sealed class SourceMetrics
|
||||
{
|
||||
[JsonPropertyName("totalRuns")]
|
||||
public long TotalRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("successfulRuns")]
|
||||
public long SuccessfulRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("failedRuns")]
|
||||
public long FailedRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("averageDurationMs")]
|
||||
public double? AverageDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("totalItemsProcessed")]
|
||||
public long TotalItemsProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("totalItemsFailed")]
|
||||
public long TotalItemsFailed { get; init; }
|
||||
|
||||
[JsonPropertyName("lastSuccessAt")]
|
||||
public DateTimeOffset? LastSuccessAt { get; init; }
|
||||
|
||||
[JsonPropertyName("lastFailureAt")]
|
||||
public DateTimeOffset? LastFailureAt { get; init; }
|
||||
|
||||
[JsonPropertyName("uptimePercent")]
|
||||
public double? UptimePercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list sources.
|
||||
/// </summary>
|
||||
internal sealed class SourceListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string? Host { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing sources.
|
||||
/// </summary>
|
||||
internal sealed class SourceListResponse
|
||||
{
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<OrchestratorSource> Sources { get; init; } = Array.Empty<OrchestratorSource>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to pause a source.
|
||||
/// </summary>
|
||||
internal sealed class SourcePauseRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMinutes")]
|
||||
public int? DurationMinutes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resume a source.
|
||||
/// </summary>
|
||||
internal sealed class SourceResumeRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test a source connection.
|
||||
/// </summary>
|
||||
internal sealed class SourceTestRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("timeout")]
|
||||
public int TimeoutSeconds { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source operation.
|
||||
/// </summary>
|
||||
internal sealed class SourceOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public OrchestratorSource? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source test operation.
|
||||
/// </summary>
|
||||
internal sealed class SourceTestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachable")]
|
||||
public bool Reachable { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("statusCode")]
|
||||
public int? StatusCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("tlsValid")]
|
||||
public bool? TlsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("tlsExpiry")]
|
||||
public DateTimeOffset? TlsExpiry { get; init; }
|
||||
|
||||
[JsonPropertyName("testedAt")]
|
||||
public DateTimeOffset TestedAt { get; init; }
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Backfill wizard and quota management models
|
||||
|
||||
/// <summary>
|
||||
/// Request to start a backfill operation for a source.
|
||||
/// </summary>
|
||||
internal sealed class BackfillRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; } = 5;
|
||||
|
||||
[JsonPropertyName("concurrency")]
|
||||
public int Concurrency { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("batchSize")]
|
||||
public int BatchSize { get; init; } = 100;
|
||||
|
||||
[JsonPropertyName("resume")]
|
||||
public bool Resume { get; init; }
|
||||
|
||||
[JsonPropertyName("filter")]
|
||||
public string? Filter { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a backfill operation.
|
||||
/// </summary>
|
||||
internal sealed class BackfillResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("backfillId")]
|
||||
public string? BackfillId { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("estimatedItems")]
|
||||
public long? EstimatedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("processedItems")]
|
||||
public long ProcessedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("failedItems")]
|
||||
public long FailedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("skippedItems")]
|
||||
public long SkippedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("estimatedDurationMs")]
|
||||
public long? EstimatedDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("actualDurationMs")]
|
||||
public long? ActualDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status values for backfill operations.
|
||||
/// </summary>
|
||||
internal static class BackfillStatuses
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Running = "running";
|
||||
public const string Completed = "completed";
|
||||
public const string Failed = "failed";
|
||||
public const string Cancelled = "cancelled";
|
||||
public const string DryRun = "dry_run";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list backfill operations.
|
||||
/// </summary>
|
||||
internal sealed class BackfillListRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing backfill operations.
|
||||
/// </summary>
|
||||
internal sealed class BackfillListResponse
|
||||
{
|
||||
[JsonPropertyName("backfills")]
|
||||
public IReadOnlyList<BackfillResult> Backfills { get; init; } = Array.Empty<BackfillResult>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to cancel a backfill operation.
|
||||
/// </summary>
|
||||
internal sealed class BackfillCancelRequest
|
||||
{
|
||||
[JsonPropertyName("backfillId")]
|
||||
public string BackfillId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota resource representing usage limits for a tenant/source.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorQuota
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public long Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("used")]
|
||||
public long Used { get; init; }
|
||||
|
||||
[JsonPropertyName("remaining")]
|
||||
public long Remaining { get; init; }
|
||||
|
||||
[JsonPropertyName("period")]
|
||||
public string Period { get; init; } = "monthly";
|
||||
|
||||
[JsonPropertyName("periodStart")]
|
||||
public DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
[JsonPropertyName("periodEnd")]
|
||||
public DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
[JsonPropertyName("resetAt")]
|
||||
public DateTimeOffset ResetAt { get; init; }
|
||||
|
||||
[JsonPropertyName("warningThreshold")]
|
||||
public double WarningThreshold { get; init; } = 0.8;
|
||||
|
||||
[JsonPropertyName("isWarning")]
|
||||
public bool IsWarning { get; init; }
|
||||
|
||||
[JsonPropertyName("isExceeded")]
|
||||
public bool IsExceeded { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota resource types.
|
||||
/// </summary>
|
||||
internal static class QuotaResourceTypes
|
||||
{
|
||||
public const string ApiCalls = "api_calls";
|
||||
public const string DataIngested = "data_ingested_bytes";
|
||||
public const string ItemsProcessed = "items_processed";
|
||||
public const string Backfills = "backfills";
|
||||
public const string ConcurrentJobs = "concurrent_jobs";
|
||||
public const string Storage = "storage_bytes";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota period types.
|
||||
/// </summary>
|
||||
internal static class QuotaPeriods
|
||||
{
|
||||
public const string Hourly = "hourly";
|
||||
public const string Daily = "daily";
|
||||
public const string Weekly = "weekly";
|
||||
public const string Monthly = "monthly";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get quotas.
|
||||
/// </summary>
|
||||
internal sealed class QuotaGetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string? ResourceType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from getting quotas.
|
||||
/// </summary>
|
||||
internal sealed class QuotaGetResponse
|
||||
{
|
||||
[JsonPropertyName("quotas")]
|
||||
public IReadOnlyList<OrchestratorQuota> Quotas { get; init; } = Array.Empty<OrchestratorQuota>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set a quota limit.
|
||||
/// </summary>
|
||||
internal sealed class QuotaSetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public long Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("period")]
|
||||
public string Period { get; init; } = QuotaPeriods.Monthly;
|
||||
|
||||
[JsonPropertyName("warningThreshold")]
|
||||
public double WarningThreshold { get; init; } = 0.8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a quota operation.
|
||||
/// </summary>
|
||||
internal sealed class QuotaOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("quota")]
|
||||
public OrchestratorQuota? Quota { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to reset a quota's usage counter.
|
||||
/// </summary>
|
||||
internal sealed class QuotaResetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
915
src/Cli/StellaOps.Cli/Services/Models/PackModels.cs
Normal file
915
src/Cli/StellaOps.Cli/Services/Models/PackModels.cs
Normal file
@@ -0,0 +1,915 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PACKS-42-001: Task Pack models for stella pack commands
|
||||
|
||||
/// <summary>
|
||||
/// Task pack metadata from the registry.
|
||||
/// </summary>
|
||||
internal sealed class TaskPackInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("author")]
|
||||
public string? Author { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public PackSignature? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<PackInputSchema> Inputs { get; init; } = Array.Empty<PackInputSchema>();
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyList<PackOutputSchema> Outputs { get; init; } = Array.Empty<PackOutputSchema>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack signature information.
|
||||
/// </summary>
|
||||
internal sealed class PackSignature
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty; // ecdsa-p256, rsa-pkcs1-sha256, etc.
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("certificate")]
|
||||
public string? Certificate { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack input parameter schema.
|
||||
/// </summary>
|
||||
internal sealed class PackInputSchema
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "string"; // string, number, boolean, array, object
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; }
|
||||
|
||||
[JsonPropertyName("default")]
|
||||
public object? Default { get; init; }
|
||||
|
||||
[JsonPropertyName("enum")]
|
||||
public IReadOnlyList<string>? Enum { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack output schema.
|
||||
/// </summary>
|
||||
internal sealed class PackOutputSchema
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "string";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to plan a pack execution.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("validateOnly")]
|
||||
public bool ValidateOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution plan step.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanStep
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dependsOn")]
|
||||
public IReadOnlyList<string> DependsOn { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("condition")]
|
||||
public string? Condition { get; init; }
|
||||
|
||||
[JsonPropertyName("timeout")]
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
[JsonPropertyName("retryPolicy")]
|
||||
public PackRetryPolicy? RetryPolicy { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresApproval")]
|
||||
public bool RequiresApproval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry policy for pack steps.
|
||||
/// </summary>
|
||||
internal sealed class PackRetryPolicy
|
||||
{
|
||||
[JsonPropertyName("maxAttempts")]
|
||||
public int MaxAttempts { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("backoffMultiplier")]
|
||||
public double BackoffMultiplier { get; init; } = 2.0;
|
||||
|
||||
[JsonPropertyName("initialDelayMs")]
|
||||
public int InitialDelayMs { get; init; } = 1000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack plan operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("planId")]
|
||||
public string? PlanId { get; init; }
|
||||
|
||||
[JsonPropertyName("planHash")]
|
||||
public string? PlanHash { get; init; }
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public IReadOnlyList<PackPlanStep> Steps { get; init; } = Array.Empty<PackPlanStep>();
|
||||
|
||||
[JsonPropertyName("requiresApproval")]
|
||||
public bool RequiresApproval { get; init; }
|
||||
|
||||
[JsonPropertyName("approvalGates")]
|
||||
public IReadOnlyList<string> ApprovalGates { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("estimatedDuration")]
|
||||
public TimeSpan? EstimatedDuration { get; init; }
|
||||
|
||||
[JsonPropertyName("validationErrors")]
|
||||
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation error from pack plan/verify.
|
||||
/// </summary>
|
||||
internal sealed class PackValidationError
|
||||
{
|
||||
[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; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "error"; // error, warning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to run a pack.
|
||||
/// </summary>
|
||||
internal sealed class PackRunRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("planId")]
|
||||
public string? PlanId { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("waitForCompletion")]
|
||||
public bool WaitForCompletion { get; init; }
|
||||
|
||||
[JsonPropertyName("timeoutMinutes")]
|
||||
public int TimeoutMinutes { get; init; } = 60;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack run status.
|
||||
/// </summary>
|
||||
internal sealed class PackRunStatus
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, cancelled, waiting_approval
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("currentStep")]
|
||||
public string? CurrentStep { get; init; }
|
||||
|
||||
[JsonPropertyName("stepStatuses")]
|
||||
public IReadOnlyList<PackStepStatus> StepStatuses { get; init; } = Array.Empty<PackStepStatus>();
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<PackArtifact> Artifacts { get; init; } = Array.Empty<PackArtifact>();
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of individual pack step.
|
||||
/// </summary>
|
||||
internal sealed class PackStepStatus
|
||||
{
|
||||
[JsonPropertyName("stepId")]
|
||||
public string StepId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, skipped
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("attempt")]
|
||||
public int Attempt { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact produced by pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifact
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // log, sbom, report, attestation
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack run operation.
|
||||
/// </summary>
|
||||
internal sealed class PackRunResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("run")]
|
||||
public PackRunStatus? Run { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to push a pack to the registry.
|
||||
/// </summary>
|
||||
internal sealed class PackPushRequest
|
||||
{
|
||||
[JsonPropertyName("packPath")]
|
||||
public string PackPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sign")]
|
||||
public bool Sign { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack push operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPushResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to pull a pack from the registry.
|
||||
/// </summary>
|
||||
internal sealed class PackPullRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("verify")]
|
||||
public bool Verify { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack pull operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPullResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a pack.
|
||||
/// </summary>
|
||||
internal sealed class PackVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("packPath")]
|
||||
public string? PackPath { get; init; }
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("checkRekor")]
|
||||
public bool CheckRekor { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("checkExpiry")]
|
||||
public bool CheckExpiry { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack verify operation.
|
||||
/// </summary>
|
||||
internal sealed class PackVerifyResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("digestMatch")]
|
||||
public bool DigestMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerified")]
|
||||
public bool? RekorVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateValid")]
|
||||
public bool? CertificateValid { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateExpiry")]
|
||||
public DateTimeOffset? CertificateExpiry { get; init; }
|
||||
|
||||
[JsonPropertyName("schemaValid")]
|
||||
public bool SchemaValid { get; init; }
|
||||
|
||||
[JsonPropertyName("validationErrors")]
|
||||
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-PACKS-43-001: Advanced pack features
|
||||
|
||||
/// <summary>
|
||||
/// Request to pause a pack run waiting for approval.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalPauseRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resume a paused pack run with approval.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalResumeRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("approved")]
|
||||
public bool Approved { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an approval operation.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("run")]
|
||||
public PackRunStatus? Run { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to inject a secret into a pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackSecretInjectRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("secretProvider")]
|
||||
public string SecretProvider { get; init; } = "vault"; // vault, aws-ssm, azure-keyvault, k8s-secret
|
||||
|
||||
[JsonPropertyName("targetEnvVar")]
|
||||
public string? TargetEnvVar { get; init; }
|
||||
|
||||
[JsonPropertyName("targetPath")]
|
||||
public string? TargetPath { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a secret injection operation.
|
||||
/// </summary>
|
||||
internal sealed class PackSecretInjectResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("injectedAt")]
|
||||
public DateTimeOffset InjectedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list pack runs.
|
||||
/// </summary>
|
||||
internal sealed class PackRunListRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("until")]
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing pack runs.
|
||||
/// </summary>
|
||||
internal sealed class PackRunListResponse
|
||||
{
|
||||
[JsonPropertyName("runs")]
|
||||
public IReadOnlyList<PackRunStatus> Runs { get; init; } = Array.Empty<PackRunStatus>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to cancel a pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackCancelRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get pack run logs.
|
||||
/// </summary>
|
||||
internal sealed class PackLogsRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
|
||||
[JsonPropertyName("follow")]
|
||||
public bool Follow { get; init; }
|
||||
|
||||
[JsonPropertyName("tail")]
|
||||
public int? Tail { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack log entry.
|
||||
/// </summary>
|
||||
internal sealed class PackLogEntry
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info"; // debug, info, warn, error
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; init; } = "stdout"; // stdout, stderr
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack logs request.
|
||||
/// </summary>
|
||||
internal sealed class PackLogsResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<PackLogEntry> Logs { get; init; } = Array.Empty<PackLogEntry>();
|
||||
|
||||
[JsonPropertyName("nextToken")]
|
||||
public string? NextToken { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to download a pack artifact.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifactDownloadRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactName")]
|
||||
public string ArtifactName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of artifact download.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifactDownloadResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact")]
|
||||
public PackArtifact? Artifact { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offline cache entry for packs.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheEntry
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cachedAt")]
|
||||
public DateTimeOffset CachedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to manage offline cache.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheRequest
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = "list"; // list, add, remove, sync, prune
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("cacheDir")]
|
||||
public string? CacheDir { get; init; }
|
||||
|
||||
[JsonPropertyName("maxAge")]
|
||||
public TimeSpan? MaxAge { get; init; }
|
||||
|
||||
[JsonPropertyName("maxSize")]
|
||||
public long? MaxSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of cache operation.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<PackCacheEntry> Entries { get; init; } = Array.Empty<PackCacheEntry>();
|
||||
|
||||
[JsonPropertyName("totalSize")]
|
||||
public long TotalSize { get; init; }
|
||||
|
||||
[JsonPropertyName("prunedCount")]
|
||||
public int PrunedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("prunedSize")]
|
||||
public long PrunedSize { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -2,16 +2,39 @@ 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);
|
||||
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);
|
||||
string? ExplainUri,
|
||||
PolicySimulationHeatmap? Heatmap = null,
|
||||
string? ManifestDownloadUri = null,
|
||||
string? ManifestDigest = null);
|
||||
|
||||
internal sealed record PolicySimulationDiff(
|
||||
string? SchemaVersion,
|
||||
@@ -24,3 +47,17 @@ internal sealed record PolicySimulationDiff(
|
||||
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);
|
||||
|
||||
1211
src/Cli/StellaOps.Cli/Services/Models/PolicyWorkspaceModels.cs
Normal file
1211
src/Cli/StellaOps.Cli/Services/Models/PolicyWorkspaceModels.cs
Normal file
File diff suppressed because it is too large
Load Diff
468
src/Cli/StellaOps.Cli/Services/Models/PromotionModels.cs
Normal file
468
src/Cli/StellaOps.Cli/Services/Models/PromotionModels.cs
Normal file
@@ -0,0 +1,468 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PROMO-70-001: Promotion attestation models
|
||||
|
||||
/// <summary>
|
||||
/// Request for assembling a promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAssembleRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("vexPath")]
|
||||
public string? VexPath { get; init; }
|
||||
|
||||
[JsonPropertyName("fromEnvironment")]
|
||||
public string FromEnvironment { get; init; } = "staging";
|
||||
|
||||
[JsonPropertyName("toEnvironment")]
|
||||
public string ToEnvironment { get; init; } = "prod";
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("pipeline")]
|
||||
public string? Pipeline { get; init; }
|
||||
|
||||
[JsonPropertyName("ticket")]
|
||||
public string? Ticket { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("skipRekor")]
|
||||
public bool SkipRekor { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion attestation predicate following stella.ops/promotion@v1 schema.
|
||||
/// </summary>
|
||||
internal sealed class PromotionPredicate
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "stella.ops/promotion@v1";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public IReadOnlyList<PromotionSubject> Subject { get; init; } = Array.Empty<PromotionSubject>();
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
|
||||
|
||||
[JsonPropertyName("promotion")]
|
||||
public PromotionMetadata Promotion { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public PromotionRekorEntry? Rekor { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation")]
|
||||
public PromotionAttestationMetadata? Attestation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in promotion attestation (image reference).
|
||||
/// </summary>
|
||||
internal sealed class PromotionSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material in promotion attestation (SBOM, VEX, etc.).
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterial
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algo")]
|
||||
public string Algo { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion metadata.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMetadata
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public string From { get; init; } = "staging";
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public string To { get; init; } = "prod";
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("pipeline")]
|
||||
public string? Pipeline { get; init; }
|
||||
|
||||
[JsonPropertyName("ticket")]
|
||||
public string? Ticket { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry in promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionRekorEntry
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string Uuid { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
public PromotionInclusionProof? InclusionProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle inclusion proof.
|
||||
/// </summary>
|
||||
internal sealed class PromotionInclusionProof
|
||||
{
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyList<string> Hashes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public long TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public PromotionCheckpoint? Checkpoint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint.
|
||||
/// </summary>
|
||||
internal sealed class PromotionCheckpoint
|
||||
{
|
||||
[JsonPropertyName("origin")]
|
||||
public string Origin { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signedNote")]
|
||||
public string? SignedNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation metadata.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestationMetadata
|
||||
{
|
||||
[JsonPropertyName("bundle_sha256")]
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("witness")]
|
||||
public string? Witness { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion assemble operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAssembleResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
|
||||
|
||||
[JsonPropertyName("rekorEntry")]
|
||||
public PromotionRekorEntry? RekorEntry { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-PROMO-70-002: Promotion attest/verify models
|
||||
|
||||
/// <summary>
|
||||
/// Request for attesting a promotion predicate.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicatePath")]
|
||||
public string? PredicatePath { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("useKeyless")]
|
||||
public bool UseKeyless { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("uploadToRekor")]
|
||||
public bool UploadToRekor { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion attest operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorEntry")]
|
||||
public PromotionRekorEntry? RekorEntry { get; init; }
|
||||
|
||||
[JsonPropertyName("auditId")]
|
||||
public string? AuditId { get; init; }
|
||||
|
||||
[JsonPropertyName("signerKeyId")]
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = "application/vnd.in-toto+json";
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<DsseSignature> Signatures { get; init; } = Array.Empty<DsseSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Sig { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cert")]
|
||||
public string? Cert { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for verifying a promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
[JsonPropertyName("predicatePath")]
|
||||
public string? PredicatePath { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("vexPath")]
|
||||
public string? VexPath { get; init; }
|
||||
|
||||
[JsonPropertyName("trustRootPath")]
|
||||
public string? TrustRootPath { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpointPath")]
|
||||
public string? CheckpointPath { get; init; }
|
||||
|
||||
[JsonPropertyName("skipRekorVerification")]
|
||||
public bool SkipRekorVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("skipSignatureVerification")]
|
||||
public bool SkipSignatureVerification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion verify operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionVerifyResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureVerification")]
|
||||
public PromotionSignatureVerification? SignatureVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("materialVerification")]
|
||||
public PromotionMaterialVerification? MaterialVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerification")]
|
||||
public PromotionRekorVerification? RekorVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionSignatureVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("certSubject")]
|
||||
public string? CertSubject { get; init; }
|
||||
|
||||
[JsonPropertyName("certIssuer")]
|
||||
public string? CertIssuer { get; init; }
|
||||
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTimeOffset? ValidFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("validTo")]
|
||||
public DateTimeOffset? ValidTo { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterialVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterialVerificationEntry> Materials { get; init; } = Array.Empty<PromotionMaterialVerificationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual material verification entry.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterialVerificationEntry
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actualDigest")]
|
||||
public string? ActualDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionRekorVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long? LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusionProofVerified")]
|
||||
public bool InclusionProofVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpointVerified")]
|
||||
public bool CheckpointVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
252
src/Cli/StellaOps.Cli/Services/Models/ReachabilityModels.cs
Normal file
252
src/Cli/StellaOps.Cli/Services/Models/ReachabilityModels.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-SIG-26-001: Reachability command models
|
||||
|
||||
/// <summary>
|
||||
/// Request to upload a call graph for reachability analysis.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityUploadCallGraphRequest
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphPath")]
|
||||
public string CallGraphPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of uploading a call graph.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityUploadCallGraphResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphId")]
|
||||
public string CallGraphId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("entriesProcessed")]
|
||||
public int EntriesProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("errorsCount")]
|
||||
public int ErrorsCount { get; init; }
|
||||
|
||||
[JsonPropertyName("uploadedAt")]
|
||||
public DateTimeOffset UploadedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list reachability analyses.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityListRequest
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing reachability analyses.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityListResponse
|
||||
{
|
||||
[JsonPropertyName("analyses")]
|
||||
public IReadOnlyList<ReachabilityAnalysisSummary> Analyses { get; init; } = Array.Empty<ReachabilityAnalysisSummary>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a reachability analysis.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityAnalysisSummary
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetName")]
|
||||
public string? AssetName { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphId")]
|
||||
public string CallGraphId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachableCount")]
|
||||
public int ReachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("unreachableCount")]
|
||||
public int UnreachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("unknownCount")]
|
||||
public int UnknownCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to explain reachability for a specific vulnerability or package.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityExplainRequest
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("includeCallPaths")]
|
||||
public bool IncludeCallPaths { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of reachability explanation.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityExplainResult
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("reachabilityState")]
|
||||
public string ReachabilityState { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachabilityScore")]
|
||||
public double? ReachabilityScore { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public string Confidence { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reasoning")]
|
||||
public string? Reasoning { get; init; }
|
||||
|
||||
[JsonPropertyName("callPaths")]
|
||||
public IReadOnlyList<ReachabilityCallPath> CallPaths { get; init; } = Array.Empty<ReachabilityCallPath>();
|
||||
|
||||
[JsonPropertyName("affectedFunctions")]
|
||||
public IReadOnlyList<ReachabilityFunction> AffectedFunctions { get; init; } = Array.Empty<ReachabilityFunction>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call path demonstrating reachability.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityCallPath
|
||||
{
|
||||
[JsonPropertyName("pathId")]
|
||||
public string PathId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("depth")]
|
||||
public int Depth { get; init; }
|
||||
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public ReachabilityFunction EntryPoint { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("frames")]
|
||||
public IReadOnlyList<ReachabilityFunction> Frames { get; init; } = Array.Empty<ReachabilityFunction>();
|
||||
|
||||
[JsonPropertyName("vulnerableFunction")]
|
||||
public ReachabilityFunction VulnerableFunction { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function in the call graph.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityFunction
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("className")]
|
||||
public string? ClassName { get; init; }
|
||||
|
||||
[JsonPropertyName("packageName")]
|
||||
public string? PackageName { get; init; }
|
||||
|
||||
[JsonPropertyName("filePath")]
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
[JsonPropertyName("lineNumber")]
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
|
||||
// CLI-SIG-26-002: Policy simulate reachability override models (extends PolicySimulationInput)
|
||||
|
||||
/// <summary>
|
||||
/// Reachability override for policy simulation.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityOverride
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string State { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
}
|
||||
448
src/Cli/StellaOps.Cli/Services/Models/RiskModels.cs
Normal file
448
src/Cli/StellaOps.Cli/Services/Models/RiskModels.cs
Normal file
@@ -0,0 +1,448 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-RISK-66-001: Risk profile list models
|
||||
|
||||
/// <summary>
|
||||
/// Request to list risk profiles.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileListRequest
|
||||
{
|
||||
[JsonPropertyName("includeDisabled")]
|
||||
public bool IncludeDisabled { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public string? Category { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of risk profiles.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileListResponse
|
||||
{
|
||||
[JsonPropertyName("profiles")]
|
||||
public IReadOnlyList<RiskProfileSummary> Profiles { get; init; } = Array.Empty<RiskProfileSummary>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a risk profile.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileSummary
|
||||
{
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("builtIn")]
|
||||
public bool BuiltIn { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleCount")]
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
[JsonPropertyName("severityWeights")]
|
||||
public RiskSeverityWeights? SeverityWeights { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity weights for risk scoring.
|
||||
/// </summary>
|
||||
internal sealed class RiskSeverityWeights
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
public double Critical { get; init; }
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
public double High { get; init; }
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
public double Medium { get; init; }
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
public double Low { get; init; }
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
public double Info { get; init; }
|
||||
}
|
||||
|
||||
// CLI-RISK-66-002: Risk simulate models
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate risk scoring.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateRequest
|
||||
{
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("diffMode")]
|
||||
public bool DiffMode { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineProfileId")]
|
||||
public string? BaselineProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of risk simulation.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileName")]
|
||||
public string ProfileName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("overallScore")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findings")]
|
||||
public RiskSimulateFindingsSummary Findings { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("componentScores")]
|
||||
public IReadOnlyList<RiskComponentScore> ComponentScores { get; init; } = Array.Empty<RiskComponentScore>();
|
||||
|
||||
[JsonPropertyName("diff")]
|
||||
public RiskSimulateDiff? Diff { get; init; }
|
||||
|
||||
[JsonPropertyName("simulatedAt")]
|
||||
public DateTimeOffset SimulatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of findings from risk simulation.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateFindingsSummary
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
public int Critical { get; init; }
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
public int High { get; init; }
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
public int Medium { get; init; }
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
public int Low { get; init; }
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
public int Info { get; init; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component-level risk score.
|
||||
/// </summary>
|
||||
internal sealed class RiskComponentScore
|
||||
{
|
||||
[JsonPropertyName("componentId")]
|
||||
public string ComponentId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentName")]
|
||||
public string ComponentName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findingCount")]
|
||||
public int FindingCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between baseline and candidate risk scores.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateDiff
|
||||
{
|
||||
[JsonPropertyName("baselineScore")]
|
||||
public double BaselineScore { get; init; }
|
||||
|
||||
[JsonPropertyName("candidateScore")]
|
||||
public double CandidateScore { get; init; }
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
public double Delta { get; init; }
|
||||
|
||||
[JsonPropertyName("improved")]
|
||||
public bool Improved { get; init; }
|
||||
|
||||
[JsonPropertyName("findingsAdded")]
|
||||
public int FindingsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("findingsRemoved")]
|
||||
public int FindingsRemoved { get; init; }
|
||||
}
|
||||
|
||||
// CLI-RISK-67-001: Risk results models
|
||||
|
||||
/// <summary>
|
||||
/// Request to get risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsRequest
|
||||
{
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("minSeverity")]
|
||||
public string? MinSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double? MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("includeExplain")]
|
||||
public bool IncludeExplain { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsResponse
|
||||
{
|
||||
[JsonPropertyName("results")]
|
||||
public IReadOnlyList<RiskResult> Results { get; init; } = Array.Empty<RiskResult>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public RiskResultsSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual risk result.
|
||||
/// </summary>
|
||||
internal sealed class RiskResult
|
||||
{
|
||||
[JsonPropertyName("resultId")]
|
||||
public string ResultId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string AssetId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("assetName")]
|
||||
public string? AssetName { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileName")]
|
||||
public string? ProfileName { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findingCount")]
|
||||
public int FindingCount { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public RiskResultExplain? Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explanation for a risk result.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultExplain
|
||||
{
|
||||
[JsonPropertyName("factors")]
|
||||
public IReadOnlyList<RiskFactor> Factors { get; init; } = Array.Empty<RiskFactor>();
|
||||
|
||||
[JsonPropertyName("recommendations")]
|
||||
public IReadOnlyList<string> Recommendations { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factor contributing to risk score.
|
||||
/// </summary>
|
||||
internal sealed class RiskFactor
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("weight")]
|
||||
public double Weight { get; init; }
|
||||
|
||||
[JsonPropertyName("contribution")]
|
||||
public double Contribution { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsSummary
|
||||
{
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public double MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("assetCount")]
|
||||
public int AssetCount { get; init; }
|
||||
|
||||
[JsonPropertyName("bySeverity")]
|
||||
public RiskSimulateFindingsSummary BySeverity { get; init; } = new();
|
||||
}
|
||||
|
||||
// CLI-RISK-68-001: Risk bundle verify models
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a risk bundle.
|
||||
/// </summary>
|
||||
internal sealed class RiskBundleVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signaturePath")]
|
||||
public string? SignaturePath { get; init; }
|
||||
|
||||
[JsonPropertyName("checkRekor")]
|
||||
public bool CheckRekor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a risk bundle.
|
||||
/// </summary>
|
||||
internal sealed class RiskBundleVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundleVersion")]
|
||||
public string BundleVersion { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileCount")]
|
||||
public int ProfileCount { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleCount")]
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signedBy")]
|
||||
public string? SignedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerified")]
|
||||
public bool? RekorVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public string? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
633
src/Cli/StellaOps.Cli/Services/Models/SbomModels.cs
Normal file
633
src/Cli/StellaOps.Cli/Services/Models/SbomModels.cs
Normal file
@@ -0,0 +1,633 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PARITY-41-001: SBOM Explorer models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// SBOM list request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAfter")]
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBefore")]
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("hasVulnerabilities")]
|
||||
public bool? HasVulnerabilities { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated SBOM list response.
|
||||
/// </summary>
|
||||
internal sealed class SbomListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary view of an SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomSummary
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityCount")]
|
||||
public int VulnerabilityCount { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesDetected")]
|
||||
public int LicensesDetected { get; init; }
|
||||
|
||||
[JsonPropertyName("determinismScore")]
|
||||
public double? DeterminismScore { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed SBOM response including components, vulnerabilities, and metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomDetailResponse
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityCount")]
|
||||
public int VulnerabilityCount { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesDetected")]
|
||||
public int LicensesDetected { get; init; }
|
||||
|
||||
[JsonPropertyName("determinismScore")]
|
||||
public double? DeterminismScore { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<SbomComponent>? Components { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public SbomMetadata? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilities")]
|
||||
public IReadOnlyList<SbomVulnerability>? Vulnerabilities { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public IReadOnlyList<SbomLicense>? Licenses { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation")]
|
||||
public SbomAttestation? Attestation { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public SbomExplainInfo? Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component information.
|
||||
/// </summary>
|
||||
internal sealed class SbomComponent
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("supplier")]
|
||||
public string? Supplier { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public IReadOnlyList<string>? Licenses { get; init; }
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyDictionary<string, string>? Hashes { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("dependencies")]
|
||||
public IReadOnlyList<string>? Dependencies { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM creation metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomMetadata
|
||||
{
|
||||
[JsonPropertyName("toolName")]
|
||||
public string? ToolName { get; init; }
|
||||
|
||||
[JsonPropertyName("toolVersion")]
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("scannerVersion")]
|
||||
public string? ScannerVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("serialNumber")]
|
||||
public string? SerialNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("documentNamespace")]
|
||||
public string? DocumentNamespace { get; init; }
|
||||
|
||||
[JsonPropertyName("creators")]
|
||||
public IReadOnlyList<string>? Creators { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability found in SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomVulnerability
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public string? AffectedComponent { get; init; }
|
||||
|
||||
[JsonPropertyName("fixedIn")]
|
||||
public string? FixedIn { get; init; }
|
||||
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License information in SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomLicense
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<string>? Components { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation information for SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomAttestation
|
||||
{
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateIssuer")]
|
||||
public string? CertificateIssuer { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateSubject")]
|
||||
public string? CertificateSubject { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explain information for SBOM generation (determinism debugging).
|
||||
/// </summary>
|
||||
internal sealed class SbomExplainInfo
|
||||
{
|
||||
[JsonPropertyName("determinismFactors")]
|
||||
public IReadOnlyList<SbomDeterminismFactor>? DeterminismFactors { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public IReadOnlyList<SbomCompositionStep>? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factor affecting SBOM determinism score.
|
||||
/// </summary>
|
||||
internal sealed class SbomDeterminismFactor
|
||||
{
|
||||
[JsonPropertyName("factor")]
|
||||
public string Factor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("impact")]
|
||||
public string Impact { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step in SBOM composition chain.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompositionStep
|
||||
{
|
||||
[JsonPropertyName("step")]
|
||||
public int Step { get; init; }
|
||||
|
||||
[JsonPropertyName("operation")]
|
||||
public string Operation { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("input")]
|
||||
public string? Input { get; init; }
|
||||
|
||||
[JsonPropertyName("output")]
|
||||
public string? Output { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM compare request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("baseSbomId")]
|
||||
public string BaseSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("targetSbomId")]
|
||||
public string TargetSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("includeUnchanged")]
|
||||
public bool IncludeUnchanged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM comparison result.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareResponse
|
||||
{
|
||||
[JsonPropertyName("baseSbomId")]
|
||||
public string BaseSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("targetSbomId")]
|
||||
public string TargetSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public SbomCompareSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("componentChanges")]
|
||||
public IReadOnlyList<SbomComponentChange>? ComponentChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityChanges")]
|
||||
public IReadOnlyList<SbomVulnerabilityChange>? VulnerabilityChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("licenseChanges")]
|
||||
public IReadOnlyList<SbomLicenseChange>? LicenseChanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of SBOM comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareSummary
|
||||
{
|
||||
[JsonPropertyName("componentsAdded")]
|
||||
public int ComponentsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsRemoved")]
|
||||
public int ComponentsRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsModified")]
|
||||
public int ComponentsModified { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsUnchanged")]
|
||||
public int ComponentsUnchanged { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilitiesAdded")]
|
||||
public int VulnerabilitiesAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilitiesRemoved")]
|
||||
public int VulnerabilitiesRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesAdded")]
|
||||
public int LicensesAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesRemoved")]
|
||||
public int LicensesRemoved { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomComponentChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentName")]
|
||||
public string ComponentName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("baseVersion")]
|
||||
public string? BaseVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public string? TargetVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("basePurl")]
|
||||
public string? BasePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("targetPurl")]
|
||||
public string? TargetPurl { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public IReadOnlyList<string>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomVulnerabilityChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public string? AffectedComponent { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomLicenseChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("licenseId")]
|
||||
public string? LicenseId { get; init; }
|
||||
|
||||
[JsonPropertyName("licenseName")]
|
||||
public string LicenseName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM export request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomExportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "spdx";
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("includeVex")]
|
||||
public bool IncludeVex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM export result.
|
||||
/// </summary>
|
||||
internal sealed class SbomExportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("exportId")]
|
||||
public string? ExportId { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string? DigestAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
}
|
||||
|
||||
// CLI-PARITY-41-001: Parity matrix models
|
||||
|
||||
/// <summary>
|
||||
/// Parity matrix entry showing CLI command coverage.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixEntry
|
||||
{
|
||||
[JsonPropertyName("commandGroup")]
|
||||
public string CommandGroup { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("command")]
|
||||
public string Command { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cliSupport")]
|
||||
public string CliSupport { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("apiEndpoint")]
|
||||
public string? ApiEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("uiEquivalent")]
|
||||
public string? UiEquivalent { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("explainSupport")]
|
||||
public bool ExplainSupport { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineSupport")]
|
||||
public bool OfflineSupport { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parity matrix summary response.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixResponse
|
||||
{
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<ParityMatrixEntry> Entries { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public ParityMatrixSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("cliVersion")]
|
||||
public string? CliVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of parity matrix coverage.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixSummary
|
||||
{
|
||||
[JsonPropertyName("totalCommands")]
|
||||
public int TotalCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("fullParity")]
|
||||
public int FullParity { get; init; }
|
||||
|
||||
[JsonPropertyName("partialParity")]
|
||||
public int PartialParity { get; init; }
|
||||
|
||||
[JsonPropertyName("noParity")]
|
||||
public int NoParity { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministicCommands")]
|
||||
public int DeterministicCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("explainEnabledCommands")]
|
||||
public int ExplainEnabledCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineCapableCommands")]
|
||||
public int OfflineCapableCommands { get; init; }
|
||||
}
|
||||
834
src/Cli/StellaOps.Cli/Services/Models/SbomerModels.cs
Normal file
834
src/Cli/StellaOps.Cli/Services/Models/SbomerModels.cs
Normal file
@@ -0,0 +1,834 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer command models for layer/compose operations
|
||||
|
||||
/// <summary>
|
||||
/// SBOM fragment from a container layer.
|
||||
/// </summary>
|
||||
internal sealed class SbomFragment
|
||||
{
|
||||
[JsonPropertyName("fragmentId")]
|
||||
public string FragmentId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeSha256")]
|
||||
public string? DsseEnvelopeSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeUri")]
|
||||
public string? DsseEnvelopeUri { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<SbomFragmentComponent>? Components { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component within an SBOM fragment.
|
||||
/// </summary>
|
||||
internal sealed class SbomFragmentComponent
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("identityKey")]
|
||||
public string? IdentityKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer list request for sbomer layer list.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer list response.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomFragment> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer show request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerShowRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("includeComponents")]
|
||||
public bool IncludeComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("includeDsse")]
|
||||
public bool IncludeDsse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer detail response.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerDetail
|
||||
{
|
||||
[JsonPropertyName("fragment")]
|
||||
public SbomFragment Fragment { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public DsseEnvelopeInfo? DsseEnvelope { get; init; }
|
||||
|
||||
[JsonPropertyName("canonicalJson")]
|
||||
public string? CanonicalJson { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProof")]
|
||||
public MerkleProofInfo? MerkleProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope information.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelopeInfo
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadSha256")]
|
||||
public string PayloadSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<DsseSignatureInfo> Signatures { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("envelopeSha256")]
|
||||
public string EnvelopeSha256 { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature information.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatureSha256")]
|
||||
public string SignatureSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool? Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateSubject")]
|
||||
public string? CertificateSubject { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateExpiry")]
|
||||
public DateTimeOffset? CertificateExpiry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle proof information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleProofInfo
|
||||
{
|
||||
[JsonPropertyName("leafHash")]
|
||||
public string LeafHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("proofHashes")]
|
||||
public IReadOnlyList<string> ProofHashes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("leafIndex")]
|
||||
public int LeafIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public int TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool? Valid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer verify request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerVerifyResult
|
||||
{
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseValid")]
|
||||
public bool DsseValid { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHashMatch")]
|
||||
public bool ContentHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProofValid")]
|
||||
public bool? MerkleProofValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition manifest (_composition.json).
|
||||
/// </summary>
|
||||
internal sealed class CompositionManifest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string MerkleRoot { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("composedSha256")]
|
||||
public string ComposedSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragments")]
|
||||
public IReadOnlyList<CompositionFragmentEntry> Fragments { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("canonicalOrder")]
|
||||
public IReadOnlyList<string> CanonicalOrder { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public IReadOnlyDictionary<string, string>? Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragment entry in composition manifest.
|
||||
/// </summary>
|
||||
internal sealed class CompositionFragmentEntry
|
||||
{
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeSha256")]
|
||||
public string? DsseEnvelopeSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose request for sbomer compose.
|
||||
/// </summary>
|
||||
internal sealed class SbomerComposeRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("verifyFragments")]
|
||||
public bool VerifyFragments { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("emitCompositionManifest")]
|
||||
public bool EmitCompositionManifest { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("emitMerkleDiagnostics")]
|
||||
public bool EmitMerkleDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerComposeResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("composedSha256")]
|
||||
public string? ComposedSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string? MerkleRoot { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("totalComponents")]
|
||||
public int TotalComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionManifestPath")]
|
||||
public string? CompositionManifestPath { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleDiagnosticsPath")]
|
||||
public string? MerkleDiagnosticsPath { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition show request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionShowRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle diagnostics for composition.
|
||||
/// </summary>
|
||||
internal sealed class MerkleDiagnostics
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public int TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("leaves")]
|
||||
public IReadOnlyList<MerkleLeafInfo> Leaves { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("intermediateNodes")]
|
||||
public IReadOnlyList<MerkleNodeInfo>? IntermediateNodes { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle leaf information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleLeafInfo
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle intermediate node information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleNodeInfo
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public int Level { get; init; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("leftChild")]
|
||||
public string? LeftChild { get; init; }
|
||||
|
||||
[JsonPropertyName("rightChild")]
|
||||
public string? RightChild { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition verify request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("recompose")]
|
||||
public bool Recompose { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("merkleRootMatch")]
|
||||
public bool MerkleRootMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("composedHashMatch")]
|
||||
public bool ComposedHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("allFragmentsValid")]
|
||||
public bool AllFragmentsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHash")]
|
||||
public string? RecomposedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedHash")]
|
||||
public string? ExpectedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection and explain models
|
||||
|
||||
/// <summary>
|
||||
/// Drift analysis request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineScanId")]
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("baselinePath")]
|
||||
public string? BaselinePath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public bool Explain { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitPath")]
|
||||
public string? OfflineKitPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift analysis result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftResult
|
||||
{
|
||||
[JsonPropertyName("hasDrift")]
|
||||
public bool HasDrift { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("baselineScanId")]
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("currentHash")]
|
||||
public string? CurrentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineHash")]
|
||||
public string? BaselineHash { get; init; }
|
||||
|
||||
[JsonPropertyName("driftSummary")]
|
||||
public DriftSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("driftDetails")]
|
||||
public IReadOnlyList<DriftDetail>? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("explanations")]
|
||||
public IReadOnlyList<DriftExplanation>? Explanations { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of drift between two SBOMs.
|
||||
/// </summary>
|
||||
internal sealed class DriftSummary
|
||||
{
|
||||
[JsonPropertyName("componentsAdded")]
|
||||
public int ComponentsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsRemoved")]
|
||||
public int ComponentsRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsModified")]
|
||||
public int ComponentsModified { get; init; }
|
||||
|
||||
[JsonPropertyName("arrayOrderingDrifts")]
|
||||
public int ArrayOrderingDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("timestampDrifts")]
|
||||
public int TimestampDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("keyOrderingDrifts")]
|
||||
public int KeyOrderingDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("whitespaceDrifts")]
|
||||
public int WhitespaceDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("totalDrifts")]
|
||||
public int TotalDrifts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed drift information.
|
||||
/// </summary>
|
||||
internal sealed class DriftDetail
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("currentValue")]
|
||||
public string? CurrentValue { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineValue")]
|
||||
public string? BaselineValue { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string? LayerDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "info";
|
||||
|
||||
[JsonPropertyName("breaksDeterminism")]
|
||||
public bool BreaksDeterminism { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explanation for drift occurrence.
|
||||
/// </summary>
|
||||
internal sealed class DriftExplanation
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedBehavior")]
|
||||
public string? ExpectedBehavior { get; init; }
|
||||
|
||||
[JsonPropertyName("actualBehavior")]
|
||||
public string? ActualBehavior { get; init; }
|
||||
|
||||
[JsonPropertyName("rootCause")]
|
||||
public string? RootCause { get; init; }
|
||||
|
||||
[JsonPropertyName("remediation")]
|
||||
public string? Remediation { get; init; }
|
||||
|
||||
[JsonPropertyName("documentationUrl")]
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedLayers")]
|
||||
public IReadOnlyList<string>? AffectedLayers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift verify request for offline verification.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitPath")]
|
||||
public string? OfflineKitPath { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposeLocally")]
|
||||
public bool RecomposeLocally { get; init; }
|
||||
|
||||
[JsonPropertyName("validateFragments")]
|
||||
public bool ValidateFragments { get; init; }
|
||||
|
||||
[JsonPropertyName("checkMerkleProofs")]
|
||||
public bool CheckMerkleProofs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("compositionValid")]
|
||||
public bool CompositionValid { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentsValid")]
|
||||
public bool FragmentsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProofsValid")]
|
||||
public bool MerkleProofsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHashMatch")]
|
||||
public bool? RecomposedHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("currentHash")]
|
||||
public string? CurrentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHash")]
|
||||
public string? RecomposedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("driftResult")]
|
||||
public SbomerDriftResult? DriftResult { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitInfo")]
|
||||
public OfflineKitInfo? OfflineKitInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about offline kit used for verification.
|
||||
/// </summary>
|
||||
internal sealed class OfflineKitInfo
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPresent")]
|
||||
public bool VerifiersPresent { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionManifestPresent")]
|
||||
public bool CompositionManifestPresent { get; init; }
|
||||
}
|
||||
282
src/Cli/StellaOps.Cli/Services/Models/SdkModels.cs
Normal file
282
src/Cli/StellaOps.Cli/Services/Models/SdkModels.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request for checking SDK updates.
|
||||
/// CLI-SDK-64-001: Supports stella sdk update command.
|
||||
/// </summary>
|
||||
internal sealed class SdkUpdateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant context for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Language filter (typescript, go, csharp, python, java).
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only check for updates without downloading.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool CheckOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include changelog information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeChangelog")]
|
||||
public bool IncludeChangelog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include deprecation notices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeDeprecations")]
|
||||
public bool IncludeDeprecations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for SDK update check.
|
||||
/// </summary>
|
||||
internal sealed class SdkUpdateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available SDK updates.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updates")]
|
||||
public IReadOnlyList<SdkVersionInfo> Updates { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation notices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecations")]
|
||||
public IReadOnlyList<SdkDeprecation> Deprecations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when updates were last checked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkedAt")]
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK version information.
|
||||
/// </summary>
|
||||
internal sealed class SdkVersionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK language (typescript, go, csharp, python, java).
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the SDK.
|
||||
/// </summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name (e.g., @stellaops/sdk, stellaops-sdk).
|
||||
/// </summary>
|
||||
[JsonPropertyName("packageName")]
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current installed version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("installedVersion")]
|
||||
public string? InstalledVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest available version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("latestVersion")]
|
||||
public required string LatestVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an update is available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updateAvailable")]
|
||||
public bool UpdateAvailable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum supported API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minApiVersion")]
|
||||
public string? MinApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum supported API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxApiVersion")]
|
||||
public string? MaxApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date of the latest version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Changelog for recent versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changelog")]
|
||||
public IReadOnlyList<SdkChangelogEntry>? Changelog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package registry URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("registryUrl")]
|
||||
public string? RegistryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("docsUrl")]
|
||||
public string? DocsUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK changelog entry.
|
||||
/// </summary>
|
||||
internal sealed class SdkChangelogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Version number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change type (feature, fix, breaking, deprecation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a breaking change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isBreaking")]
|
||||
public bool IsBreaking { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to more details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("link")]
|
||||
public string? Link { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK deprecation notice.
|
||||
/// </summary>
|
||||
internal sealed class SdkDeprecation
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK language affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated feature or API.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feature")]
|
||||
public required string Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version when deprecation was introduced.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecatedInVersion")]
|
||||
public string? DeprecatedInVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version when feature will be removed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedInVersion")]
|
||||
public string? RemovedInVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replacement or migration path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replacement")]
|
||||
public string? Replacement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to migration guide.
|
||||
/// </summary>
|
||||
[JsonPropertyName("migrationGuide")]
|
||||
public string? MigrationGuide { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the deprecation (info, warning, critical).
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "warning";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing installed SDKs.
|
||||
/// </summary>
|
||||
internal sealed class SdkListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Installed SDK versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sdks")]
|
||||
public IReadOnlyList<SdkVersionInfo> Sdks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -14,6 +14,15 @@ internal sealed class PolicySimulationRequestDocument
|
||||
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
|
||||
@@ -21,6 +30,13 @@ 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
|
||||
@@ -55,3 +71,28 @@ internal sealed class PolicySimulationRuleDeltaDocument
|
||||
|
||||
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,18 +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; }
|
||||
}
|
||||
|
||||
292
src/Cli/StellaOps.Cli/Services/Models/VexObservationModels.cs
Normal file
292
src/Cli/StellaOps.Cli/Services/Models/VexObservationModels.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-LNM-22-002: VEX observation models for CLI commands
|
||||
|
||||
/// <summary>
|
||||
/// Query options for VEX observations.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationQuery
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { 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("statuses")]
|
||||
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("providerIds")]
|
||||
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from VEX observation query.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
|
||||
|
||||
[JsonPropertyName("aggregate")]
|
||||
public VexObservationAggregate? Aggregate { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX observation document.
|
||||
/// </summary>
|
||||
internal sealed class VexObservation
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("providerId")]
|
||||
public string ProviderId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("product")]
|
||||
public VexObservationProduct? Product { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public VexObservationDocument? Document { get; init; }
|
||||
|
||||
[JsonPropertyName("firstSeen")]
|
||||
public DateTimeOffset FirstSeen { get; init; }
|
||||
|
||||
[JsonPropertyName("lastSeen")]
|
||||
public DateTimeOffset LastSeen { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public VexObservationConfidence? Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product information in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationProduct
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
[JsonPropertyName("componentIdentifiers")]
|
||||
public IReadOnlyList<string> ComponentIdentifiers { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Document reference in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationDocument
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceUri")]
|
||||
public string SourceUri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("revision")]
|
||||
public string? Revision { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public VexObservationSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature metadata for VEX document.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationSignature
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("transparencyLogReference")]
|
||||
public string? TransparencyLogReference { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationConfidence
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate data from VEX observation query.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationAggregate
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { 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("providerIds")]
|
||||
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statusCounts")]
|
||||
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX linkset query options.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetQuery
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX linkset response showing linked observations.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetResponse
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public VexLinksetSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<VexLinksetConflict> Conflicts { get; init; } = Array.Empty<VexLinksetConflict>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of VEX linkset.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetSummary
|
||||
{
|
||||
[JsonPropertyName("totalObservations")]
|
||||
public int TotalObservations { get; init; }
|
||||
|
||||
[JsonPropertyName("providers")]
|
||||
public IReadOnlyList<string> Providers { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("products")]
|
||||
public IReadOnlyList<string> Products { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statusCounts")]
|
||||
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
|
||||
|
||||
[JsonPropertyName("hasConflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict between VEX observations.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetConflict
|
||||
{
|
||||
[JsonPropertyName("productKey")]
|
||||
public string ProductKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("conflictingStatuses")]
|
||||
public IReadOnlyList<string> ConflictingStatuses { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
@@ -0,0 +1,654 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Notify API operations.
|
||||
/// Per CLI-PARITY-41-002.
|
||||
/// </summary>
|
||||
internal sealed class NotifyClient : INotifyClient
|
||||
{
|
||||
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<NotifyClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public NotifyClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<NotifyClient> 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.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelListResponse> ListChannelsAsync(
|
||||
NotifyChannelListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildChannelListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify channels (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyChannelListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify channels");
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify channels");
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelDetail?> GetChannelAsync(
|
||||
string channelId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/notify/channels/{Uri.EscapeDataString(channelId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, 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 notify channel (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelDetail>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting notify channel");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting notify channel");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelTestResult> TestChannelAsync(
|
||||
NotifyChannelTestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{Uri.EscapeDataString(request.ChannelId)}/test")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to test notify channel (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelTestResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = "Empty response" };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while testing notify channel");
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = $"Connection error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while testing notify channel");
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = "Request timed out"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyRuleListResponse> ListRulesAsync(
|
||||
NotifyRuleListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.Enabled.HasValue)
|
||||
{
|
||||
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EventType))
|
||||
{
|
||||
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ChannelId))
|
||||
{
|
||||
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/notify/rules{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify rules (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyRuleListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyRuleListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify rules");
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify rules");
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
|
||||
NotifyDeliveryListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ChannelId))
|
||||
{
|
||||
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EventType))
|
||||
{
|
||||
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
|
||||
}
|
||||
if (request.Since.HasValue)
|
||||
{
|
||||
queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.Until.HasValue)
|
||||
{
|
||||
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/notify/deliveries{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify deliveries (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyDeliveryListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyDeliveryListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify deliveries");
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify deliveries");
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryDetail?> GetDeliveryAsync(
|
||||
string deliveryId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/notify/deliveries/{Uri.EscapeDataString(deliveryId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, 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 notify delivery (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<NotifyDeliveryDetail>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting notify delivery");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting notify delivery");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyRetryResult> RetryDeliveryAsync(
|
||||
NotifyRetryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/deliveries/{Uri.EscapeDataString(request.DeliveryId)}/retry")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
// Add idempotency key header if present
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to retry notify delivery (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyRetryResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while retrying notify delivery");
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while retrying notify delivery");
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifySendResult> SendAsync(
|
||||
NotifySendRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/send")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
// Add idempotency key header if present
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to send notification (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifySendResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifySendResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while sending notification");
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while sending notification");
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildChannelListUri(NotifyChannelListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
queryParams.Add($"type={Uri.EscapeDataString(request.Type)}");
|
||||
}
|
||||
if (request.Enabled.HasValue)
|
||||
{
|
||||
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/notify/channels{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
557
src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs
Normal file
557
src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs
Normal file
@@ -0,0 +1,557 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for observability API operations.
|
||||
/// Per CLI-OBS-51-001.
|
||||
/// </summary>
|
||||
internal sealed class ObservabilityClient : IObservabilityClient
|
||||
{
|
||||
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<ObservabilityClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ObservabilityClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ObservabilityClient> 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.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ObsTopResult> GetHealthSummaryAsync(
|
||||
ObsTopRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildHealthSummaryUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get health summary (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summary = await JsonSerializer
|
||||
.DeserializeAsync<PlatformHealthSummary>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = true,
|
||||
Summary = summary ?? new PlatformHealthSummary()
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching health summary");
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching health summary");
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildHealthSummaryUri(ObsTopRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (request.Services.Count > 0)
|
||||
{
|
||||
foreach (var service in request.Services)
|
||||
{
|
||||
queryParams.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"includeQueues={request.IncludeQueues.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"maxAlerts={request.MaxAlerts}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/observability/health{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = ["obs:read"] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Trace retrieval
|
||||
public async Task<ObsTraceResult> GetTraceAsync(
|
||||
ObsTraceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildTraceUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Trace not found: {request.TraceId}"]
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get trace (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var trace = await JsonSerializer
|
||||
.DeserializeAsync<DistributedTrace>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = true,
|
||||
Trace = trace
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching trace");
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching trace");
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildTraceUri(ObsTraceRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"includeEvidence={request.IncludeEvidence.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/observability/traces/{Uri.EscapeDataString(request.TraceId)}{query}";
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Logs retrieval
|
||||
public async Task<ObsLogsResult> GetLogsAsync(
|
||||
ObsLogsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildLogsUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get logs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ObsLogsResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ObsLogsResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching logs");
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching logs");
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLogsUri(ObsLogsRequest request)
|
||||
{
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"from={Uri.EscapeDataString(request.From.ToString("o"))}",
|
||||
$"to={Uri.EscapeDataString(request.To.ToString("o"))}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
foreach (var service in request.Services)
|
||||
{
|
||||
queryParams.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
|
||||
foreach (var level in request.Levels)
|
||||
{
|
||||
queryParams.Add($"level={Uri.EscapeDataString(level)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
queryParams.Add($"q={Uri.EscapeDataString(request.Query)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"pageSize={request.PageSize}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
{
|
||||
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
|
||||
}
|
||||
|
||||
var query = "?" + string.Join("&", queryParams);
|
||||
return $"/api/v1/observability/logs{query}";
|
||||
}
|
||||
|
||||
// CLI-OBS-55-001: Incident mode operations
|
||||
public async Task<IncidentModeResult> GetIncidentModeStatusAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var query = !string.IsNullOrWhiteSpace(tenant)
|
||||
? $"?tenant={Uri.EscapeDataString(tenant)}"
|
||||
: string.Empty;
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/observability/incident-mode{query}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get incident mode status (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var state = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeState>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = true,
|
||||
State = state
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching incident mode status");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching incident mode status");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IncidentModeResult> EnableIncidentModeAsync(
|
||||
IncidentModeEnableRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/enable");
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to enable incident mode (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new IncidentModeResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while enabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while enabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IncidentModeResult> DisableIncidentModeAsync(
|
||||
IncidentModeDisableRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/disable");
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to disable incident mode (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new IncidentModeResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while disabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while disabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
463
src/Cli/StellaOps.Cli/Services/OrchestratorClient.cs
Normal file
463
src/Cli/StellaOps.Cli/Services/OrchestratorClient.cs
Normal file
@@ -0,0 +1,463 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.Client.Scopes;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for orchestrator API operations.
|
||||
/// Per CLI-ORCH-32-001.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IStellaOpsTokenClient _tokenClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<OrchestratorClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public OrchestratorClient(
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptions<StellaOpsCliOptions> options,
|
||||
ILogger<OrchestratorClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SourceListResponse> ListSourcesAsync(
|
||||
SourceListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildSourcesListUrl(request);
|
||||
|
||||
_logger.LogDebug("Listing orchestrator sources: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to list sources: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to list sources: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceListResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceListResponse();
|
||||
}
|
||||
|
||||
public async Task<OrchestratorSource?> GetSourceAsync(
|
||||
string sourceId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(sourceId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
url += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Getting orchestrator source: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get source: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OrchestratorSource>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> PauseSourceAsync(
|
||||
SourcePauseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:pause";
|
||||
|
||||
_logger.LogDebug("Pausing orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to pause source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to pause source: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> ResumeSourceAsync(
|
||||
SourceResumeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:resume";
|
||||
|
||||
_logger.LogDebug("Resuming orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to resume source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to resume source: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<SourceTestResult> TestSourceAsync(
|
||||
SourceTestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:test";
|
||||
|
||||
_logger.LogDebug("Testing orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to test source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceTestResult
|
||||
{
|
||||
Success = false,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = false,
|
||||
ErrorMessage = $"Failed to test source: {response.StatusCode} - {errorContent}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceTestResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceTestResult
|
||||
{
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = true,
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Backfill operations
|
||||
|
||||
public async Task<BackfillResult> StartBackfillAsync(
|
||||
BackfillRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills";
|
||||
|
||||
_logger.LogDebug("Starting backfill for source: {SourceId} from {From} to {To}",
|
||||
request.SourceId, request.From, request.To);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to start backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new BackfillResult
|
||||
{
|
||||
Success = false,
|
||||
SourceId = request.SourceId,
|
||||
Status = BackfillStatuses.Failed,
|
||||
From = request.From,
|
||||
To = request.To,
|
||||
DryRun = request.DryRun,
|
||||
Errors = new[] { $"Failed to start backfill: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new BackfillResult
|
||||
{
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Status = request.DryRun ? BackfillStatuses.DryRun : BackfillStatuses.Pending,
|
||||
From = request.From,
|
||||
To = request.To,
|
||||
DryRun = request.DryRun
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BackfillResult?> GetBackfillAsync(
|
||||
string backfillId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(backfillId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
url += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Getting backfill status: {BackfillId}", backfillId);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get backfill: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<BackfillListResponse> ListBackfillsAsync(
|
||||
BackfillListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildBackfillsListUrl(request);
|
||||
|
||||
_logger.LogDebug("Listing backfills: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to list backfills: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to list backfills: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BackfillListResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new BackfillListResponse();
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> CancelBackfillAsync(
|
||||
BackfillCancelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(request.BackfillId)}:cancel";
|
||||
|
||||
_logger.LogDebug("Cancelling backfill: {BackfillId}", request.BackfillId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to cancel backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to cancel backfill: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Quota management
|
||||
|
||||
public async Task<QuotaGetResponse> GetQuotasAsync(
|
||||
QuotaGetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildQuotasGetUrl(request);
|
||||
|
||||
_logger.LogDebug("Getting quotas: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get quotas: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get quotas: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaGetResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaGetResponse();
|
||||
}
|
||||
|
||||
public async Task<QuotaOperationResult> SetQuotaAsync(
|
||||
QuotaSetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/quotas";
|
||||
|
||||
_logger.LogDebug("Setting quota for tenant: {Tenant}, resource: {ResourceType}",
|
||||
request.Tenant, request.ResourceType);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to set quota: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new QuotaOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to set quota: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<QuotaOperationResult> ResetQuotaAsync(
|
||||
QuotaResetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/quotas:reset";
|
||||
|
||||
_logger.LogDebug("Resetting quota for tenant: {Tenant}, resource: {ResourceType}",
|
||||
request.Tenant, request.ResourceType);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to reset quota: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new QuotaOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to reset quota: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaOperationResult { Success = true };
|
||||
}
|
||||
|
||||
private async Task ConfigureAuthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokenClient.GetCachedAccessTokenAsync(
|
||||
new[] { StellaOpsScope.OrchRead },
|
||||
cancellationToken);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.AccessToken);
|
||||
}
|
||||
|
||||
private string GetBaseUrl()
|
||||
{
|
||||
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "https://api.stellaops.local";
|
||||
return $"{baseUrl}/api/v1/orchestrator";
|
||||
}
|
||||
|
||||
private string BuildSourcesListUrl(SourceListRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/sources");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
query["type"] = request.Type;
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
query["status"] = request.Status;
|
||||
if (request.Enabled.HasValue)
|
||||
query["enabled"] = request.Enabled.Value.ToString().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(request.Host))
|
||||
query["host"] = request.Host;
|
||||
if (!string.IsNullOrWhiteSpace(request.Tag))
|
||||
query["tag"] = request.Tag;
|
||||
if (request.PageSize != 50)
|
||||
query["page_size"] = request.PageSize.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
query["page_token"] = request.PageToken;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string BuildBackfillsListUrl(BackfillListRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/backfills");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SourceId))
|
||||
query["source_id"] = request.SourceId;
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
query["status"] = request.Status;
|
||||
if (request.PageSize != 20)
|
||||
query["page_size"] = request.PageSize.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
query["page_token"] = request.PageToken;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string BuildQuotasGetUrl(QuotaGetRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/quotas");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.SourceId))
|
||||
query["source_id"] = request.SourceId;
|
||||
if (!string.IsNullOrWhiteSpace(request.ResourceType))
|
||||
query["resource_type"] = request.ResourceType;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
1017
src/Cli/StellaOps.Cli/Services/PackClient.cs
Normal file
1017
src/Cli/StellaOps.Cli/Services/PackClient.cs
Normal file
File diff suppressed because it is too large
Load Diff
1118
src/Cli/StellaOps.Cli/Services/PromotionAssembler.cs
Normal file
1118
src/Cli/StellaOps.Cli/Services/PromotionAssembler.cs
Normal file
File diff suppressed because it is too large
Load Diff
483
src/Cli/StellaOps.Cli/Services/SbomClient.cs
Normal file
483
src/Cli/StellaOps.Cli/Services/SbomClient.cs
Normal file
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for SBOM API operations.
|
||||
/// Per CLI-PARITY-41-001.
|
||||
/// </summary>
|
||||
internal sealed class SbomClient : ISbomClient
|
||||
{
|
||||
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<SbomClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public SbomClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<SbomClient> 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.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomListResponse> ListAsync(
|
||||
SbomListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list SBOMs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new SbomListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<SbomListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new SbomListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing SBOMs");
|
||||
return new SbomListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing SBOMs");
|
||||
return new SbomListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomDetailResponse?> GetAsync(
|
||||
string sbomId,
|
||||
string? tenant,
|
||||
bool includeComponents,
|
||||
bool includeVulnerabilities,
|
||||
bool includeLicenses,
|
||||
bool explain,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(tenant)}");
|
||||
}
|
||||
if (includeComponents)
|
||||
{
|
||||
queryParams.Add("includeComponents=true");
|
||||
}
|
||||
if (includeVulnerabilities)
|
||||
{
|
||||
queryParams.Add("includeVulnerabilities=true");
|
||||
}
|
||||
if (includeLicenses)
|
||||
{
|
||||
queryParams.Add("includeLicenses=true");
|
||||
}
|
||||
if (explain)
|
||||
{
|
||||
queryParams.Add("explain=true");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(sbomId)}{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, 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 SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomDetailResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting SBOM");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting SBOM");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomCompareResponse?> CompareAsync(
|
||||
SbomCompareRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"base={Uri.EscapeDataString(request.BaseSbomId)}",
|
||||
$"target={Uri.EscapeDataString(request.TargetSbomId)}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.IncludeUnchanged)
|
||||
{
|
||||
queryParams.Add("includeUnchanged=true");
|
||||
}
|
||||
|
||||
var query = string.Join("&", queryParams);
|
||||
var uri = $"/api/v1/sboms/compare?{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to compare SBOMs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomCompareResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while comparing SBOMs");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while comparing SBOMs");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
|
||||
SbomExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"format={Uri.EscapeDataString(request.Format)}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.FormatVersion))
|
||||
{
|
||||
queryParams.Add($"formatVersion={Uri.EscapeDataString(request.FormatVersion)}");
|
||||
}
|
||||
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"includeVex={request.IncludeVex.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = string.Join("&", queryParams);
|
||||
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(request.SbomId)}/export?{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to export SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
});
|
||||
}
|
||||
|
||||
// Parse export metadata from headers if present
|
||||
SbomExportResult? result = null;
|
||||
if (response.Headers.TryGetValues("X-Export-Metadata", out var metadataValues))
|
||||
{
|
||||
var metadataJson = string.Join("", metadataValues);
|
||||
if (!string.IsNullOrWhiteSpace(metadataJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
result = JsonSerializer.Deserialize<SbomExportResult>(metadataJson, SerializerOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore parse errors for optional header
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result ??= new SbomExportResult
|
||||
{
|
||||
Success = true,
|
||||
Format = request.Format,
|
||||
Signed = request.Signed
|
||||
};
|
||||
|
||||
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (contentStream, result);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while exporting SBOM");
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
});
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while exporting SBOM");
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ParityMatrixResponse> GetParityMatrixAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = "/api/v1/cli/parity-matrix";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "cli.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get parity matrix (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ParityMatrixResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ParityMatrixResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting parity matrix");
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting parity matrix");
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildListUri(SbomListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ImageRef))
|
||||
{
|
||||
queryParams.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Digest))
|
||||
{
|
||||
queryParams.Add($"digest={Uri.EscapeDataString(request.Digest)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Format))
|
||||
{
|
||||
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
|
||||
}
|
||||
if (request.CreatedAfter.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdAfter={Uri.EscapeDataString(request.CreatedAfter.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.CreatedBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdBefore={Uri.EscapeDataString(request.CreatedBefore.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.HasVulnerabilities.HasValue)
|
||||
{
|
||||
queryParams.Add($"hasVulnerabilities={request.HasVulnerabilities.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/sboms{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
254
src/Cli/StellaOps.Cli/Services/SbomerClient.cs
Normal file
254
src/Cli/StellaOps.Cli/Services/SbomerClient.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for Sbomer API operations.
|
||||
/// Per CLI-SBOM-60-001.
|
||||
/// </summary>
|
||||
internal sealed class SbomerClient : ISbomerClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly ILogger<SbomerClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public SbomerClient(
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient? tokenClient,
|
||||
ILogger<SbomerClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerListResponse> ListLayersAsync(
|
||||
SbomerLayerListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildQueryString(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers{query}", cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerLayerListResponse();
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerDetail?> GetLayerAsync(
|
||||
SbomerLayerShowRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildLayerShowQuery(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerDetail>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerVerifyResult> VerifyLayerAsync(
|
||||
SbomerLayerVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
$"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerLayerVerifyResult { LayerDigest = request.LayerDigest };
|
||||
}
|
||||
|
||||
public async Task<CompositionManifest?> GetCompositionManifestAsync(
|
||||
SbomerCompositionShowRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildCompositionShowQuery(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<CompositionManifest>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<SbomerComposeResult> ComposeAsync(
|
||||
SbomerComposeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/compose",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerComposeResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerComposeResult();
|
||||
}
|
||||
|
||||
public async Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
|
||||
SbomerCompositionVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/composition/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerCompositionVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerCompositionVerifyResult();
|
||||
}
|
||||
|
||||
public async Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
|
||||
string scanId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = string.IsNullOrWhiteSpace(tenant) ? "" : $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition/{Uri.EscapeDataString(scanId)}/merkle{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<MerkleDiagnostics>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection methods
|
||||
|
||||
public async Task<SbomerDriftResult> AnalyzeDriftAsync(
|
||||
SbomerDriftRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/drift/analyze",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerDriftResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerDriftResult();
|
||||
}
|
||||
|
||||
public async Task<SbomerDriftVerifyResult> VerifyDriftAsync(
|
||||
SbomerDriftVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/drift/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerDriftVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerDriftVerifyResult();
|
||||
}
|
||||
|
||||
private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tokenClient == null)
|
||||
return;
|
||||
|
||||
var token = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildQueryString(SbomerLayerListRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ImageRef))
|
||||
parts.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Digest))
|
||||
parts.Add($"digest={Uri.EscapeDataString(request.Digest)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (request.Limit.HasValue)
|
||||
parts.Add($"limit={request.Limit.Value}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
parts.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
|
||||
private static string BuildLayerShowQuery(SbomerLayerShowRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (request.IncludeComponents)
|
||||
parts.Add("includeComponents=true");
|
||||
if (request.IncludeDsse)
|
||||
parts.Add("includeDsse=true");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
|
||||
private static string BuildCompositionShowQuery(SbomerCompositionShowRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.CompositionPath))
|
||||
parts.Add($"compositionPath={Uri.EscapeDataString(request.CompositionPath)}");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
}
|
||||
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP transport implementation for online mode.
|
||||
/// CLI-SDK-62-001: Provides HTTP transport for online API operations.
|
||||
/// </summary>
|
||||
public sealed class HttpTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<HttpTransport> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
_httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient.Timeout = _options.Timeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "http";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.MaxRetries);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Sending {Method} request to {Uri} (attempt {Attempt}/{MaxAttempts})",
|
||||
request.Method, request.RequestUri, attempt, maxAttempts);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Received response {StatusCode} from {Uri}",
|
||||
(int)response.StatusCode, request.RequestUri);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (HttpRequestException ex) when (attempt < maxAttempts && IsRetryableException(ex))
|
||||
{
|
||||
var delay = GetRetryDelay(attempt);
|
||||
_logger.LogWarning(ex, "Request failed (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s...",
|
||||
attempt, maxAttempts, delay.TotalSeconds);
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clone the request for retry
|
||||
request = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// For HTTP transport, we return a memory stream that will be uploaded
|
||||
// The caller is responsible for writing to the stream and then calling upload
|
||||
return await Task.FromResult<Stream>(new MemoryStream()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||
var response = await SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsRetryableException(HttpRequestException ex)
|
||||
{
|
||||
// Retry on connection errors, timeouts, and server errors
|
||||
return ex.InnerException is IOException
|
||||
|| ex.InnerException is OperationCanceledException
|
||||
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
// Exponential backoff with jitter
|
||||
var baseDelay = Math.Pow(2, attempt);
|
||||
var jitter = Random.Shared.NextDouble() * 0.5;
|
||||
return TimeSpan.FromSeconds(baseDelay + jitter);
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
|
||||
|
||||
// Copy headers
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
// Copy content if present
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var content = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
clone.Content = new ByteArrayContent(content);
|
||||
|
||||
foreach (var header in request.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy options
|
||||
foreach (var option in request.Options)
|
||||
{
|
||||
clone.Options.TryAdd(option.Key, option.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
// Note: We don't dispose _httpClient as it's typically managed by DI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Transport abstraction for CLI operations.
|
||||
/// CLI-SDK-62-001: Supports modular transport for online and air-gapped modes.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTransport : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this transport is operating in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
bool IsOffline { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport mode identifier.
|
||||
/// </summary>
|
||||
string TransportMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response with streaming content.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for uploading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for downloading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transport configuration options.
|
||||
/// </summary>
|
||||
public sealed class TransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL for the backend API.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to operate in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory for offline kit data.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate SSL certificates.
|
||||
/// </summary>
|
||||
public bool ValidateSsl { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom CA certificate path for SSL validation.
|
||||
/// </summary>
|
||||
public string? CaCertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Proxy URL if required.
|
||||
/// </summary>
|
||||
public string? ProxyUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent string.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-CLI/1.0";
|
||||
}
|
||||
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Offline transport implementation for air-gapped mode.
|
||||
/// CLI-SDK-62-001: Provides offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public sealed class OfflineTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<OfflineTransport> _logger;
|
||||
private readonly string _offlineKitDirectory;
|
||||
private bool _disposed;
|
||||
|
||||
public OfflineTransport(TransportOptions options, ILogger<OfflineTransport> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new ArgumentException("OfflineKitDirectory must be specified for offline transport.", nameof(options));
|
||||
}
|
||||
|
||||
_offlineKitDirectory = options.OfflineKitDirectory;
|
||||
|
||||
if (!Directory.Exists(_offlineKitDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Offline kit directory not found: {_offlineKitDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "offline";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Offline transport handling {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
// Map the request to an offline resource
|
||||
var (found, content) = await TryGetOfflineContentAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (found)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = content,
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
// Return a 503 Service Unavailable for operations that require online access
|
||||
_logger.LogWarning("Operation not available in offline mode: {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "ERR_AIRGAP_EGRESS_BLOCKED",
|
||||
message = "This operation is not available in offline/air-gapped mode."
|
||||
}
|
||||
})),
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Create a staging area for uploads in offline mode
|
||||
var stagingDir = Path.Combine(_offlineKitDirectory, "staging", "uploads");
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
var stagingFile = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.dat");
|
||||
_logger.LogDebug("Creating offline upload staging file: {Path}", stagingFile);
|
||||
|
||||
return Task.FromResult<Stream>(File.Create(stagingFile));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Map endpoint to offline file
|
||||
var localPath = MapEndpointToLocalPath(endpoint);
|
||||
|
||||
if (!File.Exists(localPath))
|
||||
{
|
||||
_logger.LogWarning("Offline resource not found: {Path}", localPath);
|
||||
throw new FileNotFoundException($"Offline resource not found: {endpoint}", localPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Opening offline resource: {Path}", localPath);
|
||||
return Task.FromResult<Stream>(File.OpenRead(localPath));
|
||||
}
|
||||
|
||||
private async Task<(bool Found, HttpContent? Content)> TryGetOfflineContentAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = request.RequestUri;
|
||||
if (uri is null)
|
||||
return (false, null);
|
||||
|
||||
var path = uri.PathAndQuery.TrimStart('/');
|
||||
|
||||
// Check for cached API responses
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", "api", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
// Try with .json extension
|
||||
var jsonPath = cachePath + ".json";
|
||||
if (File.Exists(jsonPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new StringContent(content, System.Text.Encoding.UTF8, "application/json"));
|
||||
}
|
||||
|
||||
// Try exact path
|
||||
if (File.Exists(cachePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
// Check for bundled data
|
||||
var bundlePath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(bundlePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private string MapEndpointToLocalPath(string endpoint)
|
||||
{
|
||||
var path = endpoint.TrimStart('/');
|
||||
|
||||
// Check data directory first
|
||||
var dataPath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(dataPath))
|
||||
return dataPath;
|
||||
|
||||
// Check cache directory
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(cachePath))
|
||||
return cachePath;
|
||||
|
||||
// Return data path as default (will throw FileNotFoundException if not found)
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SDK-generated clients.
|
||||
/// CLI-SDK-62-001: Provides common functionality for SDK clients with modular transport.
|
||||
/// </summary>
|
||||
public abstract class StellaOpsClientBase : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly IStellaOpsTransport _transport;
|
||||
private readonly ILogger _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization token for API requests.
|
||||
/// </summary>
|
||||
protected string? AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current tenant context.
|
||||
/// </summary>
|
||||
protected string? TenantId { get; set; }
|
||||
|
||||
protected StellaOpsClientBase(IStellaOpsTransport transport, ILogger logger)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the client is operating in offline mode.
|
||||
/// </summary>
|
||||
public bool IsOffline => _transport.IsOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the access token for authenticated requests.
|
||||
/// </summary>
|
||||
public void SetAccessToken(string? token)
|
||||
{
|
||||
AccessToken = token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the tenant context.
|
||||
/// </summary>
|
||||
public void SetTenant(string? tenantId)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the operation requires online connectivity and transport is offline.
|
||||
/// </summary>
|
||||
protected void ThrowIfOffline(string operation)
|
||||
{
|
||||
if (_transport.IsOffline)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Operation '{operation}' is not available in offline/air-gapped mode. " +
|
||||
"Please use online mode or import the required data to the offline kit.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GET request and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> GetAsync<TResponse>(
|
||||
string relativeUrl,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a POST request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PostAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a PUT request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PutAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Put, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a DELETE request.
|
||||
/// </summary>
|
||||
protected async Task DeleteAsync(string relativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Delete, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request and parses any error response.
|
||||
/// </summary>
|
||||
protected async Task<(TResponse? Result, CliError? Error)> TrySendAsync<TResponse>(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return (result, null);
|
||||
}
|
||||
|
||||
var error = await ParseErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP request with standard headers.
|
||||
/// </summary>
|
||||
protected HttpRequestMessage CreateRequest(HttpMethod method, string relativeUrl)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, relativeUrl);
|
||||
|
||||
// Add authorization header
|
||||
if (!string.IsNullOrWhiteSpace(AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
|
||||
}
|
||||
|
||||
// Add tenant header
|
||||
if (!string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", TenantId);
|
||||
}
|
||||
|
||||
// Add standard headers
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request through the transport.
|
||||
/// </summary>
|
||||
protected async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Sending {Method} request to {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return await _transport.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an error response into a CliError.
|
||||
/// </summary>
|
||||
protected async Task<CliError> ParseErrorAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
string? content = null;
|
||||
|
||||
try
|
||||
{
|
||||
content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore content read errors
|
||||
}
|
||||
|
||||
// Try to parse as error envelope
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = JsonSerializer.Deserialize<ApiErrorEnvelope>(content, JsonOptions);
|
||||
if (envelope?.Error is not null)
|
||||
{
|
||||
return CliError.FromApiErrorEnvelope(envelope, statusCode);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not an error envelope
|
||||
}
|
||||
|
||||
// Try to parse as problem details
|
||||
try
|
||||
{
|
||||
var problem = JsonSerializer.Deserialize<ProblemDocument>(content, JsonOptions);
|
||||
if (problem is not null)
|
||||
{
|
||||
return new CliError(
|
||||
Code: problem.Type ?? $"ERR_HTTP_{statusCode}",
|
||||
Message: problem.Title ?? $"HTTP error {statusCode}",
|
||||
Detail: problem.Detail);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a problem document
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HTTP status-based error
|
||||
return CliError.FromHttpStatus(statusCode, content);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_transport.Dispose();
|
||||
}
|
||||
}
|
||||
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating transport instances based on configuration.
|
||||
/// CLI-SDK-62-001: Provides modular transport selection for online/offline modes.
|
||||
/// </summary>
|
||||
public sealed class TransportFactory : ITransportFactory
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly CliProfileManager _profileManager;
|
||||
|
||||
public TransportFactory(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
StellaOpsCliOptions options,
|
||||
CliProfileManager profileManager)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_profileManager = profileManager ?? throw new ArgumentNullException(nameof(profileManager));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateTransport()
|
||||
{
|
||||
var transportOptions = CreateTransportOptions();
|
||||
|
||||
if (transportOptions.IsOffline)
|
||||
{
|
||||
return CreateOfflineTransport(transportOptions);
|
||||
}
|
||||
|
||||
return CreateHttpTransport(transportOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient("StellaOps");
|
||||
var logger = _loggerFactory.CreateLogger<HttpTransport>();
|
||||
|
||||
return new HttpTransport(httpClient, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Offline kit directory must be specified for offline transport.");
|
||||
}
|
||||
|
||||
var logger = _loggerFactory.CreateLogger<OfflineTransport>();
|
||||
return new OfflineTransport(options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
public TransportOptions CreateTransportOptions()
|
||||
{
|
||||
var profile = _profileManager.GetCurrentProfileAsync().GetAwaiter().GetResult();
|
||||
|
||||
return new TransportOptions
|
||||
{
|
||||
BackendUrl = profile?.BackendUrl ?? _options.BackendUrl,
|
||||
IsOffline = profile?.IsOffline ?? _options.IsOffline,
|
||||
OfflineKitDirectory = profile?.OfflineKitDirectory ?? _options.OfflineKitDirectory,
|
||||
Timeout = TimeSpan.FromMinutes(5),
|
||||
MaxRetries = 3,
|
||||
ValidateSsl = true,
|
||||
UserAgent = $"StellaOps-CLI/{GetVersion()}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetVersion()
|
||||
{
|
||||
var assembly = typeof(TransportFactory).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "1.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory interface for creating transport instances.
|
||||
/// </summary>
|
||||
public interface ITransportFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateTransport();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
TransportOptions CreateTransportOptions();
|
||||
}
|
||||
228
src/Cli/StellaOps.Cli/Services/VexObservationsClient.cs
Normal file
228
src/Cli/StellaOps.Cli/Services/VexObservationsClient.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for VEX observation queries.
|
||||
/// Per CLI-LNM-22-002.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationsClient : IVexObservationsClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenClient? _tokenClient;
|
||||
private readonly ILogger<VexObservationsClient> _logger;
|
||||
private string? _cachedToken;
|
||||
private DateTimeOffset _tokenExpiry;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public VexObservationsClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<VexObservationsClient> logger,
|
||||
ITokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
}
|
||||
|
||||
public async Task<VexObservationResponse> GetObservationsAsync(
|
||||
VexObservationQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = BuildObservationRequestUri(query);
|
||||
_logger.LogDebug("Fetching VEX observations from {Uri}", requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX observations request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX observations: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexObservationResponse>(content, SerializerOptions)
|
||||
?? new VexObservationResponse();
|
||||
}
|
||||
|
||||
public async Task<VexLinksetResponse> GetLinksetAsync(
|
||||
VexLinksetQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = BuildLinksetRequestUri(query);
|
||||
_logger.LogDebug("Fetching VEX linkset from {Uri}", requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX linkset request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX linkset: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexLinksetResponse>(content, SerializerOptions)
|
||||
?? new VexLinksetResponse();
|
||||
}
|
||||
|
||||
public async Task<VexObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = $"api/v1/tenants/{Uri.EscapeDataString(tenant)}/vex/observations/{Uri.EscapeDataString(observationId)}";
|
||||
_logger.LogDebug("Fetching VEX observation {ObservationId} from {Uri}", observationId, requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX observation request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX observation: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexObservation>(content, SerializerOptions);
|
||||
}
|
||||
|
||||
private async Task EnsureAuthorizationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tokenClient is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_cachedToken) && DateTimeOffset.UtcNow < _tokenExpiry)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenResult = await _tokenClient.GetAccessTokenAsync(
|
||||
new[] { StellaOpsScopes.VexRead },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (tokenResult.IsSuccess && !string.IsNullOrWhiteSpace(tokenResult.AccessToken))
|
||||
{
|
||||
_cachedToken = tokenResult.AccessToken;
|
||||
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to acquire token for VEX API access.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildObservationRequestUri(VexObservationQuery query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/observations?");
|
||||
|
||||
foreach (var vulnId in query.VulnerabilityIds)
|
||||
{
|
||||
sb.Append($"vulnerabilityId={Uri.EscapeDataString(vulnId)}&");
|
||||
}
|
||||
|
||||
foreach (var productKey in query.ProductKeys)
|
||||
{
|
||||
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
|
||||
}
|
||||
|
||||
foreach (var purl in query.Purls)
|
||||
{
|
||||
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
|
||||
}
|
||||
|
||||
foreach (var cpe in query.Cpes)
|
||||
{
|
||||
sb.Append($"cpe={Uri.EscapeDataString(cpe)}&");
|
||||
}
|
||||
|
||||
foreach (var status in query.Statuses)
|
||||
{
|
||||
sb.Append($"status={Uri.EscapeDataString(status)}&");
|
||||
}
|
||||
|
||||
foreach (var providerId in query.ProviderIds)
|
||||
{
|
||||
sb.Append($"providerId={Uri.EscapeDataString(providerId)}&");
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
sb.Append($"limit={query.Limit.Value}&");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
sb.Append($"cursor={Uri.EscapeDataString(query.Cursor)}&");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd('&', '?');
|
||||
}
|
||||
|
||||
private static string BuildLinksetRequestUri(VexLinksetQuery query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/linkset/{Uri.EscapeDataString(query.VulnerabilityId)}?");
|
||||
|
||||
foreach (var productKey in query.ProductKeys)
|
||||
{
|
||||
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
|
||||
}
|
||||
|
||||
foreach (var purl in query.Purls)
|
||||
{
|
||||
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
|
||||
}
|
||||
|
||||
foreach (var status in query.Statuses)
|
||||
{
|
||||
sb.Append($"status={Uri.EscapeDataString(status)}&");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd('&', '?');
|
||||
}
|
||||
}
|
||||
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP message handler that propagates W3C Trace Context (traceparent) headers.
|
||||
/// Per CLI-OBS-50-001, ensures CLI HTTP client propagates traceparent headers for all commands,
|
||||
/// prints correlation IDs on failure, and records trace IDs in verbose logs.
|
||||
/// </summary>
|
||||
public sealed class TraceparentHttpMessageHandler : DelegatingHandler
|
||||
{
|
||||
private const string TraceparentHeader = "traceparent";
|
||||
private const string TracestateHeader = "tracestate";
|
||||
private const string RequestIdHeader = "x-request-id";
|
||||
private const string CorrelationIdHeader = "x-correlation-id";
|
||||
|
||||
private readonly ILogger<TraceparentHttpMessageHandler> _logger;
|
||||
private readonly bool _verbose;
|
||||
|
||||
public TraceparentHttpMessageHandler(
|
||||
ILogger<TraceparentHttpMessageHandler> logger,
|
||||
bool verbose = false)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verbose = verbose;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
string? traceId = null;
|
||||
string? spanId = null;
|
||||
|
||||
// Generate or use existing trace context
|
||||
if (activity is not null)
|
||||
{
|
||||
traceId = activity.TraceId.ToString();
|
||||
spanId = activity.SpanId.ToString();
|
||||
|
||||
// Add W3C traceparent header if not already present
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-{(activity.Recorded ? "01" : "00")}";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Added traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tracestate if present
|
||||
if (!string.IsNullOrWhiteSpace(activity.TraceStateString) &&
|
||||
!request.Headers.Contains(TracestateHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TracestateHeader, activity.TraceStateString);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate a new trace ID if no activity exists
|
||||
traceId = Guid.NewGuid().ToString("N");
|
||||
spanId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-00";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Generated new traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also add x-request-id for legacy compatibility
|
||||
if (!request.Headers.Contains(RequestIdHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(RequestIdHeader, traceId);
|
||||
}
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Sending {Method} {Uri} with trace_id={TraceId}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Request failed: {Method} {Uri} trace_id={TraceId} error={Error}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
ex.Message);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Extract correlation ID from response if present
|
||||
var responseTraceId = GetResponseTraceId(response);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Request returned {StatusCode}: {Method} {Uri} trace_id={TraceId} response_trace_id={ResponseTraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
responseTraceId ?? "(not provided)");
|
||||
}
|
||||
else if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Request completed {StatusCode}: {Method} {Uri} trace_id={TraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string? GetResponseTraceId(HttpResponseMessage response)
|
||||
{
|
||||
if (response.Headers.TryGetValues(CorrelationIdHeader, out var correlationValues))
|
||||
{
|
||||
return string.Join(",", correlationValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues(RequestIdHeader, out var requestIdValues))
|
||||
{
|
||||
return string.Join(",", requestIdValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues("x-trace-id", out var traceIdValues))
|
||||
{
|
||||
return string.Join(",", traceIdValues);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ScrubUrl(Uri? uri)
|
||||
{
|
||||
if (uri is null)
|
||||
return "(null)";
|
||||
|
||||
// Remove query string to avoid logging sensitive parameters
|
||||
return $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension to add traceparent propagation to HTTP client.
|
||||
/// </summary>
|
||||
public static class TraceparentHttpClientBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds W3C Trace Context (traceparent) header propagation to the HTTP client.
|
||||
/// Per CLI-OBS-50-001.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddTraceparentPropagation(
|
||||
this IHttpClientBuilder builder,
|
||||
bool verbose = false)
|
||||
{
|
||||
return builder.AddHttpMessageHandler(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new TraceparentHttpMessageHandler(
|
||||
loggerFactory.CreateLogger<TraceparentHttpMessageHandler>(),
|
||||
verbose);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user